Deployment supports nginx and PostgreSQL installed on the host, added import of an existing installation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/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/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.
|
- `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`.
|
- `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.
|
- `style/main.scss` is compiled to `/pkg/rezervator.css`, but nothing links that file, so styles written there have no effect.
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -6,20 +6,40 @@ REZERVATOR_PREFIX=rezervator
|
|||||||
# Image the customer containers are started from
|
# Image the customer containers are started from
|
||||||
REZERVATOR_IMAGE=rezervator:latest
|
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
|
# true: HTTPS with Let's Encrypt certificates, HTTP is redirected. Needs public DNS records
|
||||||
# pointing to this host and ACME_EMAIL.
|
# pointing to this host and ACME_EMAIL.
|
||||||
# false: plain HTTP only, for local testing or behind another TLS terminating proxy.
|
# false: plain HTTP only, for local testing or behind another TLS terminating proxy.
|
||||||
REZERVATOR_TLS=true
|
REZERVATOR_TLS=true
|
||||||
ACME_EMAIL=
|
ACME_EMAIL=
|
||||||
|
|
||||||
# Ports published by the proxy
|
# Ports published by Traefik
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
HTTPS_PORT=443
|
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=
|
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.
|
# (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
|
PG_MAX_CONNECTIONS=300
|
||||||
CUSTOMER_MAX_CONNECTIONS=5
|
CUSTOMER_MAX_CONNECTIONS=5
|
||||||
|
|||||||
+86
-23
@@ -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:
|
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, imports, upgrades, backs up, restores and removes customers
|
||||||
- `rezervator.sh` — creates, 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
|
- `config.template.toml` — template of a customer's configuration
|
||||||
- `.env.example` — settings of the stack
|
- `.env.example` — settings of the stack
|
||||||
|
|
||||||
```text
|
The reverse proxy and PostgreSQL can each be either a container of this stack or the one already installed on the host:
|
||||||
┌─ rezervator-app-alpha ── database rz_alpha, volume rezervator-data-alpha
|
|
||||||
internet ── proxy ────┼─ rezervator-app-beta ── database rz_beta, volume rezervator-data-beta
|
| `.env` | Value | Meaning |
|
||||||
(TLS, by └─ ...
|
|---|---|---|
|
||||||
domain) all databases live in the rezervator-postgres container
|
| `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
|
## Requirements
|
||||||
|
|
||||||
- Docker with the Compose plugin, `bash` and `openssl` on the host
|
- 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)
|
- 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.
|
||||||
- For HTTPS: a DNS record for every customer domain pointing to the host, and ports 80 and 443 reachable from the internet
|
- A DNS record for every customer domain pointing to the host
|
||||||
|
|
||||||
## First setup
|
## First setup
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./rezervator.sh init # creates .env with a generated PostgreSQL password and stops
|
./rezervator.sh init # creates .env and stops
|
||||||
$EDITOR .env # image, TLS, ACME_EMAIL, ports
|
$EDITOR .env # proxy, PostgreSQL, image
|
||||||
$EDITOR config.template.toml # the [mailing] section: your SMTP server
|
$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
|
## Adding a customer
|
||||||
|
|
||||||
@@ -37,11 +99,11 @@ $EDITOR config.template.toml # the [mailing] section: your SMTP server
|
|||||||
./rezervator.sh add alpha rezervace.alpha.example
|
./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>`).
|
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
|
## 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.
|
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
|
## 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
|
./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
|
## 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
|
## 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.
|
- 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; 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.
|
- 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
|
## 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`).
|
- `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.
|
- 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
|
## 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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ user = '@DB_USER@'
|
|||||||
password = '@DB_PASSWORD@'
|
password = '@DB_PASSWORD@'
|
||||||
db_name = '@DB_NAME@'
|
db_name = '@DB_NAME@'
|
||||||
host = '@DB_HOST@'
|
host = '@DB_HOST@'
|
||||||
port = 5432
|
port = @DB_PORT@
|
||||||
max_connections = @MAX_CONNECTIONS@
|
max_connections = @MAX_CONNECTIONS@
|
||||||
|
|
||||||
[mailing]
|
[mailing]
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
# Shared infrastructure for running one Rezervovator container per customer:
|
# Shared infrastructure for running one Rezervovator container per customer:
|
||||||
# the reverse proxy and the PostgreSQL server. Customer containers are not listed
|
# 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).
|
# 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}
|
name: ${REZERVATOR_PREFIX:-rezervator}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
proxy:
|
proxy:
|
||||||
|
profiles: ["traefik"]
|
||||||
image: traefik:v3
|
image: traefik:v3
|
||||||
container_name: ${REZERVATOR_PREFIX:-rezervator}-proxy
|
container_name: ${REZERVATOR_PREFIX:-rezervator}-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -31,11 +35,12 @@ services:
|
|||||||
- rezervator
|
- rezervator
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
|
profiles: ["postgres"]
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: ${REZERVATOR_PREFIX:-rezervator}-postgres
|
container_name: ${REZERVATOR_PREFIX:-rezervator}-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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
|
# Every customer instance may open up to CUSTOMER_MAX_CONNECTIONS connections
|
||||||
command: ["postgres", "-c", "max_connections=${PG_MAX_CONNECTIONS:-300}"]
|
command: ["postgres", "-c", "max_connections=${PG_MAX_CONNECTIONS:-300}"]
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,9 +53,12 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- rezervator
|
- rezervator
|
||||||
|
|
||||||
|
# Created by ./rezervator.sh init, because customer containers use it even when
|
||||||
|
# neither service above runs
|
||||||
networks:
|
networks:
|
||||||
rezervator:
|
rezervator:
|
||||||
name: ${REZERVATOR_PREFIX:-rezervator}
|
name: ${REZERVATOR_PREFIX:-rezervator}
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
letsencrypt:
|
letsencrypt:
|
||||||
|
|||||||
+258
-47
@@ -11,12 +11,16 @@ CUSTOMERS_DIR="$DEPLOY_DIR/customers"
|
|||||||
BACKUPS_DIR="$DEPLOY_DIR/backups"
|
BACKUPS_DIR="$DEPLOY_DIR/backups"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<USAGE
|
||||||
Usage: $(basename "$0") <command> [arguments]
|
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.
|
add <name> <domain> [from] Create a customer: database, config, volume, container.
|
||||||
[from] is the sender address of e-mails, default noreply@<domain>
|
[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
|
list List customers and the state of their containers
|
||||||
upgrade <name>...|--all Recreate containers from REZERVATOR_IMAGE, one by one.
|
upgrade <name>...|--all Recreate containers from REZERVATOR_IMAGE, one by one.
|
||||||
Backs up first, rolls back and stops when one fails.
|
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).
|
the data volume and the config (asks for confirmation).
|
||||||
|
|
||||||
<name> is lowercase letters, digits and dashes, starting with a letter.
|
<name> is lowercase letters, digits and dashes, starting with a letter.
|
||||||
EOF
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
@@ -53,15 +57,43 @@ load_env() {
|
|||||||
PREFIX="${REZERVATOR_PREFIX:-rezervator}"
|
PREFIX="${REZERVATOR_PREFIX:-rezervator}"
|
||||||
IMAGE="${REZERVATOR_IMAGE:-rezervator:latest}"
|
IMAGE="${REZERVATOR_IMAGE:-rezervator:latest}"
|
||||||
TLS="${REZERVATOR_TLS:-true}"
|
TLS="${REZERVATOR_TLS:-true}"
|
||||||
|
PROXY="${REZERVATOR_PROXY:-traefik}"
|
||||||
|
POSTGRES="${REZERVATOR_POSTGRES:-container}"
|
||||||
PG_CONTAINER="$PREFIX-postgres"
|
PG_CONTAINER="$PREFIX-postgres"
|
||||||
|
PG_PORT="${PG_PORT:-5432}"
|
||||||
NETWORK="$PREFIX"
|
NETWORK="$PREFIX"
|
||||||
CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}"
|
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() {
|
compose() {
|
||||||
docker compose --project-directory "$DEPLOY_DIR" --env-file "$ENV_FILE" "$@"
|
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() {
|
check_name() {
|
||||||
[[ "$1" =~ ^[a-z][a-z0-9-]{0,30}$ ]] || die "Invalid customer name '$1'"
|
[[ "$1" =~ ^[a-z][a-z0-9-]{0,30}$ ]] || die "Invalid customer name '$1'"
|
||||||
}
|
}
|
||||||
@@ -83,16 +115,41 @@ require_customer() {
|
|||||||
all_customers() {
|
all_customers() {
|
||||||
[ -d "$CUSTOMERS_DIR" ] || return 0
|
[ -d "$CUSTOMERS_DIR" ] || return 0
|
||||||
for dir in "$CUSTOMERS_DIR"/*/; do
|
for dir in "$CUSTOMERS_DIR"/*/; do
|
||||||
[ -f "${dir}customer.env" ] && basename "$dir"
|
if [ -f "${dir}customer.env" ]; then basename "$dir"; fi
|
||||||
done
|
done
|
||||||
return 0
|
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() {
|
psql_admin() {
|
||||||
docker exec -i "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -q "$@"
|
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() {
|
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
|
for _ in $(seq 1 60); do
|
||||||
# Over TCP: while the image initializes a new cluster, its temporary server listens on the socket only
|
# 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
|
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"
|
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)
|
# Starts the container of a customer. $1 = name, $2 = image, $3 = initial admin password (optional)
|
||||||
run_container() {
|
run_container() {
|
||||||
local name="$1" image="$2" admin_password="${3:-}"
|
local name="$1" image="$2" admin_password="${3:-}"
|
||||||
local container router domain
|
local container router domain port
|
||||||
container="$(container_name "$name")"
|
container="$(container_name "$name")"
|
||||||
router="$PREFIX-$name"
|
router="$PREFIX-$name"
|
||||||
# shellcheck disable=SC1090
|
domain="$(customer_value "$name" DOMAIN)"
|
||||||
domain="$(. "$CUSTOMERS_DIR/$name/customer.env" && echo "$DOMAIN")"
|
port="$(customer_value "$name" PORT)"
|
||||||
|
|
||||||
local args=(
|
local args=(
|
||||||
--detach
|
--detach
|
||||||
@@ -122,12 +229,20 @@ run_container() {
|
|||||||
--label "rezervator.stack=$PREFIX"
|
--label "rezervator.stack=$PREFIX"
|
||||||
--label "rezervator.customer=$name"
|
--label "rezervator.customer=$name"
|
||||||
--label "rezervator.domain=$domain"
|
--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+=(
|
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.rule=Host(\`$domain\`)"
|
||||||
--label "traefik.http.routers.$router.entrypoints=websecure"
|
--label "traefik.http.routers.$router.entrypoints=websecure"
|
||||||
--label "traefik.http.routers.$router.tls.certresolver=le"
|
--label "traefik.http.routers.$router.tls.certresolver=le"
|
||||||
@@ -138,6 +253,8 @@ run_container() {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
args+=(
|
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.rule=Host(\`$domain\`)"
|
||||||
--label "traefik.http.routers.$router.entrypoints=web"
|
--label "traefik.http.routers.$router.entrypoints=web"
|
||||||
)
|
)
|
||||||
@@ -156,8 +273,10 @@ wait_healthy() {
|
|||||||
for _ in $(seq 1 90); do
|
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")"
|
status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container" 2>/dev/null || echo "missing")"
|
||||||
case "$status" in
|
case "$status" in
|
||||||
# The proxy picks a new container up a moment after it turns healthy
|
"running healthy")
|
||||||
"running healthy") sleep 3; return 0 ;;
|
# 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
|
# Image without a health check: running for a while is the best we can tell
|
||||||
"running none") sleep 5
|
"running none") sleep 5
|
||||||
[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" = "running" ] && return 0
|
[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" = "running" ] && return 0
|
||||||
@@ -179,49 +298,57 @@ cmd_init() {
|
|||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
sed "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(random_string 24)/" "$DEPLOY_DIR/.env.example" > "$ENV_FILE"
|
sed "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(random_string 24)/" "$DEPLOY_DIR/.env.example" > "$ENV_FILE"
|
||||||
chmod 600 "$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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
load_env
|
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"
|
die "REZERVATOR_TLS=true needs ACME_EMAIL in $ENV_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$CUSTOMERS_DIR" "$BACKUPS_DIR"
|
mkdir -p "$CUSTOMERS_DIR" "$BACKUPS_DIR"
|
||||||
chmod 700 "$CUSTOMERS_DIR" "$BACKUPS_DIR"
|
chmod 700 "$CUSTOMERS_DIR" "$BACKUPS_DIR"
|
||||||
compose up --detach
|
compose_up
|
||||||
wait_for_postgres
|
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() {
|
# Validates the input and creates everything a customer needs except the running container:
|
||||||
[ $# -ge 2 ] || die "Usage: add <name> <domain> [from]"
|
# database and role, config, data volume. $1 = name, $2 = domain, $3 = sender address
|
||||||
local name="$1" domain="$2" mail_from="${3:-noreply@$2}"
|
prepare_customer() {
|
||||||
load_env
|
local name="$1" domain="$2" mail_from="$3"
|
||||||
check_name "$name"
|
check_name "$name"
|
||||||
[[ "$domain" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "Invalid domain '$domain'"
|
[[ "$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'"
|
[[ "$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"
|
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first"
|
||||||
wait_for_postgres
|
wait_for_postgres
|
||||||
|
check_db_reachable
|
||||||
|
|
||||||
local db db_password admin_password dir
|
local db db_password dir
|
||||||
db="$(db_name "$name")"
|
db="$(db_name "$name")"
|
||||||
db_password="$(random_string 24)"
|
db_password="$(random_string 24)"
|
||||||
admin_password="$(random_string 9)"
|
|
||||||
dir="$CUSTOMERS_DIR/$name"
|
dir="$CUSTOMERS_DIR/$name"
|
||||||
|
|
||||||
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then
|
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then
|
||||||
die "Database $db already exists"
|
die "Database $db already exists"
|
||||||
fi
|
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"
|
info "Creating database $db"
|
||||||
psql_admin <<EOF
|
psql_admin <<SQL
|
||||||
CREATE ROLE "$db" LOGIN PASSWORD '$db_password';
|
CREATE ROLE "$db" LOGIN PASSWORD '$db_password';
|
||||||
CREATE DATABASE "$db" OWNER "$db";
|
CREATE DATABASE "$db" OWNER "$db";
|
||||||
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
|
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
|
||||||
EOF
|
SQL
|
||||||
|
|
||||||
info "Writing config to $dir"
|
info "Writing config to $dir"
|
||||||
mkdir -p "$dir"
|
mkdir -p "$dir"
|
||||||
@@ -230,43 +357,122 @@ EOF
|
|||||||
-e "s|@DB_USER@|$db|" \
|
-e "s|@DB_USER@|$db|" \
|
||||||
-e "s|@DB_PASSWORD@|$db_password|" \
|
-e "s|@DB_PASSWORD@|$db_password|" \
|
||||||
-e "s|@DB_NAME@|$db|" \
|
-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|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
|
||||||
-e "s|@MAIL_FROM@|$mail_from|" \
|
-e "s|@MAIL_FROM@|$mail_from|" \
|
||||||
"$DEPLOY_DIR/config.template.toml" > "$dir/config.toml"
|
"$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"
|
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
|
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"
|
run_container "$name" "$IMAGE" "$admin_password"
|
||||||
|
|
||||||
if ! wait_healthy "$(container_name "$name")"; then
|
if ! wait_healthy "$(container_name "$name")"; then
|
||||||
docker logs --tail 20 "$(container_name "$name")" >&2 || true
|
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
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
local scheme="http"
|
print_address() {
|
||||||
[ "$TLS" = "true" ] && scheme="https"
|
local name="$1" domain
|
||||||
cat <<EOF
|
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 login: admin
|
||||||
Admin password: $admin_password
|
Admin password: $admin_password
|
||||||
|
|
||||||
The password is shown only now. Hand it over and have it changed after the first login.
|
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() {
|
cmd_list() {
|
||||||
load_env
|
load_env
|
||||||
printf '%-20s %-34s %-12s %s\n' "CUSTOMER" "DOMAIN" "STATE" "IMAGE"
|
printf '%-20s %-34s %-7s %-18s %s\n' "CUSTOMER" "DOMAIN" "PORT" "STATE" "IMAGE"
|
||||||
local name domain state image
|
local name domain port state image
|
||||||
for name in $(all_customers); do
|
for name in $(all_customers); do
|
||||||
# shellcheck disable=SC1090
|
domain="$(customer_value "$name" DOMAIN)"
|
||||||
domain="$(. "$CUSTOMERS_DIR/$name/customer.env" && echo "$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")"
|
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)"
|
image="$(docker inspect --format '{{.Image}}' "$(container_name "$name")" 2>/dev/null || true)"
|
||||||
if [ -n "$image" ]; then
|
if [ -n "$image" ]; then
|
||||||
@@ -275,7 +481,7 @@ cmd_list() {
|
|||||||
else
|
else
|
||||||
image="-"
|
image="-"
|
||||||
fi
|
fi
|
||||||
printf '%-20s %-34s %-12s %s\n' "$name" "$domain" "$state" "$image"
|
printf '%-20s %-34s %-7s %-18s %s\n' "$name" "$domain" "${port:--}" "$state" "$image"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +494,7 @@ backup_customer() {
|
|||||||
mkdir -p "$dir"
|
mkdir -p "$dir"
|
||||||
chmod 700 "$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
|
# Any image with tar would do; the application image is certainly present
|
||||||
docker run --rm --volume "$(volume_name "$name"):/data:ro" --entrypoint tar "$IMAGE" \
|
docker run --rm --volume "$(volume_name "$name"):/data:ro" --entrypoint tar "$IMAGE" \
|
||||||
czf - -C /data . > "$dir/$timestamp-data.tar.gz"
|
czf - -C /data . > "$dir/$timestamp-data.tar.gz"
|
||||||
@@ -323,12 +529,13 @@ restore_customer() {
|
|||||||
db="$(db_name "$name")"
|
db="$(db_name "$name")"
|
||||||
dir="$BACKUPS_DIR/$name"
|
dir="$BACKUPS_DIR/$name"
|
||||||
|
|
||||||
psql_admin <<EOF
|
psql_admin <<SQL
|
||||||
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
|
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
|
||||||
CREATE DATABASE "$db" OWNER "$db";
|
CREATE DATABASE "$db" OWNER "$db";
|
||||||
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
|
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
|
||||||
EOF
|
SQL
|
||||||
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
|
# 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" \
|
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"
|
-c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < "$dir/$timestamp-data.tar.gz"
|
||||||
@@ -429,13 +636,16 @@ cmd_remove() {
|
|||||||
|
|
||||||
wait_for_postgres
|
wait_for_postgres
|
||||||
db="$(db_name "$name")"
|
db="$(db_name "$name")"
|
||||||
psql_admin <<EOF
|
psql_admin <<SQL
|
||||||
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
|
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
|
||||||
DROP ROLE IF EXISTS "$db";
|
DROP ROLE IF EXISTS "$db";
|
||||||
EOF
|
SQL
|
||||||
docker volume rm "$(volume_name "$name")" >/dev/null 2>&1 || true
|
docker volume rm "$(volume_name "$name")" >/dev/null 2>&1 || true
|
||||||
rm -rf "${CUSTOMERS_DIR:?}/$name"
|
rm -rf "${CUSTOMERS_DIR:?}/$name"
|
||||||
info "Customer '$name' deleted. Backups in $BACKUPS_DIR/$name were kept."
|
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() {
|
main() {
|
||||||
@@ -445,6 +655,7 @@ main() {
|
|||||||
case "$command" in
|
case "$command" in
|
||||||
init) cmd_init "$@" ;;
|
init) cmd_init "$@" ;;
|
||||||
add) cmd_add "$@" ;;
|
add) cmd_add "$@" ;;
|
||||||
|
import) cmd_import "$@" ;;
|
||||||
list) cmd_list "$@" ;;
|
list) cmd_list "$@" ;;
|
||||||
upgrade) cmd_upgrade "$@" ;;
|
upgrade) cmd_upgrade "$@" ;;
|
||||||
backup) cmd_backup "$@" ;;
|
backup) cmd_backup "$@" ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user