forked from public/pyceo
Compare commits
191 Commits
Author | SHA1 | Date |
---|---|---|
Max Erenberg | cf42e49ae6 | |
Max Erenberg | bf2afd1195 | |
Max Erenberg | 5f8de94393 | |
Max Erenberg | 2164ceddf0 | |
Max Erenberg | 7716f7bd10 | |
Nathan Chung | 32cb22665a | |
Nathan Chung | 56a59186e0 | |
Nathan Chung | 5ecec2b54c | |
Nathan Chung | f584a89cec | |
Nathan Chung | 7d9ec99f8f | |
Nathan Chung | 28adf6e13d | |
Max Erenberg | 9c51ad3a01 | |
Nathan Chung | bf7f1c7724 | |
Leon Zhang | 194b5ec4a6 | |
Nathan Chung | c83bbe2563 | |
Max Erenberg | 32709ad401 | |
Leon Zhang | 3780662ba4 | |
Nathan Chung | b1dac8ce07 | |
Nathan Chung | c5edf5ea48 | |
Max Erenberg | a4a4ef089c | |
Max Erenberg | bd1da799c6 | |
Leon Zhang | 25994af312 | |
Ohm Patel | de23296413 | |
Ohm Patel | f06ccdc3f9 | |
Max Erenberg | 5332259731 | |
Max Erenberg | 392ec153d0 | |
Max Erenberg | 36bf340385 | |
Max Erenberg | 7e851daa8f | |
Max Erenberg | e0ed4fa23a | |
Max Erenberg | 6786c8e44e | |
Max Erenberg | 337c05c511 | |
Max Erenberg | 65688c72da | |
Justin Chung | 968f0815c7 | |
Daniel Sun | 010937ea17 | |
Max Erenberg | 234ab62f27 | |
Max Erenberg | f9bda2f724 | |
Max Erenberg | 239b992107 | |
Max Erenberg | 754731ba5f | |
Max Erenberg | b33339817f | |
Max Erenberg | 6dccd8b659 | |
Max Erenberg | 3f58d1aff5 | |
Justin Chung | 5e8f1b5ba5 | |
Max Erenberg | f84965c8e1 | |
Max Erenberg | 4394c4e277 | |
Jonathan Leung | b507c56136 | |
Max Erenberg | c0c9736593 | |
Max Erenberg | 1e452d10ce | |
Max Erenberg | 6a1fa81b82 | |
Max Erenberg | 6df1f4d459 | |
Edwin | 2cf9e25b59 | |
Edwin | 9ff3d850c9 | |
Edwin | b4a1373559 | |
Raymond Li | dceb5d6572 | |
Jonathan Leung | c30ca54752 | |
Nathan Chung | 3b7c89c925 | |
Max Erenberg | a2324090f3 | |
Max Erenberg | f66f7c8f5a | |
Max Erenberg | 3e5b829085 | |
Rio Liu | 57ba72ef26 | |
Max Erenberg | 779e35a08e | |
Raymond Li | 3cc9b011c3 | |
Max Erenberg | 2739c45aff | |
Max Erenberg | 651f4fb702 | |
Max Erenberg | 953bee549e | |
Max Erenberg | 0334e7e667 | |
Max Erenberg | 8decd3bc30 | |
Max Erenberg | 8ad8271db1 | |
Raymond Li | 4ebb9bb0a8 | |
Max Erenberg | cfb5f77711 | |
Max Erenberg | 32b2dbb307 | |
Max Erenberg | dc412ef5cb | |
Max Erenberg | dbb5bf1c8d | |
Max Erenberg | 00c7d562ad | |
Max Erenberg | 6fae2e4115 | |
Max Erenberg | 1fc432bb0f | |
Max Erenberg | 2678bdf16e | |
Rio Liu | 55c4b2151d | |
Max Erenberg | 87470e1f3b | |
Max Erenberg | b543f0eb0c | |
Max Erenberg | 19496b4568 | |
Raymond Li | 8da700472f | |
Max Erenberg | 5197228d68 | |
Max Erenberg | 9b8425f30e | |
Max Erenberg | f3c542208a | |
Max Erenberg | 2487ab3668 | |
Max Erenberg | 539de01c4d | |
Max Erenberg | af4e342f3c | |
Max Erenberg | 00ced22950 | |
Max Erenberg | 5200259cfa | |
Max Erenberg | 7d3e03e7fd | |
Max Erenberg | 71e6b474a4 | |
Max Erenberg | 5351cf8aee | |
Max Erenberg | 2ee9511337 | |
Max Erenberg | feb16ee625 | |
Max Erenberg | 0c166f93ad | |
Max Erenberg | ef45344724 | |
Max Erenberg | 28b5000e89 | |
Max Erenberg | 7908d49840 | |
Max Erenberg | 41d293ee3b | |
Max Erenberg | fa05c4ad4a | |
Max Erenberg | 02598fa3bc | |
Max Erenberg | 7ec17b2b4d | |
Raymond Li | 5f93b0e912 | |
Raymond Li | 7cb07547fa | |
Raymond Li | f45efefaca | |
Raymond Li | d7b6ac2307 | |
Raymond Li | 70d27c5817 | |
Max Erenberg | 88b40b79cc | |
Max Erenberg | 1e94132e97 | |
Max Erenberg | d200d3d6cf | |
Max Erenberg | 5e03ff932f | |
Max Erenberg | 0422e4487b | |
Max Erenberg | 6e96e409be | |
d278liu | 250d24ae37 | |
Max Erenberg | 0640337564 | |
Max Erenberg | afb63f44dc | |
Max Erenberg | 19c860b4ed | |
Max Erenberg | f08f4872cf | |
Rio Liu | b4110d887d | |
Max Erenberg | f1c0ce3dd6 | |
Max Erenberg | 1338825c5d | |
Max Erenberg | 3a30f45672 | |
Max Erenberg | bd50f4142f | |
Max Erenberg | 0d55f01bfc | |
Max Erenberg | e71d9b7d30 | |
Max Erenberg | aa2efcb26a | |
Max Erenberg | a7c5098b67 | |
Max Erenberg | 0798419e34 | |
Max Erenberg | 7306241a78 | |
Max Erenberg | eda5ca576a | |
Max Erenberg | ac98aaf38d | |
Max Erenberg | 798510511f | |
Max Erenberg | ed9893604f | |
Max Erenberg | 52db130ef8 | |
Max Erenberg | 99819ce4fe | |
Max Erenberg | 89febf0400 | |
Max Erenberg | 620ef8ef8e | |
Max Erenberg | ae48bcd98a | |
Max Erenberg | 1f107b0614 | |
Max Erenberg | f9f5d70ad3 | |
Max Erenberg | bdc2f9b31b | |
Max Erenberg | 729f443e72 | |
Max Erenberg | dbbc533111 | |
Max Erenberg | 57787c170a | |
Max Erenberg | beec46a951 | |
Max Erenberg | 3218938909 | |
Max Erenberg | 2493bb1a6b | |
Max Erenberg | 02aff43e7f | |
Max Erenberg | 2970736105 | |
Max Erenberg | a0cc29738b | |
Max Erenberg | 3e1af74f0c | |
Max Erenberg | a5bab379e2 | |
Max Erenberg | ac573039da | |
Max Erenberg | 1374ff95aa | |
Max Erenberg | 23f40c74f9 | |
Max Erenberg | ac7f41801b | |
Max Erenberg | e3c50d867a | |
Max Erenberg | 1fbc068f3b | |
Max Erenberg | 1fcc49ef12 | |
Max Erenberg | 3e1131c4e4 | |
Rio Liu | 7edc01e42b | |
Max Erenberg | 2a5d903eba | |
Max Erenberg | 3cba9680f5 | |
Max Erenberg | 749ca41080 | |
Max Erenberg | de18a9f293 | |
Max Erenberg | 652620a7c5 | |
Max Erenberg | 155c96c500 | |
Max Erenberg | ad38588141 | |
Andrew Wang | 33323fd112 | |
Max Erenberg | cb6243c3e2 | |
Neil Parikh | 6e2b9dee24 | |
Rio Liu | 651988bb08 | |
Max Erenberg | 82b7b2c015 | |
Max Erenberg | 0bf24230a0 | |
Max Erenberg | 4aaf10b687 | |
Max Erenberg | df7148940a | |
Max Erenberg | 21173d1b8c | |
Max Erenberg | beb16b1740 | |
Max Erenberg | 6b3ad28e89 | |
Max Erenberg | ebaeeaaf13 | |
Max Erenberg | a08fca4c60 | |
Max Erenberg | 1406899ea2 | |
Max Erenberg | d3c98e418a | |
Max Erenberg | af73dd713d | |
Max Erenberg | 39158676ae | |
Max Erenberg | 40ee927b91 | |
Max Erenberg | ee21873ad7 | |
Max Erenberg | cce920d6ba | |
Andrew Wang | c6c01d8720 | |
Max Erenberg | 6f1851fc19 | |
Max Erenberg | bb56870652 |
19
.drone.yml
19
.drone.yml
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 &
|
||||
}
|
||||
|
|
|
@ -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 &
|
||||
}
|
||||
|
|
113
.drone/common.sh
113
.drone/common.sh
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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-----
|
|
@ -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 &
|
||||
}
|
|
@ -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 ) )
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
|
||||
|
||||
exit $?
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
include ceod/model/templates/*.j2
|
|
@ -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
|
|
@ -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
102
README.md
|
@ -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).
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
1.0.31
|
|
@ -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."""
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.")
|
|
@ -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.')
|
|
@ -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.")
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
|
@ -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')
|
|
@ -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.')
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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,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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
|||
from .Controller import Controller
|
||||
|
||||
|
||||
class WelcomeController(Controller):
|
||||
def __init__(self, model, app):
|
||||
super().__init__(model, app)
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class AddGroupModel:
|
||||
name = 'AddGroup'
|
||||
title = 'Add group'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.description = ''
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
class ChangeLoginShellModel:
|
||||
name = 'ChangeLoginShell'
|
||||
title = 'Change login shell'
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.login_shell = ''
|
||||
self.resp_json = None
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class GetGroupModel:
|
||||
name = 'GetGroup'
|
||||
title = 'Get group members'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.resp_json = None
|
|
@ -0,0 +1,4 @@
|
|||
class GetPositionsModel:
|
||||
name = 'GetPositions'
|
||||
title = 'Get positions'
|
||||
positions = {}
|
|
@ -0,0 +1,7 @@
|
|||
class GetUserModel:
|
||||
name = 'GetUser'
|
||||
title = 'Get user info'
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.resp_json = None
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class ResetPasswordModel:
|
||||
name = 'ResetPassword'
|
||||
title = 'Reset password'
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.resp_json = None
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
class SetPositionsModel:
|
||||
name = 'SetPositions'
|
||||
title = 'Set positions'
|
||||
positions = {}
|
|
@ -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
|
|
@ -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,
|
||||
],
|
||||
}
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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')
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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')
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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'),
|
||||
)
|
|
@ -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',
|
||||
)
|
|
@ -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)
|
|
@ -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
Loading…
Reference in New Issue