Compare commits

...

191 Commits

Author SHA1 Message Date
Max Erenberg cf42e49ae6 fix home links
continuous-integration/drone/push Build is passing Details
2024-03-24 18:11:48 -04:00
Max Erenberg bf2afd1195 Remove FCGI support
continuous-integration/drone/push Build is passing Details
Apache's ProxyPass directive doesn't seem to be passing the URI for
FCGI. There's probably a way to configure this, but it's easier to just
use HTTP instead.
2024-03-24 17:54:13 -04:00
Max Erenberg 5f8de94393 restrict app.sock access to www-data
continuous-integration/drone/push Build is passing Details
2024-03-23 23:00:37 -04:00
Max Erenberg 2164ceddf0 fix compilation error in api.go
continuous-integration/drone/push Build is passing Details
2024-03-23 19:32:23 -04:00
Max Erenberg 7716f7bd10 Add web UI for password resets (#123)
continuous-integration/drone/push Build is passing Details
Reviewed-on: public/pyceo#123
2024-03-23 19:26:30 -04:00
Nathan Chung 32cb22665a
packaging: system update commands
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:24:41 -05:00
Nathan Chung 56a59186e0
extra packaging instructions and notes
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:58 -05:00
Nathan Chung 5ecec2b54c
1.0.31: changelog
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:42 -05:00
Nathan Chung f584a89cec
update pgp packaging info
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:53:10 -05:00
Nathan Chung 7d9ec99f8f
release 1.0.31
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:40:22 -05:00
Nathan Chung 28adf6e13d
Merge branch '1.0.30-update-packaging'
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:39:50 -05:00
Max Erenberg 9c51ad3a01 Allow offsck to add members to the office group (#126)
continuous-integration/drone/push Build is passing Details
Closes #62.

Reviewed-on: public/pyceo#126
2024-02-17 19:31:03 -05:00
Nathan Chung bf7f1c7724
update debian packaging instructions
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-14 14:16:40 -05:00
Leon Zhang 194b5ec4a6 See #125 (#127)
continuous-integration/drone/push Build is passing Details
I couldn't figure out how to reopen a PR after merge, so I'm opening another one.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#127
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-11 16:33:39 -05:00
Nathan Chung c83bbe2563 update packaging version and instructions (#122)
continuous-integration/drone/push Build is passing Details
- push version to release version `1.0.30`
- add additional instructions for packaging eg. pgp, additional build dependencies
- git ignore gpg private keys

Reviewed-on: public/pyceo#122
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
Co-committed-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-11 12:20:30 -05:00
Max Erenberg 32709ad401 Revert "Fix for regression in issue #124 (#125)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3780662ba4.
2024-02-10 15:37:06 -05:00
Leon Zhang 3780662ba4 Fix for regression in issue #124 (#125)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#125
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-10 15:32:52 -05:00
Nathan Chung b1dac8ce07
1.0.30: update changelog and package uploaders
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:47:10 -05:00
Nathan Chung c5edf5ea48
update packaging version and instructions
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:24:22 -05:00
Max Erenberg a4a4ef089c Query Active Directory LDAP for alumni (#120)
continuous-integration/drone/push Build is passing Details
Closes #116.

UWLDAP has program information for current students, so we should continue using it by default.
If the sn attribute (last name) is missing from the entry, then we query ADLDAP instead.

Reviewed-on: public/pyceo#120
2024-02-01 23:57:53 -05:00
Max Erenberg bd1da799c6 Allow ceod/* principals for all requests (#121)
continuous-integration/drone/push Build is passing Details
Allow the ceod/\* principals (which should only be used by the ceod daemons) to make requests to all API endpoints.

Reviewed-on: public/pyceo#121
2024-01-28 21:37:34 -05:00
Leon Zhang 25994af312 Update available positions in configuration files, pertaining to issue #63 (#117)
continuous-integration/drone/push Build is passing Details
Updated available positions adding new positions and removing 2 unused positions in ceo. Passed Drone CI.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#117
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2024-01-22 22:00:30 -05:00
Ohm Patel de23296413 Validate usernames across tui & for create_user on cli/api (#115)
continuous-integration/drone/push Build is passing Details
Current changes should address issues raised by @merenber in #114 excluding public/pyceo#114 (comment) (both CLI and TUI validation)

* Unit test for invalid name was added but needs to be modified as regex should be changed to disallow underscores eventually.

Reviewed-on: public/pyceo#115
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: o32patel <ohm.patel@uwaterloo.ca>
Co-committed-by: o32patel <ohm.patel@uwaterloo.ca>
2024-01-22 13:15:40 -05:00
Ohm Patel f06ccdc3f9 Add username verification (#114)
continuous-integration/drone/push Build is passing Details
Move and add validation to Controller.get_username_from_view(). This addresses #101
+ Add tests for username validator

Reviewed-on: public/pyceo#114
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
Co-authored-by: o32patel <ohm.patel@uwaterloo.ca>
Co-committed-by: o32patel <ohm.patel@uwaterloo.ca>
2024-01-15 20:01:44 -05:00
Max Erenberg 5332259731 Use persistent Docker images for development (#113)
continuous-integration/drone/push Build is passing Details
This PR adds support for building Docker images during development which can be re-used multiple times. This allows us to easily run `docker-compose up` and `docker-compose down` many times without having to reinstall packages from scratch every time.

Reviewed-on: public/pyceo#113
2023-12-03 23:29:11 -05:00
Max Erenberg 392ec153d0 release 1.0.29
continuous-integration/drone/push Build is passing Details
2023-07-31 21:29:56 -04:00
Max Erenberg 36bf340385 remove ceod.postinst 2023-07-31 20:06:32 -04:00
Max Erenberg 7e851daa8f update deps 2023-07-31 19:27:45 -04:00
Max Erenberg e0ed4fa23a check that forwarding_addresses is a list 2023-07-31 18:26:06 -04:00
Max Erenberg 6786c8e44e shorten tests for group search API 2023-07-31 18:24:34 -04:00
Max Erenberg 337c05c511 release 1.0.27
continuous-integration/drone/push Build is passing Details
2023-06-09 02:51:35 -04:00
Max Erenberg 65688c72da make forwarding_addresses mandatory when creating member (#97)
continuous-integration/drone/push Build is passing Details
Closes #96.

Reviewed-on: public/pyceo#97
2023-06-09 02:39:50 -04:00
Justin Chung 968f0815c7 Add linting pre-commit hook and hook install script (#86)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Justin Chung <20733699+justin13888@users.noreply.github.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#86
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
Co-committed-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
2023-05-29 00:14:05 -04:00
Daniel Sun 010937ea17 Add group lookup functionality (#88)
continuous-integration/drone/push Build is passing Details
note: **I am unaware of best practices** but I tried my best to keep changes consistent with the codebase

feedback would be much appreciated

notable changes:
**new api endpoint**: `/groups/search` -- I moved searching into the api so it could be used in tui and cli, also seemed like a good idea to keep the json response as small as possible
**tui searching** -- at first I wanted to make this realtime interactable, but the work required seemed inappropriate to a feature I am assuming will only be used sparingly

Co-authored-by: Daniel Sun <dandancool@github.com>
Co-authored-by: Daniel Sun <d6sun@uwaterloo.ca>
Reviewed-on: public/pyceo#88
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Daniel Sun <d6sun@csclub.uwaterloo.ca>
Co-committed-by: Daniel Sun <d6sun@csclub.uwaterloo.ca>
2023-03-04 01:21:04 -05:00
Max Erenberg 234ab62f27 add accounttype=0 to CloudStack listAccounts query
continuous-integration/drone/push Build is passing Details
2023-02-18 11:16:36 -05:00
Max Erenberg f9bda2f724 release 1.0.26
continuous-integration/drone/push Build is passing Details
2023-02-13 17:43:41 -05:00
Max Erenberg 239b992107 reduce UWLDAP batch size to 10
continuous-integration/drone/push Build is passing Details
2023-02-13 17:34:49 -05:00
Max Erenberg 754731ba5f upgrade Debian dependencies
continuous-integration/drone/push Build is passing Details
2023-02-06 02:16:17 -05:00
Max Erenberg b33339817f fix logging messages for renewing a member
continuous-integration/drone/push Build is passing Details
2023-02-06 00:29:45 -05:00
Max Erenberg 6dccd8b659 release 1.0.25 2023-02-06 00:12:22 -05:00
Max Erenberg 3f58d1aff5 print warning message when cloud account is created
continuous-integration/drone/push Build is passing Details
2023-02-05 23:48:38 -05:00
Justin Chung 5e8f1b5ba5 Implement TUI support for multiple users in each position (#80)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Justin Chung <20733699+justin13888@users.noreply.github.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#80
Co-authored-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
Co-committed-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
2023-01-23 02:26:13 -05:00
Max Erenberg f84965c8e1 reload all NGINX servers after adding a vhost (#90)
continuous-integration/drone/push Build is passing Details
Currently, only the NGINX server on biloba is reloaded after adding a new vhost or renewing an SSL certificate. The NGINX server on chamomile should also be reloaded, since chamomile is a warm standby for biloba.

This PR adds a new config option in ceod.ini to specify the shell command to reload the web servers.

Reviewed-on: public/pyceo#90
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:20:55 -05:00
Max Erenberg 4394c4e277 use bullseye for base container (#91)
continuous-integration/drone/push Build is passing Details
All of the machines running ceod are on bullseye, so we don't need to support buster anymore.

Reviewed-on: public/pyceo#91
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:15:40 -05:00
Jonathan Leung b507c56136 Show groups in member for API, CLI and TUI (#82)
continuous-integration/drone/push Build is passing Details
Closes #69.

Tests are failing locally with many `assert os.geteuid() == 0` errors even on the master branch. I will add tests after I figure this out.

Reviewed-on: public/pyceo#82
Co-authored-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
Co-committed-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
2022-11-26 20:09:05 -05:00
Max Erenberg c0c9736593 Use the admin creds in the HTTPClient when necessary (#85)
continuous-integration/drone/push Build is passing Details
Currently, ceod uses the Kerberos credentials of the client when making requests to other services. This requires the client to send delegated credentials. Unfortunately the NPM krb5 package appears to be unable to perform delegation. So we will use the admin credentials instead (when appropriate).

Reviewed-on: public/pyceo#85
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-11-06 15:23:27 -05:00
Max Erenberg 1e452d10ce Assume program is Alumni if UWLDAP is missing data (#84)
continuous-integration/drone/push Build is passing Details
This PR sets 'program=Alumni' for members who either do not have an 'ou' attribute in UWLDAP, or who do not have a UWLDAP entry at all.

Reviewed-on: public/pyceo#84
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
2022-11-01 21:02:05 -04:00
Max Erenberg 6a1fa81b82 merenber signs the packages
continuous-integration/drone/push Build is passing Details
Something went wrong when e42zhang tried to upload the packages to the
mirror. reprepro kept on complaining that no distribution would accept
the new package. I modified the changelog, re-signed and re-uploaded
the packages, and that worked, so I'm still not sure what the problem
was.
2022-10-23 22:04:32 -04:00
Max Erenberg 6df1f4d459 Revert "Simplify packaging"
This reverts commit b4a1373559.
2022-10-23 22:00:48 -04:00
Edwin 2cf9e25b59 More fixes
continuous-integration/drone/push Build is passing Details
2022-10-23 21:16:58 -04:00
Edwin 9ff3d850c9 Release 1.0.24 2022-10-23 19:50:38 -04:00
Edwin b4a1373559 Simplify packaging 2022-10-23 19:50:06 -04:00
Raymond Li dceb5d6572
Revert "#63: Add positions to CEO (#79)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3b7c89c925.
2022-10-13 18:12:36 -04:00
Jonathan Leung c30ca54752 Sort group member listing by WatIAM ID (#78)
continuous-integration/drone/push Build is failing Details
Closes #74.

Co-authored-by: Jono <jowonowo@gmail.com>
Reviewed-on: public/pyceo#78
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
Co-committed-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
2022-10-13 14:58:50 -04:00
Nathan Chung 3b7c89c925 #63: Add positions to CEO (#79)
continuous-integration/drone/push Build is failing Details
public/pyceo#63

Added the following positions:
* ext affairs lead
* marketing lead
* design lead
* events lead
* reps lead
* mods lead
* photography lead
* other

Signed-off-by: n4chung <n4chung@csclub.uwaterloo.ca>

Co-authored-by: n4chung <n4chung@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#79
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
Co-committed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2022-10-13 14:58:34 -04:00
Max Erenberg a2324090f3 update README instructions for curl
continuous-integration/drone/push Build is passing Details
2022-10-12 01:56:54 -04:00
Max Erenberg f66f7c8f5a remove override_dh_systemd_start
continuous-integration/drone/push Build is passing Details
2022-10-09 17:52:37 -04:00
Max Erenberg 3e5b829085 check if mail_local_addresses exists in UWLDAP entry
continuous-integration/drone/push Build is passing Details
2022-10-07 08:13:59 -04:00
Rio Liu 57ba72ef26 Add support for using number in member terms renwewal API (#77)
continuous-integration/drone/push Build is passing Details
Closed #75

Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#77
Co-authored-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
Co-committed-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
2022-10-07 07:58:23 -04:00
Max Erenberg 779e35a08e fix shadowExpire deserialization
continuous-integration/drone/push Build is passing Details
2022-09-10 16:01:40 -04:00
Raymond Li 3cc9b011c3 Use HTTPS in sample
continuous-integration/drone/push Build is passing Details
2022-09-10 14:18:17 -04:00
Max Erenberg 2739c45aff use LDAP instead of NSS for authz (#73)
continuous-integration/drone/push Build is passing Details
Closes #71.

Reviewed-on: public/pyceo#73
2022-09-09 17:26:54 -04:00
Max Erenberg 651f4fb702 add more logging (#72)
continuous-integration/drone/push Build is passing Details
Closes #70.

Reviewed-on: public/pyceo#72
2022-09-09 14:42:43 -04:00
Max Erenberg 953bee549e fix tests
continuous-integration/drone/push Build is passing Details
2022-09-05 17:34:28 -04:00
Max Erenberg 0334e7e667 fix email formatting bug in ClubWebHostingService
continuous-integration/drone/push Build is failing Details
2022-09-05 15:37:35 -04:00
Max Erenberg 8decd3bc30 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-09-05 00:36:50 -04:00
Max Erenberg 8ad8271db1 Fix some bugs in ClubWebHostingService
continuous-integration/drone/push Build is passing Details
* Don't read the value of an Apache directive unless we are sure
  it can only accept one argument
* Handle the case where a club's www directory is not readable
2022-09-04 23:22:28 -04:00
Raymond Li 4ebb9bb0a8
Release v1.0.22
continuous-integration/drone/push Build is passing Details
2022-08-06 02:20:46 +00:00
Max Erenberg cfb5f77711 Disable inactive club sites (#68)
continuous-integration/drone/push Build is passing Details
Closes #51.

An API argument called `remove_inactive_club_reps` was added so that we can dynamically control whether we want to remove inactive club reps or not. The default action is only to disable club websites without changing group membership.

Reviewed-on: public/pyceo#68
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-07-22 23:51:59 -04:00
Max Erenberg 32b2dbb307 Allow addmember and removemember to accept multiple usernames (#67)
continuous-integration/drone/push Build is passing Details
Closes #57.

Reviewed-on: public/pyceo#67
2022-07-02 11:26:38 -04:00
Max Erenberg dc412ef5cb implement renewal reminders (#61)
continuous-integration/drone/push Build is passing Details
Closes #55.

Once this is merged and deployed, a cron job will be used to automatically run `ceo members remindexpire` at the beginning of every term.

Reviewed-on: public/pyceo#61
2022-06-30 20:02:06 -04:00
Max Erenberg dbb5bf1c8d packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-06-09 10:10:31 -04:00
Max Erenberg 00c7d562ad fix URL bug in ContainerRegistryService
continuous-integration/drone/push Build is passing Details
2022-06-09 09:07:12 -04:00
Max Erenberg 6fae2e4115 use quote_plus for signing CloudStack API requests
continuous-integration/drone/push Build is passing Details
2022-06-09 01:23:04 -04:00
Max Erenberg 1fc432bb0f add hint to GetGroupResponseView
continuous-integration/drone/push Build is passing Details
2022-06-05 10:20:22 -04:00
Max Erenberg 2678bdf16e packaging for bullseye and focal
continuous-integration/drone/push Build is passing Details
2022-06-04 22:47:05 -04:00
Rio Liu 55c4b2151d Unsubscribe/resubscribe members when they're expired and renewed (#53)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#53
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2022-06-02 02:06:49 -04:00
Max Erenberg 87470e1f3b don't reset password for local MySQL users
continuous-integration/drone/push Build is passing Details
2022-05-23 23:01:09 -04:00
Max Erenberg b543f0eb0c Rewrite TUI (#52)
continuous-integration/drone/push Build is passing Details
Closes #44.
Closes #47.
Closes #49.
Closes #50.

The TUI has been rewritten using urwid instead of asciimatics. The MVC pattern was also used to help increase organization and readability.

The mouse has been disabled, which allows users to easily copy text from the terminal.

Terms are now sorted when displayed, for both the CLI and the TUI.

Reviewed-on: public/pyceo#52
2022-05-22 14:09:46 -04:00
Max Erenberg 19496b4568 use mailman for mailman_host
continuous-integration/drone/push Build is failing Details
2022-05-01 03:39:17 -04:00
Raymond Li 8da700472f Update 'ceod/model/templates/welcome_message.j2'
continuous-integration/drone/push Build is passing Details
2022-03-18 21:35:32 -04:00
Max Erenberg 5197228d68 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-03-13 08:03:23 -04:00
Max Erenberg 9b8425f30e bump version 2022-03-12 21:26:55 -05:00
Max Erenberg f3c542208a send cloud warning emails to root
continuous-integration/drone/push Build is passing Details
2022-03-12 16:09:19 -05:00
Max Erenberg 2487ab3668 update security section of docs 2022-03-12 15:50:42 -05:00
Max Erenberg 539de01c4d use admin GSSAPI creds for some API endpoints (#45)
continuous-integration/drone/push Build is passing Details
Office staff currently can't sign up new members because ceod uses their GSSAPI credentials to authenticate to LDAP, and those credentials are insufficient.

This PR uses the ceod/admin credentials instead for signing up new members and for renewing existing memberships.

Reviewed-on: public/pyceo#45
2022-03-12 15:19:14 -05:00
Max Erenberg af4e342f3c Add ':z' to Docker volume mounts
continuous-integration/drone/push Build is failing Details
On systems with SELinux enabled (e.g. Fedora), Docker/Podman cannot
access directories on the host by default.
2022-03-12 14:31:12 -05:00
Max Erenberg 00ced22950 add script to extend a term
continuous-integration/drone/push Build is passing Details
2022-01-15 23:44:23 -05:00
Max Erenberg 5200259cfa allow loginShell to be optional
continuous-integration/drone/push Build is passing Details
2022-01-10 01:32:26 -05:00
Max Erenberg 7d3e03e7fd increase retries for Postfix
continuous-integration/drone/push Build is passing Details
2022-01-07 23:06:58 -05:00
Max Erenberg 71e6b474a4 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-01-05 01:32:58 -05:00
Max Erenberg 5351cf8aee Revert "don't subscribe club reps to csc-general"
This reverts commit fa05c4ad4a.
2022-01-05 01:28:05 -05:00
Max Erenberg 2ee9511337 set HOME environment variable in /etc/default/ceod
continuous-integration/drone/push Build is passing Details
2022-01-05 00:54:40 -05:00
Max Erenberg feb16ee625 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-01-05 00:26:41 -05:00
Max Erenberg 0c166f93ad bump version
continuous-integration/drone/push Build is passing Details
2022-01-05 00:17:51 -05:00
Max Erenberg ef45344724 Revert "use Kubernetes runner"
This reverts commit 28b5000e89.
2022-01-05 00:17:04 -05:00
Max Erenberg 28b5000e89 use Kubernetes runner
continuous-integration/drone/push Build was killed Details
2022-01-04 23:54:51 -05:00
Max Erenberg 7908d49840 include ACME challenge location snippet in NGINX template 2022-01-04 23:53:44 -05:00
Max Erenberg 41d293ee3b add retry mechanism when sending email to new user
continuous-integration/drone/push Build is passing Details
2022-01-04 23:45:04 -05:00
Max Erenberg fa05c4ad4a don't subscribe club reps to csc-general
continuous-integration/drone/push Build is passing Details
2022-01-03 20:43:58 -05:00
Max Erenberg 02598fa3bc allow ignored Harbor projects to be configurable
continuous-integration/drone/push Build is passing Details
2022-01-02 22:43:22 -05:00
Max Erenberg 7ec17b2b4d chmod 600 the kubeconfig 2022-01-02 18:49:11 -05:00
Raymond Li 5f93b0e912 Update 'README.md'
continuous-integration/drone/push Build is passing Details
2022-01-02 16:06:37 -05:00
Raymond Li 7cb07547fa Update 'README.md'
continuous-integration/drone/push Build is passing Details
2022-01-02 16:06:03 -05:00
Raymond Li f45efefaca Autofill set positions (#41)
continuous-integration/drone/push Build is passing Details
Implements #40

Co-authored-by: Raymond Li <hi@raymond.li>
Reviewed-on: public/pyceo#41
Co-authored-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-committed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
2022-01-02 02:41:28 -05:00
Raymond Li d7b6ac2307 Update 'PACKAGING.md'
continuous-integration/drone/push Build is passing Details
2022-01-01 22:36:43 -05:00
Raymond Li 70d27c5817 Update 'PACKAGING.md'
continuous-integration/drone/push Build is passing Details
2022-01-01 22:34:39 -05:00
Max Erenberg 88b40b79cc Don't expire syscom members (#43)
continuous-integration/drone/push Build is passing Details
Closes #37.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#43
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-01-01 12:15:32 -05:00
Max Erenberg 1e94132e97 Add container registry API (#42)
continuous-integration/drone/push Build is passing Details
Add an API for members to create a project on Harbor.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#42
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-01-01 00:49:05 -05:00
Max Erenberg d200d3d6cf add packaging for bullseye and move Packaging documentation
continuous-integration/drone/push Build is passing Details
2021-12-25 12:43:05 -05:00
Max Erenberg 5e03ff932f bump version 2021-12-25 11:52:44 -05:00
Max Erenberg 0422e4487b fix flaky test for vhost rate limiting
continuous-integration/drone/push Build is passing Details
2021-12-25 11:31:54 -05:00
Max Erenberg 6e96e409be add (objectClass=member) filter for expired members 2021-12-25 11:23:06 -05:00
d278liu 250d24ae37 use binary search when finding new uid (#39)
continuous-integration/drone/push Build is passing Details
closes #36

Co-authored-by: Daniel Liu <mr.picklepinosaur@gmail.com>
Reviewed-on: public/pyceo#39
Co-authored-by: d278liu <d278liu@localhost>
Co-committed-by: d278liu <d278liu@localhost>
2021-12-23 17:00:27 -05:00
Max Erenberg 0640337564 Add ROOT environment variable to /etc/default/ceod
continuous-integration/drone/push Build is passing Details
kubectl was failing because it couldn't find the kubeconfig in
/root/.kube/config
2021-12-18 17:45:20 -05:00
Max Erenberg afb63f44dc add packaging for buster and bullseye 2021-12-18 16:59:13 -05:00
Max Erenberg 19c860b4ed bump version 2021-12-18 16:42:26 -05:00
Max Erenberg f08f4872cf Add Kubernetes API endpoint (#38)
continuous-integration/drone/push Build is passing Details
Add an API for members to create their own Kubernetes namespace.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#38
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-12-18 16:35:05 -05:00
Rio Liu b4110d887d Expire member cli and api (#33)
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
Closes #23

Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#33
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-12-11 16:30:18 -05:00
Max Erenberg f1c0ce3dd6 packaging for buster and bullseye 2021-11-28 23:22:16 -05:00
Max Erenberg 1338825c5d use NGINX with acme.sh 2021-11-28 22:35:46 -05:00
Max Erenberg 3a30f45672 add packaging for buster and bullseye 2021-11-28 15:42:59 -05:00
Max Erenberg bd50f4142f use Caddy instead of NGINX for vhosts 2021-11-28 15:21:48 -05:00
Max Erenberg 0d55f01bfc packaging for buster 2021-11-27 18:23:32 -05:00
Max Erenberg e71d9b7d30 packaging for bullseye 2021-11-27 18:10:52 -05:00
Max Erenberg aa2efcb26a use master branch in CI badge 2021-11-27 18:01:18 -05:00
Max Erenberg a7c5098b67 Add cloud vhost API (#35)
continuous-integration/drone/push Build is passing Details
Add an API for members to create their own virtual hosts.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#35
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-11-27 17:59:21 -05:00
Max Erenberg 0798419e34 packaging for buster
continuous-integration/drone/push Build is passing Details
2021-11-21 12:31:11 -05:00
Max Erenberg 7306241a78 packaging for bullseye 2021-11-21 12:06:07 -05:00
Max Erenberg eda5ca576a add cloud API to docs 2021-11-21 11:53:25 -05:00
Max Erenberg ac98aaf38d Add API to manage cloud accounts (#34)
continuous-integration/drone/push Build is passing Details
This PR adds API endpoints and a CLI to create cloud accounts and to purge accounts of expired members.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#34
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-11-21 11:11:20 -05:00
Max Erenberg 798510511f fix first/last name script
continuous-integration/drone/push Build is passing Details
2021-11-07 01:12:49 -04:00
Max Erenberg ed9893604f fix lint error
continuous-integration/drone/push Build is passing Details
2021-11-03 21:23:16 -04:00
Max Erenberg 52db130ef8 update docs for packaging in a container
continuous-integration/drone/push Build is failing Details
2021-11-02 20:34:44 -04:00
Max Erenberg 99819ce4fe go back to symlinks 2021-11-02 20:30:56 -04:00
Max Erenberg 89febf0400 use python binaries in virtualenv when packaging 2021-11-02 20:11:42 -04:00
Max Erenberg 620ef8ef8e fix UWLDAP multiple-UID bug 2021-11-02 19:02:07 -04:00
Max Erenberg ae48bcd98a fix bug in email sender address
continuous-integration/drone/push Build is passing Details
2021-11-02 03:05:46 -04:00
Max Erenberg 1f107b0614 use port 465 for smtps
continuous-integration/drone/push Build is passing Details
2021-11-02 02:46:05 -04:00
Max Erenberg f9f5d70ad3 use pip install . instead of python setup.py install 2021-11-02 02:07:15 -04:00
Max Erenberg bdc2f9b31b include Jinja templates in MANIFEST.in 2021-11-02 01:53:53 -04:00
Max Erenberg 729f443e72 move term check to LDAPService instead of User constructor 2021-11-02 01:30:01 -04:00
Max Erenberg dbbc533111 add cryptography package to requirements.txt 2021-11-02 00:35:26 -04:00
Max Erenberg 57787c170a remove v1 from drone.yml
continuous-integration/drone/push Build is passing Details
2021-11-01 22:11:16 -04:00
Max Erenberg beec46a951 re-run CI
continuous-integration/drone/push Build is passing Details
2021-10-29 00:18:16 -04:00
Max Erenberg 3218938909 add suggestions from ehashman
continuous-integration/drone/push Build is failing Details
2021-10-28 22:27:03 -04:00
Max Erenberg 2493bb1a6b modify CSC schema instead of using inetOrgPerson
continuous-integration/drone/push Build is passing Details
2021-10-28 21:33:32 -04:00
Max Erenberg 02aff43e7f Add debian packaging (#32)
continuous-integration/drone/push Build is failing Details
Closes #31.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#32
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-28 20:52:19 -04:00
Max Erenberg 2970736105 place CI fix into common.sh
continuous-integration/drone/push Build is passing Details
2021-10-25 08:18:25 -04:00
Max Erenberg a0cc29738b change perms on /tmp in CI container
continuous-integration/drone/push Build is passing Details
2021-10-25 08:09:07 -04:00
Max Erenberg 3e1af74f0c re-run CI
continuous-integration/drone/push Build is failing Details
2021-10-25 07:42:04 -04:00
Max Erenberg a5bab379e2 Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-25 07:41:52 -04:00
Max Erenberg ac573039da add mailLocalAddress to each record (#30)
continuous-integration/drone/push Build is failing Details
Closes #26.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#30
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-25 07:36:25 -04:00
Max Erenberg 1374ff95aa Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-24 07:20:10 -04:00
Max Erenberg 23f40c74f9 Use inetOrgPerson instead of account (#29)
continuous-integration/drone/push Build is passing Details
Closes #25.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#29
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-23 23:21:09 -04:00
Max Erenberg ac7f41801b Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-23 10:24:44 -04:00
Max Erenberg e3c50d867a Add isClubRep attribute (#27)
continuous-integration/drone/push Build is passing Details
Closes #24.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#27
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-23 10:23:43 -04:00
Max Erenberg 1fbc068f3b update README 2021-10-05 00:10:15 -04:00
Max Erenberg 1fcc49ef12 add documentation (#22)
continuous-integration/drone/push Build is passing Details
Add OpenAPI spec and man pages

Co-authored-by: Max Erenberg <>
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Andrew Wang <a268wang@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#22
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-05 00:07:10 -04:00
Max Erenberg 3e1131c4e4 no-resizing (#21)
continuous-integration/drone/push Build is passing Details
This PR disables resizing the TUI.
Unfortunately this is a regression from the old ceo. But trying to preserve state when destroying and creating new views in asciimatics proved to be very difficult.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#21
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-04 20:04:05 -04:00
Rio Liu 7edc01e42b Positions TUI (#20)
continuous-integration/drone/push Build is passing Details
Closes #17

Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#20
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-09-26 15:23:47 -04:00
Max Erenberg 2a5d903eba add mailman CLI command
continuous-integration/drone/push Build is passing Details
2021-09-25 15:16:22 -04:00
Max Erenberg 3cba9680f5 test that email is sent when user is created 2021-09-25 13:56:23 -04:00
Max Erenberg 749ca41080 use custom TransactionView for AddUser
continuous-integration/drone/push Build is passing Details
2021-09-17 23:46:12 -04:00
Max Erenberg de18a9f293 add option to use Docker instead of VM (#16)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Max Erenberg <>
Co-authored-by: Rio <r345liu@localhost>
Reviewed-on: public/pyceo#16
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-09-17 22:39:27 -04:00
Max Erenberg 652620a7c5 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-12 09:36:54 -04:00
Max Erenberg 155c96c500 implement Database views in TUI
continuous-integration/drone/push Build is failing Details
2021-09-12 02:08:15 -04:00
Max Erenberg ad38588141 use single ListBox in WelcomeView
continuous-integration/drone/push Build is passing Details
2021-09-11 17:24:23 -04:00
Andrew Wang 33323fd112 Add database CLI (#15)
continuous-integration/drone/push Build is passing Details
Closes #12

Co-authored-by: Andrew Wang <a268wang@csclub.uwaterloo.ca>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#15
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-09-11 13:33:43 -04:00
Max Erenberg cb6243c3e2 remove unused handler
continuous-integration/drone/push Build is passing Details
2021-09-08 22:29:36 +00:00
Neil Parikh 6e2b9dee24 Update discord link (#13)
continuous-integration/drone/push Build is passing Details
Co-authored-by: n3parikh <n3parikh@localhost>
Reviewed-on: public/pyceo#13
Co-authored-by: n3parikh <n3parikh@csclub.uwaterloo.ca>
Co-committed-by: n3parikh <n3parikh@csclub.uwaterloo.ca>
2021-09-08 18:23:29 -04:00
Rio Liu 651988bb08 Positions CLI (#11)
continuous-integration/drone/push Build is passing Details
Closes #9

Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#11
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-09-08 09:32:34 -04:00
Max Erenberg 82b7b2c015 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-08 04:11:03 +00:00
Max Erenberg 0bf24230a0 add global quit button
continuous-integration/drone/push Build is failing Details
2021-09-08 04:10:21 +00:00
Max Erenberg 4aaf10b687 add Databases and Positions menus 2021-09-08 03:38:12 +00:00
Max Erenberg df7148940a implement RemoveMemberFromGroupView
continuous-integration/drone/push Build is passing Details
2021-09-08 03:20:51 +00:00
Max Erenberg 21173d1b8c implement AddMemberToGroupView 2021-09-08 02:59:56 +00:00
Max Erenberg beb16b1740 implement GetGroupView 2021-09-07 05:22:20 +00:00
Max Erenberg 6b3ad28e89 implement AddGroupView
continuous-integration/drone/push Build is passing Details
2021-09-07 05:02:34 +00:00
Max Erenberg ebaeeaaf13 implement ChanageLoginShellView and SetForwardingAddressesView
continuous-integration/drone/push Build is failing Details
2021-09-07 04:16:29 +00:00
Max Erenberg a08fca4c60 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-07 03:05:13 +00:00
Max Erenberg 1406899ea2 implement ResetPasswordView
continuous-integration/drone/push Build is failing Details
2021-09-07 03:03:30 +00:00
Max Erenberg d3c98e418a implement GetUser in TUI 2021-09-07 02:29:53 +00:00
Max Erenberg af73dd713d add flash_message 2021-09-06 20:16:45 +00:00
Max Erenberg 39158676ae use CeoFrame as parent class for TransactionView
continuous-integration/drone/push Build is passing Details
2021-09-06 16:48:07 +00:00
Max Erenberg 40ee927b91 Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-09-06 16:42:13 +00:00
Max Erenberg ee21873ad7 implement membership renewals in TUI 2021-09-06 16:40:05 +00:00
Max Erenberg cce920d6ba save view state in model 2021-09-05 22:48:20 +00:00
Andrew Wang c6c01d8720 allow mysql connections from unix socket (#14)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Andrew Wang <someone.zip@gmail.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#14
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-09-04 22:25:37 -04:00
Max Erenberg 6f1851fc19 Merge branch 'v1' into tui
continuous-integration/drone/push Build is failing Details
2021-09-04 23:57:50 +00:00
Max Erenberg bb56870652 add skeleton for TUI 2021-09-04 23:05:19 +00:00
302 changed files with 17619 additions and 915 deletions

View File

@ -5,37 +5,36 @@ name: default
steps:
# use the step name to mock out the gethostname() call in our tests
- name: phosphoric-acid
image: python:3.7-buster
image: python:3.9-slim-bullseye
# unfortunately we have to do everything in one step because there's no
# way to share system packages between steps
commands:
# install dependencies
- apt update && apt install -y libkrb5-dev libpq-dev python3-dev
- apt update
- apt install --no-install-recommends -y gcc libkrb5-dev libaugeas0
- python3 -m venv venv
- . venv/bin/activate
- pip install -r dev-requirements.txt
- pip install -r requirements.txt
- venv/bin/pip install -r dev-requirements.txt -r requirements.txt
# lint
- flake8
# unit + integration tests
- .drone/phosphoric-acid-setup.sh
- bash -c ". .drone/phosphoric-acid-setup.sh && IMAGE__setup && CONTAINER__setup"
- pytest -v
services:
- name: auth1
image: debian:buster
image: debian:bullseye-slim
commands:
- .drone/auth1-setup.sh
- bash -c ". .drone/auth1-setup.sh && IMAGE__setup && CONTAINER__setup"
- sleep infinity
- name: coffee
image: debian:buster
image: debian:bullseye-slim
commands:
- .drone/coffee-setup.sh
- bash -c ". .drone/coffee-setup.sh && IMAGE__setup && CONTAINER__setup"
- sleep infinity
trigger:
branch:
- master
- v1

17
.drone/adldap_data.ldif Normal file
View File

@ -0,0 +1,17 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

View File

@ -4,80 +4,128 @@ set -ex
. .drone/common.sh
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1
# If we don't do this then OpenLDAP uses a lot of RAM
ulimit -n 1024
# I'm not sure why, but we also need to remove the hosts entry for the
# container's real hostname, otherwise slapd only looks for the principal
# ldap/<container hostname> (this is with the sasl-host option)
sed -E "/\\b$(hostname)\\b/d" /etc/hosts > /tmp/hosts
cat /tmp/hosts > /etc/hosts
rm /tmp/hosts
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1
if [ -n "$CI" ]; then
# I'm not sure why, but we also need to remove the hosts entry for the
# container's real hostname, otherwise slapd only looks for the principal
# ldap/<container hostname> (this is with the sasl-host option)
sed -E "/\\b$(hostname)\\b/d" /etc/hosts > /tmp/hosts
cat /tmp/hosts > /etc/hosts
rm /tmp/hosts
fi
}
export DEBIAN_FRONTEND=noninteractive
apt update
apt install -y psmisc
IMAGE__setup_ldap() {
# In the "slim" Docker images, /usr/share/doc/* is excluded by default
echo 'path-include /usr/share/doc/sudo-ldap/schema.OpenLDAP' > /etc/dpkg/dpkg.cfg.d/zz-ceo
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
# `service slapd stop` doesn't seem to work
killall slapd || true
service nslcd stop || true
rm -rf /etc/ldap/slapd.d
rm /var/lib/ldap/*
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \
echo 'export EDITOR=vim' >> /root/.bashrc
grep -q 'alias ldapvi' /root/.bashrc || \
echo 'alias ldapvi="ldapvi -Y EXTERNAL -h ldapi:///"' >> /root/.bashrc
fi
}
# LDAP
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
# `service slapd stop` doesn't seem to work
killall slapd || true
service nslcd stop || true
rm -rf /etc/ldap/slapd.d
rm /var/lib/ldap/*
cp /usr/share/slapd/DB_CONFIG /var/lib/ldap/DB_CONFIG
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/rfc2307bis.schema /etc/ldap/schema/
cp .drone/csc.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
service nslcd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
# KERBEROS
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
cp .drone/krb5.conf /etc/krb5.conf
cp .drone/kdc.conf /etc/krb5kdc.conf
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm -f /var/lib/krb5kdc/*
echo -e 'krb5\nkrb5' | krb5_newrealm
service krb5-kdc start
service krb5-admin-server start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin.local
IMAGE__setup_krb5() {
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
cp .drone/krb5.conf /etc/krb5.conf
cp .drone/kdc.conf /etc/krb5kdc.conf
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm -f /var/lib/krb5kdc/*
echo -e 'krb5\nkrb5' | krb5_newrealm
service krb5-kdc start
service krb5-admin-server start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin.local
addpol -minlength 4 default
addprinc -pw krb5 sysadmin/admin
addprinc -pw krb5 ctdalek
addprinc -pw krb5 regular1
addprinc -randkey ceod/admin
addprinc -randkey host/auth1.csclub.internal
addprinc -randkey ldap/auth1.csclub.internal
ktadd host/auth1.csclub.internal
ktadd ldap/auth1.csclub.internal
EOF
groupadd keytab || true
chgrp keytab /etc/krb5.keytab
chmod 640 /etc/krb5.keytab
usermod -a -G keytab openldap
usermod -a -G sasl openldap
cat <<EOF > /usr/lib/sasl2/slapd.conf
# Add all of the people defined in data.ldif
for princ in ctdalek exec1 regular1 office1; do
echo "addprinc -pw krb5 $princ" | kadmin.local
done
groupadd keytab || true
chgrp keytab /etc/krb5.keytab
chmod 640 /etc/krb5.keytab
usermod -a -G keytab openldap
usermod -a -G sasl openldap
cat <<EOF > /usr/lib/sasl2/slapd.conf
mech_list: plain login gssapi external
pwcheck_method: saslauthd
EOF
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
service saslauthd start
killall slapd && sleep 0.5 && service slapd start
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
}
# sync with phosphoric-acid
apt install -y netcat-openbsd
nc -l 0.0.0.0 9000
IMAGE__setup() {
# slapd needs /etc/hosts to be setup properly
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
apt update
# for the 'killall' command
apt install -y psmisc
IMAGE__setup_ldap
IMAGE__setup_krb5
IMAGE__common_setup
service slapd stop || true
killall slapd || true
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
local started_slapd=false
for i in {1..5}; do
if service slapd start; then
started_slapd=true
break
fi
sleep 1
done
if [ $started_slapd != "true" ]; then
echo "Failed to start slapd" >&2
return 1
fi
service krb5-admin-server start
service krb5-kdc start
service saslauthd start
service nslcd start
# Let other containers know that we're ready
nc -l -k 0.0.0.0 9000 &
}

View File

@ -4,45 +4,64 @@ set -ex
. .drone/common.sh
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
export DEBIAN_FRONTEND=noninteractive
apt update
IMAGE__setup() {
IMAGE__ceod_setup
apt install --no-install-recommends -y default-mysql-server postgresql
apt install --no-install-recommends -y default-mysql-server postgresql
service mysql stop
sed -E -i 's/^(bind-address[[:space:]]+= 127.0.0.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mysql start
cat <<EOF | mysql
CREATE USER 'mysql' IDENTIFIED BY 'mysql';
# MYSQL
service mariadb stop
sed -E -i 's/^(bind-address[[:space:]]+= 127\.0\.0\.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mariadb start
cat <<EOF | mysql
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
EOF
service postgresql stop
POSTGRES_DIR=/etc/postgresql/11/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# POSTGRESQL
service postgresql stop
local POSTGRES_DIR=/etc/postgresql/*/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres localhost md5
host all postgres 0.0.0.0/0 md5
host all postgres ::/0 md5
local all all peer
host all all localhost md5
local sameuser all md5
local sameuser all peer
host sameuser all 0.0.0.0/0 md5
host sameuser all ::/0 md5
EOF
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
service postgresql start
su -c "
cat <<EOF | psql
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
service postgresql start
su -c "
cat <<EOF | psql
ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres;
EOF" postgres
# sync with phosphoric-acid
apt install -y netcat-openbsd
nc -l 0.0.0.0 9000
service mariadb stop || true
service postgresql stop || true
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
if [ -z "$CI" ]; then
CONTAINER__auth_setup coffee
fi
service mariadb start
service postgresql start
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

View File

@ -1,17 +1,116 @@
# don't resolve container names to *real* CSC machines
sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /tmp/resolv.conf
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
export DEBIAN_FRONTEND=noninteractive
# The IMAGE__ functions should be called when building the image.
# The CONTAINER__ functions should be called when running an instance of the
# image in a container.
IMAGE__auth_setup() {
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
mkdir -p /etc/ldap
cp .drone/ldap.conf /etc/ldap/ldap.conf
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
}
IMAGE__common_setup() {
apt update
# netcat is used for synchronization between the containers
apt install -y netcat-openbsd
IMAGE__auth_setup
}
IMAGE__ceod_setup() {
IMAGE__common_setup
# ceod uses Augeas, which is not installed by default in the Python
# Docker container
apt install -y libaugeas0
}
CONTAINER__fix_resolv_conf() {
# don't resolve container names to *real* CSC machines
sed -E 's/([[:alnum:]-]+\.)*uwaterloo\.ca//g' /etc/resolv.conf > /tmp/resolv.conf
# remove empty 'search' lines, if we created them
sed -E -i '/^search[[:space:]]*$/d' /tmp/resolv.conf
# also remove the 'rotate' option, since this can cause the Docker DNS server
# to be circumvented
sed -E -i '/^options.*\brotate/d' /tmp/resolv.conf
# we can't replace /etc/resolv.conf using 'mv' because it's mounted into the container
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
}
CONTAINER__auth_setup() {
local hostname=$1
sync_with auth1
service nslcd start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
}
CONTAINER__ceod_setup() {
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
}
# Common utility functions
get_ip_addr() {
getent hosts $1 | cut -d' ' -f1
# There appears to be a bug in newer versions of Podman where using both
# --name and --hostname causes a container to have two identical DNS
# entries, which causes `getent hosts` to print two lines.
# So we use `head -n 1` to select just the first line.
getent hosts $1 | head -n 1 | cut -d' ' -f1
}
add_fqdn_to_hosts() {
ip_addr=$1
hostname=$2
local ip_addr=$1
local hostname=$2
sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts
# we can't replace /etc/hosts using 'mv' because it's mounted into the container
cp /tmp/hosts /etc/hosts
rm /tmp/hosts
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
}
sync_with() {
local host=$1
local port=9000
local synced=false
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
for i in {1..240}; do
if nc -vz $host $port ; then
synced=true
break
fi
sleep 5
done
test $synced = true
}

View File

@ -20,10 +20,15 @@ attributetype ( 1.3.6.1.4.1.27934.1.1.5 NAME 'nonMemberTerm'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{5} )
attributetype ( 1.3.6.1.4.1.27934.1.1.6 NAME 'isClubRep'
EQUALITY booleanMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 )
objectclass ( 1.3.6.1.4.1.27934.1.2.1 NAME 'member'
SUP top AUXILIARY
MUST ( cn $ uid )
MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position ) )
MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position $
isClubRep $ sn $ givenName ) )
objectclass ( 1.3.6.1.4.1.27934.1.2.2 NAME 'club'
SUP top AUXILIARY

View File

@ -61,6 +61,7 @@ objectClass: posixGroup
gidNumber: 10003
cn: office
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
uniqueMember: uid=office1,ou=People,dc=csclub,dc=internal
dn: cn=src,ou=Group,dc=csclub,dc=internal
objectClass: top
@ -80,6 +81,8 @@ uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: uid=ctdalek,ou=People,dc=csclub,dc=internal
cn: Calum Dalek
givenName: Calum
sn: Dalek
userPassword: {SASL}ctdalek@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/ctdalek
@ -92,7 +95,7 @@ objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: s2021
term: f2021
dn: cn=ctdalek,ou=Group,dc=csclub,dc=internal
objectClass: top
@ -103,6 +106,8 @@ gidNumber: 20001
dn: uid=regular1,ou=People,dc=csclub,dc=internal
cn: Regular One
givenName: Regular
sn: One
userPassword: {SASL}regular1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/regular1
@ -115,7 +120,7 @@ objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: s2021
term: f2021
dn: cn=regular1,ou=Group,dc=csclub,dc=internal
objectClass: top
@ -123,3 +128,86 @@ objectClass: group
objectClass: posixGroup
cn: regular1
gidNumber: 20002
dn: uid=exec1,ou=People,dc=csclub,dc=internal
cn: Exec One
givenName: Exec
sn: One
userPassword: {SASL}exec1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/exec1
uid: exec1
uidNumber: 20003
gidNumber: 20003
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=exec1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: exec1
gidNumber: 20003
dn: cn=exec,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: exec
gidNumber: 10013
uniqueMember: uid=exec1,ou=People,dc=csclub,dc=internal
dn: uid=office1,ou=People,dc=csclub,dc=internal
cn: Office One
givenName: Office
sn: One
userPassword: {SASL}office1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/office1
uid: office1
uidNumber: 20004
gidNumber: 20004
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=office1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: office1
gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

19
.drone/k8s-authority.crt Normal file
View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC/jCCAeagAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
cm5ldGVzMB4XDTIxMTIwNDIxNDcxOVoXDTMxMTIwMjIxNDcxOVowFTETMBEGA1UE
AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN50
H4RcrV5ZDDqT5XMfN1ml8MalyMDAG8mE+lNT1rsUGBUp2jhNfG0OpFUm55yGarI9
2BrNGXLyFGm3yy6MWJorSUqaSBzt9+JHtBDVQwCgTX9PYSX1X/kFNQFLZkNrMtO4
417WELlkl9miCWWmTPOZAMYZWbnRKrndd3MsrhOcuDwqT5rX+LLl6VktWx5+qmuc
49sd3fWJ1MxLZ+Q6/Eo5jPuPVOPl8wLcwf9MD0rgRMVU+XycwDKr/3vmBbs22hiw
PcWIPHugAy4PRbiWfHOymO+c4WSCCS7nre3mIAyXuT0EEPDnEnrkbYoSuwIJ0tLp
N8/6vaLbBfO5ckAU2VUCAwEAAaNZMFcwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB
/wQFMAMBAf8wHQYDVR0OBBYEFNqlikMIHwY+A1/PHzwPB0CtSLX+MBUGA1UdEQQO
MAyCCmt1YmVybmV0ZXMwDQYJKoZIhvcNAQELBQADggEBAJ2j87US8VTVTFoayNSk
mzip60VzgKxawi/lP1F0/JqCHtdcaA/JmlN8FggzaSxS6AA/gxNTriLNLedhqgNF
f5F5Lq0bQAebzbijsEMr+wGE6zYBgg2L0u55jqSSU1Quhay83eCD0b0O3XHGdzg0
29jC+r8pOYWuwCBaIU8NN8EouHbQ25jqJAPLCIjuqPSEPfxjZla9f2ZO7Zpx+Yud
jDYHz9ZwBYmeR7Z74/oStJ+eIFfwlJKIQL0QFzKgw2KUHmmzHVxpx60rajiGNAb8
7FNPWTjIYX11Hy56jZAUirfwCak1IxfI8O0/X1LzVPCs7uaE1SG8TCsJgjrD2Nwm
2w4=
-----END CERTIFICATE-----

30
.drone/mail-setup.sh Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
set -ex
. .drone/common.sh
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
IMAGE__setup() {
IMAGE__ceod_setup
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
CONTAINER__auth_setup mail
# for the VHostManager
mkdir -p /run/ceod/member-vhosts
# mock services
venv/bin/python -m tests.MockMailmanServer &
venv/bin/python -m tests.MockSMTPServer &
venv/bin/python -m tests.MockCloudStackServer &
venv/bin/python -m tests.MockHarborServer &
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

10
.drone/mock_ad.schema Normal file
View File

@ -0,0 +1,10 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

19
.drone/mock_kubectl Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
if [ "$1" = apply ]; then
exit
elif [ "$1" = delete ]; then
exit
elif [ "$1" = certificate ]; then
exit
elif [ "$1" = get ]; then
if [ "$2" = csr -a "$4" = "-o" -a "$5" = 'jsonpath={.status.certificate}' ]; then
echo -n 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWI4Q0ZHeVk0ZVpVMnAvTjMzU0pCTlptMm1vSlE5TXFNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1DZ3gKRURBT0JnTlZCQU1NQjJOMFpHRnNaV3N4RkRBU0JnTlZCQW9NQzJOell5MXRaVzFpWlhKek1CNFhEVEl4TVRJeApNekEwTWpJek4xb1hEVEl5TURFeE1qQTBNakl6TjFvd0tERVFNQTRHQTFVRUF3d0hZM1JrWVd4bGF6RVVNQklHCkExVUVDZ3dMWTNOakxXMWxiV0psY25Nd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUIKQVFEV09vaTd6ejE0c3VBZ0V2QkgrSHFHSzlCUUlQTm5QQ0llVkxXenlFRTNxUWZRV2YvcWNzeGNST2pSKzVCTgpKSXBaQlNZdjRmNE52WFZqaHlQendoWUd0bXJRYksyT3RCTDlqMDJMWjhMVHp2TnE0MW9CYVdXUFhhaVdIVys2CjkzQnlBdXFPMmdnSEt0elNkV09TcTZpeFBXMVNGUzJRMkFWaXdZUEg3b1pQYnZacUZvMzdhbVdwd1pWUHVuVi8KV2tFRUttNUVqV05DSVUzVWpPdS9HeEJOT1g0WEpqWld4bFcwQUVROVp3K2ZSazBkdU5ScVVyUDQxbDZvcG4rKwpLRkE5NFg2NUlzcUMvMlJ4OWgrNkZFRHhIcjJPcjhOcGFuMXRjZEZHQlFyMGMxV1JxRkNHTytIM0VTeUNya1BjCmdnRDlVN3c0TmdGYkQyaVU0QXc3ZkhwakFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUY3VWUwc3YKcFhSUzN1TFl1Y0k3UkRNRGpOZnFpZ0R3NnorbzZxVmxTdGpZTGpDNjFXRyswZ0g4TDJIbm5jZVYyelhjNDkrQQp6TjFna0lWT3JlRUQvRitKbGRPUGgySUpOY1pGYTBsckFFV0dNNWRRR3pDSUM0cEtmSGxOMTZ0c0w2bGdqWTYzCmUvZlhMTFdLdktDR2lRMUlBUTh4KzYyaTVvSmU3aDBlQ1Q0aEEyM0JTRnRNelo2aEdGUURNNGxxaWhHQjEyT2UKZE5yYStsNVdLemNFR21aVFBYTXNudEZVVndPejhaNld2eGo0UW1zL1dQUElKWDdLM2NiRUo4L1RQWG1tUzJrQwowNUtueUxVQzltYnR2TGZoYldhbFZVVlJVUkYwT1RaVk5mNkt6MDJWYlRqQjRJQXdyWGZKZC9lMkMvNFpGWlJTCjVWMnlJSnBJeVJGWTdQST0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='
exit
elif [ "$2" = namespaces ]; then
echo '{"items":[{"metadata":{"name":"default"}}]}'
exit
fi
fi
echo 'Unrecognized command'
exit 1

View File

@ -4,61 +4,44 @@ set -ex
. .drone/common.sh
sync_with() {
host=$1
synced=false
# give it 5 minutes
for i in {1..60}; do
if nc -vz $host 9000 ; then
synced=true
break
fi
sleep 5
done
test $synced = true
CONTAINER__fix_hosts() {
add_fqdn_to_hosts "$(get_ip_addr $(hostname))" phosphoric-acid
add_fqdn_to_hosts "$(get_ip_addr auth1)" auth1
add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee
# mail container doesn't run in CI
if [ -z "$CI" ]; then
add_fqdn_to_hosts $(get_ip_addr mail) mail
fi
}
# set FQDN in /etc/hosts
add_fqdn_to_hosts $(get_ip_addr $(hostname)) phosphoric-acid
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
add_fqdn_to_hosts $(get_ip_addr coffee) coffee
CONTAINER__setup_userdirs() {
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
export DEBIAN_FRONTEND=noninteractive
apt update
# create directories for users
for user in ctdalek regular1 exec1; do
mkdir -p /users/$user
chown $user:$user /users/$user
done
}
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
cp .drone/ldap.conf /etc/ldap/ldap.conf
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
IMAGE__setup() {
IMAGE__ceod_setup
# git is required by the ClubWebHostingService
apt install --no-install-recommends -y git
}
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
apt install -y netcat-openbsd
sync_with auth1
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin
krb5
addprinc -randkey host/phosphoric-acid.csclub.internal
ktadd host/phosphoric-acid.csclub.internal
addprinc -randkey ceod/phosphoric-acid.csclub.internal
ktadd ceod/phosphoric-acid.csclub.internal
addprinc -randkey ceod/admin
ktadd ceod/admin
EOF
service nslcd start
sync_with coffee
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
CONTAINER__auth_setup phosphoric-acid
CONTAINER__setup_userdirs
echo "ktadd ceod/admin" | kadmin -p sysadmin/admin -w krb5
sync_with coffee
if [ -z "$CI" ]; then
sync_with mail
fi
}

View File

@ -7,6 +7,7 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid
@ -40,6 +41,11 @@ access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access
access to *
by dn="cn=ceod,dc=csclub,dc=internal" write

17
.drone/supervise.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# A script that supervises a program. The program is restarted TIMEOUT second after it exits.
# SIGHUP restarts the program
# SIGTERM and SIGINT stops the program
TIMEOUT=1
running=1
trap 'kill -TERM $! 2>/dev/null' HUP
trap 'running=0; kill -TERM $! 2>/dev/null' TERM INT
trap 'running=0; kill -KILL $! 2>/dev/null' EXIT
while [ "$running" = 1 ]; do
"$@" &
wait
sleep "$TIMEOUT"
done

123
.drone/uwldap_data.ldif Normal file
View File

@ -0,0 +1,123 @@
dn: ou=UWLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: UWLDAP
dn: uid=ctdalek,ou=UWLDAP,dc=csclub,dc=internal
displayName: Calum Dalek
givenName: Calum
sn: Dalek
cn: Calum Dalek
ou: MAT/Mathematics Computer Science
mailLocalAddress: ctdalek@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: ctdalek
mail: ctdalek@uwaterloo.internal
dn: uid=regular1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular One
givenName: Regular
sn: One
cn: Regular One
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular1
mail: regular1@uwaterloo.internal
dn: uid=regular2,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular Two
givenName: Regular
sn: Two
cn: Regular Two
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular2@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular2
mail: regular2@uwaterloo.internal
dn: uid=regular3,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular Three
givenName: Regular
sn: Three
cn: Regular Three
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular3@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular3
mail: regular3@uwaterloo.internal
dn: uid=exec1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec One
givenName: Exec
sn: One
cn: Exec One
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec1
mail: exec1@uwaterloo.internal
dn: uid=exec2,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec Two
givenName: Exec
sn: Two
cn: Exec Two
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec2@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec2
mail: exec2@uwaterloo.internal
dn: uid=exec3,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec Three
givenName: Exec
sn: Three
cn: Exec Three
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec3@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec3
mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

5
.githooks/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
exit $?

22
.gitignore vendored
View File

@ -1,7 +1,27 @@
# If you update this file, please also update the extend-diff-ignore option
# in debian/source/options.
*.key
*.gpg
*.pgp
__pycache__/
*.pyc
/venv/
/dist/
/build/
/*.egg-info/
.vscode/
*.o
*.so
*.swp
.idea/
/docs/*.1
/docs/*.5
/debian/ceo/
/debian/ceod/
/debian/ceo-common/
/debian/tmp/
/debian/ceo.substvars
/debian/files
/debian/.debhelper/
/debian/debhelper-build-stamp

1
MANIFEST.in Normal file
View File

@ -0,0 +1 @@
include ceod/model/templates/*.j2

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/make -f
SCDFILES = $(wildcard docs/*.scd)
MANPAGES = $(patsubst docs/%.scd,docs/%,${SCDFILES})
CEO_HOME = /var/lib/ceo
build: docs venv
venv:
python3 -m venv venv && \
. venv/bin/activate && \
pip install --upgrade pip && \
pip install setuptools wheel && \
pip install -r requirements.txt && \
pip install .
install:
@# Prepare the virtualenv to be moved (dangerous!)
@# Make sure you don't have '|' in your paths
grep -IRl $(CURDIR)/venv venv/bin | \
xargs perl -pe 's|\Q$(CURDIR)/venv\E|$(CEO_HOME)/venv|g' -i
mkdir -p $(DESTDIR)$(CEO_HOME)
mv venv $(DESTDIR)$(CEO_HOME)
docs:
for file in ${SCDFILES} ; do \
scdoc < $$file > `echo $$file | grep -oP '.*(?=\.scd$$)'` ; \
done
clean:
rm -f ${MANPAGES}
rm -rf venv
rm -rf debian/{ceo,ceod,ceo-common,tmp}
.PHONY: build docs clean venv install

122
PACKAGING.md Normal file
View File

@ -0,0 +1,122 @@
# Packaging
This is a guide for creating Debian packages for ceo. The instructions below
probably do not follow best practices, but at least I am confident that they
work.
## Prerequisites
Make sure your GPG key is in /srv/debian/gpg on potassium-benzoate. See
[here](https://wiki.csclub.uwaterloo.ca/Debian_Repository#Step_1:_Add_to_Uploaders)
for instructions.
Make sure you are in the `csc-mirror` group too.
## Creating the package
Use Docker/Podman to avoid screwing up your main system.
For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker):
```sh
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm bash
# if disconnected from shell, reconnect with:
podman start pyceo-packaging
podman exec -it pyceo-packaging bash
```
**Important**: Make sure to use a container image for the same distribution which you're packaging.
For example, if you're creating a package for bullseye, you should be using the debian:bullseye
Docker image (this is because the virtualenv symlinks python to the OS' version of python).
Here are some of the prerequisites you'll need to build the deb files
(run this inside the container):
```sh
apt update
apt install -y devscripts debhelper git-buildpackage vim
apt install -y python3-dev python3-venv libkrb5-dev libpq-dev libaugeas0 scdoc # dependencies for building ceo
```
Make sure to also install all of the packages in the 'Build-Depends' section in debian/control.
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`).
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
```sh
# (pre-requisite) if container doesn't have your gpg key
## step 1: export from host/another computer with your keyring
gpg --armor --output private.key --export-secret-key <your pgp key's id email>
## step 2: import into build container
gpg --import private.key
## step 3: find your key's public key
gpg --list-secret-keys # get key id
## step 4: trust ids (before building)
gpg --edit <pub key id>
gpg> trust # run when gpg editing prompt appears
> 5 # "ultimate" trust
gpg> save # gpg will report no changes were made, but trust of ids should be changed
# alternatively, sign with `debsign` after creating unsigned package
# build (signed) package
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC --lintian-opts --no-lintian
```
This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory.
Now do another git commit (since you edited the changelog file).
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**):
```sh
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
```
## Uploading the package
Inside the container, go up one directory, and create a tarball with all the package files:
```
cd .. # within the container, generated files are in the parent directory of your git repo
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
```
Outside of the container (i.e. on your personal machine), copy the tarball out of the
container into your current directory, e.g.
```
podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
# or generally, if you're in the pyceo repo:
podman cp pyceo-packaging:$(cd ../ && pwd)/pyceo.tar.gz .
```
(Replace `/home/max/repos` by the directory in the container with the tarball.)
Now upload the tarball to a CSC machine, e.g.
```
# on "HOST" machine
scp pyceo.tar.gz mannitol:~
```
SSH into that machine and extract the tarball into a separate directory:
```
ssh mannitol
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
tar zxvf pyceo.tar.gz
```
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage).
Now upload the package to potassium-benzoate:
```
kinit
dupload *.changes
```
Now SSH into potassium-benzoate and run the following:
```
# note: this is AUTOMATICALLY done (within 10-20 minutes by a cron job)
sudo /srv/debian/bin/rrr-incoming
```
To check if mirror has accepted the new package, visit: http://debian.csclub.uwaterloo.ca/dists/bookworm/
<<<<<<< Updated upstream
=======
To update CEO:
```
# repeat this for all systems, starting from ceod servers
sudo apt update
# NOTE: be careful of changing configs!!
sudo apt install --only-upgrade ceod
````
>>>>>>> Stashed changes
There, that wasn't so bad...right? :')

102
README.md
View File

@ -1,18 +1,74 @@
# pyceo
[![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg?ref=refs/heads/v1)](https://ci.csclub.uwaterloo.ca/public/pyceo)
[![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg)](https://ci.csclub.uwaterloo.ca/public/pyceo)
![Main TUI view](https://wiki.csclub.uwaterloo.ca/images/b/bb/Pyceo.png)
CEO (**C**SC **E**lectronic **O**ffice) is the tool used by CSC to manage
club accounts and memberships. See [architecture.md](architecture.md) for an
club accounts and memberships. See [docs/architecture.md](docs/architecture.md) for an
overview of its architecture.
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
## Development
First, make sure that you have installed the
### Podman
If you are not modifying code related to email or Mailman, then you may use
Podman containers instead, which are much easier to work with than the VM.
If you are using Podman, make sure to set the `DOCKER_HOST` environment variable
if you have not done so already:
```bash
# Add the following to e.g. your ~/.bashrc
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
```
The Podman socket also needs to be running:
```bash
# Enabled by default on Debian, but not on Fedora
systemctl --user enable --now podman.socket
```
First, create the container images:
```sh
scripts/build-all-images.sh
```
Then bring up the containers:
```sh
docker-compose up -d
```
This will create some containers with the bare minimum necessary for ceod to
run, and start ceod on each of phosphoric-acid, mail, and coffee container.
You can check the containers status using:
```sh
docker-compose logs -f
```
To use ceo, run the following:
```sh
docker-compose exec phosphoric-acid bash
su ctdalek
. venv/bin/activate
python -m ceo # the password is krb5
```
This should bring up the TUI.
Normally, ceod should autoamtically restart when the source files are changed.
To manually restart the service, run:
```sh
docker-compose kill -s SIGHUP phosphoric-acid
```
To stop the containers, run:
```sh
docker-compose down
```
Alternatively, if you started docker-compose in the foreground, just press Ctrl-C.
### VM
If you need the full environment running in VM, follow the guide on
[syscom dev environment](https://git.uwaterloo.ca/csc/syscom-dev-environment).
This will setup all of the services needed for ceo to work. You should clone
this repo in the phosphoric-acid container under ctdalek's home directory; you
will then be able to access it from any container thanks to NFS.
### Environment setup
Once you have the dev environment setup, there are a few more steps you'll
need to do for ceo.
@ -81,7 +137,7 @@ host all postgres 0.0.0.0/0 md5
local all all peer
host all all localhost md5
local sameuser all md5
local sameuser all peer
host sameuser all 0.0.0.0/0 md5
```
**Warning**: in prod, the postgres user should only be allowed to connect locally,
@ -129,7 +185,7 @@ pip install -r requirements.txt
pip install -r dev-requirements.txt
```
## Running the application
#### Running the application
ceod is a distributed application, with instances on different hosts offering
different services.
Therefore, you will need to run ceod on multiple hosts. Currently, those are
@ -139,17 +195,25 @@ replaced by coffee).
To run ceod on a single host (as root, since the app needs to read the keytab):
```sh
export FLASK_APP=ceod.api
export FLASK_ENV=development
export FLASK_DEBUG=true
flask run -h 0.0.0.0 -p 9987
```
Sometimes changes you make in the source code don't show up while Flask
is running. Stop the flask app (Ctrl-C), run `clear_cache.sh`, then
is running. Stop the flask app (Ctrl-C), run `scripts/clear_cache.sh`, then
restart the app.
## Interacting with the application
The client part of ceo hasn't been written yet, so we'll use curl to
interact with ceod for now.
To use the TUI:
```
python -m ceo
```
To use the CLI:
```
python -m ceo --help
```
Alternatively, you may use curl to send HTTP requests.
ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication,
and TLS for confidentiality and integrity. In development mode, TLS can be
@ -162,12 +226,24 @@ curl -V
```
Your should see 'SPNEGO' in the 'Features' section.
Here's an example of making a request to an endpoint which writes to LDAP:
Here's an example of making a request to add a user (in the Docker container):
```sh
# Get a Kerberos TGT first
# If you're root, switch to the ctdalek user first
su ctdalek
# Get a Kerberos TGT (password is krb5)
kinit
# Make the request
curl --negotiate -u : --service-name ceod --delegation always \
-d '{"uid":"test_1","cn":"Test One","program":"Math","terms":["s2021"]}' \
-d '{"uid":"test_1","cn":"Test One","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \
-X POST http://phosphoric-acid:9987/api/members
# To delete the user:
curl --negotiate -u : --service-name ceod --delegation always \
-X DELETE http://phosphoric-acid:9987/api/members/test_1
# In prod, use the following base URL instead:
# https://phosphoric-acid.csclub.uwaterloo.ca:9987
```
## Packaging
See [PACKAGING.md](./PACKAGING.md).

1
VERSION.txt Normal file
View File

@ -0,0 +1 @@
1.0.31

View File

@ -0,0 +1,43 @@
from abc import ABC, abstractmethod
from typing import Union
import requests
class StreamResponseHandler(ABC):
"""
An abstract class to handle stream responses from the server.
The CLI and TUI should implement a child class.
"""
@abstractmethod
def handle_non_200(self, resp: requests.Response):
"""Handle a non-200 response."""
@abstractmethod
def begin(self):
"""Begin the transaction."""
@abstractmethod
def handle_aborted(self, err_msg: str):
"""Handle an aborted transaction."""
@abstractmethod
def handle_completed(self):
"""Handle a completed transaction."""
@abstractmethod
def handle_successful_operation(self):
"""Handle a successful operation."""
@abstractmethod
def handle_failed_operation(self, err_msg: Union[str, None]):
"""Handle a failed operation."""
@abstractmethod
def handle_skipped_operation(self):
"""Handle a skipped operation."""
@abstractmethod
def handle_unrecognized_operation(self, operation: str):
"""Handle an unrecognized operation."""

View File

@ -1,4 +1,44 @@
import os
import sys
from zope import component
from .cli import cli
from .krb_check import krb_check
from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services():
# Using base component directly so events get triggered
baseComponent = component.getGlobalSiteManager()
# Config
if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG']
else:
if is_in_development():
config_file = './tests/ceo_dev.ini'
else:
config_file = '/etc/csc/ceo.ini'
cfg = Config(config_file)
baseComponent.registerUtility(cfg, IConfig)
# HTTPService
http_client = HTTPClient()
baseComponent.registerUtility(http_client, IHTTPClient)
def main():
krb_check()
register_services()
if len(sys.argv) > 1:
cli(obj={})
else:
tui_main()
if __name__ == '__main__':
cli(obj={})
main()

View File

@ -0,0 +1,70 @@
import sys
from typing import List, Union
import click
import requests
from ..StreamResponseHandler import StreamResponseHandler
from ..operation_strings import descriptions as op_desc
class Abort(click.ClickException):
"""Abort silently."""
def __init__(self, exit_code=1):
super().__init__('')
self.exit_code = exit_code
def show(self):
pass
class CLIStreamResponseHandler(StreamResponseHandler):
def __init__(self, operations: List[str]):
super().__init__()
self.operations = operations
self.idx = 0
def handle_non_200(self, resp: requests.Response):
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
def begin(self):
click.echo(op_desc[self.operations[0]] + '... ', nl=False)
def handle_aborted(self, err_msg: str):
click.echo(click.style('ABORTED', fg='red'))
click.echo('The transaction was rolled back.')
click.echo('The error was: ' + err_msg)
click.echo('Please check the ceod logs.')
sys.exit(1)
def handle_completed(self):
click.echo('Transaction successfully completed.')
def _go_to_next_op(self):
"""
Increment the operation index and print the next operation, if
there is one.
"""
self.idx += 1
if self.idx < len(self.operations):
click.echo(op_desc[self.operations[self.idx]] + '... ', nl=False)
def handle_successful_operation(self):
click.echo(click.style('Done', fg='green'))
self._go_to_next_op()
def handle_failed_operation(self, err_msg: Union[str, None]):
click.echo(click.style('Failed', fg='red'))
if err_msg is not None:
click.echo(' Error message: ' + err_msg)
self._go_to_next_op()
def handle_skipped_operation(self):
click.echo('Skipped')
self._go_to_next_op()
def handle_unrecognized_operation(self, operation: str):
click.echo('Unrecognized operation: ' + operation)

91
ceo/cli/cloud.py Normal file
View File

@ -0,0 +1,91 @@
import click
from zope import component
from ceo_common.interfaces import IConfig
from ..utils import http_post, http_put, http_get, http_delete
from .utils import Abort, handle_sync_response, print_colon_kv
@click.group(short_help='Perform operations on the CSC cloud')
def cloud():
pass
@cloud.group(short_help='Manage your cloud account')
def account():
pass
@account.command(short_help='Activate your cloud account')
def activate():
cfg = component.getUtility(IConfig)
base_domain = cfg.get('base_domain')
resp = http_post('/api/cloud/accounts/create')
handle_sync_response(resp)
lines = [
'Congratulations! Your cloud account has been activated.',
f'You may now login into https://cloud.{base_domain} with your CSC credentials.',
"Make sure to enter 'Members' for the domain (no quotes).",
'',
'Please note that your cloud account will be PERMANENTLY DELETED when',
'your CSC membership expires, so make sure to purchase enough membership',
'terms in advance. You will receive a warning email one week before your',
'cloud account is deleted, so please make sure to check your Junk folder.',
]
for line in lines:
click.echo(line)
@cloud.group(short_help='Manage cloud accounts')
def accounts():
pass
@accounts.command(short_help='Purge expired cloud accounts')
def purge():
resp = http_post('/api/cloud/accounts/purge')
result = handle_sync_response(resp)
click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted']))
click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted']))
@cloud.group(short_help='Manage your virtual hosts')
def vhosts():
pass
@vhosts.command(name='add', short_help='Add a virtual host')
@click.argument('domain')
@click.argument('ip_address')
def add_vhost(domain, ip_address):
body = {'ip_address': ip_address}
if '/' in domain:
raise Abort('invalid domain name')
click.echo('Please wait, this may take a while...')
resp = http_put('/api/cloud/vhosts/' + domain, json=body)
handle_sync_response(resp)
click.echo('Done.')
@vhosts.command(name='delete', short_help='Delete a virtual host')
@click.argument('domain')
def delete_vhost(domain):
if '/' in domain:
raise Abort('invalid domain name')
resp = http_delete('/api/cloud/vhosts/' + domain)
handle_sync_response(resp)
click.echo('Done.')
@vhosts.command(name='list', short_help='List virtual hosts')
def list_vhosts():
resp = http_get('/api/cloud/vhosts')
result = handle_sync_response(resp)
vhosts = result['vhosts']
if not vhosts:
click.echo('No vhosts found.')
return
pairs = [(d['domain'], d['ip_address']) for d in vhosts]
print_colon_kv(pairs)

70
ceo/cli/database.py Normal file
View File

@ -0,0 +1,70 @@
import os
from typing import Dict
import click
from zope import component
from ..utils import http_post, http_get, http_delete, write_db_creds
from .utils import handle_sync_response, check_if_in_development
from ceo_common.interfaces import IConfig
def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str):
cfg_srv = component.getUtility(IConfig)
db_host = cfg_srv.get(f'{db_type}_host')
if db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
wrote_to_file = write_db_creds(filename, user_dict, password, db_type, db_host)
if op == 'create':
click.echo(f'{db_type_name} database created.')
username = user_dict['uid']
click.echo(f'''Connection Information:
Database: {username}
Username: {username}
Password: {password}
Host: {db_host}''')
if wrote_to_file:
click.echo(f"\nThese settings have been written to {filename}.")
else:
click.echo(f"\nWe were unable to write these settings to {filename}.")
def create(username: str, db_type: str):
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
resp = http_get(f'/api/members/{username}')
user_dict = handle_sync_response(resp)
click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
resp = http_post(f'/api/db/{db_type}/{username}')
result = handle_sync_response(resp)
password = result['password']
db_cli_response(info_file_path, user_dict, password, db_type, 'create')
def pwreset(username: str, db_type: str):
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
resp = http_get(f'/api/members/{username}')
user_dict = handle_sync_response(resp)
click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
resp = http_post(f'/api/db/{db_type}/{username}/pwreset')
result = handle_sync_response(resp)
password = result['password']
db_cli_response(info_file_path, user_dict, password, db_type, 'pwreset')
def delete(username: str, db_type: str):
check_if_in_development()
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
click.confirm(f"Are you sure you want to delete the {db_type_name} database for {username}?", abort=True)
resp = http_delete(f'/api/db/{db_type}/{username}')
handle_sync_response(resp)

View File

@ -1,48 +1,31 @@
import importlib.resources
import os
import socket
import click
from zope import component
from ..krb_check import krb_check
from .members import members
from .groups import groups
from .positions import positions
from .updateprograms import updateprograms
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
from .mysql import mysql
from .postgresql import postgresql
from .mailman import mailman
from .cloud import cloud
from .k8s import k8s
from .registry import registry
from .webhosting import webhosting
@click.group()
@click.pass_context
def cli(ctx):
# ensure ctx exists and is a dict
ctx.ensure_object(dict)
princ = krb_check()
user = princ[:princ.index('@')]
ctx.obj['user'] = user
if os.environ.get('PYTEST') != '1':
register_services()
def cli():
pass
cli.add_command(members)
cli.add_command(groups)
cli.add_command(positions)
cli.add_command(updateprograms)
def register_services():
# Config
# This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
with importlib.resources.path('tests', 'ceo_dev.ini') as p:
config_file = p.__fspath__()
else:
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
cfg = Config(config_file)
component.provideUtility(cfg, IConfig)
# HTTPService
http_client = HTTPClient()
component.provideUtility(http_client, IHTTPClient)
cli.add_command(mysql)
cli.add_command(postgresql)
cli.add_command(mailman)
cli.add_command(cloud)
cli.add_command(k8s)
cli.add_command(registry)
cli.add_command(webhosting)

View File

@ -68,75 +68,78 @@ def get(group_name):
print_group_lines(result)
@groups.command(short_help='Add a member to a group')
@groups.command(short_help='Add one or more members to a group')
@click.argument('group_name')
@click.argument('username')
@click.argument('usernames', nargs=-1)
@click.option('--no-subscribe', is_flag=True, default=False,
help='Do not subscribe the member to any auxiliary mailing lists.')
def addmember(group_name, username, no_subscribe):
click.confirm(f'Are you sure you want to add {username} to {group_name}?',
abort=True)
help='Do not subscribe the member(s) to any auxiliary mailing lists.')
def addmember(group_name, username, usernames, no_subscribe):
usernames = [username, *usernames]
if len(usernames) == 1:
click.confirm(f'Are you sure you want to add {username} to {group_name}?',
abort=True)
else:
click.echo(f'The following users will be added to {group_name}:')
click.echo(', '.join(usernames))
click.confirm('Do you want to continue?', abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
url = f'/api/groups/{group_name}/members/{username}'
operations = AddMemberToGroupTransaction.operations
if no_subscribe:
url += '?subscribe_to_lists=false'
operations.remove('subscribe_user_to_auxiliary_mailing_lists')
resp = http_post(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
lines = []
for i, group in enumerate(result['added_to_groups']):
if i == 0:
prefix = 'Added to groups'
else:
prefix = ''
lines.append((prefix, group))
for i, mailing_list in enumerate(result.get('subscribed_to_lists', [])):
if i == 0:
prefix = 'Subscribed to lists'
else:
prefix = ''
if '@' not in mailing_list:
mailing_list += '@' + base_domain
lines.append((prefix, mailing_list))
print_colon_kv(lines)
for username in usernames:
url = f'/api/groups/{group_name}/members/{username}'
if no_subscribe:
url += '?subscribe_to_lists=false'
resp = http_post(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
click.echo(f'Added {username} to ' + ', '.join(result['added_to_groups']))
if result.get('subscribed_to_lists'):
mailing_lists = [
mailing_list + '@' + base_domain
if '@' not in mailing_list
else mailing_list
for mailing_list in result['subscribed_to_lists']
]
click.echo(f'Subscribed {username} to ' + ', '.join(mailing_lists))
@groups.command(short_help='Remove a member from a group')
@groups.command(short_help='Remove one or more members from a group')
@click.argument('group_name')
@click.argument('username')
@click.argument('usernames', nargs=-1)
@click.option('--no-unsubscribe', is_flag=True, default=False,
help='Do not unsubscribe the member from any auxiliary mailing lists.')
def removemember(group_name, username, no_unsubscribe):
click.confirm(f'Are you sure you want to remove {username} from {group_name}?',
abort=True)
help='Do not unsubscribe the member(s) from any auxiliary mailing lists.')
def removemember(group_name, username, usernames, no_unsubscribe):
usernames = [username, *usernames]
if len(usernames) == 1:
click.confirm(f'Are you sure you want to remove {username} from {group_name}?',
abort=True)
else:
click.echo(f'The following users will be removed from {group_name}:')
click.echo(', '.join(usernames))
click.confirm('Do you want to continue?', abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
url = f'/api/groups/{group_name}/members/{username}'
operations = RemoveMemberFromGroupTransaction.operations
if no_unsubscribe:
url += '?unsubscribe_from_lists=false'
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists')
resp = http_delete(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
lines = []
for i, group in enumerate(result['removed_from_groups']):
if i == 0:
prefix = 'Removed from groups'
else:
prefix = ''
lines.append((prefix, group))
for i, mailing_list in enumerate(result.get('unsubscribed_from_lists', [])):
if i == 0:
prefix = 'Unsubscribed from lists'
else:
prefix = ''
if '@' not in mailing_list:
mailing_list += '@' + base_domain
lines.append((prefix, mailing_list))
print_colon_kv(lines)
for username in usernames:
url = f'/api/groups/{group_name}/members/{username}'
if no_unsubscribe:
url += '?unsubscribe_from_lists=false'
resp = http_delete(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
click.echo(f'Removed {username} from ' + ', '.join(result['removed_from_groups']))
if result.get('unsubscribed_from_lists'):
mailing_lists = [
mailing_list + '@' + base_domain
if '@' not in mailing_list
else mailing_list
for mailing_list in result['unsubscribed_from_lists']
]
click.echo(f'Unsubscribed {username} from ' + ', '.join(mailing_lists))
@groups.command(short_help='Delete a group')
@ -146,3 +149,15 @@ def delete(group_name):
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
resp = http_delete(f'/api/groups/{group_name}')
handle_stream_response(resp, DeleteGroupTransaction.operations)
@groups.command(short_help='Search for groups')
@click.argument('query')
@click.option('--count', default=10, help='number of results to show')
def search(query, count):
check_if_in_development()
resp = http_get(f'/api/groups/search/{query}/{count}')
result = handle_sync_response(resp)
for cn in result:
if cn != "":
click.echo(cn)

43
ceo/cli/k8s.py Normal file
View File

@ -0,0 +1,43 @@
import os
import traceback
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage your CSC Kubernetes resources')
def k8s():
pass
@k8s.group(short_help='Manage your CSC Kubernetes account')
def account():
pass
@account.command(short_help='Obtain a kubeconfig')
def activate():
kubedir = os.path.join(os.environ['HOME'], '.kube')
if not os.path.isdir(kubedir):
os.mkdir(kubedir)
kubeconfig = os.path.join(kubedir, 'config')
resp = http_post('/api/cloud/k8s/accounts/create')
result = handle_sync_response(resp)
try:
if os.path.isfile(kubeconfig):
kubeconfig_bak = os.path.join(kubedir, 'config.bak')
os.rename(kubeconfig, kubeconfig_bak)
with open(kubeconfig, 'w') as fo:
fo.write(result['kubeconfig'])
os.chmod(kubeconfig, 0o600)
except Exception:
click.echo(traceback.format_exc())
click.echo("We weren't able to write the kubeconfig file, so here it is.")
click.echo("Make sure to paste this into your ~/.kube/config.")
click.echo()
click.echo(result['kubeconfig'])
return
click.echo("Congratulations! You have a new kubeconfig in ~/.kube/config.")
click.echo("Run `kubectl cluster-info` to make sure everything is working.")

29
ceo/cli/mailman.py Normal file
View File

@ -0,0 +1,29 @@
import click
from ..utils import http_post, http_delete
from .utils import handle_sync_response
@click.group(short_help='Manage mailing list subscriptions')
def mailman():
pass
@mailman.command(short_help='Subscribe a member to a mailing list')
@click.argument('username')
@click.argument('mailing_list')
def subscribe(username, mailing_list):
click.confirm(f'Are you sure you want to subscribe {username} to {mailing_list}?', abort=True)
resp = http_post(f'/api/mailman/{mailing_list}/{username}')
handle_sync_response(resp)
click.echo('Done.')
@mailman.command(short_help='Unsubscribe a member from a mailing list')
@click.argument('username')
@click.argument('mailing_list')
def unsubscribe(username, mailing_list):
click.confirm(f'Are you sure you want to unsubscribe {username} from {mailing_list}?', abort=True)
resp = http_delete(f'/api/mailman/{mailing_list}/{username}')
handle_sync_response(resp)
click.echo('Done.')

View File

@ -3,16 +3,17 @@ from typing import Dict
import click
from zope import component
from ceo_common.utils import validate_username
from ..utils import http_post, http_get, http_patch, http_delete, get_failed_operations
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
from ..term_utils import get_terms_for_renewal_for_user
from ..utils import http_post, http_get, http_patch, http_delete, \
get_failed_operations, user_dict_lines, get_adduser_operations
from .utils import handle_stream_response, handle_sync_response, print_lines, \
check_if_in_development
from ceo_common.interfaces import IConfig
from ceo_common.model import Term
from ceod.transactions.members import (
AddMemberTransaction,
DeleteMemberTransaction,
)
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import DeleteMemberTransaction
@click.group(short_help='Perform operations on CSC members and club reps')
@ -22,47 +23,57 @@ def members():
@members.command(short_help='Add a new member or club rep')
@click.argument('username')
@click.option('--cn', help='Full name', prompt='Full name')
@click.option('--cn', help='Full name', required=False)
@click.option('--given-name', help='First name', required=False)
@click.option('--sn', help='Last name', required=False)
@click.option('--program', required=False, help='Academic program')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', prompt='Number of terms')
help='Number of terms to add', default=1)
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
@click.option('--forwarding-address', required=False,
help=('Forwarding address to set in ~/.forward. '
'Default is UW address. '
'Set to the empty string to disable forwarding.'))
def add(username, cn, program, num_terms, clubrep, forwarding_address):
def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_address):
cfg = component.getUtility(IConfig)
uw_domain = cfg.get('uw_domain')
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
terms = list(map(str, terms))
# Verify that the username is valid before requesting data from UWLDAP
username_validator = validate_username(username)
if not username_validator.is_valid:
return click.echo("The provided username is invalid")
# Try to get info from UWLDAP
resp = http_get('/api/uwldap/' + username)
if resp.ok:
result = handle_sync_response(resp)
if cn is None and result.get('cn'):
cn = result['cn']
if given_name is None and result.get('given_name'):
given_name = result['given_name']
if sn is None and result.get('sn'):
sn = result['sn']
if program is None and result.get('program'):
program = result['program']
if forwarding_address is None and result.get('mail_local_addresses'):
forwarding_address = result['mail_local_addresses'][0]
if cn is None:
cn = click.prompt('Full name')
if given_name is None:
given_name = click.prompt('First name')
if sn is None:
sn = click.prompt('Last name')
if forwarding_address is None:
forwarding_address = username + '@' + uw_domain
click.echo("The following user will be created:")
lines = [
('uid', username),
('cn', cn),
]
if program is not None:
lines.append(('program', program))
if clubrep:
lines.append(('non-member terms', ','.join(terms)))
else:
lines.append(('member terms', ','.join(terms)))
if forwarding_address != '':
lines.append(('forwarding address', forwarding_address))
print_colon_kv(lines)
click.confirm('Do you want to continue?', abort=True)
terms = get_terms_for_new_user(num_terms)
body = {
'uid': username,
'cn': cn,
'given_name': given_name,
'sn': sn,
}
if program is not None:
body['program'] = program
@ -72,10 +83,14 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address):
body['terms'] = terms
if forwarding_address != '':
body['forwarding_addresses'] = [forwarding_address]
operations = AddMemberTransaction.operations
if forwarding_address == '':
# don't bother displaying this because it won't be run
operations.remove('set_forwarding_addresses')
else:
body['forwarding_addresses'] = []
click.echo("The following user will be created:")
print_user_lines(body)
click.confirm('Do you want to continue?', abort=True)
operations = get_adduser_operations(body)
resp = http_post('/api/members', json=body)
data = handle_stream_response(resp, operations)
@ -89,30 +104,9 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address):
'send the user their password.', fg='yellow'))
def print_user_lines(result: Dict):
"""Pretty-print a user JSON response."""
lines = [
('uid', result['uid']),
('cn', result['cn']),
('program', result.get('program', 'Unknown')),
('UID number', result['uid_number']),
('GID number', result['gid_number']),
('login shell', result['login_shell']),
('home directory', result['home_directory']),
('is a club', result['is_club']),
]
if 'forwarding_addresses' in result:
if len(result['forwarding_addresses']) != 0:
lines.append(('forwarding addresses', result['forwarding_addresses'][0]))
for address in result['forwarding_addresses'][1:]:
lines.append(('', address))
if 'terms' in result:
lines.append(('terms', ','.join(result['terms'])))
if 'non_member_terms' in result:
lines.append(('non-member terms', ','.join(result['non_member_terms'])))
if 'password' in result:
lines.append(('password', result['password']))
print_colon_kv(lines)
def print_user_lines(d: Dict):
"""Pretty-print a serialized User."""
print_lines(user_dict_lines(d))
@members.command(short_help='Get info about a user')
@ -169,22 +163,7 @@ def modify(username, login_shell, forwarding_addresses):
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
def renew(username, num_terms, clubrep):
resp = http_get('/api/members/' + username)
result = handle_sync_response(resp)
max_term = None
current_term = Term.current()
if clubrep and 'non_member_terms' in result:
max_term = max(Term(s) for s in result['non_member_terms'])
elif not clubrep and 'terms' in result:
max_term = max(Term(s) for s in result['terms'])
if max_term is not None and max_term >= current_term:
next_term = max_term + 1
else:
next_term = Term.current()
terms = [next_term + i for i in range(num_terms)]
terms = list(map(str, terms))
terms = get_terms_for_renewal_for_user(username, num_terms, clubrep)
if clubrep:
body = {'non_member_terms': terms}
@ -216,3 +195,38 @@ def delete(username):
click.confirm(f"Are you sure you want to delete {username}?", abort=True)
resp = http_delete(f'/api/members/{username}')
handle_stream_response(resp, DeleteMemberTransaction.operations)
@members.command(short_help="Check for and mark expired members")
@click.option('--dry-run', is_flag=True, default=False)
def expire(dry_run):
resp = http_post(f'/api/members/expire?dry_run={dry_run and "yes" or "no"}')
result = handle_sync_response(resp)
if len(result) > 0:
if dry_run:
click.echo("The following members will be marked as expired:")
else:
click.echo("The following members has been marked as expired:")
for username in result:
click.echo(username)
@members.command(short_help="Send renewal reminder emails to expiring members")
@click.option('--dry-run', is_flag=True, default=False)
def remindexpire(dry_run):
url = '/api/members/remindexpire'
if dry_run:
url += '?dry_run=true'
resp = http_post(url)
result = handle_sync_response(resp)
if len(result) > 0:
if dry_run:
click.echo("The following members will be sent membership renewal reminders:")
else:
click.echo("The following members were sent membership renewal reminders:")
for username in result:
click.echo(username)
else:
click.echo("No members are pending expiration.")

26
ceo/cli/mysql.py Normal file
View File

@ -0,0 +1,26 @@
import click
from .database import create as db_create, pwreset as db_pwreset, delete as db_delete
@click.group(short_help='Perform operations on MySQL')
def mysql():
pass
@mysql.command(short_help='Create a MySQL database for a user')
@click.argument('username')
def create(username):
db_create(username, 'mysql')
@mysql.command(short_help='Reset the password of a MySQL user')
@click.argument('username')
def pwreset(username):
db_pwreset(username, 'mysql')
@mysql.command(short_help="Delete the database of a MySQL user")
@click.argument('username')
def delete(username):
db_delete(username, 'mysql')

53
ceo/cli/positions.py Normal file
View File

@ -0,0 +1,53 @@
import click
from zope import component
from ..utils import http_get, http_post
from .utils import handle_sync_response, handle_stream_response, print_colon_kv
from ceo_common.interfaces import IConfig
from ceod.transactions.members import UpdateMemberPositionsTransaction
@click.group(short_help='List or change exec positions')
def positions():
update_commands()
@positions.command(short_help='Get current positions')
def get():
resp = http_get('/api/positions')
result = handle_sync_response(resp)
print_colon_kv([
(position, ', '.join(usernames))
for position, usernames in result.items()
])
@positions.command(short_help='Update positions')
def set(**kwargs):
body = {
k.replace('_', '-'): v.replace(' ', '').split(',') if v else None
for k, v in kwargs.items()
}
print_body = {
k: ', '.join(v) if v else ''
for k, v in body.items()
}
click.echo('The positions will be updated:')
print_colon_kv(print_body.items())
click.confirm('Do you want to continue?', abort=True)
resp = http_post('/api/positions', json=body)
handle_stream_response(resp, UpdateMemberPositionsTransaction.operations)
# Provides dynamic parameters for `set' command using config file
def update_commands():
global set
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
required = cfg.get('positions_required')
for pos in avail:
r = pos in required
set = click.option(f'--{pos}', metavar='USERNAME', required=r, prompt=r)(set)

26
ceo/cli/postgresql.py Normal file
View File

@ -0,0 +1,26 @@
import click
from .database import create as db_create, pwreset as db_pwreset, delete as db_delete
@click.group(short_help='Perform operations on PostgreSQL')
def postgresql():
pass
@postgresql.command(short_help='Create a PostgreSQL database for a user')
@click.argument('username')
def create(username):
db_create(username, 'postgresql')
@postgresql.command(short_help='Reset the password of a PostgreSQL user')
@click.argument('username')
def pwreset(username):
db_pwreset(username, 'postgresql')
@postgresql.command(short_help="Delete the database of a PostgreSQL user")
@click.argument('username')
def delete(username):
db_delete(username, 'postgresql')

21
ceo/cli/registry.py Normal file
View File

@ -0,0 +1,21 @@
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage your container registry account')
def registry():
pass
@registry.group(short_help='Manage your container registry project')
def project():
pass
@project.command(short_help='Create a registry project')
def create():
resp = http_post('/api/cloud/registry/projects')
handle_sync_response(resp)
click.echo('Congratulations! Your registry project was successfully created.')

View File

@ -1,12 +1,11 @@
import json
import socket
import sys
from typing import List, Tuple, Dict
import click
import requests
from ..operation_strings import descriptions as op_desc
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler
class Abort(click.ClickException):
@ -20,86 +19,23 @@ class Abort(click.ClickException):
pass
def print_lines(lines: List[str]):
"""Print multiple lines to stdout."""
for line in lines:
click.echo(line)
def print_colon_kv(pairs: List[Tuple[str, str]]):
"""
Pretty-print a list of key-value pairs such that the key and value
columns align.
Example:
key1: value1
key1000: value2
Pretty-print a list of key-value pairs.
"""
maxlen = max(len(key) for key, val in pairs)
for key, val in pairs:
if key != '':
click.echo(key + ': ', nl=False)
else:
# assume this is a continuation from the previous line
click.echo(' ', nl=False)
extra_space = ' ' * (maxlen - len(key))
click.echo(extra_space, nl=False)
click.echo(val)
for line in space_colon_kv(pairs):
click.echo(line)
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
"""
Print output to the console while operations are being streamed
from the server over HTTP.
Returns the parsed JSON data streamed from the server.
"""
if resp.status_code != 200:
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
click.echo(op_desc[operations[0]] + '... ', nl=False)
idx = 0
data = []
for line in resp.iter_lines(decode_unicode=True, chunk_size=8):
d = json.loads(line)
data.append(d)
if d['status'] == 'aborted':
click.echo(click.style('ABORTED', fg='red'))
click.echo('The transaction was rolled back.')
click.echo('The error was: ' + d['error'])
click.echo('Please check the ceod logs.')
sys.exit(1)
elif d['status'] == 'completed':
if idx < len(operations):
click.echo('Skipped')
click.echo('Transaction successfully completed.')
return data
operation = d['operation']
oper_failed = False
err_msg = None
prefix = 'failed_to_'
if operation.startswith(prefix):
operation = operation[len(prefix):]
oper_failed = True
# sometimes the operation looks like
# "failed_to_do_something: error message"
if ':' in operation:
operation, err_msg = operation.split(': ', 1)
while idx < len(operations) and operations[idx] != operation:
click.echo('Skipped')
idx += 1
if idx == len(operations):
break
click.echo(op_desc[operations[idx]] + '... ', nl=False)
if idx == len(operations):
click.echo('Unrecognized operation: ' + operation)
continue
if oper_failed:
click.echo(click.style('Failed', fg='red'))
if err_msg is not None:
click.echo(' Error message: ' + err_msg)
else:
click.echo(click.style('Done', fg='green'))
idx += 1
if idx < len(operations):
click.echo(op_desc[operations[idx]] + '... ', nl=False)
raise Exception('server response ended abruptly')
handler = CLIStreamResponseHandler(operations)
return generic_handle_stream_response(resp, operations, handler)
def handle_sync_response(resp: requests.Response):
@ -116,6 +52,6 @@ def handle_sync_response(resp: requests.Response):
def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'):
if not is_in_development():
click.echo('This command may only be called during development.')
raise Abort()

37
ceo/cli/webhosting.py Normal file
View File

@ -0,0 +1,37 @@
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage websites hosted by the main CSC web server')
def webhosting():
pass
@webhosting.command(short_help='Disable club sites with no active club reps')
@click.option('--dry-run', is_flag=True, default=False)
@click.option('--remove-inactive-club-reps', is_flag=True, default=False)
def disableclubsites(dry_run, remove_inactive_club_reps):
params = {}
if dry_run:
params['dry_run'] = 'true'
if remove_inactive_club_reps:
params['remove_inactive_club_reps'] = 'true'
if not dry_run:
click.confirm('Are you sure you want to disable the websites of clubs with no active club reps?', abort=True)
resp = http_post('/api/webhosting/disableclubsites', params=params)
disabled_club_names = handle_sync_response(resp)
if len(disabled_club_names) == 0:
if dry_run:
click.echo('No websites would have been disabled.')
else:
click.echo('No websites were disabled.')
else:
if dry_run:
click.echo('The following club websites would have been disabled:')
else:
click.echo('The following club websites were disabled:')
for club_name in disabled_club_names:
click.echo(club_name)

View File

@ -3,17 +3,28 @@ import subprocess
import gssapi
_username = None
def get_username():
"""Get the user currently logged into CEO."""
return _username
def krb_check():
"""
Spawns a `kinit` process if no credentials are available or the
credentials have expired.
Returns the principal string 'user@REALM'.
Stores the username for later use by get_username().
"""
global _username
for _ in range(2):
try:
creds = gssapi.Credentials(usage='initiate')
result = creds.inquire()
return str(result.name)
princ = str(result.name)
_username = princ[:princ.index('@')]
return
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
kinit()

View File

@ -24,4 +24,7 @@ descriptions = {
'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups',
'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists',
'remove_sudo_role': 'Remove sudo role from LDAP',
'update_positions_ldap': 'Update positions in LDAP',
'update_exec_group_ldap': 'Update executive group in LDAP',
'subscribe_to_mailing_lists': 'Subscribe to mailing lists',
}

22
ceo/term_utils.py Normal file
View File

@ -0,0 +1,22 @@
from typing import List
from .utils import http_get
from ceo_common.model.Term import get_terms_for_renewal
import ceo.cli.utils as cli_utils
import ceo.tui.utils as tui_utils
def get_terms_for_renewal_for_user(
username: str, num_terms: int, clubrep: bool, tui_controller=None,
) -> List[str]:
resp = http_get('/api/members/' + username)
# FIXME: this is ugly, we shouldn't need a hacky if statement like this
if tui_controller is None:
result = cli_utils.handle_sync_response(resp)
else:
result = tui_utils.handle_sync_response(resp, tui_controller)
if clubrep:
return get_terms_for_renewal(result.get('non_member_terms'), num_terms)
else:
return get_terms_for_renewal(result.get('terms'), num_terms)

0
ceo/tui/__init__.py Normal file
View File

30
ceo/tui/app.py Normal file
View File

@ -0,0 +1,30 @@
import os
from queue import SimpleQueue
class App:
REL_WIDTH_PCT = 60
REL_HEIGHT_PCT = 70
# On a full-screen (1366x768) gnome-terminal window,
# I had 168 cols and 36 rows
WIDTH = int(0.6 * 168)
HEIGHT = int(0.7 * 36)
def __init__(self, loop, main_widget):
self.loop = loop
self.main_widget = main_widget
self.history = []
self.queued_pipe_callbacks = SimpleQueue()
self.pipefd = loop.watch_pipe(self._pipe_callback)
def run_in_main_loop(self, func):
self.queued_pipe_callbacks.put(func)
os.write(self.pipefd, b'\x00')
def _pipe_callback(self, data):
# We need to clear the whole queue because select()
# will only send one "notification" if there are two
# consecutive writes
while not self.queued_pipe_callbacks.empty():
self.queued_pipe_callbacks.get()()
return True

View File

@ -0,0 +1,36 @@
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddGroupConfirmationView, TransactionView
from ceod.transactions.groups import AddGroupTransaction
class AddGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.description = self.view.description_edit.edit_text
if not self.model.description:
self.view.popup('Description must not be empty')
raise Controller.InvalidInput()
except Controller.InvalidInput:
return
view = AddGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
body = {
'cn': self.model.name,
'description': self.model.description,
}
model = TransactionModel(
AddGroupTransaction.operations,
'POST', '/api/groups', json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,37 @@
from .Controller import Controller
from ceod.transactions.groups import AddMemberToGroupTransaction
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddMemberToGroupConfirmationView, TransactionView
class AddMemberToGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_list_subscribe_checkbox_change(self, checkbox, new_state):
self.model.subscribe_to_lists = new_state
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
view = AddMemberToGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
cn = self.model.name
uid = self.model.username
url = f'/api/groups/{cn}/members/{uid}'
if not self.model.subscribe_to_lists:
url += '?subscribe_to_lists=false'
model = TransactionModel(
AddMemberToGroupTransaction.operations,
'POST', url
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,110 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .AddUserTransactionController import AddUserTransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddUserConfirmationView, TransactionView
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import AddMemberTransaction
class AddUserController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.right_col_idx = 0
self.prev_searched_username = None
def on_confirmation_button_pressed(self, button):
body = {
'uid': self.model.username,
'cn': self.model.full_name,
'given_name': self.model.first_name,
'sn': self.model.last_name,
}
if self.model.program:
body['program'] = self.model.program
if self.model.forwarding_address:
body['forwarding_addresses'] = [self.model.forwarding_address]
new_terms = get_terms_for_new_user(self.model.num_terms)
if self.model.membership_type == 'club_rep':
body['non_member_terms'] = new_terms
else:
body['terms'] = new_terms
model = TransactionModel(
AddMemberTransaction.operations,
'POST', '/api/members',
json=body
)
controller = AddUserTransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
def on_next_button_pressed(self, button):
try:
username = self.get_username_from_view()
num_terms = self.get_num_terms_from_view()
except Controller.InvalidInput:
return
full_name = self.view.full_name_edit.edit_text
# TODO: share validation logic between CLI and TUI
if not full_name:
self.view.popup('Full name must not be empty')
return
self.model.username = username
self.model.full_name = full_name
self.model.first_name = self.view.first_name_edit.edit_text
self.model.last_name = self.view.last_name_edit.edit_text
self.model.program = self.view.program_edit.edit_text
self.model.forwarding_address = self.view.forwarding_address_edit.edit_text
self.model.num_terms = num_terms
view = AddUserConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_membership_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.membership_type = selected_type
def on_row_focus_changed(self):
_, idx = self.view.listwalker.get_focus()
old_idx = self.right_col_idx
self.right_col_idx = idx
# The username field is the third row, so when
# idx changes from 2 to 3, this means the user
# moved from the username field to the next field
if old_idx == 2 and idx == 3:
Thread(
target=self._lookup_user,
args=(self.view.username_edit.edit_text,)
).start()
def _set_flash_text(self, *args):
self.view.flash_text.set_text('Looking up user...')
def _clear_flash_text(self):
self.view.flash_text.set_text('')
def _on_lookup_user_success(self):
self._clear_flash_text()
self.view.update_fields()
def _lookup_user(self, username):
if not username:
return
if username == self.prev_searched_username:
return
self.prev_searched_username = username
self.app.run_in_main_loop(self._set_flash_text)
resp = http_get('/api/uwldap/' + username)
if not resp.ok:
self.app.run_in_main_loop(self._clear_flash_text)
return
data = resp.json()
self.model.full_name = data.get('cn', '')
self.model.first_name = data.get('given_name', '')
self.model.last_name = data.get('sn', '')
self.model.program = data.get('program', '')
self.model.forwarding_address = (data.get('mail_local_addresses') or [''])[0]
self.app.run_in_main_loop(self._on_lookup_user_success)

View File

@ -0,0 +1,38 @@
from typing import Dict, List
from ...utils import get_failed_operations
from .TransactionController import TransactionController
class AddUserTransactionController(TransactionController):
def __init__(self, model, app):
super().__init__(model, app)
def handle_completed(self):
# We don't want to write to the message_text yet, but
# we still need to enable the Next button.
self.app.run_in_main_loop(self.view.enable_next_button)
def write_extra_txn_info(self, data: List[Dict]):
if data[-1]['status'] != 'completed':
return
result = data[-1]['result']
failed_operations = get_failed_operations(data)
lines = []
if failed_operations:
lines.append('Transaction successfully completed with some errors.')
else:
lines.append('Transaction successfully completed.')
lines.append('')
lines.append('User password is: ' + result['password'])
if 'send_welcome_message' in failed_operations:
lines.extend([
'',
'Since the welcome message was not sent, '
'you need to email this password to the user.'
])
def target():
self._show_lines(lines)
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,79 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import ChangeLoginShellConfirmationView, TransactionView
class ChangeLoginShellController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.right_col_idx = 0
self.prev_searched_username = None
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
self.model.login_shell = self.view.login_shell_edit.edit_text
if not self.model.login_shell:
self.view.popup('Login shell must not be empty')
raise Controller.InvalidInput()
except Controller.InvalidInput:
return
view = ChangeLoginShellConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
body = {'login_shell': self.model.login_shell}
model = TransactionModel(
['replace_login_shell'],
'PATCH', f'/api/members/{self.model.username}',
json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
# TODO: reduce code duplication with AddUserController
def on_row_focus_changed(self):
_, idx = self.view.listwalker.get_focus()
old_idx = self.right_col_idx
self.right_col_idx = idx
# The username field is the first row, so when
# idx changes from 0 to 1, this means the user
# moved from the username field to the next field
if old_idx == 0 and idx == 1:
Thread(
target=self._lookup_user,
args=(self.view.username_edit.edit_text,)
).start()
def _set_flash_text(self, *args):
self.view.flash_text.set_text('Looking up user...')
def _clear_flash_text(self):
self.view.flash_text.set_text('')
def _on_lookup_user_success(self):
self._clear_flash_text()
self.view.update_fields()
def _lookup_user(self, username):
if not username:
return
if username == self.prev_searched_username:
return
self.prev_searched_username = username
self.app.run_in_main_loop(self._set_flash_text)
resp = http_get('/api/members/' + username)
if not resp.ok:
self.app.run_in_main_loop(self._clear_flash_text)
return
data = resp.json()
self.model.login_shell = data.get('login_shell', '')
self.app.run_in_main_loop(self._on_lookup_user_success)

View File

@ -0,0 +1,80 @@
from abc import ABC
import ceo.tui.utils as utils
from ceo_common.utils import validate_username
# NOTE: one controller can control multiple views,
# but each view must have exactly one controller
class Controller(ABC):
class InvalidInput(Exception):
pass
class RequestFailed(Exception):
pass
def __init__(self, model, app):
super().__init__()
self.model = model
self.app = app
# Since the view and the controller both have a reference to each
# other, this needs to be initialized in a separate step
self.view = None
def _push_history(self, old_view, new_view):
if new_view.model.name == 'Welcome':
self.app.history.clear()
else:
self.app.history.append(old_view)
def switch_to_view(self, new_view):
self._push_history(self.view, new_view)
self.view = new_view
new_view.activate()
def go_to_next_menu(self, next_menu_name):
_, new_view, _ = utils.get_mvc(self.app, next_menu_name)
self._push_history(self.view, new_view)
new_view.activate()
def prev_menu_callback(self, button):
prev_view = self.app.history.pop()
prev_view.controller.view = prev_view
prev_view.activate()
def next_menu_callback(self, button, next_menu_name):
self.go_to_next_menu(next_menu_name)
def get_next_menu_callback(self, next_menu_name):
def callback(button):
self.next_menu_callback(button, next_menu_name)
return callback
def get_username_from_view(self):
username = self.view.username_edit.edit_text
# TODO: share validation logic between CLI and TUI
verification_res = validate_username(username)
if not verification_res.is_valid:
self.view.popup(verification_res.error_message)
raise Controller.InvalidInput()
return username
def get_group_name_from_view(self):
name = self.view.name_edit.edit_text
# TODO: share validation logic between CLI and TUI
if not name:
self.view.popup('Name must not be empty')
raise Controller.InvalidInput()
return name
def get_num_terms_from_view(self):
num_terms_str = self.view.num_terms_edit.edit_text
if num_terms_str:
num_terms = int(num_terms_str)
else:
num_terms = 0
# TODO: share validation logic between CLI and TUI
if num_terms <= 0:
self.view.popup('Number of terms must be a positive integer')
raise Controller.InvalidInput()
return num_terms

View File

@ -0,0 +1,50 @@
import os
from zope import component
from ...utils import http_get, http_post, write_db_creds
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import CreateDatabaseConfirmationView, CreateDatabaseResponseView
from ceo_common.interfaces import IConfig
class CreateDatabaseController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_db_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.db_type = selected_type
def on_next_button_pressed(self, button):
view = CreateDatabaseConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def get_resp(self):
db_type = self.model.db_type
username = krb.get_username()
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
self.model.user_dict = resp.json()
return http_post(f'/api/db/{db_type}/{username}')
def get_response_view(self):
return CreateDatabaseResponseView(self.model, self, self.app)
def write_db_creds_to_file(self):
password = self.model.resp_json['password']
db_type = self.model.db_type
cfg = component.getUtility(IConfig)
db_host = cfg.get(f'{db_type}_host')
homedir = self.model.user_dict['home_directory']
filename = os.path.join(homedir, f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, self.model.user_dict, password, db_type, db_host
)
self.model.password = password
self.model.db_host = db_host
self.model.filename = filename
self.model.wrote_to_file = wrote_to_file

View File

@ -0,0 +1,22 @@
from ...utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import GetGroupResponseView
class GetGroupController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_get(f'/api/groups/{self.model.name}')
def get_response_view(self):
return GetGroupResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,29 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
import ceo.tui.utils as tui_utils
class GetPositionsController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def lookup_positions_async(self):
self.view.flash_text.set_text('Looking up positions...')
Thread(target=self.lookup_positions_sync).start()
def lookup_positions_sync(self):
resp = http_get('/api/positions')
try:
positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
for pos, usernames in positions.items():
self.model.positions[pos] = ','.join(usernames)
def target():
self.view.flash_text.set_text('')
self.view.update_fields()
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,22 @@
from ...utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import GetUserResponseView
class GetUserController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_get(f'/api/members/{self.model.username}')
def get_response_view(self):
return GetUserResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,37 @@
from .Controller import Controller
from ceod.transactions.groups import RemoveMemberFromGroupTransaction
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import RemoveMemberFromGroupConfirmationView, TransactionView
class RemoveMemberFromGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_list_unsubscribe_checkbox_change(self, checkbox, new_state):
self.model.unsubscribe_from_lists = new_state
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
view = RemoveMemberFromGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
cn = self.model.name
uid = self.model.username
url = f'/api/groups/{cn}/members/{uid}'
if not self.model.unsubscribe_from_lists:
url += '?unsubscribe_from_lists=false'
model = TransactionModel(
RemoveMemberFromGroupTransaction.operations,
'DELETE', url
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,54 @@
from threading import Thread
from ...utils import http_post
from .Controller import Controller
from .SyncRequestController import SyncRequestController
import ceo.term_utils as term_utils
from ceo.tui.views import RenewUserConfirmationView
class RenewUserController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_membership_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.membership_type = selected_type
def on_next_button_pressed(self, button):
try:
username = self.get_username_from_view()
num_terms = self.get_num_terms_from_view()
except Controller.InvalidInput:
return
self.model.username = username
self.model.num_terms = num_terms
self.view.flash_text.set_text('Looking up user...')
Thread(target=self._get_next_terms).start()
def _get_next_terms(self):
try:
self.model.new_terms = term_utils.get_terms_for_renewal_for_user(
self.model.username,
self.model.num_terms,
self.model.membership_type == 'club_rep',
self
)
except Controller.RequestFailed:
return
def target():
self.view.flash_text.set_text('')
view = RenewUserConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
self.app.run_in_main_loop(target)
def get_resp(self):
uid = self.model.username
body = {'uid': uid}
if self.model.membership_type == 'club_rep':
body['non_member_terms'] = self.model.new_terms
else:
body['terms'] = self.model.new_terms
return http_post(f'/api/members/{uid}/renew', json=body)

View File

@ -0,0 +1,49 @@
import os
from zope import component
from ...utils import http_get, http_post, write_db_creds
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import ResetDatabasePasswordConfirmationView, ResetDatabasePasswordResponseView
from ceo_common.interfaces import IConfig
class ResetDatabasePasswordController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_db_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.db_type = selected_type
def on_next_button_pressed(self, button):
view = ResetDatabasePasswordConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def get_resp(self):
db_type = self.model.db_type
username = krb.get_username()
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
self.model.user_dict = resp.json()
return http_post(f'/api/db/{db_type}/{username}/pwreset')
def get_response_view(self):
return ResetDatabasePasswordResponseView(self.model, self, self.app)
def write_db_creds_to_file(self):
password = self.model.resp_json['password']
db_type = self.model.db_type
cfg = component.getUtility(IConfig)
db_host = cfg.get(f'{db_type}_host')
homedir = self.model.user_dict['home_directory']
filename = os.path.join(homedir, f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, self.model.user_dict, password, db_type, db_host
)
self.model.password = password
self.model.filename = filename
self.model.wrote_to_file = wrote_to_file

View File

@ -0,0 +1,27 @@
from ...utils import http_post
from .Controller import Controller
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import ResetPasswordUsePasswdView, ResetPasswordConfirmationView, ResetPasswordResponseView
class ResetPasswordController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_post(f'/api/members/{self.model.username}/pwreset')
def get_response_view(self):
return ResetPasswordResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
if self.model.username == krb.get_username():
view = ResetPasswordUsePasswdView(self.model, self, self.app)
else:
view = ResetPasswordConfirmationView(self.model, self, self.app)
self.switch_to_view(view)

View File

@ -0,0 +1,40 @@
from ceo.utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import SearchGroupResponseView, GetGroupResponseView
# this is a little bit bad because it relies on zero coupling between
# the GetGroupResponseView and the GetGroupController
# coupling is also introduced between this controller and the
# SearchGroupResponseView as it requires this class's callback
class SearchGroupController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
if self.model.want_info:
return http_get(f'/api/groups/{self.model.name}')
else:
return http_get(f'/api/groups/search/{self.model.name}/{self.model.count}')
def get_response_view(self):
if self.model.want_info:
return GetGroupResponseView(self.model, self, self.app)
else:
return SearchGroupResponseView(self.model, self, self.app)
def group_info_callback(self, button, cn):
self.model.name = cn
self.model.want_info = True
self.request_in_progress = False
self.on_next_button_pressed(button)
def on_next_button_pressed(self, button):
try:
if not self.model.want_info:
self.model.name = self.get_username_from_view()
self.model.count = 10
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,47 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
import ceo.tui.utils as tui_utils
from ceo.tui.views import TransactionView
from ceod.transactions.members import UpdateMemberPositionsTransaction
class SetPositionsController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_next_button_pressed(self, button):
body = {}
for pos, field in self.view.position_fields.items():
if field.edit_text != '':
body[pos] = field.edit_text.replace(' ', '').split(',')
model = TransactionModel(
UpdateMemberPositionsTransaction.operations,
'POST', '/api/positions', json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
def lookup_positions_async(self):
self.view.flash_text.set_text('Looking up positions...')
Thread(target=self.lookup_positions_sync).start()
def lookup_positions_sync(self):
resp = http_get('/api/positions')
try:
positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
for pos, usernames in positions.items():
self.model.positions[pos] = ','.join(usernames)
def target():
self.view.flash_text.set_text('')
self.view.update_fields()
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,39 @@
from threading import Thread
from .Controller import Controller
import ceo.tui.utils as tui_utils
from ceo.tui.views import SyncResponseView
class SyncRequestController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.request_in_progress = False
def get_resp(self):
# To be implemented by child classes
raise NotImplementedError()
def get_response_view(self):
return SyncResponseView(self.model, self, self.app)
def on_confirmation_button_pressed(self, button):
if self.request_in_progress:
return
self.request_in_progress = True
self.view.flash_text.set_text('Sending request...')
def main_loop_target():
self.view.flash_text.set_text('')
view = self.get_response_view()
self.switch_to_view(view)
def thread_target():
resp = self.get_resp()
try:
self.model.resp_json = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
self.app.run_in_main_loop(main_loop_target)
Thread(target=thread_target).start()

View File

@ -0,0 +1,110 @@
from threading import Thread
from typing import Dict, List
from ...StreamResponseHandler import StreamResponseHandler
from ...utils import http_request, generic_handle_stream_response
from .Controller import Controller
class TransactionController(Controller, StreamResponseHandler):
def __init__(self, model, app):
super().__init__(model, app)
self.op_idx = 0
self.error_messages = []
def start(self):
Thread(target=self._start_txn).start()
def _start_txn(self):
resp = http_request(
self.model.http_verb,
self.model.req_path,
**self.model.req_kwargs
)
data = generic_handle_stream_response(resp, self.model.operations, self)
self.write_extra_txn_info(data)
# to be overridden in child classes if desired
def write_extra_txn_info(self, data: List[Dict]):
pass
def _show_lines(self, lines):
num_lines = len(lines)
# Since the message_text is at the bottom of the window,
# we want to add sufficient padding to the bottom of the text
lines += [''] * max(4 - num_lines, 0)
for i, line in enumerate(lines):
if type(line) is str:
lines[i] = line + '\n'
else: # tuple (attr, text)
lines[i] = (line[0], line[1] + '\n')
self.view.message_text.set_text(lines)
def _abort(self):
for elem in self.view.right_col_elems[self.op_idx:]:
elem.set_text(('red', 'ABORTED'))
self.view.enable_next_button()
def begin(self):
pass
def handle_non_200(self, resp):
def target():
self._abort()
lines = ['An error occurred:']
if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error']
else:
err_msg = resp.text
lines.extend(err_msg.split('\n'))
self._show_lines(lines)
self.app.run_in_main_loop(target)
def handle_aborted(self, err_msg):
def target():
self._abort()
lines = [
'The transaction was rolled back.',
'The error was:',
'',
*err_msg.split('\n'),
]
self._show_lines(lines)
self.app.run_in_main_loop(target)
def handle_completed(self):
def target():
lines = ['Transaction successfully completed.']
if len(self.error_messages) > 0:
lines.append('There were some errors:')
for msg in self.error_messages:
lines.extend(msg.split('\n'))
self._show_lines(lines)
self.view.enable_next_button()
self.app.run_in_main_loop(target)
def handle_successful_operation(self):
def target():
self.view.right_col_elems[self.op_idx].set_text(('green', 'Done'))
self.op_idx += 1
self.app.run_in_main_loop(target)
def handle_failed_operation(self, err_msg):
def target():
self.view.right_col_elems[self.op_idx].set_text(('red', 'Failed'))
self.op_idx += 1
if err_msg is not None:
self.error_messages.append(err_msg)
self.app.run_in_main_loop(target)
def handle_skipped_operation(self):
def target():
self.view.right_col_elems[self.op_idx].set_text('Skipped')
self.op_idx += 1
self.app.run_in_main_loop(target)
def handle_unrecognized_operation(self, operation):
def target():
self.error_messages.append('Unrecognized operation: ' + operation)
self.op_idx += 1
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,6 @@
from .Controller import Controller
class WelcomeController(Controller):
def __init__(self, model, app):
super().__init__(model, app)

View File

@ -0,0 +1,19 @@
from .Controller import Controller
from .WelcomeController import WelcomeController
from .AddUserController import AddUserController
from .AddUserTransactionController import AddUserTransactionController
from .RenewUserController import RenewUserController
from .GetUserController import GetUserController
from .ResetPasswordController import ResetPasswordController
from .ChangeLoginShellController import ChangeLoginShellController
from .AddGroupController import AddGroupController
from .GetGroupController import GetGroupController
from .SearchGroupController import SearchGroupController
from .AddMemberToGroupController import AddMemberToGroupController
from .RemoveMemberFromGroupController import RemoveMemberFromGroupController
from .CreateDatabaseController import CreateDatabaseController
from .ResetDatabasePasswordController import ResetDatabasePasswordController
from .GetPositionsController import GetPositionsController
from .SetPositionsController import SetPositionsController
from .TransactionController import TransactionController
from .SyncRequestController import SyncRequestController

View File

@ -0,0 +1,7 @@
class AddGroupModel:
name = 'AddGroup'
title = 'Add group'
def __init__(self):
self.name = ''
self.description = ''

View File

@ -0,0 +1,8 @@
class AddMemberToGroupModel:
name = 'AddMemberToGroup'
title = 'Add member to group'
def __init__(self):
self.name = ''
self.username = ''
self.subscribe_to_lists = True

View File

@ -0,0 +1,13 @@
class AddUserModel:
name = 'AddUser'
title = 'Add user'
def __init__(self):
self.membership_type = 'general_member'
self.username = ''
self.full_name = ''
self.first_name = ''
self.last_name = ''
self.program = ''
self.forwarding_address = ''
self.num_terms = 1

View File

@ -0,0 +1,8 @@
class ChangeLoginShellModel:
name = 'ChangeLoginShell'
title = 'Change login shell'
def __init__(self):
self.username = ''
self.login_shell = ''
self.resp_json = None

View File

@ -0,0 +1,12 @@
class CreateDatabaseModel:
name = 'CreateDatabase'
title = 'Create database'
def __init__(self):
self.db_type = 'mysql'
self.user_dict = None
self.resp_json = None
self.password = None
self.db_host = None
self.filename = None
self.wrote_to_file = False

View File

@ -0,0 +1,7 @@
class GetGroupModel:
name = 'GetGroup'
title = 'Get group members'
def __init__(self):
self.name = ''
self.resp_json = None

View File

@ -0,0 +1,4 @@
class GetPositionsModel:
name = 'GetPositions'
title = 'Get positions'
positions = {}

View File

@ -0,0 +1,7 @@
class GetUserModel:
name = 'GetUser'
title = 'Get user info'
def __init__(self):
self.username = ''
self.resp_json = None

View File

@ -0,0 +1,8 @@
class RemoveMemberFromGroupModel:
name = 'RemoveMemberFromGroup'
title = 'Remove member from group'
def __init__(self):
self.name = ''
self.username = ''
self.unsubscribe_from_lists = True

View File

@ -0,0 +1,10 @@
class RenewUserModel:
name = 'RenewUser'
title = 'Renew user'
def __init__(self):
self.membership_type = 'general_member'
self.username = ''
self.num_terms = 1
self.new_terms = None
self.resp_json = None

View File

@ -0,0 +1,11 @@
class ResetDatabasePasswordModel:
name = 'ResetDatabasePassword'
title = 'Reset database password'
def __init__(self):
self.db_type = 'mysql'
self.user_dict = None
self.resp_json = None
self.password = None
self.filename = None
self.wrote_to_file = False

View File

@ -0,0 +1,7 @@
class ResetPasswordModel:
name = 'ResetPassword'
title = 'Reset password'
def __init__(self):
self.username = ''
self.resp_json = None

View File

@ -0,0 +1,9 @@
class SearchGroupModel:
name = 'SearchGroup'
title = 'Search groups'
def __init__(self):
self.name = ''
self.resp_json = None
self.count = 10
self.want_info = False

View File

@ -0,0 +1,4 @@
class SetPositionsModel:
name = 'SetPositions'
title = 'Set positions'
positions = {}

View File

@ -0,0 +1,9 @@
class TransactionModel:
name = 'Transaction'
title = 'Running transaction'
def __init__(self, operations, http_verb, req_path, **req_kwargs):
self.operations = operations
self.http_verb = http_verb
self.req_path = req_path
self.req_kwargs = req_kwargs

View File

@ -0,0 +1,45 @@
from .AddUserModel import AddUserModel
from .RenewUserModel import RenewUserModel
from .GetUserModel import GetUserModel
from .ResetPasswordModel import ResetPasswordModel
from .ChangeLoginShellModel import ChangeLoginShellModel
from .AddGroupModel import AddGroupModel
from .GetGroupModel import GetGroupModel
from .SearchGroupModel import SearchGroupModel
from .AddMemberToGroupModel import AddMemberToGroupModel
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
from .CreateDatabaseModel import CreateDatabaseModel
from .ResetDatabasePasswordModel import ResetDatabasePasswordModel
from .GetPositionsModel import GetPositionsModel
from .SetPositionsModel import SetPositionsModel
class WelcomeModel:
name = 'Welcome'
title = 'CSC Electronic Office'
def __init__(self):
self.categories = {
'Members': [
AddUserModel,
RenewUserModel,
GetUserModel,
ResetPasswordModel,
ChangeLoginShellModel,
],
'Groups': [
AddGroupModel,
GetGroupModel,
SearchGroupModel,
AddMemberToGroupModel,
RemoveMemberFromGroupModel,
],
'Databases': [
CreateDatabaseModel,
ResetDatabasePasswordModel,
],
'Positions': [
GetPositionsModel,
SetPositionsModel,
],
}

View File

@ -0,0 +1,16 @@
from .WelcomeModel import WelcomeModel
from .AddUserModel import AddUserModel
from .RenewUserModel import RenewUserModel
from .GetUserModel import GetUserModel
from .ResetPasswordModel import ResetPasswordModel
from .ChangeLoginShellModel import ChangeLoginShellModel
from .AddGroupModel import AddGroupModel
from .GetGroupModel import GetGroupModel
from .SearchGroupModel import SearchGroupModel
from .AddMemberToGroupModel import AddMemberToGroupModel
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
from .CreateDatabaseModel import CreateDatabaseModel
from .ResetDatabasePasswordModel import ResetDatabasePasswordModel
from .GetPositionsModel import GetPositionsModel
from .SetPositionsModel import SetPositionsModel
from .TransactionModel import TransactionModel

42
ceo/tui/start.py Normal file
View File

@ -0,0 +1,42 @@
import urwid
from .app import App
from .utils import get_mvc
def exit_on_special_chars(key):
if key in ('q', 'Q', 'esc'):
raise urwid.ExitMainLoop()
def main():
# Just put some empty placeholder in the main widget for now
# (will be replaced by the WelcomeView)
main_widget = urwid.Padding(urwid.Text(''), left=2, right=2)
top = urwid.Overlay(
main_widget,
urwid.AttrMap(urwid.SolidFill(' '), 'background'),
align='center',
width=('relative', App.REL_WIDTH_PCT),
valign='middle',
height=('relative', App.REL_HEIGHT_PCT),
min_width=App.WIDTH,
min_height=App.HEIGHT,
)
loop = urwid.MainLoop(
top,
palette=[
('reversed', 'standout', ''),
('bold', 'bold', ''),
('green', 'light green', ''),
('red', 'light red', ''),
('background', 'standout,light cyan', ''),
],
# Disable the mouse (makes it hard to copy text from the screen)
handle_mouse=False,
unhandled_input=exit_on_special_chars
)
app = App(loop, main_widget)
_, view, _ = get_mvc(app, 'Welcome')
view.activate()
loop.run()

93
ceo/tui/utils.py Normal file
View File

@ -0,0 +1,93 @@
import json
from ceo.tui.controllers import *
from ceo.tui.models import *
from ceo.tui.views import *
def handle_sync_response(resp, controller):
if resp.ok:
if resp.headers.get('content-type') == 'application/json':
return resp.json()
# streaming response
return [json.loads(line) for line in resp.text.splitlines()]
def target():
view = ErrorView(controller.model, controller, controller.app)
controller.switch_to_view(view)
if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error']
else:
err_msg = resp.text.rstrip()
controller.model.error_message = err_msg
controller.app.run_in_main_loop(target)
raise Controller.RequestFailed()
# this can probably be simplified with getattr or something
def get_mvc(app, name):
if name == WelcomeModel.name:
model = WelcomeModel()
controller = WelcomeController(model, app)
view = WelcomeView(model, controller, app)
elif name == AddUserModel.name:
model = AddUserModel()
controller = AddUserController(model, app)
view = AddUserView(model, controller, app)
elif name == RenewUserModel.name:
model = RenewUserModel()
controller = RenewUserController(model, app)
view = RenewUserView(model, controller, app)
elif name == GetUserModel.name:
model = GetUserModel()
controller = GetUserController(model, app)
view = GetUserView(model, controller, app)
elif name == ResetPasswordModel.name:
model = ResetPasswordModel()
controller = ResetPasswordController(model, app)
view = ResetPasswordView(model, controller, app)
elif name == ChangeLoginShellModel.name:
model = ChangeLoginShellModel()
controller = ChangeLoginShellController(model, app)
view = ChangeLoginShellView(model, controller, app)
elif name == AddGroupModel.name:
model = AddGroupModel()
controller = AddGroupController(model, app)
view = AddGroupView(model, controller, app)
elif name == GetGroupModel.name:
model = GetGroupModel()
controller = GetGroupController(model, app)
view = GetGroupView(model, controller, app)
elif name == SearchGroupModel.name:
model = SearchGroupModel()
controller = SearchGroupController(model, app)
view = SearchGroupView(model, controller, app)
elif name == AddMemberToGroupModel.name:
model = AddMemberToGroupModel()
controller = AddMemberToGroupController(model, app)
view = AddMemberToGroupView(model, controller, app)
elif name == RemoveMemberFromGroupModel.name:
model = RemoveMemberFromGroupModel()
controller = RemoveMemberFromGroupController(model, app)
view = RemoveMemberFromGroupView(model, controller, app)
elif name == CreateDatabaseModel.name:
model = CreateDatabaseModel()
controller = CreateDatabaseController(model, app)
view = CreateDatabaseView(model, controller, app)
elif name == ResetDatabasePasswordModel.name:
model = ResetDatabasePasswordModel()
controller = ResetDatabasePasswordController(model, app)
view = ResetDatabasePasswordView(model, controller, app)
elif name == GetPositionsModel.name:
model = GetPositionsModel()
controller = GetPositionsController(model, app)
view = GetPositionsView(model, controller, app)
elif name == SetPositionsModel.name:
model = SetPositionsModel()
controller = SetPositionsController(model, app)
view = SetPositionsView(model, controller, app)
else:
raise NotImplementedError()
controller.view = view
return model, view, controller

View File

@ -0,0 +1,10 @@
from .ConfirmationView import ConfirmationView
class AddGroupConfirmationView(ConfirmationView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
lines = [
f"A new group '{self.model.name}' will be created."
]
self.set_lines(lines)

View File

@ -0,0 +1,21 @@
import urwid
from .ColumnView import ColumnView
class AddGroupView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.name_edit = urwid.Edit()
self.description_edit = urwid.Edit()
rows = [
(
urwid.Text('Name:', align='right'),
self.name_edit
),
(
urwid.Text('Description:', align='right'),
self.description_edit
)
]
self.set_rows(rows)

View File

@ -0,0 +1,10 @@
from .ConfirmationView import ConfirmationView
class AddMemberToGroupConfirmationView(ConfirmationView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
lines = [
f"User '{self.model.username}' will be added to the group '{self.model.name}'."
]
self.set_lines(lines)

View File

@ -0,0 +1,33 @@
import urwid
from .ColumnView import ColumnView
class AddMemberToGroupView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.name_edit = urwid.Edit()
self.username_edit = urwid.Edit()
rows = [
(
urwid.Text('Group name:', align='right'),
self.name_edit
),
(
urwid.Text('New group member:', align='right'),
self.username_edit
)
]
checkbox = urwid.CheckBox(
'Subscribe to auxiliary mailing lists',
state=True,
on_state_change=self.controller.on_list_subscribe_checkbox_change
)
# This is necessary to place the checkbox in the center of the page
# (urwid.Padding doesn't seem to have an effect on it)
checkbox = urwid.Columns([
('weight', 1, urwid.Text('')),
('weight', 3, checkbox)
])
extra_widgets = [urwid.Divider(), checkbox]
self.set_rows(rows, extra_widgets=extra_widgets)

View File

@ -0,0 +1,12 @@
from .ConfirmationView import ConfirmationView
class AddUserConfirmationView(ConfirmationView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
lines = ['Please make sure that:', '']
if self.model.membership_type == 'general_member':
lines.append(f'\N{BULLET} The new member has paid ${self.model.num_terms * 2} in club fees')
lines.append("\N{BULLET} You have verified the name on the new member's WatCard")
lines.append("\N{BULLET} The new member has signed the machine usage agreement")
self.set_lines(lines, align='left')

View File

@ -0,0 +1,78 @@
import urwid
from .ColumnView import ColumnView
class AddUserView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
membership_types_group = []
self.username_edit = urwid.Edit()
self.full_name_edit = urwid.Edit()
self.first_name_edit = urwid.Edit()
self.last_name_edit = urwid.Edit()
self.program_edit = urwid.Edit()
self.forwarding_address_edit = urwid.Edit()
self.num_terms_edit = urwid.IntEdit(default=1)
rows = [
(
urwid.Text('Membership type:', align='right'),
urwid.RadioButton(
membership_types_group,
'General membership ($2)',
on_state_change=self.controller.on_membership_type_changed,
user_data='general_member'
)
),
(
urwid.Divider(),
urwid.RadioButton(
membership_types_group,
'Club rep (free)',
on_state_change=self.controller.on_membership_type_changed,
user_data='club_rep'
)
),
(
urwid.Text('Username:', align='right'),
self.username_edit
),
(
urwid.Text('Full name:', align='right'),
self.full_name_edit
),
(
urwid.Text('First name:', align='right'),
self.first_name_edit
),
(
urwid.Text('Last name:', align='right'),
self.last_name_edit
),
(
urwid.Text('Program:', align='right'),
self.program_edit
),
(
urwid.Text('Forwarding address:', align='right'),
self.forwarding_address_edit
),
(
urwid.Text('Number of terms:', align='right'),
self.num_terms_edit
),
]
self.set_rows(
rows,
# We want to know when the username field loses focus
notify_when_focus_changes=True,
right_col_weight=2
)
def update_fields(self):
self.full_name_edit.edit_text = self.model.full_name
self.first_name_edit.edit_text = self.model.first_name
self.last_name_edit.edit_text = self.model.last_name
self.program_edit.edit_text = self.model.program
self.forwarding_address_edit.edit_text = self.model.forwarding_address
self.num_terms_edit.edit_text = str(self.model.num_terms)

View File

@ -0,0 +1,10 @@
from .ConfirmationView import ConfirmationView
class ChangeLoginShellConfirmationView(ConfirmationView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
lines = [
f"{self.model.username}'s login shell will be set to {self.model.login_shell}."
]
self.set_lines(lines)

View File

@ -0,0 +1,27 @@
import urwid
from .ColumnView import ColumnView
class ChangeLoginShellView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.username_edit = urwid.Edit()
self.login_shell_edit = urwid.Edit()
rows = [
(
urwid.Text('Username:', align='right'),
self.username_edit
),
(
urwid.Text('Login shell:', align='right'),
self.login_shell_edit
)
]
self.set_rows(
rows,
notify_when_focus_changes=True
)
def update_fields(self):
self.login_shell_edit.edit_text = self.model.login_shell

View File

@ -0,0 +1,32 @@
import urwid
from .ColumnView import ColumnView
class ColumnResponseView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
def set_pairs(self, pairs, right_col_weight=1):
for i, (left, right) in enumerate(pairs):
if type(right) is list:
pairs[i] = (left, ','.join(map(str, right)))
else:
pairs[i] = (left, str(right))
rows = [
(
urwid.Text(
left + ':' if left != '' else '',
align='right'
),
urwid.Text(right)
)
for left, right in pairs
]
self.set_rows(
rows,
right_col_weight=right_col_weight,
disable_cols=True,
no_back_button=True,
on_next=self.controller.get_next_menu_callback('Welcome')
)

View File

@ -0,0 +1,59 @@
import urwid
from .View import View
from .utils import wrap_in_frame
class ColumnView(View):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
def set_rows(
self,
rows,
right_col_weight=1,
notify_when_focus_changes=False,
disable_cols=False,
extra_widgets=None,
no_back_button=False,
on_next=None,
no_next_button=False,
):
# Each item in the list is two columns
columns_list = [
urwid.Columns(
[('weight', 1, left), ('weight', right_col_weight, right)],
dividechars=3,
focus_column=1
)
for left, right in rows
]
if extra_widgets is not None:
columns_list.extend(extra_widgets)
listwalker = urwid.SimpleFocusListWalker(columns_list)
if notify_when_focus_changes:
# See https://stackoverflow.com/a/43125172
urwid.connect_signal(
listwalker, 'modified',
self.controller.on_row_focus_changed
)
# Keep a reference for the controller
self.listwalker = listwalker
cols = urwid.ListBox(listwalker)
if disable_cols:
cols = urwid.WidgetDisable(cols)
self.flash_text = urwid.Text('')
if no_back_button:
on_back = None
else:
on_back = self.controller.prev_menu_callback
if on_next is None and not no_next_button:
on_next = self.controller.on_next_button_pressed
body = cols
self.original_widget = wrap_in_frame(
body,
self.model.title,
on_back=on_back,
on_next=on_next,
flash_text=self.flash_text,
)

View File

@ -0,0 +1,18 @@
import urwid
from .PlainTextView import PlainTextView
class ConfirmationView(PlainTextView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.flash_text = urwid.Text('')
def set_lines(self, lines, align='center'):
super().set_lines(
lines,
align=align,
on_back=self.controller.prev_menu_callback,
on_next=self.controller.on_confirmation_button_pressed,
flash_text=self.flash_text,
)

View File

@ -0,0 +1,14 @@
from .ConfirmationView import ConfirmationView
import ceo.krb_check as krb
class CreateDatabaseConfirmationView(ConfirmationView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
db_type = self.model.db_type
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
username = krb.get_username()
lines = [
f"A new {db_type_name} database will be created for {username}."
]
self.set_lines(lines)

View File

@ -0,0 +1,33 @@
from .PlainTextView import PlainTextView
class CreateDatabaseResponseView(PlainTextView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
def activate(self):
self.controller.write_db_creds_to_file()
username = self.model.user_dict['uid']
password = self.model.password
db_host = self.model.db_host
filename = self.model.filename
wrote_to_file = self.model.wrote_to_file
lines = [
'Connection information:',
'',
f'Database: {username}',
f'Username: {username}',
f'Password: {password}',
f'Host: {db_host}',
''
]
if wrote_to_file:
lines.append(f"These settings have been written to {filename}.")
else:
lines.append(f"We were unable to write these settings to {filename}.")
self.set_lines(
lines,
align='left',
on_next=self.controller.get_next_menu_callback('Welcome'),
)
super().activate()

View File

@ -0,0 +1,30 @@
import urwid
from .ColumnView import ColumnView
class CreateDatabaseView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
db_types_group = []
rows = [
(
urwid.Text('Database type:', align='right'),
urwid.RadioButton(
db_types_group,
'MySQL',
on_state_change=self.controller.on_db_type_changed,
user_data='mysql'
)
),
(
urwid.Divider(),
urwid.RadioButton(
db_types_group,
'PostgreSQL',
on_state_change=self.controller.on_db_type_changed,
user_data='postgresql'
)
),
]
self.set_rows(rows)

View File

@ -0,0 +1,15 @@
from .PlainTextView import PlainTextView
class ErrorView(PlainTextView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
lines = [
'An error occurred:',
'',
*model.error_message.split('\n')
]
self.set_lines(
lines,
on_next=self.controller.get_next_menu_callback('Welcome'),
)

View File

@ -0,0 +1,26 @@
from .PlainTextView import PlainTextView
class GetGroupResponseView(PlainTextView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
d = self.model.resp_json
if 'description' in d:
desc = d['description'] + ' (' + d['cn'] + ')'
else:
desc = d['cn']
lines = [
'Members of ' + desc + ':',
''
]
lines.extend([
member['cn'] + ' (' + member['uid'] + ')'
for member in self.model.resp_json['members']
])
self.set_lines(
lines,
scrollable=True,
on_next=self.controller.get_next_menu_callback('Welcome'),
linebox=True,
subtitle='Press the Down key to move focus',
)

View File

@ -0,0 +1,16 @@
import urwid
from .ColumnView import ColumnView
class GetGroupView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.name_edit = urwid.Edit()
rows = [
(
urwid.Text('Group name:', align='right'),
self.name_edit
)
]
self.set_rows(rows)

View File

@ -0,0 +1,30 @@
from zope import component
import urwid
from .ColumnView import ColumnView
from .position_names import position_names
from ceo_common.interfaces import IConfig
class GetPositionsView(ColumnView):
def __init__(self, model, controller, app):
super().__init__(model, controller, app)
self.position_fields = {}
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
rows = []
for pos in avail:
name = position_names[pos]
field = urwid.Text('...')
self.position_fields[pos] = field
self.model.positions[pos] = ''
rows.append((urwid.Text(name, align='right'), field))
self.set_rows(rows, disable_cols=True, no_next_button=True)
def activate(self):
self.controller.lookup_positions_async()
super().activate()
def update_fields(self):
for pos, field in self.position_fields.items():
field.set_text(self.model.positions[pos])

Some files were not shown because too many files have changed in this diff Show More