#!/usr/bin/env bash # # Management of a Rezervovator stack with one container per customer. # See README.md in this directory. set -euo pipefail DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$DEPLOY_DIR/.env" CUSTOMERS_DIR="$DEPLOY_DIR/customers" BACKUPS_DIR="$DEPLOY_DIR/backups" usage() { cat < [arguments] init Create .env; then start the shared services and check the setup add [from] Create a customer: database, config, volume, container. [from] is the sender address of e-mails, default noreply@ import [data-dir] [from] Create a customer from an existing installation: copies its database and the files from its data directory. The source is only read. list List customers and the state of their containers upgrade ...|--all Recreate containers from REZERVATOR_IMAGE, one by one. Backs up first, rolls back and stops when one fails. backup ...|--all Dump the database and the data volume to backups// restore Restore a customer from backups//-* remove [--purge] Stop and remove the container. With --purge also the database, the data volume and the config (asks for confirmation). is lowercase letters, digits and dashes, starting with a letter. USAGE } die() { echo "Error: $*" >&2 exit 1 } info() { echo "==> $*" } random_string() { # $1 = number of bytes, output is twice as many hex characters openssl rand -hex "$1" } load_env() { [ -f "$ENV_FILE" ] || die "$ENV_FILE not found, run '$(basename "$0") init' first" set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a PREFIX="${REZERVATOR_PREFIX:-rezervator}" IMAGE="${REZERVATOR_IMAGE:-rezervator:latest}" TLS="${REZERVATOR_TLS:-true}" PROXY="${REZERVATOR_PROXY:-traefik}" POSTGRES="${REZERVATOR_POSTGRES:-container}" PG_CONTAINER="$PREFIX-postgres" PG_PORT="${PG_PORT:-5432}" NETWORK="$PREFIX" CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}" BASE_PORT="${BASE_PORT:-3100}" case "$PROXY" in traefik|nginx) ;; *) die "REZERVATOR_PROXY must be traefik or nginx" ;; esac case "$POSTGRES" in container|host) ;; *) die "REZERVATOR_POSTGRES must be container or host" ;; esac if [ "$POSTGRES" = "host" ]; then # Address of the Docker host as the containers see it DB_HOST="${PG_HOST:-host.docker.internal}" # How to run psql and pg_dump as the PostgreSQL superuser; may be empty PG_ADMIN="${PG_ADMIN-sudo -u postgres}" else DB_HOST="$PG_CONTAINER" fi } compose() { docker compose --project-directory "$DEPLOY_DIR" --env-file "$ENV_FILE" "$@" } # Starts the shared services that run as containers in this setup, if any compose_up() { local profiles=() if [ "$PROXY" = "traefik" ]; then profiles+=(--profile traefik); fi if [ "$POSTGRES" = "container" ]; then profiles+=(--profile postgres); fi docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null if [ "${#profiles[@]}" -gt 0 ]; then compose "${profiles[@]}" up --detach fi } check_name() { [[ "$1" =~ ^[a-z][a-z0-9-]{0,30}$ ]] || die "Invalid customer name '$1'" } container_name() { echo "$PREFIX-app-$1"; } volume_name() { echo "$PREFIX-data-$1"; } # Database and role have the same name db_name() { echo "rz_${1//-/_}"; } customer_exists() { [ -f "$CUSTOMERS_DIR/$1/customer.env" ] } require_customer() { check_name "$1" customer_exists "$1" || die "Unknown customer '$1'" } all_customers() { [ -d "$CUSTOMERS_DIR" ] || return 0 for dir in "$CUSTOMERS_DIR"/*/; do if [ -f "${dir}customer.env" ]; then basename "$dir"; fi done return 0 } # Reads a value from the customer's customer.env. $1 = name, $2 = variable customer_value() { sed -n "s/^$2=//p" "$CUSTOMERS_DIR/$1/customer.env" | head -n 1 } # psql as the PostgreSQL superuser, on the host or in the container psql_admin() { if [ "$POSTGRES" = "host" ]; then # shellcheck disable=SC2086 $PG_ADMIN psql -v ON_ERROR_STOP=1 -q "$@" else docker exec -i "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -q "$@" fi } pg_dump_admin() { if [ "$POSTGRES" = "host" ]; then # shellcheck disable=SC2086 $PG_ADMIN pg_dump "$@" else docker exec "$PG_CONTAINER" pg_dump -U postgres "$@" fi } wait_for_postgres() { if [ "$POSTGRES" = "host" ]; then psql_admin -tAc "SELECT 1" >/dev/null 2>&1 \ || die "Cannot run psql as the PostgreSQL superuser (PG_ADMIN='$PG_ADMIN')" return 0 fi for _ in $(seq 1 60); do # Over TCP: while the image initializes a new cluster, its temporary server listens on the socket only if docker exec "$PG_CONTAINER" pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then return 0 fi sleep 1 done die "PostgreSQL in container $PG_CONTAINER is not ready" } # With PostgreSQL on the host, the containers must be able to reach it over the Docker network check_db_reachable() { [ "$POSTGRES" = "host" ] || return 0 docker run --rm --network "$NETWORK" --add-host "host.docker.internal:host-gateway" \ --entrypoint bash "$IMAGE" -c "exec 3<>/dev/tcp/$DB_HOST/$PG_PORT" 2>/dev/null \ || die "Containers cannot reach PostgreSQL at $DB_HOST:$PG_PORT. Check listen_addresses, pg_hba.conf and the firewall, see README.md." } # First free port for a customer container behind nginx next_port() { local port="$BASE_PORT" name used for name in $(all_customers); do used="$(customer_value "$name" PORT)" if [ -n "$used" ] && [ "$used" -ge "$port" ]; then port=$((used + 1)) fi done echo "$port" } # Writes the nginx server block of a customer to customers//nginx.conf write_nginx_conf() { local name="$1" domain port domain="$(customer_value "$name" DOMAIN)" port="$(customer_value "$name" PORT)" cat > "$CUSTOMERS_DIR/$name/nginx.conf" </dev/null } # Waits until the container reports healthy. Returns 1 when it stops or stays unhealthy. wait_healthy() { local container="$1" status for _ in $(seq 1 90); do status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container" 2>/dev/null || echo "missing")" case "$status" in "running healthy") # Traefik picks a new container up a moment after it turns healthy if [ "$PROXY" = "traefik" ]; then sleep 3; fi return 0 ;; # Image without a health check: running for a while is the best we can tell "running none") sleep 5 [ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" = "running" ] && return 0 return 1 ;; "running starting") ;; "running unhealthy") return 1 ;; running*) ;; *) return 1 ;; esac sleep 2 done return 1 } cmd_init() { command -v docker >/dev/null || die "docker not found" command -v openssl >/dev/null || die "openssl not found" if [ ! -f "$ENV_FILE" ]; then sed "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(random_string 24)/" "$DEPLOY_DIR/.env.example" > "$ENV_FILE" chmod 600 "$ENV_FILE" info "Created $ENV_FILE. Review it (proxy, PostgreSQL, image) and run init again." exit 0 fi load_env if [ "$PROXY" = "traefik" ] && [ "$TLS" = "true" ] && [ -z "${ACME_EMAIL:-}" ]; then die "REZERVATOR_TLS=true needs ACME_EMAIL in $ENV_FILE" fi mkdir -p "$CUSTOMERS_DIR" "$BACKUPS_DIR" chmod 700 "$CUSTOMERS_DIR" "$BACKUPS_DIR" compose_up wait_for_postgres if docker image inspect "$IMAGE" >/dev/null 2>&1; then check_db_reachable else info "Image $IMAGE is not on this host yet, build or pull it before adding customers" fi info "Ready (proxy: $PROXY, PostgreSQL: $POSTGRES)" } # Validates the input and creates everything a customer needs except the running container: # database and role, config, data volume. $1 = name, $2 = domain, $3 = sender address prepare_customer() { local name="$1" domain="$2" mail_from="$3" check_name "$name" [[ "$domain" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "Invalid domain '$domain'" [[ "$mail_from" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$ ]] || die "Invalid sender address '$mail_from'" if customer_exists "$name"; then die "Customer '$name' already exists"; fi docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first" wait_for_postgres check_db_reachable local db db_password dir db="$(db_name "$name")" db_password="$(random_string 24)" dir="$CUSTOMERS_DIR/$name" if [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then die "Database $db already exists" fi if [ "$(psql_admin -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$db'")" = "1" ]; then die "Database role $db already exists" fi info "Creating database $db" psql_admin < "$dir/config.toml" # The directory is closed to other users; the application in the container must be able to read the file chmod 644 "$dir/config.toml" if [ "$PROXY" = "nginx" ]; then # Computed before customer.env exists, so the new customer is not counted local port port="$(next_port)" printf 'DOMAIN=%s\nPORT=%s\n' "$domain" "$port" > "$dir/customer.env" write_nginx_conf "$name" else printf 'DOMAIN=%s\n' "$domain" > "$dir/customer.env" fi docker volume create "$(volume_name "$name")" >/dev/null } # Starts a prepared customer and waits for it. $1 = name, $2 = initial admin password (optional) start_new_customer() { local name="$1" admin_password="${2:-}" info "Starting container $(container_name "$name")" run_container "$name" "$IMAGE" "$admin_password" if ! wait_healthy "$(container_name "$name")"; then docker logs --tail 20 "$(container_name "$name")" >&2 || true die "Container $(container_name "$name") did not become healthy. Fix the problem and run 'remove $name --purge' before creating it again." fi } print_address() { local name="$1" domain domain="$(customer_value "$name" DOMAIN)" echo if [ "$PROXY" = "nginx" ]; then echo "Customer '$name' is running on 127.0.0.1:$(customer_value "$name" PORT)." echo "To publish it at $domain, install $CUSTOMERS_DIR/$name/nginx.conf" echo "into nginx; the commands are at the top of that file." elif [ "$TLS" = "true" ]; then echo "Customer '$name' is running at https://$domain" else echo "Customer '$name' is running at http://$domain" fi } cmd_add() { [ $# -ge 2 ] || die "Usage: add [from]" local name="$1" domain="$2" mail_from="${3:-noreply@$2}" admin_password load_env prepare_customer "$name" "$domain" "$mail_from" admin_password="$(random_string 9)" start_new_customer "$name" "$admin_password" print_address "$name" cat < [data-dir] [from]" local name="$1" domain="$2" source_db="$3" data_dir="${4:-}" mail_from="${5:-noreply@$2}" local dir timestamp load_env [[ "$source_db" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "Invalid database name '$source_db'" if [ -n "$data_dir" ] && [ ! -d "$data_dir" ]; then die "Data directory '$data_dir' not found" fi wait_for_postgres [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$source_db'")" = "1" ] \ || die "Source database '$source_db' not found" prepare_customer "$name" "$domain" "$mail_from" # The copy goes through the same files as a backup, so it can be restored again later dir="$BACKUPS_DIR/$name" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$dir" chmod 700 "$dir" info "Copying database $source_db" pg_dump_admin --no-owner --no-privileges "$source_db" | gzip > "$dir/$timestamp-db.sql.gz" if [ -n "$data_dir" ]; then info "Copying files from $data_dir" # Old installations kept the config file with passwords next to the uploads tar czf "$dir/$timestamp-data.tar.gz" --exclude='*.toml' -C "$data_dir" . else tar czf "$dir/$timestamp-data.tar.gz" --files-from /dev/null fi restore_customer "$name" "$timestamp" # The accounts come with the database, so no initial admin password start_new_customer "$name" print_address "$name" echo echo "Accounts and passwords were taken over from database $source_db." if docker logs "$(container_name "$name")" 2>&1 | grep -q "still uses the default password"; then echo "WARNING: the account 'admin' still has the default password 'admin'. Change it now." fi echo "The source database and files were not changed. Stop the old installation before" echo "switching the domain over, otherwise bookings made there in the meantime are lost." } cmd_list() { load_env printf '%-20s %-34s %-7s %-18s %s\n' "CUSTOMER" "DOMAIN" "PORT" "STATE" "IMAGE" local name domain port state image for name in $(all_customers); do domain="$(customer_value "$name" DOMAIN)" port="$(customer_value "$name" PORT)" state="$(docker inspect --format '{{.State.Status}}{{if .State.Health}}/{{.State.Health.Status}}{{end}}' "$(container_name "$name")" 2>/dev/null || echo "no container")" image="$(docker inspect --format '{{.Image}}' "$(container_name "$name")" 2>/dev/null || true)" if [ -n "$image" ]; then # Tag of the image when it still has one, otherwise the short id image="$(docker image inspect --format '{{if .RepoTags}}{{index .RepoTags 0}}{{else}}{{slice .Id 7 19}}{{end}}' "$image" 2>/dev/null || echo "${image:7:12}")" else image="-" fi printf '%-20s %-34s %-7s %-18s %s\n' "$name" "$domain" "${port:--}" "$state" "$image" done } # Dumps the database and the data volume. $1 = name. Prints the timestamp of the backup. backup_customer() { local name="$1" db dir timestamp db="$(db_name "$name")" dir="$BACKUPS_DIR/$name" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$dir" chmod 700 "$dir" pg_dump_admin --no-owner --no-privileges "$db" | gzip > "$dir/$timestamp-db.sql.gz" # Any image with tar would do; the application image is certainly present docker run --rm --volume "$(volume_name "$name"):/data:ro" --entrypoint tar "$IMAGE" \ czf - -C /data . > "$dir/$timestamp-data.tar.gz" # An empty or truncated dump must not pass for a backup gzip -t "$dir/$timestamp-db.sql.gz" && gzip -t "$dir/$timestamp-data.tar.gz" [ "$(gzip -dc "$dir/$timestamp-db.sql.gz" | wc -c)" -gt 0 ] || die "Database dump of '$name' is empty" echo "$timestamp" } cmd_backup() { [ $# -ge 1 ] || die "Usage: backup ...|--all" load_env wait_for_postgres local names=("$@") name timestamp if [ "$1" = "--all" ]; then # shellcheck disable=SC2207 names=($(all_customers)) fi [ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; } for name in "${names[@]}"; do require_customer "$name" timestamp="$(backup_customer "$name")" info "Backup of '$name': $BACKUPS_DIR/$name/$timestamp-db.sql.gz, $timestamp-data.tar.gz" done } # Replaces the database and the data volume by a backup. The container must be stopped. restore_customer() { local name="$1" timestamp="$2" db dir db="$(db_name "$name")" dir="$BACKUPS_DIR/$name" psql_admin </dev/null docker run --rm -i --volume "$(volume_name "$name"):/data" --entrypoint sh "$IMAGE" \ -c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < "$dir/$timestamp-data.tar.gz" } cmd_restore() { [ $# -eq 2 ] || die "Usage: restore " local name="$1" timestamp="$2" container image load_env require_customer "$name" [ -f "$BACKUPS_DIR/$name/$timestamp-db.sql.gz" ] && [ -f "$BACKUPS_DIR/$name/$timestamp-data.tar.gz" ] \ || die "Backup $timestamp of '$name' not found in $BACKUPS_DIR/$name" wait_for_postgres container="$(container_name "$name")" # Keep the image the customer runs now; the configured one may be newer than the backup image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || echo "$IMAGE")" info "Stopping $container" docker rm --force "$container" >/dev/null 2>&1 || true info "Restoring '$name' from backup $timestamp" restore_customer "$name" "$timestamp" run_container "$name" "$image" wait_healthy "$container" || die "Container $container is not healthy after the restore, see 'docker logs $container'" info "Customer '$name' restored and running" } cmd_upgrade() { [ $# -ge 1 ] || die "Usage: upgrade ...|--all" load_env docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first" wait_for_postgres local names=("$@") name container old_image new_image timestamp if [ "$1" = "--all" ]; then # shellcheck disable=SC2207 names=($(all_customers)) fi [ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; } new_image="$(docker image inspect --format '{{.Id}}' "$IMAGE")" for name in "${names[@]}"; do require_customer "$name" container="$(container_name "$name")" old_image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)" if [ "$old_image" = "$new_image" ]; then info "'$name' already runs $IMAGE, skipping" continue fi info "Upgrading '$name'" timestamp="$(backup_customer "$name")" docker rm --force "$container" >/dev/null 2>&1 || true run_container "$name" "$IMAGE" if wait_healthy "$container"; then info "'$name' upgraded (backup before the upgrade: $timestamp)" continue fi echo "Upgrade of '$name' failed, last log lines of the new container:" >&2 docker logs --tail 20 "$container" >&2 || true docker rm --force "$container" >/dev/null 2>&1 || true if [ -z "$old_image" ]; then die "'$name' had no container before, nothing to roll back to. Backup: $timestamp" fi # Migrations of the new version may have changed the database, so the old version # gets the database from before the upgrade info "Rolling '$name' back to the previous image and to backup $timestamp" restore_customer "$name" "$timestamp" run_container "$name" "$old_image" if wait_healthy "$container"; then die "'$name' was rolled back. Upgrade stopped, the remaining customers were not touched." fi die "'$name' is DOWN: the rollback did not become healthy either. Backup: $timestamp" done } cmd_remove() { [ $# -ge 1 ] || die "Usage: remove [--purge]" local name="$1" purge="${2:-}" db answer load_env require_customer "$name" info "Removing container $(container_name "$name")" docker rm --force "$(container_name "$name")" >/dev/null 2>&1 || true if [ "$purge" != "--purge" ]; then info "Database, data volume and config of '$name' were kept. Start it again with 'upgrade $name'." return 0 fi read -r -p "This deletes the database, uploaded files and config of '$name'. Type the name to confirm: " answer [ "$answer" = "$name" ] || die "Not confirmed, nothing else was deleted" wait_for_postgres db="$(db_name "$name")" psql_admin </dev/null 2>&1 || true rm -rf "${CUSTOMERS_DIR:?}/$name" info "Customer '$name' deleted. Backups in $BACKUPS_DIR/$name were kept." if [ "$PROXY" = "nginx" ]; then info "Remove its server block from nginx as well." fi } main() { [ $# -ge 1 ] || { usage; exit 1; } local command="$1" shift case "$command" in init) cmd_init "$@" ;; add) cmd_add "$@" ;; import) cmd_import "$@" ;; list) cmd_list "$@" ;; upgrade) cmd_upgrade "$@" ;; backup) cmd_backup "$@" ;; restore) cmd_restore "$@" ;; remove) cmd_remove "$@" ;; -h|--help|help) usage ;; *) usage; exit 1 ;; esac } main "$@"