diff --git a/quadlets/README.md b/quadlets/README.md new file mode 100644 index 00000000..fb217972 --- /dev/null +++ b/quadlets/README.md @@ -0,0 +1,67 @@ +This directory contains the files needed to deploy Sourcebot via Podman Quadlets. This is an alternative to Docker Compose that has a number of notable differences: + +- Containers are managed as systemd services, including logging as such. +- Online Auto-Update of container images with automatic rollback on update failure. NOTE: This is not scheduled by default, but you can manually run it via `podman auto-update`. +- Supports injecting podman secrets as environmental values (not just as files like docker does). This is very useful for keeping things like SOURCEBOT_AUTH_SECRET, SOURCEBOT_ENCRYPTION_KEY, DATABASE_URL, and various other sensitive environmental variables secret. +- Supports podman pods (podman 5+ only), which make it easy to isolate inter-container networking. + +This particular deployment assumes you are running podman 5+ as it uses Quadlets to define a Pod. + +# Usage +1. Copy the contents of this directory to a [valid quadlet directory](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#synopsis) on the destination machine. At the time of this writing that can be: + +> Podman rootful unit search path +> +> Quadlet files for the root user can be placed in the following directories ordered in precedence. Meaning duplicate named quadlets found under /run take precedence over ones in /etc, as well as those in /usr: +> +> Temporary quadlets, usually used for testing: +> +> /run/containers/systemd/ +> +> System administrator’s defined quadlets: +> +> /etc/containers/systemd/ +> +> Distribution defined quadlets: +> +> /usr/share/containers/systemd/ +> +> Podman rootless unit search path +> +> Quadlet files for non-root users can be placed in the following directories: +> +> $XDG_RUNTIME_DIR/containers/systemd/ +> +> $XDG_CONFIG_HOME/containers/systemd/ or ~/.config/containers/systemd/ +> +> /etc/containers/systemd/users/$(UID) +> +> /etc/containers/systemd/users/ +> +> Using symbolic links +> +> Quadlet supports using symbolic links for the base of the search paths and inside them. +> +> *Source: https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#synopsis* + +Note that as systemd services can specify the user they run as, rootful quadlets do not necessarily run as the root user. This is demonstrated in [sourcebot.container](sourcebot.container), where user `sourcebot` is specified. + +2. If you are *not* using Enterprise Edition, edit the [sourcebot.container](sourcebot.container) file and remove the `Secret=SOURCEBOT_EE_LICENSE_KEY,type=env` line. + +3. Create podman secrets for sensitive settings. As an example, see [setup-quadlets.sh](setup-quadlets.sh), which generates basic required secrets. You'll need to add others like API Keys yourself. + +> [!important] +> `podman secret create` does not trim newlines from input. If you do not account for this then secrets can 'mysteriously' not work. +> +> Workarounds: +> 1. Use `printf` instead of `echo` to pipe values to `podman secret create` without appending a newline character. +> 2. Pipe values to `tr -d '\n'` prior to piping to `podman secret create` to remove newline characters. + +4. Optionally delete the `secrets` subdirectory. This is more secure, but will prevent rerunning the `setup-quadlets.sh` script with `GENERATE_NEW_SECRETS` set to 'N'. That is used to drop and recreate the secrets without changing them. Useful if you suspect you've succumbed to the important issue noted above. + +5. Once everything is in place, you can start the pod via: +```bash +systemctl daemon-reload +systemctl start sourcebot-pod +``` +This will start all services in the pod. diff --git a/quadlets/postgres.container b/quadlets/postgres.container new file mode 100644 index 00000000..b2e81011 --- /dev/null +++ b/quadlets/postgres.container @@ -0,0 +1,24 @@ +# sourcebot-postgres.container +[Unit] +Description=PostgreSQL Instance for Sourcebot + +[Container] +Image=docker.io/library/postgres:17 +Pull=always +Pod=sourcebot.pod +AutoUpdate=registry +HealthCmd=pg_isready -U postgres +HealthInterval=3s +HealthRetries=10 +HealthTimeout=3s +Volume=sourcebot_postgres_data:/var/lib/postgresql/data +Environment=POSTGRES_USER=postgres +Environment=POSTGRES_PASSWORD_FILE=/run/secrets/POSTGRES_ADMIN_PASSWORD +Environment=POSTGRES_DB=postgres +Secret=POSTGRES_ADMIN_PASSWORD + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/quadlets/redis.container b/quadlets/redis.container new file mode 100644 index 00000000..2bbddc43 --- /dev/null +++ b/quadlets/redis.container @@ -0,0 +1,18 @@ +# sourcebot-redis.container +[Unit] +Description=Redis Instance for Sourcebot + +[Container] +Image=docker.io/library/redis:8 +HealthCmd=["redis-cli","ping"] +HealthInterval=3s +HealthRetries=10 +HealthTimeout=10s +Pod=sourcebot.pod +Volume=sourcebot_redis_data:/data + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/quadlets/setup-quadlets.sh b/quadlets/setup-quadlets.sh new file mode 100644 index 00000000..e1f37330 --- /dev/null +++ b/quadlets/setup-quadlets.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env sh + +# Generate Random Passwords, ignoring any existing secrets. +GENERATE_NEW_SECRETS='Y' + +# Verify Variables. +if [ $GENERATE_NEW_SECRETS != 'Y' -a $GENERATE_NEW_SECRETS != 'y' -a $GENERATE_NEW_SECRETS != 'N' -a $GENERATE_NEW_SECRETS != 'n' ]; then + echo "Environment variable GENERATE_NEW_SECRETS must be either Y or N."; exit 1; +fi + +echo "(Re)creating Podman Secret Cache" +if [ $GENERATE_NEW_SECRETS = 'Y' -o $GENERATE_NEW_SECRETS = 'y' ]; then + if [ ! -d './secrets' ]; then + mkdir --mode='u=rwx,g=,o=' './secrets' + else + chmod 'u=rwx,g=,o=' './secrets' + fi + find './secrets' -type f -exec chmod 'u=rw,g=,o=' {} + + + # Use gpg dry-run to generate random passwords without worrying about profile polution. + # Additionally, it does not add linebreaks to long random strings like openssl does. + gpg --dry-run --gen-random --armor 1 64 > ./secrets/postgres_admin_password 2> /dev/null + gpg --dry-run --gen-random --armor 1 33 > ./secrets/sourcebot_auth_secret 2> /dev/null + gpg --dry-run --gen-random --armor 1 24 > ./secrets/sourcebot_encryption_key 2> /dev/null +fi + +# Removing old versions of these secrets +podman secret ls -f name="(POSTGRES_ADMIN_PASSWORD|SOURCEBOT_AUTH_SECRET|SOURCEBOT_ENCRYPTION_KEY|SOURCEBOT_DATABASE_URL)" --format "{{.ID}}" | xargs --no-run-if-empty podman secret rm +# If you want to create secrets inline: printf 'Hello World!' | podman secret create hello_world - +podman secret create POSTGRES_ADMIN_PASSWORD "./secrets/postgres_admin_password" +podman secret create SOURCEBOT_AUTH_SECRET "./secrets/sourcebot_auth_secret" +podman secret create SOURCEBOT_ENCRYPTION_KEY "./secrets/sourcebot_encryption_key" + +# URL encodes everything following the function name using just native sh and printf. +# Invoke via: +# url_encode test me out +# url_encode 'test me out' +# url_encode $(cat /run/secrets/password) +# echo 'test me out' | xargs -I {} sh -c 'url_encode "$@"' _ {} +url_encode () { + string=$* + while [ -n "$string" ]; do + tail=${string#?} + head=${string%$tail} + case $head in + [-._~0-9A-Za-z]) printf %c "$head";; + *) printf %%%02x "'$head" + esac + string=$tail + done + echo +} + +# * Generate URL-encoded DATABASE_URL based on secrets. Allows use of special characters in passwords. +# When running on podman 5+, all containers run in the same pod, so the correct address for postgres is 'localhost'. +printf 'postgresql://postgres:%s@localhost/postgres' "$(url_encode $(cat ./secrets/postgres_admin_password))" | podman secret create SOURCEBOT_DATABASE_URL - + +echo "Setup complete!" diff --git a/quadlets/sourcebot.container b/quadlets/sourcebot.container new file mode 100644 index 00000000..7777cc1b --- /dev/null +++ b/quadlets/sourcebot.container @@ -0,0 +1,28 @@ +# sourcebot-sourcebot.container +[Unit] +Description=Sourcebot Instance +Requires=postgres.service redis.service +After=postgres.service redis.service + +[Container] +ContainerName=sourcebot +Image=ghcr.io/sourcebot-dev/sourcebot:latest +Pull=always +AutoUpdate=registry +Pod=sourcebot.pod +User=sourcebot +Volume=./config.json:/data/config.json:ro,z +Volume=sourcebot_data:/data +Environment=CONFIG_PATH=/data/config.json +Environment=AUTH_URL=${AUTH_URL:-http://localhost:3000} +Environment=REDIS_URL=redis://localhost:6379 +Secret=SOURCEBOT_AUTH_SECRET,type=env,target=AUTH_SECRET +Secret=SOURCEBOT_ENCRYPTION_KEY,type=env +Secret=SOURCEBOT_DATABASE_URL,type=env,target=DATABASE_URL +Secret=SOURCEBOT_EE_LICENSE_KEY,type=env + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/quadlets/sourcebot.pod b/quadlets/sourcebot.pod new file mode 100644 index 00000000..2b2d52ea --- /dev/null +++ b/quadlets/sourcebot.pod @@ -0,0 +1,3 @@ +# sourcebot.pod +[Pod] +PublishPort=3000:3000