Deployment supports nginx and PostgreSQL installed on the host, added import of an existing installation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 18:24:20 +02:00
co-authored by Claude Opus 5.5
parent 156fe1a0f4
commit 6c59c87f27
6 changed files with 378 additions and 76 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal).
- `src/locales/` — `trl()` and the translation catalogue.
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
- `deploy/` — running one container per customer on a Docker host: `docker-compose.yml` (Traefik + PostgreSQL), `rezervator.sh` (add, upgrade with rollback, backup, restore, remove), `README.md`. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it. App styles are plain CSS files in `assets/css/`, linked in `src/components/header.rs`.
- `style/main.scss` is compiled to `/pkg/rezervator.css`, but nothing links that file, so styles written there have no effect.
+23 -3
View File
@@ -6,20 +6,40 @@ REZERVATOR_PREFIX=rezervator
# Image the customer containers are started from
REZERVATOR_IMAGE=rezervator:latest
# Reverse proxy in front of the customer containers:
# traefik runs as a container of this stack, routes by domain, can issue certificates itself
# nginx the nginx already installed on this host. Every customer container listens on
# 127.0.0.1 on its own port (from BASE_PORT up) and the script generates an nginx
# server block for it. TLS is up to nginx; the settings below REZERVATOR_TLS are ignored.
REZERVATOR_PROXY=traefik
BASE_PORT=3100
# PostgreSQL server holding the customer databases:
# container runs as a container of this stack
# host the PostgreSQL already installed on this host. The script manages databases
# with "PG_ADMIN psql" and the containers connect to PG_HOST:PG_PORT.
REZERVATOR_POSTGRES=container
PG_ADMIN="sudo -u postgres"
PG_HOST=host.docker.internal
PG_PORT=5432
# Only for REZERVATOR_PROXY=traefik:
# true: HTTPS with Let's Encrypt certificates, HTTP is redirected. Needs public DNS records
# pointing to this host and ACME_EMAIL.
# false: plain HTTP only, for local testing or behind another TLS terminating proxy.
REZERVATOR_TLS=true
ACME_EMAIL=
# Ports published by the proxy
# Ports published by Traefik
HTTP_PORT=80
HTTPS_PORT=443
# Password of the PostgreSQL superuser. Generated by ./rezervator.sh init.
# Only for REZERVATOR_POSTGRES=container: password of the PostgreSQL superuser.
# Generated by ./rezervator.sh init.
POSTGRES_PASSWORD=
# Connection limits. PG_MAX_CONNECTIONS must be above
# Connection limits. max_connections of PostgreSQL must be above
# (number of customers) x CUSTOMER_MAX_CONNECTIONS, or put PgBouncer in front of PostgreSQL.
# PG_MAX_CONNECTIONS sets it for REZERVATOR_POSTGRES=container; on the host, set it in postgresql.conf.
PG_MAX_CONNECTIONS=300
CUSTOMER_MAX_CONNECTIONS=5
+86 -23
View File
@@ -2,34 +2,96 @@
The application serves one company per instance. To host many customers, every customer gets their own container from the same image, with their own database, configuration and data volume. This directory contains what is needed to run that on one Docker host:
- `docker-compose.yml` — the shared part: the reverse proxy (Traefik) and one PostgreSQL server
- `rezervator.sh` — creates, upgrades, backs up, restores and removes customers
- `rezervator.sh` — creates, imports, upgrades, backs up, restores and removes customers
- `docker-compose.yml` — the optional shared services: a reverse proxy (Traefik) and a PostgreSQL server
- `config.template.toml` — template of a customer's configuration
- `.env.example` — settings of the stack
```text
┌─ rezervator-app-alpha ── database rz_alpha, volume rezervator-data-alpha
internet ── proxy ────┼─ rezervator-app-beta ── database rz_beta, volume rezervator-data-beta
(TLS, by └─ ...
domain) all databases live in the rezervator-postgres container
```
The reverse proxy and PostgreSQL can each be either a container of this stack or the one already installed on the host:
| `.env` | Value | Meaning |
|---|---|---|
| `REZERVATOR_PROXY` | `traefik` (default) | Traefik container, routes by domain, issues Let's Encrypt certificates itself |
| | `nginx` | nginx installed on the host; the script generates a server block per customer |
| `REZERVATOR_POSTGRES` | `container` (default) | PostgreSQL container of this stack |
| | `host` | PostgreSQL installed on the host |
The two settings are independent. [Setup with nginx and PostgreSQL on the host](#setup-with-nginx-and-postgresql-on-the-host) below describes the combination for a server that already runs both.
## Requirements
- Docker with the Compose plugin, `bash` and `openssl` on the host
- The application image on the host (`docker build -t rezervator:latest .` in the repository root, or pulled from a registry)
- For HTTPS: a DNS record for every customer domain pointing to the host, and ports 80 and 443 reachable from the internet
- The application image on the host: `docker build -t rezervator:latest .` in the repository root, or pulled from a registry. The image must match the CPU architecture of the host.
- A DNS record for every customer domain pointing to the host
## First setup
```sh
./rezervator.sh init # creates .env with a generated PostgreSQL password and stops
$EDITOR .env # image, TLS, ACME_EMAIL, ports
./rezervator.sh init # creates .env and stops
$EDITOR .env # proxy, PostgreSQL, image
$EDITOR config.template.toml # the [mailing] section: your SMTP server
./rezervator.sh init # starts the proxy and PostgreSQL
./rezervator.sh init # starts the shared services, if any, and checks the setup
```
`REZERVATOR_TLS=true` gets a Let's Encrypt certificate for every customer domain and redirects HTTP to HTTPS. Use `false` only for local testing or behind another proxy that terminates TLS: the session cookie is marked `Secure`, so logging in over plain HTTP works only on `localhost` addresses.
With `REZERVATOR_PROXY=traefik`, `REZERVATOR_TLS=true` gets a Let's Encrypt certificate for every customer domain and redirects HTTP to HTTPS; it needs `ACME_EMAIL` and ports 80 and 443 reachable from the internet. Use `false` only for local testing or behind another proxy that terminates TLS.
The session cookie is marked `Secure`. Whatever the setup, customers must reach the application over HTTPS, otherwise logging in works only on `localhost` addresses.
## Setup with nginx and PostgreSQL on the host
```ini
# .env
REZERVATOR_PROXY=nginx
REZERVATOR_POSTGRES=host
PG_ADMIN="sudo -u postgres"
```
Nothing from `docker-compose.yml` runs in this setup; `init` only creates the Docker network and checks that it can manage PostgreSQL and that containers can reach it.
Run the script as a user who may use Docker and `sudo -u postgres`.
### PostgreSQL
The script manages databases with `sudo -u postgres psql` and `pg_dump`. The customer containers connect over the Docker network to `host.docker.internal:5432`, which is the host's address on the Docker bridge, so PostgreSQL has to accept connections from there. A default installation listens on `localhost` only.
1. `postgresql.conf`: listen on the Docker bridge too. `listen_addresses = '*'` is the simple way; the next two steps keep it closed to everybody else. Check `max_connections` (see [Sizing](#sizing)).
2. `pg_hba.conf`: allow the Docker networks with password authentication. Every customer has a database and a role of the same name, so `sameuser` limits each role to its own database:
```text
host sameuser all 172.16.0.0/12 scram-sha-256
```
3. Firewall: allow port 5432 from `172.16.0.0/12` and keep it closed from outside.
4. Restart PostgreSQL and run `./rezervator.sh init`. It fails with a message when the containers cannot reach the database.
`172.16.0.0/12` covers Docker's default address pools; adjust it if your Docker uses other ranges. These steps were not tried on a Linux server yet — the script was tested against a PostgreSQL outside the stack reached through the host address, on Docker Desktop.
### nginx
Every customer container listens on `127.0.0.1` on its own port (`BASE_PORT` and up), not reachable from outside. `add` writes `customers/<name>/nginx.conf`, a plain HTTP server block that proxies the domain to that port; the commands to install it and to add a certificate with certbot are at the top of the file. Adapt it to how TLS is done on your server.
nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB for banner images.
### Moving an existing installation into a container
`import` creates a customer from an installation that runs directly on the host: it copies its database into a new one and the uploaded files from its data directory into a new volume. The accounts and passwords come along. The source is only read.
```sh
./rezervator.sh import main rezervace.example.com rezervator /srv/rezervator/target/site/data
```
The arguments are the new customer name, its domain, the name of the existing database and the directory with the uploaded banner (in old versions `target/site/data` next to the binary; leave it out if there is no banner). Config files in that directory are not copied.
Suggested order:
1. Copy the `[mailing]` section of the old `config.toml` into `config.template.toml`.
2. Try it: run `import`, check the new instance on its local port (`./rezervator.sh list` shows it), then `./rezervator.sh remove main --purge`.
3. For real: stop the old installation, run `import` again, and point the existing nginx server block of the domain to the new port (`proxy_pass http://127.0.0.1:<port>;`) — keeping its TLS settings is easier than installing the generated file. Reload nginx.
4. Keep the old database until the new instance has proven itself, then drop it.
Bookings made in the old installation between the import and the switch are not in the new one, hence stopping it first.
The new instance gets a new session key and a new database password, so everybody has to log in again. If the old installation kept its `config.toml` in `target/site/data`, that file was publicly downloadable: change the SMTP password too. `import` warns when the account `admin` still has the password `admin`.
## Adding a customer
@@ -37,11 +99,11 @@ $EDITOR config.template.toml # the [mailing] section: your SMTP server
./rezervator.sh add alpha rezervace.alpha.example
```
This creates the database and its user, writes `customers/alpha/config.toml` with a unique session key and database password, creates the data volume, starts the container and waits until it is healthy. At the end it prints the generated password of the `admin` account — it is shown only once.
This creates the database and its role, writes `customers/alpha/config.toml` with a unique session key and database password, creates the data volume, starts the container and waits until it is healthy. At the end it prints the generated password of the `admin` account — it is shown only once.
An optional third argument sets the sender address of e-mails (default `noreply@<domain>`).
`./rezervator.sh list` shows all customers and the state of their containers.
`./rezervator.sh list` shows all customers, their ports and the state of their containers.
## Upgrading
@@ -54,7 +116,7 @@ Build or pull the new image under the name set in `REZERVATOR_IMAGE`, then:
Customers are upgraded one by one. For each one the script makes a backup, replaces the container (database migrations run when the new version starts) and waits for it to become healthy. Customers that already run the image are skipped, so an interrupted upgrade can simply be run again.
If a customer does not become healthy, the script puts back the database and data from the backup it just made, starts the previous image again and stops. The remaining customers are not touched. The customer is unavailable for the duration of the attempt.
If a customer does not become healthy, the script puts back the database and data from the backup it just made, starts the previous image again and stops. The remaining customers are not touched. A customer is unavailable for a few seconds while its container is replaced.
## Backup and restore
@@ -74,23 +136,24 @@ The script does not schedule backups, does not delete old ones and does not copy
./rezervator.sh remove alpha --purge # also deletes the database, data volume and config
```
After a plain `remove`, `upgrade alpha` starts the customer again. `--purge` asks for the customer name as confirmation and keeps existing backups.
After a plain `remove`, `upgrade alpha` starts the customer again. `--purge` asks for the customer name as confirmation and keeps existing backups. With nginx, remove the customer's server block yourself.
## Health and monitoring
Every instance answers `GET /health` with 200 when it can reach its database and with 503 otherwise. The image uses it as its Docker health check, and Traefik sends traffic only to healthy containers. Point an external monitor to `https://<domain>/health`.
Every instance answers `GET /health` with 200 when it can reach its database and with 503 otherwise. The image uses it as its Docker health check; `list` shows the result, and Traefik sends traffic only to healthy containers. Point an external monitor to `https://<domain>/health`.
## Sizing
- One idle instance used about 7 MiB of memory in a test, about 12 MiB after some use. PostgreSQL itself needs far more than that.
- Every instance may open up to `CUSTOMER_MAX_CONNECTIONS` database connections (5 by default; it is written into the customer's config when the customer is added) and closes idle ones. `PG_MAX_CONNECTIONS` (300 by default) must stay above the number of customers times that limit. Beyond that, put PgBouncer in front of PostgreSQL.
- One idle instance used about 7 MiB of memory in a test, about 12 MiB after some use. PostgreSQL needs far more than that.
- Every instance may open up to `CUSTOMER_MAX_CONNECTIONS` database connections (5 by default; written into the customer's config when the customer is created) and closes idle ones. `max_connections` of PostgreSQL (100 in a default installation, `PG_MAX_CONNECTIONS` for the container) must stay above the number of customers times that limit, plus what else uses the server. Beyond that, put PgBouncer in front of PostgreSQL.
## Security notes
- `customers/` and `.env` contain secrets and are readable only by the user who ran the script. Keep them out of version control (they are in `.gitignore`).
- The proxy has read access to the Docker socket to discover containers. That is equivalent to root on the host; do not expose the proxy's API or dashboard.
- Customers share one PostgreSQL server but each has its own database and role, without access to the others.
- With Traefik: the proxy has read access to the Docker socket to discover containers, which is equivalent to root on the host. Do not expose its API or dashboard.
- With PostgreSQL on the host: the script runs `psql` as the PostgreSQL superuser, so whoever may run it controls all databases on that server, not only the customers'.
## Running a second stack
Change `REZERVATOR_PREFIX` (and the ports) in `.env` of another copy of this directory. All container, network and volume names are derived from the prefix, and each proxy only sees the containers of its own stack.
Change `REZERVATOR_PREFIX` (and the ports) in `.env` of another copy of this directory. All container, network and volume names are derived from the prefix.
+1 -1
View File
@@ -14,7 +14,7 @@ user = '@DB_USER@'
password = '@DB_PASSWORD@'
db_name = '@DB_NAME@'
host = '@DB_HOST@'
port = 5432
port = @DB_PORT@
max_connections = @MAX_CONNECTIONS@
[mailing]
+9 -1
View File
@@ -1,11 +1,15 @@
# Shared infrastructure for running one Rezervovator container per customer:
# the reverse proxy and the PostgreSQL server. Customer containers are not listed
# here, they are created with ./rezervator.sh (see README.md in this directory).
#
# Both services are optional and selected by profiles: ./rezervator.sh init starts
# "traefik" unless REZERVATOR_PROXY=nginx, and "postgres" unless REZERVATOR_POSTGRES=host.
name: ${REZERVATOR_PREFIX:-rezervator}
services:
proxy:
profiles: ["traefik"]
image: traefik:v3
container_name: ${REZERVATOR_PREFIX:-rezervator}-proxy
restart: unless-stopped
@@ -31,11 +35,12 @@ services:
- rezervator
postgres:
profiles: ["postgres"]
image: postgres:17
container_name: ${REZERVATOR_PREFIX:-rezervator}-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env (./rezervator.sh init creates it)}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
# Every customer instance may open up to CUSTOMER_MAX_CONNECTIONS connections
command: ["postgres", "-c", "max_connections=${PG_MAX_CONNECTIONS:-300}"]
volumes:
@@ -48,9 +53,12 @@ services:
networks:
- rezervator
# Created by ./rezervator.sh init, because customer containers use it even when
# neither service above runs
networks:
rezervator:
name: ${REZERVATOR_PREFIX:-rezervator}
external: true
volumes:
letsencrypt:
+257 -46
View File
@@ -11,12 +11,16 @@ CUSTOMERS_DIR="$DEPLOY_DIR/customers"
BACKUPS_DIR="$DEPLOY_DIR/backups"
usage() {
cat <<EOF
cat <<USAGE
Usage: $(basename "$0") <command> [arguments]
init Create .env and start the proxy and PostgreSQL
init Create .env; then start the shared services and check the setup
add <name> <domain> [from] Create a customer: database, config, volume, container.
[from] is the sender address of e-mails, default noreply@<domain>
import <name> <domain> <database> [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 <name>...|--all Recreate containers from REZERVATOR_IMAGE, one by one.
Backs up first, rolls back and stops when one fails.
@@ -26,7 +30,7 @@ Usage: $(basename "$0") <command> [arguments]
the data volume and the config (asks for confirmation).
<name> is lowercase letters, digits and dashes, starting with a letter.
EOF
USAGE
}
die() {
@@ -53,15 +57,43 @@ load_env() {
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'"
}
@@ -83,16 +115,41 @@ require_customer() {
all_customers() {
[ -d "$CUSTOMERS_DIR" ] || return 0
for dir in "$CUSTOMERS_DIR"/*/; do
[ -f "${dir}customer.env" ] && basename "$dir"
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
@@ -103,14 +160,64 @@ wait_for_postgres() {
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/<name>/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" <<NGINX
# Rezervovator, customer '$name'. Generated by rezervator.sh.
# Install it into nginx and add TLS, e.g.:
# sudo cp $CUSTOMERS_DIR/$name/nginx.conf /etc/nginx/conf.d/$PREFIX-$name.conf
# sudo nginx -t && sudo systemctl reload nginx
# sudo certbot --nginx -d $domain
server {
listen 80;
listen [::]:80;
server_name $domain;
# Banner images are uploaded through the application
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
}
# Starts the container of a customer. $1 = name, $2 = image, $3 = initial admin password (optional)
run_container() {
local name="$1" image="$2" admin_password="${3:-}"
local container router domain
local container router domain port
container="$(container_name "$name")"
router="$PREFIX-$name"
# shellcheck disable=SC1090
domain="$(. "$CUSTOMERS_DIR/$name/customer.env" && echo "$DOMAIN")"
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
local args=(
--detach
@@ -122,12 +229,20 @@ run_container() {
--label "rezervator.stack=$PREFIX"
--label "rezervator.customer=$name"
--label "rezervator.domain=$domain"
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
)
if [ "$TLS" = "true" ]; then
if [ "$POSTGRES" = "host" ]; then
args+=(--add-host "host.docker.internal:host-gateway")
fi
if [ "$PROXY" = "nginx" ]; then
[ -n "$port" ] || die "Customer '$name' has no PORT in customer.env"
# Only the nginx on this host may reach the container
args+=(--publish "127.0.0.1:$port:3000")
elif [ "$TLS" = "true" ]; then
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=websecure"
--label "traefik.http.routers.$router.tls.certresolver=le"
@@ -138,6 +253,8 @@ run_container() {
)
else
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=web"
)
@@ -156,8 +273,10 @@ wait_healthy() {
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
# The proxy picks a new container up a moment after it turns healthy
"running healthy") sleep 3; return 0 ;;
"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
@@ -179,49 +298,57 @@ cmd_init() {
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 with a generated PostgreSQL password. Review it (image, TLS, ACME_EMAIL) and run init again."
info "Created $ENV_FILE. Review it (proxy, PostgreSQL, image) and run init again."
exit 0
fi
load_env
if [ "$TLS" = "true" ] && [ -z "${ACME_EMAIL:-}" ]; then
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 --detach
compose_up
wait_for_postgres
info "Proxy and PostgreSQL are running"
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)"
}
cmd_add() {
[ $# -ge 2 ] || die "Usage: add <name> <domain> [from]"
local name="$1" domain="$2" mail_from="${3:-noreply@$2}"
load_env
# 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'"
customer_exists "$name" && die "Customer '$name' already exists"
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 admin_password dir
local db db_password dir
db="$(db_name "$name")"
db_password="$(random_string 24)"
admin_password="$(random_string 9)"
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 <<EOF
psql_admin <<SQL
CREATE ROLE "$db" LOGIN PASSWORD '$db_password';
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
EOF
SQL
info "Writing config to $dir"
mkdir -p "$dir"
@@ -230,43 +357,122 @@ EOF
-e "s|@DB_USER@|$db|" \
-e "s|@DB_PASSWORD@|$db_password|" \
-e "s|@DB_NAME@|$db|" \
-e "s|@DB_HOST@|$PG_CONTAINER|" \
-e "s|@DB_HOST@|$DB_HOST|" \
-e "s|@DB_PORT@|$PG_PORT|" \
-e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
-e "s|@MAIL_FROM@|$mail_from|" \
"$DEPLOY_DIR/config.template.toml" > "$dir/config.toml"
# The application in the container runs as another user and must be able to read it
# The directory is closed to other users; the application in the container must be able to read the file
chmod 644 "$dir/config.toml"
printf 'DOMAIN=%s\n' "$domain" > "$dir/customer.env"
info "Starting container $(container_name "$name")"
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 adding it again."
die "Container $(container_name "$name") did not become healthy. Fix the problem and run 'remove $name --purge' before creating it again."
fi
}
local scheme="http"
[ "$TLS" = "true" ] && scheme="https"
cat <<EOF
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
}
Customer '$name' is running at $scheme://$domain
cmd_add() {
[ $# -ge 2 ] || die "Usage: add <name> <domain> [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 <<MSG
Admin login: admin
Admin password: $admin_password
The password is shown only now. Hand it over and have it changed after the first login.
EOF
MSG
}
cmd_import() {
[ $# -ge 3 ] || die "Usage: import <name> <domain> <database> [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 %-12s %s\n' "CUSTOMER" "DOMAIN" "STATE" "IMAGE"
local name domain state image
printf '%-20s %-34s %-7s %-18s %s\n' "CUSTOMER" "DOMAIN" "PORT" "STATE" "IMAGE"
local name domain port state image
for name in $(all_customers); do
# shellcheck disable=SC1090
domain="$(. "$CUSTOMERS_DIR/$name/customer.env" && echo "$DOMAIN")"
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
@@ -275,7 +481,7 @@ cmd_list() {
else
image="-"
fi
printf '%-20s %-34s %-12s %s\n' "$name" "$domain" "$state" "$image"
printf '%-20s %-34s %-7s %-18s %s\n' "$name" "$domain" "${port:--}" "$state" "$image"
done
}
@@ -288,7 +494,7 @@ backup_customer() {
mkdir -p "$dir"
chmod 700 "$dir"
docker exec "$PG_CONTAINER" pg_dump -U postgres --no-owner --no-privileges "$db" | gzip > "$dir/$timestamp-db.sql.gz"
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"
@@ -323,12 +529,13 @@ restore_customer() {
db="$(db_name "$name")"
dir="$BACKUPS_DIR/$name"
psql_admin <<EOF
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
EOF
gzip -dc "$dir/$timestamp-db.sql.gz" | docker exec -i "$PG_CONTAINER" psql -U "$db" -d "$db" -v ON_ERROR_STOP=1 -q >/dev/null
SQL
# Loaded by the superuser acting as the customer's role, so that role owns everything
{ echo "SET ROLE \"$db\";"; gzip -dc "$dir/$timestamp-db.sql.gz"; } | psql_admin -d "$db" >/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"
@@ -429,13 +636,16 @@ cmd_remove() {
wait_for_postgres
db="$(db_name "$name")"
psql_admin <<EOF
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
DROP ROLE IF EXISTS "$db";
EOF
SQL
docker volume rm "$(volume_name "$name")" >/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() {
@@ -445,6 +655,7 @@ main() {
case "$command" in
init) cmd_init "$@" ;;
add) cmd_add "$@" ;;
import) cmd_import "$@" ;;
list) cmd_list "$@" ;;
upgrade) cmd_upgrade "$@" ;;
backup) cmd_backup "$@" ;;