Compare commits
No commits in common. "master" and "master" have entirely different histories.
40
.drone.yml
40
.drone.yml
|
@ -1,40 +0,0 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
# use the step name to mock out the gethostname() call in our tests
|
||||
- name: phosphoric-acid
|
||||
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 --no-install-recommends -y gcc libkrb5-dev libaugeas0
|
||||
- python3 -m venv venv
|
||||
- . venv/bin/activate
|
||||
- venv/bin/pip install -r dev-requirements.txt -r requirements.txt
|
||||
|
||||
# lint
|
||||
- flake8
|
||||
|
||||
# unit + integration tests
|
||||
- bash -c ". .drone/phosphoric-acid-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- pytest -v
|
||||
|
||||
services:
|
||||
- name: auth1
|
||||
image: debian:bullseye-slim
|
||||
commands:
|
||||
- bash -c ". .drone/auth1-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- sleep infinity
|
||||
- name: coffee
|
||||
image: debian:bullseye-slim
|
||||
commands:
|
||||
- bash -c ". .drone/coffee-setup.sh && IMAGE__setup && CONTAINER__setup"
|
||||
- sleep infinity
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
|
@ -1,17 +0,0 @@
|
|||
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
|
|
@ -1,131 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
. .drone/common.sh
|
||||
|
||||
# If we don't do this then OpenLDAP uses a lot of RAM
|
||||
ulimit -n 1024
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 -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
|
||||
# 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
|
||||
}
|
||||
|
||||
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 &
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
. .drone/common.sh
|
||||
|
||||
CONTAINER__fix_hosts() {
|
||||
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
|
||||
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
|
||||
}
|
||||
|
||||
IMAGE__setup() {
|
||||
IMAGE__ceod_setup
|
||||
apt install --no-install-recommends -y default-mysql-server postgresql
|
||||
|
||||
# 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
|
||||
|
||||
# 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 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
|
||||
ALTER USER postgres WITH PASSWORD 'postgres';
|
||||
REVOKE ALL ON SCHEMA public FROM public;
|
||||
GRANT ALL ON SCHEMA public TO postgres;
|
||||
EOF" postgres
|
||||
|
||||
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 &
|
||||
}
|
116
.drone/common.sh
116
.drone/common.sh
|
@ -1,116 +0,0 @@
|
|||
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() {
|
||||
# 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() {
|
||||
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
|
||||
}
|
213
.drone/data.ldif
213
.drone/data.ldif
|
@ -1,213 +0,0 @@
|
|||
dn: dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
dc: csclub
|
||||
o: Computer Science Club
|
||||
|
||||
dn: ou=People,dc=csclub,dc=internal
|
||||
objectClass: organizationalUnit
|
||||
ou: People
|
||||
|
||||
dn: ou=Group,dc=csclub,dc=internal
|
||||
objectClass: organizationalUnit
|
||||
ou: Group
|
||||
|
||||
dn: ou=SUDOers,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: organizationalUnit
|
||||
ou: SUDOers
|
||||
|
||||
dn: cn=defaults,ou=SUDOers,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: sudoRole
|
||||
cn: defaults
|
||||
sudoOption: !insults
|
||||
sudoOption: !lecture
|
||||
sudoOption: env_reset
|
||||
sudoOption: listpw=never
|
||||
sudoOption: shell_noargs
|
||||
sudoOption: !mail_badpass
|
||||
|
||||
dn: cn=syscom,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
cn: syscom
|
||||
gidNumber: 10001
|
||||
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
||||
|
||||
dn: cn=%syscom,ou=SUDOers,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: sudoRole
|
||||
cn: %syscom
|
||||
sudoUser: %syscom
|
||||
sudoHost: ALL
|
||||
sudoCommand: ALL
|
||||
sudoRunAsUser: ALL
|
||||
|
||||
dn: cn=adm,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
gidNumber: 4
|
||||
cn: adm
|
||||
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
||||
|
||||
dn: cn=office,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
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
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
gidNumber: 40
|
||||
cn: src
|
||||
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
||||
|
||||
dn: cn=staff,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
gidNumber: 50
|
||||
cn: staff
|
||||
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
|
||||
uid: ctdalek
|
||||
uidNumber: 20001
|
||||
gidNumber: 20001
|
||||
objectClass: top
|
||||
objectClass: account
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: member
|
||||
program: MAT/Mathematics Computer Science
|
||||
term: f2021
|
||||
|
||||
dn: cn=ctdalek,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
objectClass: group
|
||||
objectClass: posixGroup
|
||||
cn: ctdalek
|
||||
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
|
||||
uid: regular1
|
||||
uidNumber: 20002
|
||||
gidNumber: 20002
|
||||
objectClass: top
|
||||
objectClass: account
|
||||
objectClass: posixAccount
|
||||
objectClass: shadowAccount
|
||||
objectClass: member
|
||||
program: MAT/Mathematics Computer Science
|
||||
term: f2021
|
||||
|
||||
dn: cn=regular1,ou=Group,dc=csclub,dc=internal
|
||||
objectClass: top
|
||||
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
|
|
@ -1,19 +0,0 @@
|
|||
-----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-----
|
|
@ -1,19 +0,0 @@
|
|||
[kdcdefaults]
|
||||
kdc_ports = 88
|
||||
|
||||
[realms]
|
||||
CSCLUB.INTERNAL = {
|
||||
database_name = /var/lib/krb5kdc/principal
|
||||
admin_keytab = FILE:/etc/krb5kdc/kadm5.keytab
|
||||
acl_file = /etc/krb5kdc/kadm5.acl
|
||||
key_stash_file = /etc/krb5kdc/stash
|
||||
kdc_ports = 88
|
||||
max_life = 10h 0m 0s
|
||||
max_renewable_life = 7d 0h 0m 0s
|
||||
master_key_type = des3-hmac-sha1
|
||||
supported_enctypes = aes256-cts:normal arcfour-hmac:normal des3-hmac-sha1:normal des3-cbc-sha1:normal des-cbc-crc:normal des:normal des:v4 des:norealm des:onlyrealm des:afs3
|
||||
default_principal_flags = +preauth
|
||||
iprop_enable = true
|
||||
iprop_slave_poll = 2m
|
||||
iprop_port = 750
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
[libdefaults]
|
||||
default_realm = CSCLUB.INTERNAL
|
||||
|
||||
kdc_timesync = 1
|
||||
ccache_type = 4
|
||||
forwardable = true
|
||||
proxiable = true
|
||||
|
||||
dns_lookup_kdc = false
|
||||
dns_lookup_realm = false
|
||||
|
||||
allow_weak_crypto = true
|
||||
|
||||
[realms]
|
||||
CSCLUB.INTERNAL = {
|
||||
kdc = auth1.csclub.internal
|
||||
admin_server = auth1.csclub.internal
|
||||
}
|
||||
|
||||
[domain_realm]
|
||||
.csclub.internal = CSCLUB.INTERNAL
|
||||
csclub.internal = CSCLUB.INTERNAL
|
||||
|
||||
[logging]
|
||||
kdc = SYSLOG:INFO:AUTH
|
||||
admin_server = SYSLOG:INFO:AUTH
|
||||
default = SYSLOG:INFO:AUTH
|
|
@ -1,3 +0,0 @@
|
|||
BASE dc=csclub,dc=internal
|
||||
URI ldap://auth1.csclub.internal
|
||||
SUDOERS_BASE ou=SUDOers,dc=csclub,dc=internal
|
|
@ -1,30 +0,0 @@
|
|||
#!/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 &
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
# 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 ) )
|
|
@ -1,19 +0,0 @@
|
|||
#!/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
|
|
@ -1,20 +0,0 @@
|
|||
# /etc/nsswitch.conf
|
||||
#
|
||||
# Example configuration of GNU Name Service Switch functionality.
|
||||
# If you have the `glibc-doc-reference' and `info' packages installed, try:
|
||||
# `info libc "Name Service Switch"' for information about this file.
|
||||
|
||||
passwd: files ldap
|
||||
group: files ldap
|
||||
shadow: files ldap
|
||||
|
||||
hosts: files dns
|
||||
networks: files
|
||||
|
||||
protocols: db files
|
||||
services: db files
|
||||
ethers: db files
|
||||
rpc: db files
|
||||
|
||||
netgroup: nis
|
||||
sudoers: files ldap
|
|
@ -1,47 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -ex
|
||||
|
||||
. .drone/common.sh
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
CONTAINER__setup_userdirs() {
|
||||
# initialize the skel directory
|
||||
shopt -s dotglob
|
||||
mkdir -p /users/skel
|
||||
cp /etc/skel/* /users/skel/
|
||||
|
||||
# create directories for users
|
||||
for user in ctdalek regular1 exec1; do
|
||||
mkdir -p /users/$user
|
||||
chown $user:$user /users/$user
|
||||
done
|
||||
}
|
||||
|
||||
IMAGE__setup() {
|
||||
IMAGE__ceod_setup
|
||||
# git is required by the ClubWebHostingService
|
||||
apt install --no-install-recommends -y git
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -1,287 +0,0 @@
|
|||
# builtin
|
||||
#attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber'
|
||||
# DESC 'An integer uniquely identifying a user in an administrative domain'
|
||||
# EQUALITY integerMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
# SINGLE-VALUE )
|
||||
#
|
||||
|
||||
# builtin
|
||||
#attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber'
|
||||
# DESC 'An integer uniquely identifying a group in an
|
||||
# administrative domain'
|
||||
# EQUALITY integerMatch
|
||||
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
# SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.2 NAME 'gecos'
|
||||
DESC 'The GECOS field; the common name'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SUBSTR caseIgnoreIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.3 NAME 'homeDirectory'
|
||||
DESC 'The absolute path to the home directory'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.4 NAME 'loginShell'
|
||||
DESC 'The path to the login shell'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.6 NAME 'shadowMin'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.7 NAME 'shadowMax'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.8 NAME 'shadowWarning'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.9 NAME 'shadowInactive'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.10 NAME 'shadowExpire'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.11 NAME 'shadowFlag'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple'
|
||||
DESC 'Netgroup triple'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.15 NAME 'ipServicePort'
|
||||
DESC 'Service port number'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol'
|
||||
DESC 'Service protocol name'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber'
|
||||
DESC 'IP protocol number'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber'
|
||||
DESC 'ONC RPC number'
|
||||
EQUALITY integerMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
|
||||
SINGLE-VALUE )
|
||||
attributetype ( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber'
|
||||
DESC 'IPv4 addresses as a dotted decimal omitting leading
|
||||
zeros or IPv6 addresses as defined in RFC2373'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber'
|
||||
DESC 'IP network as a dotted decimal, eg. 192.168,
|
||||
omitting leading zeros'
|
||||
SUP name
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber'
|
||||
DESC 'IP netmask as a dotted decimal, eg. 255.255.255.0,
|
||||
omitting leading zeros'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.22 NAME 'macAddress'
|
||||
DESC 'MAC address in maximal, colon separated hex
|
||||
notation, eg. 00:00:92:90:ee:e2'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.23 NAME 'bootParameter'
|
||||
DESC 'rpc.bootparamd parameter'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.24 NAME 'bootFile'
|
||||
DESC 'Boot image name'
|
||||
EQUALITY caseExactIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.26 NAME 'nisMapName'
|
||||
DESC 'Name of a A generic NIS map'
|
||||
SUP name )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry'
|
||||
DESC 'A generic NIS entry'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
|
||||
SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.28 NAME 'nisPublicKey'
|
||||
DESC 'NIS public key'
|
||||
EQUALITY octetStringMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.29 NAME 'nisSecretKey'
|
||||
DESC 'NIS secret key'
|
||||
EQUALITY octetStringMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.30 NAME 'nisDomain'
|
||||
DESC 'NIS domain'
|
||||
EQUALITY caseIgnoreIA5Match
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26)
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.31 NAME 'automountMapName'
|
||||
DESC 'automount Map Name'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.32 NAME 'automountKey'
|
||||
DESC 'Automount Key value'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
attributetype ( 1.3.6.1.1.1.1.33 NAME 'automountInformation'
|
||||
DESC 'Automount information'
|
||||
EQUALITY caseExactIA5Match
|
||||
SUBSTR caseExactIA5SubstringsMatch
|
||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount' SUP top AUXILIARY
|
||||
DESC 'Abstraction of an account with POSIX attributes'
|
||||
MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory )
|
||||
MAY ( userPassword $ loginShell $ gecos $
|
||||
description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.1 NAME 'shadowAccount' SUP top AUXILIARY
|
||||
DESC 'Additional attributes for shadow passwords'
|
||||
MUST uid
|
||||
MAY ( userPassword $ description $
|
||||
shadowLastChange $ shadowMin $ shadowMax $
|
||||
shadowWarning $ shadowInactive $
|
||||
shadowExpire $ shadowFlag ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' SUP top AUXILIARY
|
||||
DESC 'Abstraction of a group of accounts'
|
||||
MUST gidNumber
|
||||
MAY ( userPassword $ memberUid $
|
||||
description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.3 NAME 'ipService' SUP top STRUCTURAL
|
||||
DESC 'Abstraction an Internet Protocol service.
|
||||
Maps an IP port and protocol (such as tcp or udp)
|
||||
to one or more names; the distinguished value of
|
||||
the cn attribute denotes the services canonical
|
||||
name'
|
||||
MUST ( cn $ ipServicePort $ ipServiceProtocol )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.4 NAME 'ipProtocol' SUP top STRUCTURAL
|
||||
DESC 'Abstraction of an IP protocol. Maps a protocol number
|
||||
to one or more names. The distinguished value of the cn
|
||||
attribute denotes the protocols canonical name'
|
||||
MUST ( cn $ ipProtocolNumber )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.5 NAME 'oncRpc' SUP top STRUCTURAL
|
||||
DESC 'Abstraction of an Open Network Computing (ONC)
|
||||
[RFC1057] Remote Procedure Call (RPC) binding.
|
||||
This class maps an ONC RPC number to a name.
|
||||
The distinguished value of the cn attribute denotes
|
||||
the RPC services canonical name'
|
||||
MUST ( cn $ oncRpcNumber )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.6 NAME 'ipHost' SUP top AUXILIARY
|
||||
DESC 'Abstraction of a host, an IP device. The distinguished
|
||||
value of the cn attribute denotes the hosts canonical
|
||||
name. Device SHOULD be used as a structural class'
|
||||
MUST ( cn $ ipHostNumber )
|
||||
MAY ( userPassword $ l $ description $ manager ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.7 NAME 'ipNetwork' SUP top STRUCTURAL
|
||||
DESC 'Abstraction of a network. The distinguished value of
|
||||
the cn attribute denotes the networks canonical name'
|
||||
MUST ipNetworkNumber
|
||||
MAY ( cn $ ipNetmaskNumber $ l $ description $ manager ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup' SUP top STRUCTURAL
|
||||
DESC 'Abstraction of a netgroup. May refer to other netgroups'
|
||||
MUST cn
|
||||
MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.9 NAME 'nisMap' SUP top STRUCTURAL
|
||||
DESC 'A generic abstraction of a NIS map'
|
||||
MUST nisMapName
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.10 NAME 'nisObject' SUP top STRUCTURAL
|
||||
DESC 'An entry in a NIS map'
|
||||
MUST ( cn $ nisMapEntry $ nisMapName )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.11 NAME 'ieee802Device' SUP top AUXILIARY
|
||||
DESC 'A device with a MAC address; device SHOULD be
|
||||
used as a structural class'
|
||||
MAY macAddress )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.12 NAME 'bootableDevice' SUP top AUXILIARY
|
||||
DESC 'A device with boot parameters; device SHOULD be
|
||||
used as a structural class'
|
||||
MAY ( bootFile $ bootParameter ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.14 NAME 'nisKeyObject' SUP top AUXILIARY
|
||||
DESC 'An object with a public and secret key'
|
||||
MUST ( cn $ nisPublicKey $ nisSecretKey )
|
||||
MAY ( uidNumber $ description ) )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.15 NAME 'nisDomainObject' SUP top AUXILIARY
|
||||
DESC 'Associates a NIS domain with a naming context'
|
||||
MUST nisDomain )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.16 NAME 'automountMap' SUP top STRUCTURAL
|
||||
MUST ( automountMapName )
|
||||
MAY description )
|
||||
|
||||
objectclass ( 1.3.6.1.1.1.2.17 NAME 'automount' SUP top STRUCTURAL
|
||||
DESC 'Automount information'
|
||||
MUST ( automountKey $ automountInformation )
|
||||
MAY description )
|
||||
## namedObject is needed for groups without members
|
||||
objectclass ( 1.3.6.1.4.1.5322.13.1.1 NAME 'namedObject' SUP top
|
||||
STRUCTURAL MAY cn )
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
# This is the main slapd configuration file. See slapd.conf(5) for more
|
||||
# info on the configuration options.
|
||||
|
||||
include /etc/ldap/schema/core.schema
|
||||
include /etc/ldap/schema/cosine.schema
|
||||
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
|
||||
argsfile /var/run/slapd/slapd.args
|
||||
|
||||
#Warning: "stats" is *lots* of logging
|
||||
loglevel sync
|
||||
#loglevel stats config sync acl
|
||||
|
||||
modulepath /usr/lib/ldap
|
||||
moduleload back_hdb
|
||||
moduleload syncprov
|
||||
moduleload auditlog
|
||||
moduleload unique
|
||||
|
||||
sizelimit unlimited
|
||||
timelimit unlimited
|
||||
|
||||
# consider local connections encrypted
|
||||
localssf 128
|
||||
|
||||
# map kerberos users to ldap users
|
||||
sasl-realm CSCLUB.INTERNAL
|
||||
sasl-host auth1.csclub.internal
|
||||
authz-regexp "uid=([^/=]*),cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
|
||||
"uid=$1,ou=people,dc=csclub,dc=internal"
|
||||
authz-regexp "uid=ceod/admin,cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
|
||||
"cn=ceod,dc=csclub,dc=internal"
|
||||
|
||||
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
|
||||
by group/group/uniqueMember="cn=syscom,ou=Group,dc=csclub,dc=internal" write
|
||||
by * break
|
||||
|
||||
# allow office staff to add terms
|
||||
# the renewal program may do the same
|
||||
access to attrs=term
|
||||
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
|
||||
by dn="cn=renewal,dc=csclub,dc=internal" add
|
||||
by * read
|
||||
access to attrs=nonMemberTerm
|
||||
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
|
||||
by dn="cn=renewal,dc=csclub,dc=internal" add
|
||||
by * read
|
||||
|
||||
# allow users to change their shells
|
||||
access to attrs=loginShell
|
||||
by self write
|
||||
by * read
|
||||
|
||||
# allow simple authentication
|
||||
access to attrs=userPassword
|
||||
by anonymous auth
|
||||
by * none
|
||||
|
||||
# allow access to attributes of top; they would otherwise be denied below
|
||||
access to attrs=@top
|
||||
by * read
|
||||
|
||||
# default permit
|
||||
access to *
|
||||
by * read
|
||||
|
||||
# main database options
|
||||
# note: the mdb backend has a horrible bug in 2.4.31
|
||||
# that causes indexing to destroy the database
|
||||
database hdb
|
||||
suffix "dc=csclub,dc=internal"
|
||||
directory "/var/lib/ldap"
|
||||
rootdn cn=root,dc=csclub,dc=internal
|
||||
index default eq
|
||||
index objectClass
|
||||
index entryCSN,entryUUID
|
||||
index uid,uidNumber
|
||||
index cn,gidNumber
|
||||
index uniqueMember,memberUid
|
||||
index sudoUser,sudoHost pres,sub,eq
|
||||
index term,nonMemberTerm
|
||||
index mailLocalAddress
|
||||
index modifyTimestamp,createTimestamp
|
||||
|
||||
# log all changes to the directory
|
||||
overlay auditlog
|
||||
auditlog /var/log/ldap/audit.log
|
||||
|
||||
# enforce uniqueness of usernames etc.
|
||||
overlay unique
|
||||
unique_uri ldap:///ou=People,dc=csclub,dc=internal?uid,uidNumber?sub
|
||||
unique_uri ldap:///ou=Group,dc=csclub,dc=internal?cn,gidNumber?sub
|
||||
|
||||
# this is the master server
|
||||
overlay syncprov
|
||||
syncprov-checkpoint 100 10
|
||||
syncprov-sessionlog 100
|
|
@ -1,17 +0,0 @@
|
|||
#!/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
|
|
@ -1,123 +0,0 @@
|
|||
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,4 @@
|
|||
[DEFAULT]
|
||||
sign-tags = True
|
||||
posttag = git push /users/git/public/pyceo.git --tags
|
||||
debian-tag=v%(version)s
|
|
@ -1,5 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
|
||||
|
||||
exit $?
|
|
@ -1,27 +1,5 @@
|
|||
# If you update this file, please also update the extend-diff-ignore option
|
||||
# in debian/source/options.
|
||||
|
||||
*.key
|
||||
*.gpg
|
||||
*.pgp
|
||||
|
||||
__pycache__/
|
||||
/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
|
||||
/build-stamp
|
||||
/build
|
||||
*.pyc
|
||||
/build-ceo
|
||||
/build-ceod
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
include ceod/model/templates/*.j2
|
34
Makefile
34
Makefile
|
@ -1,34 +0,0 @@
|
|||
#!/usr/bin/make -f
|
||||
SCDFILES = $(wildcard docs/*.scd)
|
||||
MANPAGES = $(patsubst docs/%.scd,docs/%,${SCDFILES})
|
||||
CEO_HOME = /var/lib/ceo
|
||||
|
||||
build: docs venv
|
||||
|
||||
venv:
|
||||
python3 -m venv venv && \
|
||||
. venv/bin/activate && \
|
||||
pip install --upgrade pip && \
|
||||
pip install setuptools wheel && \
|
||||
pip install -r requirements.txt && \
|
||||
pip install .
|
||||
|
||||
install:
|
||||
@# Prepare the virtualenv to be moved (dangerous!)
|
||||
@# Make sure you don't have '|' in your paths
|
||||
grep -IRl $(CURDIR)/venv venv/bin | \
|
||||
xargs perl -pe 's|\Q$(CURDIR)/venv\E|$(CEO_HOME)/venv|g' -i
|
||||
mkdir -p $(DESTDIR)$(CEO_HOME)
|
||||
mv venv $(DESTDIR)$(CEO_HOME)
|
||||
|
||||
docs:
|
||||
for file in ${SCDFILES} ; do \
|
||||
scdoc < $$file > `echo $$file | grep -oP '.*(?=\.scd$$)'` ; \
|
||||
done
|
||||
|
||||
clean:
|
||||
rm -f ${MANPAGES}
|
||||
rm -rf venv
|
||||
rm -rf debian/{ceo,ceod,ceo-common,tmp}
|
||||
|
||||
.PHONY: build docs clean venv install
|
122
PACKAGING.md
122
PACKAGING.md
|
@ -1,122 +0,0 @@
|
|||
# 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? :')
|
249
README.md
249
README.md
|
@ -1,249 +0,0 @@
|
|||
# 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 [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
|
||||
### 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.
|
||||
|
||||
Once you have the dev environment setup, there are a few more steps you'll
|
||||
need to do for ceo.
|
||||
|
||||
#### Kerberos principals
|
||||
First, you'll need `ceod/<hostname>` principals for each of phosphoric-acid,
|
||||
coffee and mail. (coffee is taking over the role of caffeine for the DB
|
||||
endpoints). For example, in the phosphoric-acid container:
|
||||
```sh
|
||||
kadmin -p sysadmin/admin
|
||||
<password is krb5>
|
||||
addprinc -randkey ceod/phosphoric-acid.csclub.internal
|
||||
ktadd ceod/phosphoric-acid.csclub.internal
|
||||
```
|
||||
Do this for coffee and mail as well. You need to actually be in the
|
||||
appropriate container when running these commands, since the credentials
|
||||
are being added to the local keytab.
|
||||
On phosphoric-acid, you will additionally need to create a principal
|
||||
called `ceod/admin` (remember to addprinc **and** ktadd).
|
||||
|
||||
#### Database
|
||||
**Note**: The instructions below apply to the dev environment only; in
|
||||
production, the DB superusers should be restricted to the host where
|
||||
the DB is running.
|
||||
|
||||
Attach to the coffee container, run `mysql`, and run the following:
|
||||
|
||||
```
|
||||
CREATE USER 'mysql' IDENTIFIED BY 'mysql';
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
|
||||
```
|
||||
(In prod, the superuser should have '@localhost' appended to its name.)
|
||||
|
||||
Now open /etc/mysql/mariadb.conf.d/50-server.cnf and comment out the following line:
|
||||
```
|
||||
bind-address = 127.0.0.1
|
||||
```
|
||||
Then restart MariaDB:
|
||||
```
|
||||
systemctl restart mariadb
|
||||
```
|
||||
|
||||
Install PostgreSQL in the container:
|
||||
```
|
||||
apt install -y postgresql
|
||||
```
|
||||
Modify the superuser `postgres` for password authentication and restrict new users:
|
||||
```
|
||||
su postgres
|
||||
psql
|
||||
|
||||
ALTER USER postgres WITH PASSWORD 'postgres';
|
||||
REVOKE ALL ON SCHEMA public FROM public;
|
||||
GRANT ALL ON SCHEMA public TO postgres;
|
||||
```
|
||||
Create a new `pg_hba.conf`:
|
||||
```
|
||||
cd /etc/postgresql/<version>/<branch>/
|
||||
mv pg_hba.conf pg_hba.conf.old
|
||||
```
|
||||
```
|
||||
# new pg_hba.conf
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
local all postgres peer
|
||||
host all postgres 0.0.0.0/0 md5
|
||||
|
||||
local all all peer
|
||||
host all all localhost 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,
|
||||
so the relevant snippet in pg_hba.conf should look something like
|
||||
```
|
||||
local all postgres md5
|
||||
host all postgres localhost md5
|
||||
host all postgres 0.0.0.0/0 reject
|
||||
host all postgres ::/0 reject
|
||||
```
|
||||
Add the following to postgresql.conf:
|
||||
```
|
||||
listen_addresses = '*'
|
||||
```
|
||||
Now restart PostgreSQL:
|
||||
```
|
||||
systemctl restart postgresql
|
||||
```
|
||||
**In prod**, users can login remotely but superusers (`postgres` and `mysql`) are only
|
||||
allowed to login from the database host.
|
||||
|
||||
#### Mailman
|
||||
You should create the following mailing lists from the mail container:
|
||||
```sh
|
||||
/opt/mailman3/bin/mailman create syscom@csclub.internal
|
||||
/opt/mailman3/bin/mailman create syscom-alerts@csclub.internal
|
||||
/opt/mailman3/bin/mailman create exec@csclub.internal
|
||||
/opt/mailman3/bin/mailman create ceo@csclub.internal
|
||||
```
|
||||
See https://git.uwaterloo.ca/csc/syscom-dev-environment/-/tree/master/mail
|
||||
for instructions on how to access the Mailman UI from your browser.
|
||||
|
||||
If you want to actually see the archived messages, you'll
|
||||
need to tweak the settings for each list from the UI so that non-member
|
||||
messages get accepted (by default they get held).
|
||||
|
||||
|
||||
#### Dependencies
|
||||
Next, install and activate a virtualenv:
|
||||
```sh
|
||||
sudo apt install libkrb5-dev libpq-dev python3-dev
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
pip install -r dev-requirements.txt
|
||||
```
|
||||
|
||||
#### 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
|
||||
phosphoric-acid, mail and caffeine (in the dev environment, caffeine is
|
||||
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_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 `scripts/clear_cache.sh`, then
|
||||
restart the app.
|
||||
|
||||
## Interacting with the application
|
||||
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
|
||||
disabled.
|
||||
|
||||
First, make sure that your version of curl has been compiled with SPNEGO
|
||||
support:
|
||||
```sh
|
||||
curl -V
|
||||
```
|
||||
Your should see 'SPNEGO' in the 'Features' section.
|
||||
|
||||
Here's an example of making a request to add a user (in the Docker container):
|
||||
```sh
|
||||
# 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","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \
|
||||
-X POST http://phosphoric-acid:9987/api/members
|
||||
|
||||
# To delete the user:
|
||||
curl --negotiate -u : --service-name ceod --delegation always \
|
||||
-X DELETE http://phosphoric-acid:9987/api/members/test_1
|
||||
|
||||
# In prod, use the following base URL instead:
|
||||
# https://phosphoric-acid.csclub.uwaterloo.ca:9987
|
||||
```
|
||||
|
||||
## Packaging
|
||||
See [PACKAGING.md](./PACKAGING.md).
|
|
@ -1 +0,0 @@
|
|||
1.0.31
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
import sys, ldap
|
||||
from getpass import getpass
|
||||
import ceo.urwid.main
|
||||
import ceo.console.main
|
||||
from ceo import ldapi, members
|
||||
|
||||
def start():
|
||||
try:
|
||||
if len(sys.argv) == 1:
|
||||
print "Reading config file...",
|
||||
members.configure()
|
||||
|
||||
print "Connecting to LDAP..."
|
||||
members.connect(AuthCallback())
|
||||
|
||||
ceo.urwid.main.start()
|
||||
else:
|
||||
members.configure()
|
||||
members.connect(AuthCallback())
|
||||
ceo.console.main.start()
|
||||
except ldap.LOCAL_ERROR, e:
|
||||
print ldapi.format_ldaperror(e)
|
||||
except ldap.INSUFFICIENT_ACCESS, e:
|
||||
print ldapi.format_ldaperror(e)
|
||||
print "You probably aren't permitted to do whatever you just tried."
|
||||
print "Admittedly, ceo probably shouldn't have crashed either."
|
||||
|
||||
class AuthCallback:
|
||||
def callback(self, error):
|
||||
try:
|
||||
print "Password: ",
|
||||
return getpass("")
|
||||
except KeyboardInterrupt:
|
||||
print ""
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
start()
|
|
@ -0,0 +1,5 @@
|
|||
if test -e .git; then
|
||||
git-buildpackage --git-ignore-new -us -uc
|
||||
else
|
||||
debuild -us -uc
|
||||
fi
|
|
@ -0,0 +1 @@
|
|||
/ceo_pb2.py
|
|
@ -1,43 +0,0 @@
|
|||
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."""
|
|
@ -0,0 +1 @@
|
|||
"""CSC Electronic Office"""
|
|
@ -1,44 +0,0 @@
|
|||
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__':
|
||||
main()
|
|
@ -1,70 +0,0 @@
|
|||
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)
|
|
@ -1 +0,0 @@
|
|||
from .entrypoint import cli
|
|
@ -1,91 +0,0 @@
|
|||
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)
|
|
@ -1,70 +0,0 @@
|
|||
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,31 +0,0 @@
|
|||
import click
|
||||
|
||||
from .members import members
|
||||
from .groups import groups
|
||||
from .positions import positions
|
||||
from .updateprograms import updateprograms
|
||||
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()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(members)
|
||||
cli.add_command(groups)
|
||||
cli.add_command(positions)
|
||||
cli.add_command(updateprograms)
|
||||
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)
|
|
@ -1,163 +0,0 @@
|
|||
from typing import Dict
|
||||
|
||||
import click
|
||||
from zope import component
|
||||
|
||||
from ..utils import http_post, http_get, http_delete
|
||||
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
|
||||
check_if_in_development
|
||||
from ceo_common.interfaces import IConfig
|
||||
from ceod.transactions.groups import (
|
||||
AddGroupTransaction,
|
||||
AddMemberToGroupTransaction,
|
||||
RemoveMemberFromGroupTransaction,
|
||||
DeleteGroupTransaction,
|
||||
)
|
||||
|
||||
|
||||
@click.group(short_help='Perform operations on CSC groups/clubs')
|
||||
def groups():
|
||||
pass
|
||||
|
||||
|
||||
@groups.command(short_help='Add a new group')
|
||||
@click.argument('group_name')
|
||||
@click.option('-d', '--description', help='Group description', prompt=True)
|
||||
def add(group_name, description):
|
||||
click.echo('The following group will be created:')
|
||||
lines = [
|
||||
('cn', group_name),
|
||||
('description', description),
|
||||
]
|
||||
print_colon_kv(lines)
|
||||
|
||||
click.confirm('Do you want to continue?', abort=True)
|
||||
|
||||
body = {
|
||||
'cn': group_name,
|
||||
'description': description,
|
||||
}
|
||||
operations = AddGroupTransaction.operations
|
||||
resp = http_post('/api/groups', json=body)
|
||||
data = handle_stream_response(resp, operations)
|
||||
result = data[-1]['result']
|
||||
print_group_lines(result)
|
||||
|
||||
|
||||
def print_group_lines(result: Dict):
|
||||
"""Pretty-print a group JSON response."""
|
||||
lines = [
|
||||
('cn', result['cn']),
|
||||
('description', result.get('description', 'Unknown')),
|
||||
('gid_number', str(result['gid_number'])),
|
||||
]
|
||||
for i, member in enumerate(result['members']):
|
||||
if i == 0:
|
||||
prefix = 'members'
|
||||
else:
|
||||
prefix = ''
|
||||
lines.append((prefix, member['cn'] + ' (' + member['uid'] + ')'))
|
||||
print_colon_kv(lines)
|
||||
|
||||
|
||||
@groups.command(short_help='Get info about a group')
|
||||
@click.argument('group_name')
|
||||
def get(group_name):
|
||||
resp = http_get('/api/groups/' + group_name)
|
||||
result = handle_sync_response(resp)
|
||||
print_group_lines(result)
|
||||
|
||||
|
||||
@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(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')
|
||||
operations = AddMemberToGroupTransaction.operations
|
||||
if no_subscribe:
|
||||
operations.remove('subscribe_user_to_auxiliary_mailing_lists')
|
||||
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 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(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')
|
||||
operations = RemoveMemberFromGroupTransaction.operations
|
||||
if no_unsubscribe:
|
||||
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists')
|
||||
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')
|
||||
@click.argument('group_name')
|
||||
def delete(group_name):
|
||||
check_if_in_development()
|
||||
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)
|
|
@ -1,43 +0,0 @@
|
|||
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.")
|
|
@ -1,29 +0,0 @@
|
|||
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.')
|
|
@ -1,232 +0,0 @@
|
|||
import sys
|
||||
from typing import Dict
|
||||
|
||||
import click
|
||||
from zope import component
|
||||
from ceo_common.utils import validate_username
|
||||
|
||||
|
||||
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.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')
|
||||
def members():
|
||||
pass
|
||||
|
||||
|
||||
@members.command(short_help='Add a new member or club rep')
|
||||
@click.argument('username')
|
||||
@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', 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, given_name, sn, program, num_terms, clubrep, forwarding_address):
|
||||
cfg = component.getUtility(IConfig)
|
||||
uw_domain = cfg.get('uw_domain')
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
if clubrep:
|
||||
body['non_member_terms'] = terms
|
||||
else:
|
||||
body['terms'] = terms
|
||||
if forwarding_address != '':
|
||||
body['forwarding_addresses'] = [forwarding_address]
|
||||
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)
|
||||
result = data[-1]['result']
|
||||
print_user_lines(result)
|
||||
|
||||
failed_operations = get_failed_operations(data)
|
||||
if 'send_welcome_message' in failed_operations:
|
||||
click.echo(click.style(
|
||||
'Warning: welcome message was not sent. You now need to manually '
|
||||
'send the user their password.', fg='yellow'))
|
||||
|
||||
|
||||
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')
|
||||
@click.argument('username')
|
||||
def get(username):
|
||||
resp = http_get('/api/members/' + username)
|
||||
result = handle_sync_response(resp)
|
||||
print_user_lines(result)
|
||||
|
||||
|
||||
@members.command(short_help="Replace a user's login shell or forwarding addresses")
|
||||
@click.argument('username')
|
||||
@click.option('--login-shell', required=False, help='Login shell')
|
||||
@click.option('--forwarding-addresses', required=False,
|
||||
help=(
|
||||
'Comma-separated list of forwarding addresses. '
|
||||
'Set to the empty string to disable forwarding.'
|
||||
))
|
||||
def modify(username, login_shell, forwarding_addresses):
|
||||
if login_shell is None and forwarding_addresses is None:
|
||||
click.echo('Nothing to do.')
|
||||
sys.exit()
|
||||
operations = []
|
||||
body = {}
|
||||
if login_shell is not None:
|
||||
body['login_shell'] = login_shell
|
||||
operations.append('replace_login_shell')
|
||||
click.echo('Login shell will be set to: ' + login_shell)
|
||||
if forwarding_addresses is not None:
|
||||
if forwarding_addresses == '':
|
||||
forwarding_addresses = []
|
||||
else:
|
||||
forwarding_addresses = forwarding_addresses.split(',')
|
||||
body['forwarding_addresses'] = forwarding_addresses
|
||||
operations.append('replace_forwarding_addresses')
|
||||
prefix = '~/.forward will be set to: '
|
||||
if len(forwarding_addresses) > 0:
|
||||
click.echo(prefix + forwarding_addresses[0])
|
||||
for address in forwarding_addresses[1:]:
|
||||
click.echo((' ' * len(prefix)) + address)
|
||||
else:
|
||||
click.echo(prefix)
|
||||
|
||||
click.confirm('Do you want to continue?', abort=True)
|
||||
|
||||
resp = http_patch('/api/members/' + username, json=body)
|
||||
handle_stream_response(resp, operations)
|
||||
|
||||
|
||||
@members.command(short_help="Renew a member or club rep's membership")
|
||||
@click.argument('username')
|
||||
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
|
||||
help='Number of terms to add', prompt='Number of terms')
|
||||
@click.option('--clubrep', is_flag=True, default=False,
|
||||
help='Add non-member terms instead of member terms')
|
||||
def renew(username, num_terms, clubrep):
|
||||
terms = get_terms_for_renewal_for_user(username, num_terms, clubrep)
|
||||
|
||||
if clubrep:
|
||||
body = {'non_member_terms': terms}
|
||||
click.echo('The following non-member terms will be added: ' + ','.join(terms))
|
||||
else:
|
||||
body = {'terms': terms}
|
||||
click.echo('The following member terms will be added: ' + ','.join(terms))
|
||||
|
||||
click.confirm('Do you want to continue?', abort=True)
|
||||
|
||||
resp = http_post(f'/api/members/{username}/renew', json=body)
|
||||
handle_sync_response(resp)
|
||||
click.echo('Done.')
|
||||
|
||||
|
||||
@members.command(short_help="Reset a user's password")
|
||||
@click.argument('username')
|
||||
def pwreset(username):
|
||||
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True)
|
||||
resp = http_post(f'/api/members/{username}/pwreset')
|
||||
result = handle_sync_response(resp)
|
||||
click.echo('New password: ' + result['password'])
|
||||
|
||||
|
||||
@members.command(short_help="Delete a user")
|
||||
@click.argument('username')
|
||||
def delete(username):
|
||||
check_if_in_development()
|
||||
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.")
|
|
@ -1,26 +0,0 @@
|
|||
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')
|
|
@ -1,53 +0,0 @@
|
|||
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)
|
|
@ -1,26 +0,0 @@
|
|||
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')
|
|
@ -1,21 +0,0 @@
|
|||
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,35 +0,0 @@
|
|||
import click
|
||||
|
||||
from ..utils import http_post
|
||||
from .utils import handle_sync_response, print_colon_kv
|
||||
|
||||
|
||||
@click.command(short_help="Sync the 'program' attribute with UWLDAP")
|
||||
@click.option('--dry-run', is_flag=True, default=False)
|
||||
@click.option('--members', required=False)
|
||||
def updateprograms(dry_run, members):
|
||||
body = {}
|
||||
if dry_run:
|
||||
body['dry_run'] = True
|
||||
if members is not None:
|
||||
body['members'] = ','.split(members)
|
||||
|
||||
if not dry_run:
|
||||
click.confirm('Are you sure that you want to sync programs with UWLDAP?', abort=True)
|
||||
|
||||
resp = http_post('/api/uwldap/updateprograms', json=body)
|
||||
result = handle_sync_response(resp)
|
||||
if len(result) == 0:
|
||||
click.echo('All programs are up-to-date.')
|
||||
return
|
||||
if dry_run:
|
||||
click.echo('Members whose program would be changed:')
|
||||
else:
|
||||
click.echo('Members whose program was changed:')
|
||||
lines = []
|
||||
for uid, csc_program, uw_program in result:
|
||||
csc_program = csc_program or 'Unknown'
|
||||
csc_program = click.style(csc_program, fg='yellow')
|
||||
uw_program = click.style(uw_program, fg='green')
|
||||
lines.append((uid, csc_program + ' -> ' + uw_program))
|
||||
print_colon_kv(lines)
|
|
@ -1,57 +0,0 @@
|
|||
from typing import List, Tuple, Dict
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
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):
|
||||
"""Abort silently."""
|
||||
|
||||
def __init__(self, exit_code=1):
|
||||
super().__init__('')
|
||||
self.exit_code = exit_code
|
||||
|
||||
def show(self):
|
||||
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.
|
||||
"""
|
||||
for line in space_colon_kv(pairs):
|
||||
click.echo(line)
|
||||
|
||||
|
||||
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
|
||||
handler = CLIStreamResponseHandler(operations)
|
||||
return generic_handle_stream_response(resp, operations, handler)
|
||||
|
||||
|
||||
def handle_sync_response(resp: requests.Response):
|
||||
"""
|
||||
Exit the program if the request was not successful.
|
||||
Returns the parsed JSON response.
|
||||
"""
|
||||
if resp.status_code != 200:
|
||||
click.echo('An error occurred:')
|
||||
click.echo(resp.text.rstrip())
|
||||
raise Abort()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def check_if_in_development() -> bool:
|
||||
"""Aborts if we are not currently in the dev environment."""
|
||||
if not is_in_development():
|
||||
click.echo('This command may only be called during development.')
|
||||
raise Abort()
|
|
@ -1,37 +0,0 @@
|
|||
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)
|
|
@ -0,0 +1,162 @@
|
|||
"""
|
||||
Configuration Utility Module
|
||||
|
||||
This module contains functions to load and verify very simple configuration
|
||||
files. Python supports ".ini" files, which suck, so this module is used
|
||||
instead.
|
||||
|
||||
Example Configuration File:
|
||||
|
||||
include /path/to/other.cf
|
||||
|
||||
# these values are the same:
|
||||
name_protected = "Michael Spang"
|
||||
name_unprotected = Michael Spang
|
||||
|
||||
# these values are not the same:
|
||||
yes_no = " yes"
|
||||
no_yes = yes
|
||||
|
||||
# this value is an integer
|
||||
arbitrary_number=2
|
||||
|
||||
# this value is not an integer
|
||||
arbitrary_string="2"
|
||||
|
||||
# this is a key with no value
|
||||
csclub
|
||||
|
||||
# this key contains whitespace
|
||||
white space = sure, why not
|
||||
|
||||
# these two lines are treated as one
|
||||
long line = first line \\
|
||||
second line
|
||||
|
||||
Resultant Dictionary:
|
||||
|
||||
{
|
||||
'name_protected': 'Michael Spang',
|
||||
'name_unprotected:' 'Michael Spang',
|
||||
'yes_no': ' yes',
|
||||
'no_yes': 'yes',
|
||||
'arbirary_number': 2,
|
||||
'arbitrary_string': '2',
|
||||
'csclub': None,
|
||||
'white space': 'sure, why not'
|
||||
'long line': 'first line \\n second line'
|
||||
|
||||
... (data from other.cf) ...
|
||||
}
|
||||
|
||||
"""
|
||||
from curses.ascii import isspace
|
||||
|
||||
|
||||
class ConfigurationException(Exception):
|
||||
"""Exception class for incomplete and incorrect configurations."""
|
||||
|
||||
|
||||
def read(filename, included=None):
|
||||
"""
|
||||
Function to read a configuration file into a dictionary.
|
||||
|
||||
Parmaeters:
|
||||
filename - the file to read
|
||||
included - files previously read (internal)
|
||||
|
||||
Exceptions:
|
||||
IOError - when the configuration file cannot be read
|
||||
"""
|
||||
|
||||
if not included:
|
||||
included = []
|
||||
if filename in included:
|
||||
return {}
|
||||
included.append(filename)
|
||||
|
||||
conffile = open(filename)
|
||||
|
||||
options = {}
|
||||
|
||||
while True:
|
||||
|
||||
line = conffile.readline()
|
||||
if line == '':
|
||||
break
|
||||
|
||||
# remove comments
|
||||
if '#' in line:
|
||||
line = line[:line.find('#')]
|
||||
|
||||
# combine lines when the newline is escaped with \
|
||||
while len(line) > 1 and line[-2] == '\\':
|
||||
line = line[:-2] + line[-1]
|
||||
next = conffile.readline()
|
||||
line += next
|
||||
if next == '':
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
|
||||
# process include statements
|
||||
if line.find("include") == 0 and isspace(line[7]):
|
||||
|
||||
filename = line[8:].strip()
|
||||
options.update(read(filename, included))
|
||||
continue
|
||||
|
||||
# split 'key = value' into key and value and strip results
|
||||
pair = map(str.strip, line.split('=', 1))
|
||||
|
||||
# found key and value
|
||||
if len(pair) == 2:
|
||||
key, val = pair
|
||||
|
||||
# found quoted string?
|
||||
if val and val[0] == val[-1] == '"':
|
||||
val = val[1:-1]
|
||||
|
||||
# unquoted, found num?
|
||||
elif val:
|
||||
try:
|
||||
if "." in val:
|
||||
val = float(val)
|
||||
elif val[0] == '0':
|
||||
val = int(val, 8)
|
||||
else:
|
||||
val = int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# save key and value
|
||||
options[key] = val
|
||||
|
||||
# found only key, value = None
|
||||
elif len(pair[0]) > 1:
|
||||
key = pair[0]
|
||||
options[key] = None
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def check_string_fields(filename, field_list, cfg):
|
||||
"""Function to verify thatfields are strings."""
|
||||
|
||||
for field in field_list:
|
||||
if field not in cfg or type(cfg[field]) is not str:
|
||||
raise ConfigurationException('expected string value for option "%s" in "%s"' % (field, filename))
|
||||
|
||||
def check_integer_fields(filename, field_list, cfg):
|
||||
"""Function to verify that fields are integers."""
|
||||
|
||||
for field in field_list:
|
||||
if field not in cfg or type(cfg[field]) not in (int, long):
|
||||
raise ConfigurationException('expected numeric value for option "%s" in "%s"' % (field, filename))
|
||||
|
||||
def check_float_fields(filename, field_list, cfg):
|
||||
"""Function to verify that fields are integers or floats."""
|
||||
|
||||
for field in field_list:
|
||||
if field not in cfg or type(cfg[field]) not in (float, long, int):
|
||||
raise ConfigurationException('expected float value for option "%s" in "%s"' % (field, filename))
|
|
@ -0,0 +1 @@
|
|||
"""Console Interface"""
|
|
@ -0,0 +1,40 @@
|
|||
import sys, ldap
|
||||
from ceo import members, uwldap, terms, ldapi
|
||||
|
||||
def max_term(term1, term2):
|
||||
if terms.compare(term1, term2) > 0:
|
||||
return term1
|
||||
else:
|
||||
return term2
|
||||
|
||||
class ExpiredAccounts:
|
||||
help = '''
|
||||
expiredaccounts [--email]
|
||||
|
||||
Displays a list of expired accounts. If --email is specified, expired account
|
||||
owners will be emailed.
|
||||
'''
|
||||
|
||||
def main(self, args):
|
||||
send_email = False
|
||||
if len(args) == 1 and args[0] == '--email':
|
||||
sys.stderr.write("If you want to send an account expiration notice to " \
|
||||
"these users then type 'Yes, do this' and hit enter\n")
|
||||
if raw_input() == 'Yes, do this':
|
||||
send_email = True
|
||||
uwl = ldap.initialize(uwldap.uri())
|
||||
mlist = members.expired_accounts()
|
||||
for member in mlist.values():
|
||||
term = "f0000"
|
||||
term = reduce(max_term, member.get("term", []), term)
|
||||
term = reduce(max_term, member.get("nonMemberTerm", []), term)
|
||||
expiredfor = terms.delta(term, terms.current())
|
||||
|
||||
if expiredfor <= 3:
|
||||
uid = member['uid'][0]
|
||||
name = member['cn'][0]
|
||||
email = None
|
||||
print '%s (expired for %d terms)' % (uid.ljust(12), expiredfor)
|
||||
if send_email:
|
||||
print " sending mail to %s" % uid
|
||||
members.send_account_expired_email(name, uid)
|
|
@ -0,0 +1,27 @@
|
|||
from ceo import members, terms
|
||||
|
||||
def max_term(term1, term2):
|
||||
if terms.compare(term1, term2) > 0:
|
||||
return term1
|
||||
else:
|
||||
return term2
|
||||
|
||||
class Inactive:
|
||||
help = '''
|
||||
inactive delta-terms
|
||||
|
||||
Prints a list of accounts that have been inactive (i.e. unpaid) for
|
||||
delta-terms.
|
||||
'''
|
||||
def main(self, args):
|
||||
if len(args) != 1:
|
||||
print self.help
|
||||
return
|
||||
delta = int(args[0])
|
||||
mlist = members.list_all()
|
||||
for member in mlist.values():
|
||||
term = "f0000"
|
||||
term = reduce(max_term, member.get("term", []), term)
|
||||
term = reduce(max_term, member.get("nonMemberTerm", []), term)
|
||||
if terms.delta(term, terms.current()) >= delta:
|
||||
print "%s %s" % (member['uid'][0].ljust(12), term)
|
|
@ -0,0 +1,49 @@
|
|||
import sys, ldap, termios
|
||||
from ceo import members, terms, uwldap, ldapi
|
||||
|
||||
from ceo.console.memberlist import MemberList
|
||||
from ceo.console.updateprograms import UpdatePrograms
|
||||
from ceo.console.expiredaccounts import ExpiredAccounts
|
||||
from ceo.console.inactive import Inactive
|
||||
from ceo.console.mysql import MySQL
|
||||
|
||||
commands = {
|
||||
'memberlist' : MemberList(),
|
||||
'updateprograms' : UpdatePrograms(),
|
||||
'expiredaccounts' : ExpiredAccounts(),
|
||||
'inactive': Inactive(),
|
||||
'mysql': MySQL(),
|
||||
}
|
||||
help_opts = [ '--help', '-h' ]
|
||||
def start():
|
||||
args = sys.argv[1:]
|
||||
if args[0] in help_opts:
|
||||
help()
|
||||
elif args[0] in commands:
|
||||
command = commands[args[0]]
|
||||
if len(args) >= 2 and args[1] in help_opts:
|
||||
print command.help
|
||||
else:
|
||||
command.main(args[1:])
|
||||
else:
|
||||
print "Invalid command '%s'" % args[0]
|
||||
|
||||
def help():
|
||||
args = sys.argv[2:]
|
||||
if len(args) == 1:
|
||||
if args[0] in commands:
|
||||
print commands[args[0]].help
|
||||
else:
|
||||
print 'Unknown command %s.' % args[0]
|
||||
else:
|
||||
print ''
|
||||
print 'To run the ceo GUI, type \'ceo\''
|
||||
print ''
|
||||
print 'To run a ceo console command, type \'ceo command\''
|
||||
print ''
|
||||
print 'Available console commands:'
|
||||
for c in commands:
|
||||
print ' %s' % c
|
||||
print ''
|
||||
print 'Run \'ceo command --help\' for help on a specific command.'
|
||||
print ''
|
|
@ -0,0 +1,24 @@
|
|||
from ceo import members, terms
|
||||
|
||||
class MemberList:
|
||||
help = '''
|
||||
memberlist [term]
|
||||
|
||||
Displays a list of members for a term; defaults to the current term if term
|
||||
is not given.
|
||||
'''
|
||||
def main(self, args):
|
||||
mlist = {}
|
||||
if len(args) == 1:
|
||||
mlist = members.list_term(args[0])
|
||||
else:
|
||||
mlist = members.list_term(terms.current())
|
||||
dns = mlist.keys()
|
||||
dns.sort()
|
||||
for dn in dns:
|
||||
member = mlist[dn]
|
||||
print '%s %s %s' % (
|
||||
member['uid'][0].ljust(12),
|
||||
member['cn'][0].ljust(30),
|
||||
member.get('program', [''])[0]
|
||||
)
|
|
@ -0,0 +1,38 @@
|
|||
from ceo import members, terms, mysql
|
||||
|
||||
class MySQL:
|
||||
help = '''
|
||||
mysql create <username>
|
||||
|
||||
Creates a mysql database for a user.
|
||||
'''
|
||||
def main(self, args):
|
||||
if len(args) != 2 or args[0] != 'create':
|
||||
print self.help
|
||||
return
|
||||
username = args[1]
|
||||
problem = None
|
||||
try:
|
||||
password = mysql.create_mysql(username)
|
||||
|
||||
try:
|
||||
mysql.write_mysql_info(username, password)
|
||||
helpfiletext = "Settings written to ~%s/ceo-mysql-info." % username
|
||||
except (KeyError, IOError, OSError), e:
|
||||
helpfiletext = "An error occured writing the settings file: %s" % e
|
||||
|
||||
print "MySQL database created"
|
||||
print ("Connection Information: \n"
|
||||
"\n"
|
||||
"Database: %s\n"
|
||||
"Username: %s\n"
|
||||
"Hostname: localhost\n"
|
||||
"Password: %s\n"
|
||||
"\n"
|
||||
"%s\n"
|
||||
% (username, username, password, helpfiletext))
|
||||
except mysql.MySQLException, e:
|
||||
print "Failed to create MySQL database"
|
||||
print
|
||||
print "We failed to create the database. The error was:\n\n%s" % e
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import ldap, sys, termios
|
||||
from ceo import members, uwldap, ldapi
|
||||
|
||||
blacklist = ('orphaned', 'expired')
|
||||
|
||||
class UpdatePrograms:
|
||||
help = '''
|
||||
updateprograms
|
||||
|
||||
Interactively updates the program field for an account by querying uwdir.
|
||||
'''
|
||||
def main(self, args):
|
||||
mlist = members.list_all().items()
|
||||
uwl = ldap.initialize(uwldap.uri())
|
||||
fd = sys.stdin.fileno()
|
||||
for (dn, member) in mlist:
|
||||
uid = member['uid'][0]
|
||||
user = uwl.search_s(uwldap.base(), ldap.SCOPE_SUBTREE,
|
||||
'(uid=%s)' % ldapi.escape(uid))
|
||||
if len(user) == 0:
|
||||
continue
|
||||
user = user[0][1]
|
||||
oldprog = member.get('program', [''])[0]
|
||||
newprog = user.get('ou', [''])[0]
|
||||
if oldprog == newprog or newprog == '' or newprog.lower() in blacklist:
|
||||
continue
|
||||
sys.stdout.write("%s: '%s' => '%s'? (y/n) " % (uid, oldprog, newprog))
|
||||
new = old = termios.tcgetattr(fd)
|
||||
new[3] = new[3] & ~termios.ICANON
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSANOW, new)
|
||||
try:
|
||||
if sys.stdin.read(1) != 'y':
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
return ''
|
||||
finally:
|
||||
print ''
|
||||
termios.tcsetattr(fd, termios.TCSANOW, old)
|
||||
old = new = {}
|
||||
if oldprog != '':
|
||||
old = {'program': [oldprog]}
|
||||
if newprog != '':
|
||||
new = {'program': [newprog]}
|
||||
mlist = ldapi.make_modlist(old, new)
|
||||
# TODO: don't use members.ld directly
|
||||
#if newprog != '':
|
||||
# members.set_program(uid, newprog)
|
||||
members.ld.modify_s(dn, mlist)
|
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
Exceptions Module
|
||||
|
||||
This module provides some simple but generally useful exception classes.
|
||||
"""
|
||||
|
||||
class InvalidArgument(Exception):
|
||||
"""Exception class for bad argument values."""
|
||||
def __init__(self, argname, argval, explanation):
|
||||
Exception.__init__(self)
|
||||
self.argname, self.argval, self.explanation = argname, argval, explanation
|
||||
def __str__(self):
|
||||
return 'Bad argument value "%s" for %s: %s' % (self.argval, self.argname, self.explanation)
|
|
@ -1,35 +0,0 @@
|
|||
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.
|
||||
Stores the username for later use by get_username().
|
||||
"""
|
||||
global _username
|
||||
for _ in range(2):
|
||||
try:
|
||||
creds = gssapi.Credentials(usage='initiate')
|
||||
result = creds.inquire()
|
||||
princ = str(result.name)
|
||||
_username = princ[:princ.index('@')]
|
||||
return
|
||||
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
|
||||
kinit()
|
||||
|
||||
raise Exception('could not acquire GSSAPI credentials')
|
||||
|
||||
|
||||
def kinit():
|
||||
subprocess.run(['kinit'], check=True)
|
|
@ -0,0 +1,148 @@
|
|||
"""
|
||||
LDAP Utilities
|
||||
|
||||
This module makes use of python-ldap, a Python module with bindings
|
||||
to libldap, OpenLDAP's native C client library.
|
||||
"""
|
||||
import ldap.modlist, os, pwd
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
|
||||
def connect_sasl(uri, mech, realm, password):
|
||||
|
||||
try:
|
||||
# open the connection
|
||||
ld = ldap.initialize(uri)
|
||||
|
||||
# authenticate
|
||||
sasl = Sasl(mech, realm, password)
|
||||
ld.sasl_interactive_bind_s('', sasl)
|
||||
|
||||
except ldap.LOCAL_ERROR, e:
|
||||
raise e
|
||||
|
||||
except:
|
||||
print "Shit, something went wrong!"
|
||||
|
||||
return ld
|
||||
|
||||
|
||||
def abslookup(ld, dn, objectclass=None):
|
||||
|
||||
# search for the specified dn
|
||||
try:
|
||||
if objectclass:
|
||||
search_filter = '(objectclass=%s)' % escape(objectclass)
|
||||
matches = ld.search_s(dn, ldap.SCOPE_BASE, search_filter)
|
||||
else:
|
||||
matches = ld.search_s(dn, ldap.SCOPE_BASE)
|
||||
except ldap.NO_SUCH_OBJECT:
|
||||
return None
|
||||
|
||||
# dn was found, but didn't match the objectclass filter
|
||||
if len(matches) < 1:
|
||||
return None
|
||||
|
||||
# return the attributes of the single successful match
|
||||
match = matches[0]
|
||||
match_dn, match_attributes = match
|
||||
return match_attributes
|
||||
|
||||
|
||||
def lookup(ld, rdntype, rdnval, base, objectclass=None):
|
||||
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
|
||||
return abslookup(ld, dn, objectclass)
|
||||
|
||||
|
||||
def search(ld, base, search_filter, params=[], scope=ldap.SCOPE_SUBTREE, attrlist=None, attrsonly=0):
|
||||
|
||||
real_filter = search_filter % tuple(escape(x) for x in params)
|
||||
|
||||
# search for entries that match the filter
|
||||
matches = ld.search_s(base, scope, real_filter, attrlist, attrsonly)
|
||||
return matches
|
||||
|
||||
|
||||
def modify(ld, rdntype, rdnval, base, mlist):
|
||||
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
|
||||
ld.modify_s(dn, mlist)
|
||||
|
||||
|
||||
def modify_attrs(ld, rdntype, rdnval, base, old, attrs):
|
||||
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
|
||||
|
||||
# build list of modifications to make
|
||||
changes = ldap.modlist.modifyModlist(old, attrs)
|
||||
|
||||
# apply changes
|
||||
ld.modify_s(dn, changes)
|
||||
|
||||
|
||||
def modify_diff(ld, rdntype, rdnval, base, old, new):
|
||||
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
|
||||
|
||||
# build list of modifications to make
|
||||
changes = make_modlist(old, new)
|
||||
|
||||
# apply changes
|
||||
ld.modify_s(dn, changes)
|
||||
|
||||
|
||||
def escape(value):
|
||||
"""
|
||||
Escapes special characters in a value so that it may be safely inserted
|
||||
into an LDAP search filter.
|
||||
"""
|
||||
|
||||
value = str(value)
|
||||
value = value.replace('\\', '\\5c').replace('*', '\\2a')
|
||||
value = value.replace('(', '\\28').replace(')', '\\29')
|
||||
value = value.replace('\x00', '\\00')
|
||||
return value
|
||||
|
||||
|
||||
def make_modlist(old, new):
|
||||
keys = set(old.keys()).union(set(new))
|
||||
mlist = []
|
||||
for key in keys:
|
||||
if key in old and not key in new:
|
||||
mlist.append((ldap.MOD_DELETE, key, list(set(old[key]))))
|
||||
elif key in new and not key in old:
|
||||
mlist.append((ldap.MOD_ADD, key, list(set(new[key]))))
|
||||
else:
|
||||
to_add = list(set(new[key]) - set(old[key]))
|
||||
if len(to_add) > 0:
|
||||
mlist.append((ldap.MOD_ADD, key, to_add))
|
||||
to_del = list(set(old[key]) - set(new[key]))
|
||||
if len(to_del) > 0:
|
||||
mlist.append((ldap.MOD_DELETE, key, to_del))
|
||||
return mlist
|
||||
|
||||
|
||||
def format_ldaperror(ex):
|
||||
desc = ex[0].get('desc', '')
|
||||
info = ex[0].get('info', '')
|
||||
if desc and info:
|
||||
return "%s: %s" % (desc, info)
|
||||
elif desc:
|
||||
return desc
|
||||
else:
|
||||
return str(ex)
|
||||
|
||||
|
||||
class Sasl:
|
||||
|
||||
def __init__(self, mech, realm, password):
|
||||
self.mech = mech
|
||||
self.realm = realm
|
||||
|
||||
if mech == 'GSSAPI' and password is not None:
|
||||
userid = pwd.getpwuid(os.getuid()).pw_name
|
||||
kinit = '/usr/bin/kinit'
|
||||
kinit_args = [ kinit, '%s@%s' % (userid, realm) ]
|
||||
kinit = Popen(kinit_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
||||
kinit.stdin.write('%s\n' % password)
|
||||
kinit.wait()
|
||||
|
||||
def callback(self, id, challenge, prompt, defresult):
|
||||
return ''
|
|
@ -0,0 +1,609 @@
|
|||
"""
|
||||
CSC Member Management
|
||||
|
||||
This module contains functions for registering new members, registering
|
||||
members for terms, searching for members, and other member-related
|
||||
functions.
|
||||
|
||||
Transactions are used in each method that modifies the database.
|
||||
Future changes to the members database that need to be atomic
|
||||
must also be moved into this module.
|
||||
"""
|
||||
import os, re, subprocess, ldap, socket
|
||||
from ceo import conf, ldapi, terms, remote, ceo_pb2
|
||||
from ceo.excep import InvalidArgument
|
||||
import dns.resolver
|
||||
|
||||
### Configuration ###
|
||||
|
||||
CONFIG_FILE = '/etc/csc/accounts.cf'
|
||||
|
||||
cfg = {}
|
||||
|
||||
def configure():
|
||||
"""Load Members Configuration"""
|
||||
|
||||
string_fields = [ 'username_regex', 'shells_file', 'ldap_server_url',
|
||||
'ldap_users_base', 'ldap_groups_base', 'ldap_sasl_mech', 'ldap_sasl_realm',
|
||||
'expire_hook' ]
|
||||
numeric_fields = [ 'min_password_length' ]
|
||||
|
||||
# read configuration file
|
||||
cfg_tmp = conf.read(CONFIG_FILE)
|
||||
|
||||
# verify configuration
|
||||
conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
|
||||
conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
|
||||
|
||||
# update the current configuration with the loaded values
|
||||
cfg.update(cfg_tmp)
|
||||
|
||||
|
||||
|
||||
### Exceptions ###
|
||||
|
||||
class MemberException(Exception):
|
||||
"""Base exception class for member-related errors."""
|
||||
def __init__(self, ex=None):
|
||||
Exception.__init__(self)
|
||||
self.ex = ex
|
||||
def __str__(self):
|
||||
return str(self.ex)
|
||||
|
||||
class InvalidTerm(MemberException):
|
||||
"""Exception class for malformed terms."""
|
||||
def __init__(self, term):
|
||||
MemberException.__init__(self)
|
||||
self.term = term
|
||||
def __str__(self):
|
||||
return "Term is invalid: %s" % self.term
|
||||
|
||||
class NoSuchMember(MemberException):
|
||||
"""Exception class for nonexistent members."""
|
||||
def __init__(self, memberid):
|
||||
MemberException.__init__(self)
|
||||
self.memberid = memberid
|
||||
def __str__(self):
|
||||
return "Member not found: %d" % self.memberid
|
||||
|
||||
|
||||
### Connection Management ###
|
||||
|
||||
# global directory connection
|
||||
ld = None
|
||||
|
||||
def connect(auth_callback):
|
||||
"""Connect to LDAP."""
|
||||
|
||||
|
||||
global ld
|
||||
password = None
|
||||
tries = 0
|
||||
while ld is None:
|
||||
try:
|
||||
ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'],
|
||||
cfg['ldap_sasl_realm'], password)
|
||||
except ldap.LOCAL_ERROR, e:
|
||||
tries += 1
|
||||
if tries > 3:
|
||||
raise e
|
||||
password = auth_callback.callback(e)
|
||||
if password == None:
|
||||
raise e
|
||||
|
||||
def connect_anonymous():
|
||||
"""Connect to LDAP."""
|
||||
|
||||
global ld
|
||||
ld = ldap.initialize(cfg['ldap_server_url'])
|
||||
|
||||
def disconnect():
|
||||
"""Disconnect from LDAP."""
|
||||
|
||||
global ld
|
||||
ld.unbind_s()
|
||||
ld = None
|
||||
|
||||
|
||||
def connected():
|
||||
"""Determine whether the connection has been established."""
|
||||
|
||||
return ld and ld.connected()
|
||||
|
||||
|
||||
|
||||
### Members ###
|
||||
|
||||
def create_member(username, password, name, program, email, club_rep=False):
|
||||
"""
|
||||
Creates a UNIX user account with options tailored to CSC members.
|
||||
|
||||
Parameters:
|
||||
username - the desired UNIX username
|
||||
password - the desired UNIX password
|
||||
name - the member's real name
|
||||
program - the member's program of study
|
||||
club_rep - whether the user is a club rep
|
||||
email - email to place in .forward
|
||||
|
||||
Exceptions:
|
||||
InvalidArgument - on bad account attributes provided
|
||||
|
||||
Returns: the uid number of the new account
|
||||
|
||||
See: create()
|
||||
"""
|
||||
|
||||
# check username format
|
||||
if not username or not re.match(cfg['username_regex'], username):
|
||||
raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
|
||||
|
||||
# check password length
|
||||
if not password or len(password) < cfg['min_password_length']:
|
||||
raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
|
||||
|
||||
try:
|
||||
request = ceo_pb2.AddUser()
|
||||
request.username = username
|
||||
request.password = password
|
||||
request.realname = name
|
||||
request.program = program
|
||||
request.email = email
|
||||
|
||||
if club_rep:
|
||||
request.type = ceo_pb2.AddUser.CLUB_REP
|
||||
else:
|
||||
request.type = ceo_pb2.AddUser.MEMBER
|
||||
|
||||
out = remote.run_remote('adduser', request.SerializeToString())
|
||||
|
||||
response = ceo_pb2.AddUserResponse()
|
||||
response.ParseFromString(out)
|
||||
|
||||
if any(message.status != 0 for message in response.messages):
|
||||
raise MemberException('\n'.join(message.message for message in response.messages))
|
||||
|
||||
except remote.RemoteException, e:
|
||||
raise MemberException(e)
|
||||
except OSError, e:
|
||||
raise MemberException(e)
|
||||
|
||||
|
||||
def check_email(email):
|
||||
match = re.match('^\S+?@(\S+)$', email)
|
||||
if not match:
|
||||
return 'Invalid email address'
|
||||
|
||||
# some characters are treated specially in .forward
|
||||
for c in email:
|
||||
if c in ('"', "'", ',', '|', '$', '/', '#', ':'):
|
||||
return 'Invalid character in address: %s' % c
|
||||
|
||||
# Start by searching for host record
|
||||
host = match.group(1)
|
||||
try:
|
||||
ip = socket.getaddrinfo(host, None)
|
||||
except:
|
||||
# Check for MX record
|
||||
try:
|
||||
dns.resolver.query(host, 'MX')
|
||||
except:
|
||||
return 'Invalid host: %s' % host
|
||||
|
||||
|
||||
def current_email(username):
|
||||
fwdpath = '%s/%s/.forward' % (cfg['member_home'], username)
|
||||
try:
|
||||
fwd = open(fwdpath).read().strip()
|
||||
if not check_email(fwd):
|
||||
return fwd
|
||||
except OSError:
|
||||
pass
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
|
||||
def change_email(username, forward):
|
||||
try:
|
||||
request = ceo_pb2.UpdateMail()
|
||||
request.username = username
|
||||
request.forward = forward
|
||||
|
||||
out = remote.run_remote('mail', request.SerializeToString())
|
||||
|
||||
response = ceo_pb2.AddUserResponse()
|
||||
response.ParseFromString(out)
|
||||
|
||||
if any(message.status != 0 for message in response.messages):
|
||||
return '\n'.join(message.message for message in response.messages)
|
||||
except remote.RemoteException, e:
|
||||
raise MemberException(e)
|
||||
except OSError, e:
|
||||
raise MemberException(e)
|
||||
|
||||
|
||||
def get(userid):
|
||||
"""
|
||||
Look up attributes of a member by userid.
|
||||
|
||||
Returns: a dictionary of attributes
|
||||
|
||||
Example: get('mspang') -> {
|
||||
'cn': [ 'Michael Spang' ],
|
||||
'program': [ 'Computer Science' ],
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
|
||||
|
||||
def get_group(group):
|
||||
"""
|
||||
Look up group by groupname
|
||||
|
||||
Returns a dictionary of group attributes
|
||||
"""
|
||||
|
||||
return ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
|
||||
|
||||
def uid2dn(uid):
|
||||
return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
|
||||
|
||||
|
||||
def list_term(term):
|
||||
"""
|
||||
Build a list of members in a term.
|
||||
|
||||
Parameters:
|
||||
term - the term to match members against
|
||||
|
||||
Returns: a list of members
|
||||
|
||||
Example: list_term('f2006'): -> {
|
||||
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
|
||||
'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
members = ldapi.search(ld, cfg['ldap_users_base'],
|
||||
'(&(objectClass=member)(term=%s))', [ term ])
|
||||
return dict([(member[0], member[1]) for member in members])
|
||||
|
||||
|
||||
def list_name(name):
|
||||
"""
|
||||
Build a list of members with matching names.
|
||||
|
||||
Parameters:
|
||||
name - the name to match members against
|
||||
|
||||
Returns: a list of member dictionaries
|
||||
|
||||
Example: list_name('Spang'): -> {
|
||||
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
members = ldapi.search(ld, cfg['ldap_users_base'],
|
||||
'(&(objectClass=member)(cn~=%s))', [ name ])
|
||||
return dict([(member[0], member[1]) for member in members])
|
||||
|
||||
|
||||
def list_group(group):
|
||||
"""
|
||||
Build a list of members in a group.
|
||||
|
||||
Parameters:
|
||||
group - the group to match members against
|
||||
|
||||
Returns: a list of member dictionaries
|
||||
|
||||
Example: list_name('syscom'): -> {
|
||||
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
members = group_members(group)
|
||||
ret = {}
|
||||
if members:
|
||||
for member in members:
|
||||
info = get(member)
|
||||
if info:
|
||||
ret[uid2dn(member)] = info
|
||||
return ret
|
||||
|
||||
|
||||
def list_all():
|
||||
"""
|
||||
Build a list of all members
|
||||
|
||||
Returns: a list of member dictionaries
|
||||
|
||||
Example: list_name('Spang'): -> {
|
||||
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
|
||||
return dict([(member[0], member[1]) for member in members])
|
||||
|
||||
|
||||
def list_positions():
|
||||
"""
|
||||
Build a list of positions
|
||||
|
||||
Returns: a list of positions and who holds them
|
||||
|
||||
Example: list_positions(): -> {
|
||||
'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
|
||||
...
|
||||
]
|
||||
"""
|
||||
|
||||
members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
|
||||
positions = {}
|
||||
for (_, member) in members:
|
||||
for position in member['position']:
|
||||
if not position in positions:
|
||||
positions[position] = {}
|
||||
positions[position][member['uid'][0]] = member
|
||||
return positions
|
||||
|
||||
|
||||
def set_position(position, members):
|
||||
"""
|
||||
Sets a position
|
||||
|
||||
Parameters:
|
||||
position - the position to set
|
||||
members - an array of members that hold the position
|
||||
|
||||
Example: set_position('president', ['dtbartle'])
|
||||
"""
|
||||
|
||||
res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
|
||||
'(&(objectClass=member)(position=%s))' % ldapi.escape(position))
|
||||
old = set([ member['uid'][0] for (_, member) in res ])
|
||||
new = set(members)
|
||||
mods = {
|
||||
'del': set(old) - set(new),
|
||||
'add': set(new) - set(old),
|
||||
}
|
||||
if len(mods['del']) == 0 and len(mods['add']) == 0:
|
||||
return
|
||||
|
||||
for action in ['del', 'add']:
|
||||
for userid in mods[action]:
|
||||
dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
|
||||
entry1 = {'position' : [position]}
|
||||
entry2 = {} #{'position' : []}
|
||||
entry = ()
|
||||
if action == 'del':
|
||||
entry = (entry1, entry2)
|
||||
elif action == 'add':
|
||||
entry = (entry2, entry1)
|
||||
mlist = ldapi.make_modlist(entry[0], entry[1])
|
||||
ld.modify_s(dn, mlist)
|
||||
|
||||
|
||||
def change_group_member(action, group, userid):
|
||||
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
|
||||
group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
|
||||
entry1 = {'uniqueMember' : []}
|
||||
entry2 = {'uniqueMember' : [user_dn]}
|
||||
entry = []
|
||||
if action == 'add' or action == 'insert':
|
||||
entry = (entry1, entry2)
|
||||
elif action == 'remove' or action == 'delete':
|
||||
entry = (entry2, entry1)
|
||||
else:
|
||||
raise InvalidArgument("action", action, "invalid action")
|
||||
mlist = ldapi.make_modlist(entry[0], entry[1])
|
||||
ld.modify_s(group_dn, mlist)
|
||||
|
||||
|
||||
|
||||
### Shells ###
|
||||
|
||||
def get_shell(userid):
|
||||
member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
|
||||
if not member:
|
||||
raise NoSuchMember(userid)
|
||||
if 'loginShell' not in member:
|
||||
return
|
||||
return member['loginShell'][0]
|
||||
|
||||
|
||||
def get_shells():
|
||||
return [ sh for sh in open(cfg['shells_file']).read().split("\n")
|
||||
if sh
|
||||
and sh[0] == '/'
|
||||
and not '#' in sh
|
||||
and os.access(sh, os.X_OK) ]
|
||||
|
||||
|
||||
def set_shell(userid, shell):
|
||||
if not shell in get_shells():
|
||||
raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
|
||||
ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
|
||||
|
||||
|
||||
|
||||
### Clubs ###
|
||||
|
||||
def create_club(username, name):
|
||||
"""
|
||||
Creates a UNIX user account with options tailored to CSC-hosted clubs.
|
||||
|
||||
Parameters:
|
||||
username - the desired UNIX username
|
||||
name - the club name
|
||||
|
||||
Exceptions:
|
||||
InvalidArgument - on bad account attributes provided
|
||||
|
||||
Returns: the uid number of the new account
|
||||
|
||||
See: create()
|
||||
"""
|
||||
|
||||
# check username format
|
||||
if not username or not re.match(cfg['username_regex'], username):
|
||||
raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
|
||||
|
||||
try:
|
||||
request = ceo_pb2.AddUser()
|
||||
request.type = ceo_pb2.AddUser.CLUB
|
||||
request.username = username
|
||||
request.realname = name
|
||||
|
||||
out = remote.run_remote('adduser', request.SerializeToString())
|
||||
|
||||
response = ceo_pb2.AddUserResponse()
|
||||
response.ParseFromString(out)
|
||||
|
||||
if any(message.status != 0 for message in response.messages):
|
||||
raise MemberException('\n'.join(message.message for message in response.messages))
|
||||
except remote.RemoteException, e:
|
||||
raise MemberException(e)
|
||||
except OSError, e:
|
||||
raise MemberException(e)
|
||||
|
||||
|
||||
|
||||
### Terms ###
|
||||
|
||||
def register(userid, term_list):
|
||||
"""
|
||||
Registers a member for one or more terms.
|
||||
|
||||
Parameters:
|
||||
userid - the member's username
|
||||
term_list - the term to register for, or a list of terms
|
||||
|
||||
Exceptions:
|
||||
InvalidTerm - if a term is malformed
|
||||
|
||||
Example: register(3349, "w2007")
|
||||
|
||||
Example: register(3349, ["w2007", "s2007"])
|
||||
"""
|
||||
|
||||
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
|
||||
|
||||
if type(term_list) in (str, unicode):
|
||||
term_list = [ term_list ]
|
||||
|
||||
ldap_member = get(userid)
|
||||
if ldap_member and 'term' not in ldap_member:
|
||||
ldap_member['term'] = []
|
||||
|
||||
if not ldap_member:
|
||||
raise NoSuchMember(userid)
|
||||
|
||||
new_member = ldap_member.copy()
|
||||
new_member['term'] = new_member['term'][:]
|
||||
|
||||
for term in term_list:
|
||||
|
||||
# check term syntax
|
||||
if not re.match('^[wsf][0-9]{4}$', term):
|
||||
raise InvalidTerm(term)
|
||||
|
||||
# add the term to the entry
|
||||
if not term in ldap_member['term']:
|
||||
new_member['term'].append(term)
|
||||
|
||||
mlist = ldapi.make_modlist(ldap_member, new_member)
|
||||
ld.modify_s(user_dn, mlist)
|
||||
|
||||
|
||||
def register_nonmember(userid, term_list):
|
||||
"""Registers a non-member for one or more terms."""
|
||||
|
||||
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
|
||||
|
||||
if type(term_list) in (str, unicode):
|
||||
term_list = [ term_list ]
|
||||
|
||||
ldap_member = get(userid)
|
||||
if not ldap_member:
|
||||
raise NoSuchMember(userid)
|
||||
|
||||
if 'term' not in ldap_member:
|
||||
ldap_member['term'] = []
|
||||
if 'nonMemberTerm' not in ldap_member:
|
||||
ldap_member['nonMemberTerm'] = []
|
||||
|
||||
new_member = ldap_member.copy()
|
||||
new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
|
||||
|
||||
for term in term_list:
|
||||
|
||||
# check term syntax
|
||||
if not re.match('^[wsf][0-9]{4}$', term):
|
||||
raise InvalidTerm(term)
|
||||
|
||||
# add the term to the entry
|
||||
if not term in ldap_member['nonMemberTerm'] \
|
||||
and not term in ldap_member['term']:
|
||||
new_member['nonMemberTerm'].append(term)
|
||||
|
||||
mlist = ldapi.make_modlist(ldap_member, new_member)
|
||||
ld.modify_s(user_dn, mlist)
|
||||
|
||||
|
||||
def registered(userid, term):
|
||||
"""
|
||||
Determines whether a member is registered
|
||||
for a term.
|
||||
|
||||
Parameters:
|
||||
userid - the member's username
|
||||
term - the term to check
|
||||
|
||||
Returns: whether the member is registered
|
||||
|
||||
Example: registered("mspang", "f2006") -> True
|
||||
"""
|
||||
|
||||
member = get(userid)
|
||||
if not member is None:
|
||||
return 'term' in member and term in member['term']
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def group_members(group):
|
||||
|
||||
"""
|
||||
Returns a list of group members
|
||||
"""
|
||||
|
||||
group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
|
||||
|
||||
if group and 'uniqueMember' in group:
|
||||
r = re.compile('^uid=([^,]*)')
|
||||
return map(lambda x: r.match(x).group(1), group['uniqueMember'])
|
||||
return []
|
||||
|
||||
def expired_accounts():
|
||||
members = ldapi.search(ld, cfg['ldap_users_base'],
|
||||
'(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
|
||||
(terms.current(), terms.current()))
|
||||
return dict([(member[0], member[1]) for member in members])
|
||||
|
||||
def send_account_expired_email(name, email):
|
||||
args = [ cfg['expire_hook'], name, email ]
|
||||
os.spawnv(os.P_WAIT, cfg['expire_hook'], args)
|
||||
|
||||
def subscribe_to_mailing_list(name):
|
||||
member = get(name)
|
||||
if member is not None:
|
||||
return remote.run_remote('mailman', name)
|
||||
else:
|
||||
return 'Error: member does not exist'
|
|
@ -0,0 +1,54 @@
|
|||
import os, re, subprocess, ldap, socket, pwd
|
||||
from ceo import conf, ldapi, terms, remote, ceo_pb2
|
||||
from ceo.excep import InvalidArgument
|
||||
|
||||
class MySQLException(Exception):
|
||||
pass
|
||||
|
||||
def write_mysql_info(username, password):
|
||||
homedir = pwd.getpwnam(username).pw_dir
|
||||
password_file = '%s/ceo-mysql-info' % homedir
|
||||
if os.path.exists(password_file):
|
||||
os.rename(password_file, password_file + '.old')
|
||||
fd = os.open(password_file, os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0660)
|
||||
fh = os.fdopen(fd, 'w')
|
||||
fh.write("""MySQL Database Information for %(username)s
|
||||
|
||||
Your new MySQL database was created. To connect, use
|
||||
the following options:
|
||||
|
||||
Database: %(username)s
|
||||
Username: %(username)s
|
||||
Password: %(password)s
|
||||
Hostname: localhost
|
||||
|
||||
The command to connect using the MySQL command-line client is
|
||||
|
||||
mysql %(username)s -u %(username)s -p
|
||||
|
||||
If you prefer a GUI you can use phpmyadmin at
|
||||
|
||||
http://csclub.uwaterloo.ca/phpmyadmin
|
||||
|
||||
This database is only accessible from caffeine.
|
||||
""" % { 'username': username, 'password': password })
|
||||
|
||||
fh.close()
|
||||
|
||||
def create_mysql(username):
|
||||
try:
|
||||
request = ceo_pb2.AddMySQLUser()
|
||||
request.username = username
|
||||
|
||||
out = remote.run_remote('mysql', request.SerializeToString())
|
||||
|
||||
response = ceo_pb2.AddMySQLUserResponse()
|
||||
response.ParseFromString(out)
|
||||
|
||||
if any(message.status != 0 for message in response.messages):
|
||||
raise MySQLException('\n'.join(message.message for message in response.messages))
|
||||
|
||||
return response.password
|
||||
except remote.RemoteException, e:
|
||||
raise MySQLException(e)
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# These descriptions are printed to the console while a transaction
|
||||
# is performed, in real time.
|
||||
descriptions = {
|
||||
'add_user_to_ldap': 'Add user to LDAP',
|
||||
'add_group_to_ldap': 'Add group to LDAP',
|
||||
'add_user_to_kerberos': 'Add user to Kerberos',
|
||||
'create_home_dir': 'Create home directory',
|
||||
'set_forwarding_addresses': 'Set forwarding addresses',
|
||||
'send_welcome_message': 'Send welcome message',
|
||||
'subscribe_to_mailing_list': 'Subscribe to mailing list',
|
||||
'announce_new_user': 'Announce new user to mailing list',
|
||||
'replace_login_shell': 'Replace login shell',
|
||||
'replace_forwarding_addresses': 'Replace forwarding addresses',
|
||||
'remove_user_from_ldap': 'Remove user from LDAP',
|
||||
'remove_group_from_ldap': 'Remove group from LDAP',
|
||||
'remove_user_from_kerberos': 'Remove user from Kerberos',
|
||||
'delete_home_dir': 'Delete home directory',
|
||||
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list',
|
||||
'add_sudo_role': 'Add sudo role to LDAP',
|
||||
'add_user_to_group': 'Add user to group',
|
||||
'add_user_to_auxiliary_groups': 'Add user to auxiliary groups',
|
||||
'subscribe_user_to_auxiliary_mailing_lists': 'Subscribe user to auxiliary mailing lists',
|
||||
'remove_user_from_group': 'Remove user from group',
|
||||
'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,24 @@
|
|||
import os, syslog, grp
|
||||
|
||||
def response_message(response, status, message):
|
||||
if status:
|
||||
priority = syslog.LOG_ERR
|
||||
else:
|
||||
priority = syslog.LOG_INFO
|
||||
syslog.syslog(priority, message)
|
||||
msg = response.messages.add()
|
||||
msg.status = status
|
||||
msg.message = message
|
||||
return status
|
||||
|
||||
def get_ceo_user():
|
||||
user = os.environ.get('CEO_USER')
|
||||
if not user:
|
||||
raise Exception("environment variable CEO_USER not set");
|
||||
return user
|
||||
|
||||
def check_group(user, group):
|
||||
try:
|
||||
return user in grp.getgrnam(group).gr_mem
|
||||
except KeyError:
|
||||
return False
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
from xml.dom import minidom, Node
|
||||
import urllib
|
||||
import time
|
||||
import datetime
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
|
||||
class PyMazonError(Exception):
|
||||
"""Holds information about an error that occured during a pymazon request"""
|
||||
def __init__(self, messages):
|
||||
self.__message = '\n'.join(messages)
|
||||
|
||||
def __get_message(self):
|
||||
return self.__message
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.__message)
|
||||
|
||||
message = property(fget=__get_message)
|
||||
|
||||
|
||||
class PyMazonBook:
|
||||
"""Stores information about a book retrieved via PyMazon."""
|
||||
def __init__(self, title, authors, publisher, year, isbn10, isbn13, edition):
|
||||
self.__title = title
|
||||
self.__authors = authors
|
||||
self.__publisher = publisher
|
||||
self.__year = year
|
||||
self.__isbn10 = isbn10
|
||||
self.__isbn13 = isbn13
|
||||
self.__edition = edition
|
||||
|
||||
def __str__(self):
|
||||
return 'Title: ' + self.title + '\n' + \
|
||||
'Author(s): ' + ', '.join(self.authors) + '\n' \
|
||||
'Publisher: ' + self.publisher + '\n' + \
|
||||
'Year: ' + self.year + '\n' + \
|
||||
'ISBN-10: ' + self.isbn10 + '\n' + \
|
||||
'ISBN-13: ' + self.isbn13 + '\n' + \
|
||||
'Edition: ' + self.edition
|
||||
|
||||
def __get_title(self):
|
||||
return self.__title
|
||||
|
||||
def __get_authors(self):
|
||||
return self.__authors
|
||||
|
||||
def __get_publisher(self):
|
||||
return self.__publisher
|
||||
|
||||
def __get_year(self):
|
||||
return self.__year
|
||||
|
||||
def __get_isbn10(self):
|
||||
return self.__isbn10
|
||||
|
||||
def __get_isbn13(self):
|
||||
return self.__isbn13
|
||||
|
||||
def __get_edition(self):
|
||||
return self.__edition
|
||||
|
||||
title = property(fget=__get_title)
|
||||
authors = property(fget=__get_authors)
|
||||
publisher = property(fget=__get_publisher)
|
||||
year = property(fget=__get_year)
|
||||
isbn10 = property(fget=__get_isbn10)
|
||||
isbn13 = property(fget=__get_isbn13)
|
||||
edition = property(fget=__get_edition)
|
||||
|
||||
|
||||
class PyMazon:
|
||||
"""A method of looking up book information on Amazon."""
|
||||
def __init__(self, accesskey, secretkey):
|
||||
self.__key = accesskey
|
||||
self.__secret = secretkey
|
||||
self.__last_query_time = 0
|
||||
|
||||
def __form_request(self, isbn):
|
||||
content = {}
|
||||
dstamp = datetime.datetime.utcfromtimestamp(time.time())
|
||||
content['Timestamp'] = dstamp.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
content['Service'] = 'AWSECommerceService'
|
||||
content['Version'] = '2008-08-19'
|
||||
content['Operation'] = 'ItemLookup'
|
||||
content['ResponseGroup'] = 'ItemAttributes'
|
||||
content['IdType'] = 'ISBN'
|
||||
content['SearchIndex'] = 'Books'
|
||||
content['ItemId'] = isbn
|
||||
content['AWSAccessKeyId'] = self.__key
|
||||
|
||||
URI_String = []
|
||||
|
||||
for key, value in sorted(content.items()):
|
||||
URI_String.append('%s=%s' % (key, urllib.quote(value)))
|
||||
|
||||
req = '&'.join(URI_String)
|
||||
to_sign_req = 'GET\necs.amazonaws.com\n/onca/xml\n' + req
|
||||
|
||||
h = hmac.new(self.__secret, to_sign_req, hashlib.sha256)
|
||||
sig = base64.b64encode(h.digest())
|
||||
req += '&Signature=%s' % urllib.quote(sig)
|
||||
|
||||
return 'http://ecs.amazonaws.com/onca/xml?' + req
|
||||
|
||||
def __elements_text(self, element, name):
|
||||
result = []
|
||||
matching = element.getElementsByTagName(name)
|
||||
for match in matching:
|
||||
if len(match.childNodes) != 1:
|
||||
continue
|
||||
child = match.firstChild
|
||||
if child.nodeType != Node.TEXT_NODE:
|
||||
continue
|
||||
result.append(child.nodeValue.strip())
|
||||
return result
|
||||
|
||||
def __format_errors(self, errors):
|
||||
error_list = []
|
||||
for error in errors:
|
||||
error_list.extend(self.__elements_text(error, 'Message'))
|
||||
return error_list
|
||||
|
||||
def __extract_single(self, element, name):
|
||||
matches = self.__elements_text(element, name)
|
||||
if len(matches) == 0:
|
||||
return ''
|
||||
return matches[0]
|
||||
|
||||
def lookup(self, isbn):
|
||||
file = urllib.urlretrieve(self.__form_request(isbn))[0]
|
||||
xmldoc = minidom.parse(file)
|
||||
|
||||
cur_time = time.time()
|
||||
while cur_time - self.__last_query_time < 1.0:
|
||||
sleep(cur_time - self.__last_query_time)
|
||||
cur_time = time.time()
|
||||
self.__last_query_time = cur_time
|
||||
|
||||
errors = xmldoc.getElementsByTagName('Errors')
|
||||
if len(errors) != 0:
|
||||
raise PyMazonError, self.__format_errors(errors)
|
||||
|
||||
title = self.__extract_single(xmldoc, 'Title')
|
||||
authors = self.__elements_text(xmldoc, 'Author')
|
||||
publisher = self.__extract_single(xmldoc, 'Publisher')
|
||||
year = self.__extract_single(xmldoc, 'PublicationDate')[0:4]
|
||||
isbn10 = self.__extract_single(xmldoc, 'ISBN')
|
||||
isbn13 = self.__extract_single(xmldoc, 'EAN')
|
||||
edition = self.__extract_single(xmldoc, 'Edition')
|
||||
|
||||
return PyMazonBook(title, authors, publisher, year, isbn10, isbn13, edition)
|
|
@ -0,0 +1,18 @@
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
class RemoteException(Exception):
|
||||
"""Exception class for bad argument values."""
|
||||
def __init__(self, status, stdout, stderr):
|
||||
self.status, self.stdout, self.stderr = status, stdout, stderr
|
||||
def __str__(self):
|
||||
return 'Error executing ceoc (%d)\n\n%s' % (self.status, self.stderr)
|
||||
|
||||
def run_remote(op, data):
|
||||
ceoc = '%s/ceoc' % os.environ.get('CEO_LIB_DIR', '/usr/lib/ceod')
|
||||
addmember = subprocess.Popen([ceoc, op], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = addmember.communicate(data)
|
||||
status = addmember.wait()
|
||||
if status:
|
||||
raise RemoteException(status, out, err)
|
||||
return out
|
|
@ -1,22 +0,0 @@
|
|||
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,254 @@
|
|||
"""
|
||||
Terms Routines
|
||||
|
||||
This module contains functions for manipulating terms, such as determining
|
||||
the current term, finding the next or previous term, converting dates to
|
||||
terms, and more.
|
||||
"""
|
||||
import time, datetime, re
|
||||
|
||||
# year to count terms from
|
||||
EPOCH = 1970
|
||||
|
||||
# seasons list
|
||||
SEASONS = [ 'w', 's', 'f' ]
|
||||
|
||||
|
||||
def validate(term):
|
||||
"""
|
||||
Determines whether a term is well-formed.
|
||||
|
||||
Parameters:
|
||||
term - the term string
|
||||
|
||||
Returns: whether the term is valid (boolean)
|
||||
|
||||
Example: validate("f2006") -> True
|
||||
"""
|
||||
|
||||
regex = '^[wsf][0-9]{4}$'
|
||||
return re.match(regex, term) is not None
|
||||
|
||||
|
||||
def parse(term):
|
||||
"""Helper function to convert a term string to the number of terms
|
||||
since the epoch. Such numbers are intended for internal use only."""
|
||||
|
||||
if not validate(term):
|
||||
raise Exception("malformed term: %s" % term)
|
||||
|
||||
year = int( term[1:] )
|
||||
season = SEASONS.index( term[0] )
|
||||
|
||||
return (year - EPOCH) * len(SEASONS) + season
|
||||
|
||||
|
||||
def generate(term):
|
||||
"""Helper function to convert a year and season to a term string."""
|
||||
|
||||
year = int(term / len(SEASONS)) + EPOCH
|
||||
season = term % len(SEASONS)
|
||||
|
||||
return "%s%04d" % ( SEASONS[season], year )
|
||||
|
||||
|
||||
def next(term):
|
||||
"""
|
||||
Returns the next term. (convenience function)
|
||||
|
||||
Parameters:
|
||||
term - the term string
|
||||
|
||||
Retuns: the term string of the following term
|
||||
|
||||
Example: next("f2006") -> "w2007"
|
||||
"""
|
||||
|
||||
return add(term, 1)
|
||||
|
||||
|
||||
def previous(term):
|
||||
"""
|
||||
Returns the previous term. (convenience function)
|
||||
|
||||
Parameters:
|
||||
term - the term string
|
||||
|
||||
Returns: the term string of the preceding term
|
||||
|
||||
Example: previous("f2006") -> "s2006"
|
||||
"""
|
||||
|
||||
return add(term, -1)
|
||||
|
||||
|
||||
def add(term, offset):
|
||||
"""
|
||||
Calculates a term relative to some base term.
|
||||
|
||||
Parameters:
|
||||
term - the base term
|
||||
offset - the number of terms since term (may be negative)
|
||||
|
||||
Returns: the term that comes offset terms after term
|
||||
"""
|
||||
|
||||
return generate(parse(term) + offset)
|
||||
|
||||
|
||||
def delta(initial, final):
|
||||
"""
|
||||
Calculates the distance between two terms.
|
||||
It should be true that add(a, delta(a, b)) == b.
|
||||
|
||||
Parameters:
|
||||
initial - the base term
|
||||
final - the term at some offset from the base term
|
||||
|
||||
Returns: the offset of final relative to initial
|
||||
"""
|
||||
|
||||
return parse(final) - parse(initial)
|
||||
|
||||
|
||||
def compare(first, second):
|
||||
"""
|
||||
Compares two terms. This function is suitable
|
||||
for use with list.sort().
|
||||
|
||||
Parameters:
|
||||
first - base term for comparison
|
||||
second - term to compare to
|
||||
|
||||
Returns: > 0 (if first > second)
|
||||
= 0 (if first == second)
|
||||
< 0 (if first < second)
|
||||
"""
|
||||
return delta(second, first)
|
||||
|
||||
|
||||
def interval(base, count):
|
||||
"""
|
||||
Returns a list of adjacent terms.
|
||||
|
||||
Parameters:
|
||||
base - the first term in the interval
|
||||
count - the number of terms to include
|
||||
|
||||
Returns: a list of count terms starting with initial
|
||||
|
||||
Example: interval('f2006', 3) -> [ 'f2006', 'w2007', 's2007' ]
|
||||
"""
|
||||
|
||||
terms = []
|
||||
|
||||
for num in xrange(count):
|
||||
terms.append( add(base, num) )
|
||||
|
||||
return terms
|
||||
|
||||
|
||||
def tstamp(timestamp):
|
||||
"""Helper to convert seconds since the epoch
|
||||
to terms since the epoch."""
|
||||
|
||||
# let python determine the month and year
|
||||
date = datetime.date.fromtimestamp(timestamp)
|
||||
|
||||
# determine season
|
||||
if date.month <= 4:
|
||||
season = SEASONS.index('w')
|
||||
elif date.month <= 8:
|
||||
season = SEASONS.index('s')
|
||||
else:
|
||||
season = SEASONS.index('f')
|
||||
|
||||
return (date.year - EPOCH) * len(SEASONS) + season
|
||||
|
||||
|
||||
def from_timestamp(timestamp):
|
||||
"""
|
||||
Converts a number of seconds since
|
||||
the epoch to a number of terms since
|
||||
the epoch.
|
||||
|
||||
This function notes that:
|
||||
WINTER = JANUARY to APRIL
|
||||
SPRING = MAY to AUGUST
|
||||
FALL = SEPTEMBER to DECEMBER
|
||||
|
||||
Parameters:
|
||||
timestamp - number of seconds since the epoch
|
||||
|
||||
Returns: the number of terms since the epoch
|
||||
|
||||
Example: from_timestamp(1166135779) -> 'f2006'
|
||||
"""
|
||||
|
||||
return generate( tstamp(timestamp) )
|
||||
|
||||
|
||||
def curr():
|
||||
"""Helper to determine the current term."""
|
||||
|
||||
return tstamp( time.time() )
|
||||
|
||||
|
||||
def current():
|
||||
"""
|
||||
Determines the current term.
|
||||
|
||||
Returns: current term
|
||||
|
||||
Example: current() -> 'f2006'
|
||||
"""
|
||||
|
||||
return generate( curr() )
|
||||
|
||||
|
||||
def next_unregistered(registered):
|
||||
"""
|
||||
Find the first future or current unregistered term.
|
||||
Intended as the 'default' for registrations.
|
||||
|
||||
Parameters:
|
||||
registered - a list of terms a member is registered for
|
||||
|
||||
Returns: the next unregistered term
|
||||
"""
|
||||
|
||||
# get current term number
|
||||
now = curr()
|
||||
|
||||
# never registered -> current term is next
|
||||
if len( registered) < 1:
|
||||
return generate( now )
|
||||
|
||||
# return the first unregistered, or the current term (whichever is greater)
|
||||
return generate(max([max(map(parse, registered))+1, now]))
|
||||
|
||||
|
||||
|
||||
### Tests ###
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
from ceo.test import test, assert_equal, success
|
||||
|
||||
test(parse); assert_equal(110, parse('f2006')); success()
|
||||
test(generate); assert_equal('f2006', generate(110)); success()
|
||||
test(next); assert_equal('w2007', next('f2006')); success()
|
||||
test(previous); assert_equal('s2006', previous('f2006')); success()
|
||||
test(delta); assert_equal(1, delta('f2006', 'w2007')); success()
|
||||
test(compare); assert_equal(-1, compare('f2006', 'w2007')); success()
|
||||
test(add); assert_equal('w2010', add('f2006', delta('f2006', 'w2010'))); success()
|
||||
test(interval); assert_equal(['f2006', 'w2007', 's2007'], interval('f2006', 3)); success()
|
||||
test(from_timestamp); assert_equal('f2006', from_timestamp(1166135779)); success()
|
||||
test(current); assert_equal(True, parse( current() ) >= 110 ); success()
|
||||
|
||||
test(next_unregistered)
|
||||
assert_equal( next(current()), next_unregistered([ current() ]))
|
||||
assert_equal( current(), next_unregistered([]))
|
||||
assert_equal( current(), next_unregistered([ previous(current()) ]))
|
||||
assert_equal( current(), next_unregistered([ add(current(), -2) ]))
|
||||
success()
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
Common Test Routines
|
||||
|
||||
This module contains helpful functions called by each module's test suite.
|
||||
"""
|
||||
from types import FunctionType, MethodType, ClassType, TypeType
|
||||
|
||||
|
||||
class TestException(Exception):
|
||||
"""Exception class for test failures."""
|
||||
|
||||
|
||||
def test(subject):
|
||||
"""Print a test message."""
|
||||
if type(subject) in (MethodType, FunctionType, ClassType, TypeType):
|
||||
print "testing %s()..." % subject.__name__,
|
||||
else:
|
||||
print "testing %s..." % subject,
|
||||
|
||||
|
||||
def success():
|
||||
"""Print a success message."""
|
||||
print "pass."
|
||||
|
||||
|
||||
def assert_equal(expected, actual):
|
||||
if expected != actual:
|
||||
message = "Expected (%s)\nWas (%s)" % (repr(expected), repr(actual))
|
||||
fail(message)
|
||||
|
||||
|
||||
def fail(message):
|
||||
print "failed!"
|
||||
raise TestException("Test failed:\n%s" % message)
|
||||
|
||||
|
||||
def negative(call, args, excep, message):
|
||||
try:
|
||||
call(*args)
|
||||
fail(message)
|
||||
except excep:
|
||||
pass
|
|
@ -1,30 +0,0 @@
|
|||
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
|
|
@ -1,36 +0,0 @@
|
|||
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)
|
|
@ -1,37 +0,0 @@
|
|||
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)
|
|
@ -1,110 +0,0 @@
|
|||
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)
|
|
@ -1,38 +0,0 @@
|
|||
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)
|
|
@ -1,79 +0,0 @@
|
|||
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)
|
|
@ -1,80 +0,0 @@
|
|||
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
|
|
@ -1,50 +0,0 @@
|
|||
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
|
|
@ -1,22 +0,0 @@
|
|||
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)
|
|
@ -1,29 +0,0 @@
|
|||
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)
|
|
@ -1,22 +0,0 @@
|
|||
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)
|
|
@ -1,37 +0,0 @@
|
|||
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)
|
|
@ -1,54 +0,0 @@
|
|||
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)
|
|
@ -1,49 +0,0 @@
|
|||
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
|
|
@ -1,27 +0,0 @@
|
|||
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)
|
|
@ -1,40 +0,0 @@
|
|||
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)
|
|
@ -1,47 +0,0 @@
|
|||
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)
|
|
@ -1,39 +0,0 @@
|
|||
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()
|
|
@ -1,110 +0,0 @@
|
|||
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)
|
|
@ -1,6 +0,0 @@
|
|||
from .Controller import Controller
|
||||
|
||||
|
||||
class WelcomeController(Controller):
|
||||
def __init__(self, model, app):
|
||||
super().__init__(model, app)
|
|
@ -1,19 +0,0 @@
|
|||
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
|
|
@ -1,7 +0,0 @@
|
|||
class AddGroupModel:
|
||||
name = 'AddGroup'
|
||||
title = 'Add group'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.description = ''
|
|
@ -1,8 +0,0 @@
|
|||
class AddMemberToGroupModel:
|
||||
name = 'AddMemberToGroup'
|
||||
title = 'Add member to group'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.username = ''
|
||||
self.subscribe_to_lists = True
|
|
@ -1,13 +0,0 @@
|
|||
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
|
|
@ -1,8 +0,0 @@
|
|||
class ChangeLoginShellModel:
|
||||
name = 'ChangeLoginShell'
|
||||
title = 'Change login shell'
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.login_shell = ''
|
||||
self.resp_json = None
|
|
@ -1,12 +0,0 @@
|
|||
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
|
|
@ -1,7 +0,0 @@
|
|||
class GetGroupModel:
|
||||
name = 'GetGroup'
|
||||
title = 'Get group members'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.resp_json = None
|
|
@ -1,4 +0,0 @@
|
|||
class GetPositionsModel:
|
||||
name = 'GetPositions'
|
||||
title = 'Get positions'
|
||||
positions = {}
|
|
@ -1,7 +0,0 @@
|
|||
class GetUserModel:
|
||||
name = 'GetUser'
|
||||
title = 'Get user info'
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.resp_json = None
|
|
@ -1,8 +0,0 @@
|
|||
class RemoveMemberFromGroupModel:
|
||||
name = 'RemoveMemberFromGroup'
|
||||
title = 'Remove member from group'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.username = ''
|
||||
self.unsubscribe_from_lists = True
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue