Files
fluxer/fluxer_docs/src/installer/install.sh
T

2174 lines
85 KiB
Bash

#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Fluxer self-hosting installer and upgrader for Linux and macOS.
#
# Three modes, one file:
#
# default Check the host, download the stack files, write .env with every
# value the stack requires, start the containers.
# --update Upgrade an instance that already exists. Record the running
# images, back up, refresh the stack files, pull, recreate, verify.
# --rollback Put the images and the stack files of the last recorded upgrade
# back.
#
# Why one script and not a separate upgrader: an upgrade needs the host checks,
# the stack download, the readiness poll and the health probe that the install
# already has. A second script either copies them or drifts from them, and
# the operator has two downloads and two checksums to verify instead of one.
# The modes share one contract, one digest and one set of exit codes.
#
# This file is also the procedure. Every step of the upgrade includes the command
# an operator types to do that step by hand, and the reason the step exists.
#
# Read this file before you run it. The default mode writes .env, which holds
# every secret the instance has. The upgrade generates a secret only for a key
# the refreshed stack requires that .env does not hold, and rewrites no
# secret. The only value either upgrade mode ever replaces in .env is
# FLUXER_IMAGE_TAG, and only during a rollback that moves off a pinned tag.
#
# Rewriting a secret in .env against volumes that already exist is the one thing
# this script could do that an operator cannot undo. A fresh POSTGRES_PASSWORD
# does not open the existing postgres-data volume, and the instance never starts
# again. Every path below is built so that no run can reach that state.
#
# Exit codes:
# 0 success
# 1 usage error
# 2 unmet prerequisite
# 3 refused to overwrite existing state
# 4 a download failed
# 5 secret generation failed
# 6 the stack did not come up
# 7 a backup failed
# 130 interrupted
set -eu
# The C locale keeps the character ranges in the validation patterns byte based.
# Under a UTF-8 locale a-z also matches uppercase and accented letters.
LC_ALL=C
export LC_ALL
FLUXER_RAW_BASE='https://raw.githubusercontent.com/fluxerapp/fluxer'
FLUXER_STACK_PATH='deploy/self-hosting'
FLUXER_MIN_ENGINE='24.0.0'
# Podman numbers its releases on its own scale, so the Docker Engine floor says
# nothing about it. 5.0 is the first release that runs this stack's compose file
# as written, healthcheck conditions and all.
FLUXER_MIN_PODMAN='5.0.0'
FLUXER_MIN_COMPOSE='2.20.2'
# Every overlay this script downloads uses the !override or !reset tag, which
# Compose 2.24.4 reads. A stack that loads none runs on the lower minimum, so the
# higher one is required only once COMPOSE_FILE names more than one file.
FLUXER_MIN_COMPOSE_OVERLAY='2.24.4'
FLUXER_READY_TIMEOUT=600
FLUXER_READY_INTERVAL=5
FLUXER_READY_REPORT=30
FLUXER_VAPID_ATTEMPTS=8
FLUXER_SEC1_HEADER='30770201010420'
FLUXER_INSPECT_FORMAT='{{index .Config.Labels "com.docker.compose.service"}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} {{.State.ExitCode}}'
# The image that copies volumes and measures them. Pinned, because an upgrade
# that reaches for a moving tag to take its backup has one more thing that can
# change under it.
FLUXER_HELPER_IMAGE='alpine:3.22'
# Names inside a record directory. A record is one upgrade: what was running,
# what the stack files said, and the backup taken before the images moved.
FLUXER_RECORD_PREFIX='record-'
FLUXER_IMAGES_FILE='images'
FLUXER_TAG_FILE='image-tag'
FLUXER_DUMP_FILE='fluxer.dump'
# Free space demanded before a volume copy, as a percentage of the measured
# volume size. A backup that fills the disk it writes to takes the instance down
# with it.
FLUXER_VOLUME_HEADROOM=110
# The keys .env holds, in the order they are written. The installer iterates
# these two lists, so a key that leaves a list is a key the installer stops
# writing. The docs CI parses the same text and compares it against
# deploy/self-hosting/.env.example.
fluxer_non_secret_keys() {
cat <<'KEYS'
FLUXER_DOMAIN domain
FLUXER_PUBLIC_SCHEME literal https
FLUXER_PUBLIC_PORT literal 443
FLUXER_VAPID_EMAIL email
FLUXER_IMAGE_TAG image_tag
FLUXER_S3_ACCESS_KEY literal fluxer
LIVEKIT_API_KEY literal fluxer
KEYS
}
fluxer_secret_keys() {
cat <<'KEYS'
POSTGRES_PASSWORD hex
MEILI_MASTER_KEY hex
FLUXER_S3_SECRET_KEY hex
FLUXER_SUDO_MODE_SECRET hex
FLUXER_CONNECTION_INITIATION_SECRET hex
FLUXER_GATEWAY_RPC_AUTH_TOKEN hex
FLUXER_ERLANG_COOKIE hex
FLUXER_MEDIA_PROXY_SECRET_KEY hex
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64
FLUXER_ADMIN_SECRET_KEY_BASE hex
FLUXER_ADMIN_OAUTH_CLIENT_SECRET hex
LIVEKIT_API_SECRET hex
FLUXER_VAPID_PUBLIC_KEY vapid_public
FLUXER_VAPID_PRIVATE_KEY vapid_private
KEYS
}
fluxer_stack_files() {
cat <<'FILES'
docker-compose.yml
docker-compose.proxy.yml
tunnel.compose.yml
external-object-store.compose.yml
Caddyfile
.env.example
FILES
}
fluxer_compose_names() {
cat <<'NAMES'
compose.yaml
compose.yml
docker-compose.yml
docker-compose.yaml
NAMES
}
fluxer_compose_name_in() {
for fluxer_candidate in $(fluxer_compose_names); do
if [ -e "$1/$fluxer_candidate" ]; then
printf '%s' "$fluxer_candidate"
return 0
fi
done
return 1
}
fluxer_compose_base='docker-compose.yml'
fluxer_compose_base_from=''
fluxer_placed_name() {
if [ "$1" = 'docker-compose.yml' ]; then
printf '%s' "$fluxer_compose_base"
else
printf '%s' "$1"
fi
}
# The file Compose bind-mounts from the working directory, with the service that
# mounts it.
#
# Compose decides whether to recreate a container by comparing its configuration.
# The contents of a bind-mounted file are not part of that comparison, so a
# changed Caddyfile survives docker compose up -d with the old bytes still loaded
# in the running container. The service has to be restarted by name.
#
# By hand, after a refresh that changed the file:
# docker compose restart edge
fluxer_mounted_files() {
cat <<'MOUNTS'
Caddyfile edge
MOUNTS
}
# The volumes an upgrade copies, and the reason the rest are absent.
#
# postgres-data is not here because the dump supersedes it. A custom-format dump
# restores into the major version that wrote it or a newer one, a tarball of the
# data directory restores only into the major that wrote it, and taking both
# doubles the downtime and the disk for a strictly weaker artifact.
#
# valkey-data and nats-data hold queued work, not records. Losing them drops
# scheduled bulk deletions and background jobs, which is degradation rather than
# loss, and the upgrade never removes a volume, so nothing here can lose them.
#
# meilisearch-data rebuilds on the next API start, edge-data is one certificate
# request, edge-config is rewritten by Caddy on every start.
#
# seaweedfs-data is the one that matters. It holds every upload, avatar, report
# and harvest, and nothing else in the stack can recreate any of it.
fluxer_backup_volumes() {
cat <<'VOLUMES'
seaweedfs-data
VOLUMES
}
fluxer_usage() {
cat <<'USAGE'
Usage: sh install.sh --domain <host> --email <address> [options]
sh install.sh --update [options]
sh install.sh --rollback [options]
Options:
--domain <host> Hostname the instance answers on. Prompted when absent.
--email <address> Contact email for web push. Prompted when absent.
--engine <command> Container engine to drive. Default docker, or podman
when docker is absent.
--dir <path> Working directory. Default ~/fluxer, or the working
directory itself when --update or --rollback is given
and it holds an instance.
--ref <git ref> Ref the stack files come from. Default: the image tag,
and main when that tag is v1 or latest.
--image-tag <tag> Image tag the stack runs. Default v1.
--tls bundled|proxy Certificate mode. Default bundled.
--edge-bind <addr:port> Plain HTTP bind under --tls proxy. Default 127.0.0.1:8080.
--non-interactive Never prompt. A missing required value is an error.
--dry-run Print the plan. Change nothing.
--no-start Write everything and skip $fluxer_engine compose up -d.
--update Upgrade: record, back up, refresh, pull, recreate, verify.
--rollback Restore the images and stack files of the last record.
--backup-dir <path> Where records go. Default <dir>/backups.
--no-volume-backup Take the database dump and skip the uploads copy.
--no-volume-compression Copy the uploads as a plain .tar. Faster, larger.
--skip-backup-accept-data-loss
Upgrade with no backup at all. Losable data is lost.
--allow-root Permit running as root.
--help Print this text.
USAGE
}
fluxer_say() {
printf '%s\n' "$1"
}
fluxer_fail() {
printf '%s\n' "$2" >&2
exit "$1"
}
fluxer_bad_usage() {
printf '%s\n\n' "$1" >&2
fluxer_usage >&2
exit 1
}
fluxer_take_value() {
if [ "$2" -lt 2 ]; then
fluxer_bad_usage "$1 needs a value."
fi
case $3 in
''|--*) fluxer_bad_usage "$1 needs a value." ;;
esac
}
fluxer_scratch=''
fluxer_record=''
fluxer_cleanup() {
if [ -n "$fluxer_scratch" ] && [ -d "$fluxer_scratch" ]; then
rm -rf "$fluxer_scratch"
fi
}
fluxer_on_signal() {
fluxer_cleanup
if [ -n "$fluxer_record" ] && [ -d "$fluxer_record" ]; then
printf 'Interrupted. The backup and the version record are in %s.\n' "$fluxer_record" >&2
else
printf 'Interrupted. The stack files and .env are left as they were.\n' >&2
fi
exit 130
}
trap fluxer_cleanup EXIT
trap fluxer_on_signal INT
trap fluxer_on_signal HUP
trap fluxer_on_signal TERM
opt_domain=''
opt_email=''
opt_dir=''
opt_engine=''
fluxer_engine='docker'
fluxer_engine_label='Docker Engine'
opt_ref=''
opt_image_tag='v1'
opt_tls='bundled'
opt_edge_bind='127.0.0.1:8080'
opt_non_interactive=0
opt_dry_run=0
opt_no_start=0
opt_update=0
opt_rollback=0
opt_backup_dir=''
opt_no_volume_backup=0
opt_no_volume_compression=0
opt_skip_backup=0
opt_allow_root=0
while [ $# -gt 0 ]; do
case $1 in
--domain)
fluxer_take_value '--domain' $# "${2:-}"
opt_domain=$2
shift 2
;;
--email)
fluxer_take_value '--email' $# "${2:-}"
opt_email=$2
shift 2
;;
--engine)
fluxer_take_value '--engine' $# "${2:-}"
opt_engine=$2
shift 2
;;
--dir)
fluxer_take_value '--dir' $# "${2:-}"
opt_dir=$2
shift 2
;;
--ref)
fluxer_take_value '--ref' $# "${2:-}"
opt_ref=$2
shift 2
;;
--image-tag)
fluxer_take_value '--image-tag' $# "${2:-}"
opt_image_tag=$2
shift 2
;;
--tls)
fluxer_take_value '--tls' $# "${2:-}"
opt_tls=$2
shift 2
;;
--edge-bind)
fluxer_take_value '--edge-bind' $# "${2:-}"
opt_edge_bind=$2
shift 2
;;
--backup-dir)
fluxer_take_value '--backup-dir' $# "${2:-}"
opt_backup_dir=$2
shift 2
;;
--non-interactive)
opt_non_interactive=1
shift
;;
--dry-run)
opt_dry_run=1
shift
;;
--no-start)
opt_no_start=1
shift
;;
--update)
opt_update=1
shift
;;
--rollback)
opt_rollback=1
shift
;;
--no-volume-backup)
opt_no_volume_backup=1
shift
;;
--no-volume-compression)
opt_no_volume_compression=1
shift
;;
--skip-backup-accept-data-loss)
opt_skip_backup=1
shift
;;
--allow-root)
opt_allow_root=1
shift
;;
--help)
fluxer_usage
exit 0
;;
*)
fluxer_bad_usage "Unknown option $1."
;;
esac
done
# An upgrade acts on an instance that already exists, and the directory holding
# one is recognisable: a compose file with content, an .env beside it, and at
# least one FLUXER_ key in that .env. Taking the working directory when it holds
# those is what an operator standing in their own instance means, and it is the
# only case where the default is not ~/fluxer.
#
# The FLUXER_ test is what keeps this off somebody else's stack. A compose file
# and an .env together describe every Compose project there is, and an upgrade
# replaces the stack files in the directory it acts on, so a looser test would
# overwrite a project that has nothing to do with Fluxer.
#
# An install writes a new instance, so it never adopts the working directory.
if [ -z "$opt_dir" ]; then
fluxer_here_compose=$(fluxer_compose_name_in "$(pwd)" || true)
if { [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; } &&
[ -n "$fluxer_here_compose" ] && [ -s "$(pwd)/$fluxer_here_compose" ] &&
[ -e "$(pwd)/.env" ] && grep -q '^FLUXER_' "$(pwd)/.env" 2>/dev/null; then
opt_dir="$(pwd)"
fluxer_say "Acting on the instance in $opt_dir, the working directory. Pass --dir to name another."
else
if [ -n "${HOME:-}" ]; then
opt_dir="$HOME/fluxer"
else
opt_dir="$(pwd)/fluxer"
fi
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
fluxer_say "The working directory holds no instance, so this acts on $opt_dir. Pass --dir to name another."
fi
fi
fi
case $opt_dir in
/*) ;;
*) opt_dir="$(pwd)/$opt_dir" ;;
esac
if [ -z "$opt_backup_dir" ]; then
opt_backup_dir="$opt_dir/backups"
fi
case $opt_backup_dir in
/*) ;;
*) opt_backup_dir="$(pwd)/$opt_backup_dir" ;;
esac
fluxer_docker_hint() {
if [ "$(uname -s)" = 'Darwin' ]; then
printf '%s' 'Install Docker Desktop from https://docs.docker.com/desktop/setup/install/mac-install/.'
return 0
fi
if command -v apt-get >/dev/null 2>&1; then
printf '%s' 'On Debian and Ubuntu, follow https://docs.docker.com/engine/install/ and install docker-ce with docker-compose-plugin. The distribution docker.io package ships no Compose plugin.'
return 0
fi
if grep -qE '^ID="?fedora"?$' /etc/os-release 2>/dev/null; then
printf '%s' 'On Fedora, install Podman and Docker Compose with dnf install -y podman docker-compose, then start the Podman API socket with systemctl --user enable --now podman.socket. podman compose runs docker-compose ahead of podman-compose, which lacks commands this script runs.'
return 0
fi
if command -v dnf >/dev/null 2>&1; then
printf '%s' 'On RHEL and derivatives, follow https://docs.docker.com/engine/install/ and install docker-ce with docker-compose-plugin.'
return 0
fi
if command -v zypper >/dev/null 2>&1; then
printf '%s' 'On openSUSE, install the docker and docker-compose packages with zypper, then enable the docker service.'
return 0
fi
if command -v pacman >/dev/null 2>&1; then
printf '%s' 'On Arch Linux, install the docker and docker-compose packages with pacman, then enable the docker service.'
return 0
fi
if command -v apk >/dev/null 2>&1; then
printf '%s' 'On Alpine Linux, install the docker and docker-cli-compose packages with apk, then enable the docker service.'
return 0
fi
printf '%s' 'Follow https://docs.docker.com/engine/install/ for this distribution.'
}
fluxer_host_tool_hint() {
if [ "$(uname -s)" = 'Darwin' ]; then
printf '%s' "Install $1 with Homebrew."
return 0
fi
if command -v apt-get >/dev/null 2>&1; then
printf '%s' "Install it with apt-get install -y $1."
return 0
fi
if command -v dnf >/dev/null 2>&1; then
printf '%s' "Install it with dnf install -y $1."
return 0
fi
if command -v zypper >/dev/null 2>&1; then
printf '%s' "Install it with zypper install -y $1."
return 0
fi
if command -v pacman >/dev/null 2>&1; then
printf '%s' "Install it with pacman -S $1."
return 0
fi
if command -v apk >/dev/null 2>&1; then
printf '%s' "Install it with apk add $1."
return 0
fi
printf '%s' "Install $1 with the package manager of this distribution."
}
fluxer_version_field() {
printf '%s' "$1" | cut -d. -f"$2"
}
fluxer_version_ge() {
fluxer_vg_index=1
while [ "$fluxer_vg_index" -le 3 ]; do
fluxer_vg_left=$(fluxer_version_field "$1" "$fluxer_vg_index")
fluxer_vg_right=$(fluxer_version_field "$2" "$fluxer_vg_index")
case $fluxer_vg_left in
''|*[!0-9]*) fluxer_vg_left=0 ;;
esac
case $fluxer_vg_right in
''|*[!0-9]*) fluxer_vg_right=0 ;;
esac
if [ "$fluxer_vg_left" -gt "$fluxer_vg_right" ]; then
return 0
fi
if [ "$fluxer_vg_left" -lt "$fluxer_vg_right" ]; then
return 1
fi
fluxer_vg_index=$((fluxer_vg_index + 1))
done
return 0
}
fluxer_engine_version=''
fluxer_compose_version=''
# Every engine that speaks the Docker CLI reports itself as "<name> version X".
# Docker says "Docker version 28.0.0, build ...", Podman says "podman version
# 5.8.4", and the podman-docker shim answers as Podman under the name docker.
# Reading the name as well as the number is what lets the floor below belong to
# the engine actually in use, because Podman's 5.x is not Docker's 5.x.
fluxer_resolve_engine() {
if [ -n "$opt_engine" ]; then
fluxer_engine=$opt_engine
elif command -v docker >/dev/null 2>&1; then
fluxer_engine='docker'
elif command -v podman >/dev/null 2>&1; then
fluxer_engine='podman'
else
fluxer_fail 2 "Neither docker nor podman is installed. $(fluxer_docker_hint)"
fi
if ! command -v "$fluxer_engine" >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine is not installed. Pass --engine with the command that drives your containers."
fi
}
fluxer_preflight() {
if [ "$opt_allow_root" -eq 0 ] && [ "$(id -u)" -eq 0 ]; then
fluxer_fail 2 'Running as root. Use an account in the docker group, or pass --allow-root.'
fi
fluxer_resolve_engine
if ! $fluxer_engine compose version >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine has no compose subcommand. $(fluxer_docker_hint)"
fi
if $fluxer_engine compose version 2>/dev/null | grep -q '^podman-compose version'; then
fluxer_fail 2 "$fluxer_engine compose runs podman-compose, which lacks compose ps -a and compose config --images that this script runs. $(fluxer_docker_hint)"
fi
fluxer_engine_report=$($fluxer_engine --version 2>/dev/null)
fluxer_engine_kind=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^\([A-Za-z][A-Za-z]*\) version .*/\1/p' | tr 'A-Z' 'a-z')
fluxer_engine_version=$(printf '%s\n' "$fluxer_engine_report" | sed -n 's/^[A-Za-z][A-Za-z]* version v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
fluxer_compose_version=$($fluxer_engine compose version --short 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9.]*\).*/\1/p')
if [ -z "$fluxer_engine_version" ] || [ -z "$fluxer_compose_version" ]; then
fluxer_fail 2 "Cannot read the engine and Compose versions from $fluxer_engine. It answered \"$fluxer_engine_report\" to --version."
fi
case $fluxer_engine_kind in
podman)
fluxer_engine_label='Podman'
fluxer_min_engine=$FLUXER_MIN_PODMAN
;;
*)
fluxer_engine_label='Docker Engine'
fluxer_min_engine=$FLUXER_MIN_ENGINE
;;
esac
if ! fluxer_version_ge "$fluxer_engine_version" "$fluxer_min_engine"; then
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs $fluxer_engine_label $fluxer_min_engine or newer."
fi
if ! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE"; then
fluxer_fail 2 "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version. Fluxer needs Compose $FLUXER_MIN_COMPOSE or newer."
fi
if ! $fluxer_engine info >/dev/null 2>&1; then
fluxer_fail 2 "$fluxer_engine does not answer. Start it and run this again."
fi
if ! command -v curl >/dev/null 2>&1; then
fluxer_fail 2 "curl is not installed. $(fluxer_host_tool_hint curl)"
fi
if ! command -v openssl >/dev/null 2>&1; then
fluxer_fail 2 "openssl is not installed. $(fluxer_host_tool_hint openssl)"
fi
fluxer_say "$fluxer_engine_label $fluxer_engine_version with Compose $fluxer_compose_version."
}
fluxer_valid_domain() {
case $1 in
''|*[!a-z0-9.-]*) return 1 ;;
.*|-*|*.|*-) return 1 ;;
*..*) return 1 ;;
esac
case $1 in
*.*) return 0 ;;
esac
return 1
}
fluxer_valid_email() {
case $1 in
*@*@*) return 1 ;;
@*|*@) return 1 ;;
*' '*) return 1 ;;
*@*) return 0 ;;
esac
return 1
}
fluxer_valid_edge_bind() {
case $1 in
*:*) ;;
*) return 1 ;;
esac
fluxer_bind_port=${1##*:}
fluxer_bind_host=${1%:*}
case $fluxer_bind_port in
''|*[!0-9]*) return 1 ;;
esac
case $fluxer_bind_host in
''|*' '*) return 1 ;;
esac
return 0
}
fluxer_prompt() {
fluxer_prompt_label=$1
fluxer_prompt_tries=0
fluxer_prompt_value=''
while [ "$fluxer_prompt_tries" -lt 3 ]; do
printf '%s: ' "$fluxer_prompt_label" >&2
if ! read -r fluxer_prompt_value; then
return 1
fi
if [ -n "$fluxer_prompt_value" ]; then
return 0
fi
fluxer_prompt_tries=$((fluxer_prompt_tries + 1))
done
return 1
}
fluxer_resolve_values() {
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
return 0
fi
if [ -z "$opt_domain" ]; then
if [ "$opt_non_interactive" -eq 1 ] || [ "$opt_dry_run" -eq 1 ] || [ ! -t 0 ]; then
fluxer_bad_usage '--domain is required.'
fi
fluxer_prompt 'Hostname the instance answers on' || fluxer_fail 1 'No hostname given.'
opt_domain=$fluxer_prompt_value
fi
if [ -z "$opt_email" ]; then
if [ "$opt_non_interactive" -eq 1 ] || [ "$opt_dry_run" -eq 1 ] || [ ! -t 0 ]; then
fluxer_bad_usage '--email is required.'
fi
fluxer_prompt 'Contact email for web push' || fluxer_fail 1 'No address given.'
opt_email=$fluxer_prompt_value
fi
fluxer_valid_domain "$opt_domain" || fluxer_bad_usage "--domain $opt_domain is not a lowercase hostname. Give a bare hostname such as chat.example.com."
fluxer_valid_email "$opt_email" || fluxer_bad_usage "--email $opt_email is not an address."
}
fluxer_validate_options() {
case $opt_tls in
bundled|proxy) ;;
*) fluxer_bad_usage "--tls takes bundled or proxy, not $opt_tls." ;;
esac
case $opt_ref in
*' '*|*/../*|../*|*/..) fluxer_bad_usage "--ref $opt_ref is not a git ref." ;;
esac
case $opt_image_tag in
''|*' '*|*/*) fluxer_bad_usage "--image-tag $opt_image_tag is not an image tag." ;;
esac
if [ "$opt_tls" = 'proxy' ]; then
fluxer_valid_edge_bind "$opt_edge_bind" || fluxer_bad_usage "--edge-bind $opt_edge_bind is not an address and port."
fi
if [ "$opt_update" -eq 1 ] && [ "$opt_rollback" -eq 1 ]; then
fluxer_bad_usage '--update and --rollback do not combine.'
fi
if [ "$opt_no_start" -eq 1 ] && { [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; }; then
fluxer_bad_usage '--no-start belongs to an install. An upgrade that does not recreate is not an upgrade.'
fi
if [ "$opt_skip_backup" -eq 1 ] && [ "$opt_update" -eq 0 ]; then
fluxer_bad_usage '--skip-backup-accept-data-loss belongs to --update.'
fi
if [ "$opt_no_volume_backup" -eq 1 ] && [ "$opt_update" -eq 0 ]; then
fluxer_bad_usage '--no-volume-backup belongs to --update.'
fi
if [ "$opt_skip_backup" -eq 1 ] && [ "$opt_no_volume_backup" -eq 1 ]; then
fluxer_bad_usage '--skip-backup-accept-data-loss already skips the volume copy.'
fi
if [ "$opt_no_volume_compression" -eq 1 ] && [ "$opt_update" -eq 0 ]; then
fluxer_bad_usage '--no-volume-compression belongs to --update.'
fi
if [ "$opt_no_volume_compression" -eq 1 ] && { [ "$opt_skip_backup" -eq 1 ] || [ "$opt_no_volume_backup" -eq 1 ]; }; then
fluxer_bad_usage '--no-volume-compression changes the volume copy, which this run skips.'
fi
}
fluxer_ref_for_tag() {
case $1 in
v1|latest) printf 'main' ;;
*) printf '%s' "$1" ;;
esac
}
# The images come from FLUXER_IMAGE_TAG and the stack files come from a git ref.
# A release tags its images and its commit with the same CalVer string, so a
# pinned tag names the commit that holds its compose files. The moving tags v1
# and latest map to main, which can run ahead of the v1 images.
fluxer_resolve_ref() {
[ -z "$opt_ref" ] || return 0
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
opt_ref=$(fluxer_ref_for_tag "$(fluxer_env_value FLUXER_IMAGE_TAG)")
else
opt_ref=$(fluxer_ref_for_tag "$opt_image_tag")
fi
if [ -z "$opt_ref" ]; then
fluxer_fail 3 "$opt_dir/.env declares no FLUXER_IMAGE_TAG, so no ref can be derived. Pass --ref."
fi
case $opt_ref in
*' '*|*/../*|../*|*/..) fluxer_fail 3 "FLUXER_IMAGE_TAG is $opt_ref, which is not a git ref. Pass --ref." ;;
esac
}
fluxer_print_plan() {
fluxer_say 'Plan:'
fluxer_say " directory $opt_dir"
fluxer_say " ref $opt_ref"
fluxer_say " image tag $opt_image_tag"
fluxer_say " tls $opt_tls"
if [ "$opt_tls" = 'proxy' ]; then
fluxer_say " edge bind $opt_edge_bind"
fi
fluxer_say " domain $opt_domain"
fluxer_say " email $opt_email"
fluxer_say ' action download the stack files, write .env, start the stack'
fluxer_say " .env keys $(fluxer_non_secret_keys | wc -l | tr -d ' ') non-secret values and $(fluxer_secret_keys | wc -l | tr -d ' ') secrets"
fluxer_say ' files docker-compose.yml docker-compose.proxy.yml tunnel.compose.yml Caddyfile .env.example'
if [ -e "$opt_dir/.env" ]; then
fluxer_say " note $opt_dir/.env exists. A run without --update refuses it."
fi
fluxer_say 'Nothing is written. Drop --dry-run to run this.'
}
# The scratch directory sits beside the files it will become, so the rename that
# puts a downloaded file in place is a rename within one filesystem and cannot
# leave a half-written file behind. The dry run passes a temporary parent
# instead, because it renames nothing.
fluxer_open_scratch() {
mkdir -p "$1"
fluxer_scratch="$1/.fluxer-install.$$"
rm -rf "$fluxer_scratch"
mkdir -m 700 "$fluxer_scratch"
}
# By hand, for one file:
# curl -fsSL https://raw.githubusercontent.com/fluxerapp/fluxer/main/deploy/self-hosting/docker-compose.yml -o docker-compose.yml
#
# The files come from a git ref and the images come from FLUXER_IMAGE_TAG. The
# ref is derived from the tag unless --ref names one. A pinned CalVer tag names
# the commit its images were built from, so its compose file asks only for
# variables those images read and pins only images the release built. The moving
# tags v1 and latest map to main, and main's compose file can run ahead of the v1
# images until the image builds are dispatched again.
fluxer_fetch_stack() {
fluxer_say "Downloading the stack files from ref $opt_ref."
fluxer_stack_files > "$fluxer_scratch/files"
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
fluxer_part="$fluxer_scratch/$fluxer_file.part"
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$fluxer_part" "$FLUXER_RAW_BASE/$opt_ref/$FLUXER_STACK_PATH/$fluxer_file"; then
fluxer_fail 4 "Download failed for $fluxer_file at ref $opt_ref. Pass --ref to name the ref the stack files come from."
fi
if [ ! -s "$fluxer_part" ]; then
fluxer_fail 4 "Downloaded $fluxer_file is empty."
fi
if [ "$fluxer_file" = 'docker-compose.yml' ] && ! grep -q '^services:' "$fluxer_part"; then
fluxer_fail 4 "The docker-compose.yml downloaded from $opt_ref holds no services block."
fi
done < "$fluxer_scratch/files"
}
# Every file lands only after all of them arrive, so a failed download leaves the
# directory on the set it already had.
#
# None of these files is part of an image, and all four are read from the working
# directory, so docker compose pull never updates any of them. That is why an
# upgrade refreshes them itself.
#
# A refreshed docker-compose.yml can declare a variable the running .env does not
# define. Compose writes ${NAME:?message} for a variable the stack requires and
# stops with that message until .env sets it, and ${NAME:-default} for one that
# needs nothing from the operator. Every optional override ships commented out in
# .env.example, so a new required key is the only kind that asks for an edit.
fluxer_place_stack() {
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
mv "$fluxer_scratch/$fluxer_file.part" "$opt_dir/$(fluxer_placed_name "$fluxer_file")"
done < "$fluxer_scratch/files"
rm -f "$fluxer_scratch/all-services"
fluxer_say "Stack files in $opt_dir are at ref $opt_ref."
}
fluxer_compose_project() {
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
printf '%s' "$COMPOSE_PROJECT_NAME"
return 0
fi
sed -n 's/^name: *//p' "$opt_dir/$fluxer_compose_base" | head -n 1
}
fluxer_project=''
fluxer_set_project() {
fluxer_project=$(fluxer_compose_project)
if [ -z "$fluxer_project" ]; then
fluxer_fail 2 "$fluxer_compose_base in $opt_dir declares no project name, so the volume names cannot be derived."
fi
}
# A second install reuses the volumes of the first, because the compose project
# name is fixed. Those volumes hold the old secrets, so the new .env never opens
# them and the stack starts into an authentication loop.
fluxer_check_volumes() {
fluxer_project=$(fluxer_compose_project)
if [ -z "$fluxer_project" ]; then
return 0
fi
$fluxer_engine volume ls -q --filter "label=com.docker.compose.project=$fluxer_project" > "$fluxer_scratch/volumes" 2>/dev/null || return 0
if [ -s "$fluxer_scratch/volumes" ]; then
fluxer_fail 3 "Docker already holds volumes for the $fluxer_project project. They hold the secrets of an earlier install, and this .env does not open them. Run install.sh --update in the directory that holds that instance, or remove the volumes with $fluxer_engine volume rm before you install again."
fi
}
fluxer_base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
fluxer_vapid_public=''
fluxer_vapid_private=''
# The VAPID pair is the one secret in .env that no random draw produces.
# FLUXER_VAPID_PUBLIC_KEY is base64url of the 65-byte uncompressed P-256 point
# and FLUXER_VAPID_PRIVATE_KEY is base64url of its 32-byte scalar. The stack
# requires both even when nobody enables browser notifications.
#
# By hand:
# openssl ecparam -name prime256v1 -genkey -noout -out vapid.pem
# openssl ec -in vapid.pem -outform DER | od -An -tx1 -N7 | tr -d ' \n'
# The line above must print 30770201010420. Any other value means a short
# scalar, so delete vapid.pem and draw again before going on.
# openssl ec -in vapid.pem -outform DER | tail -c +8 | head -c 32 | openssl base64 -A | tr '+/' '-_' | tr -d '='
# openssl ec -in vapid.pem -pubout -outform DER | tail -c 65 | openssl base64 -A | tr '+/' '-_' | tr -d '='
#
# The private key is 43 characters and the public key is 87.
#
# LibreSSL trims a leading zero byte off the SEC1 private key, which shifts the
# scalar and yields a well formed value that no push service accepts. The header
# check rejects that key and the loop draws another one.
fluxer_generate_vapid() {
fluxer_vapid_try=1
while [ "$fluxer_vapid_try" -le "$FLUXER_VAPID_ATTEMPTS" ]; do
openssl ecparam -name prime256v1 -genkey -noout -out "$fluxer_scratch/vapid.pem" 2>/dev/null
openssl ec -in "$fluxer_scratch/vapid.pem" -outform DER -out "$fluxer_scratch/vapid.der" 2>/dev/null
openssl ec -in "$fluxer_scratch/vapid.pem" -pubout -outform DER -out "$fluxer_scratch/vapid.pub.der" 2>/dev/null
fluxer_vapid_header=$(od -An -tx1 -N7 < "$fluxer_scratch/vapid.der" | tr -d ' \n')
if [ "$fluxer_vapid_header" = "$FLUXER_SEC1_HEADER" ]; then
fluxer_vapid_private=$(tail -c +8 "$fluxer_scratch/vapid.der" | head -c 32 | fluxer_base64url)
fluxer_vapid_public=$(tail -c 65 "$fluxer_scratch/vapid.pub.der" | fluxer_base64url)
rm -f "$fluxer_scratch/vapid.pem" "$fluxer_scratch/vapid.der" "$fluxer_scratch/vapid.pub.der"
if [ ${#fluxer_vapid_private} -eq 43 ] && [ ${#fluxer_vapid_public} -eq 87 ]; then
return 0
fi
return 1
fi
fluxer_vapid_try=$((fluxer_vapid_try + 1))
done
return 1
}
# The same file by hand, which is what this function does in one pass:
#
# cp .env.example .env
# chmod 600 .env
#
# Then set FLUXER_DOMAIN and FLUXER_VAPID_EMAIL, the two values only the
# operator knows. The five other non-secret keys in the list above ship correct
# in .env.example and need no edit.
#
# Every secret in .env.example contains the literal CHANGE_ME. A key whose name
# ends in _BASE64 takes openssl rand -base64 32, every other key takes
# openssl rand -hex 32, and the VAPID pair comes from the generator above.
#
# Under --tls proxy, COMPOSE_FILE and FLUXER_EDGE_BIND already sit in
# .env.example as commented lines, so by hand they are uncommented rather than
# added.
fluxer_write_env() {
fluxer_env_tmp="$fluxer_scratch/env"
fluxer_old_umask=$(umask)
umask 077
: > "$fluxer_env_tmp"
fluxer_non_secret_keys > "$fluxer_scratch/non-secret-keys"
while read -r fluxer_key fluxer_kind fluxer_literal; do
[ -n "$fluxer_key" ] || continue
case $fluxer_kind in
domain) fluxer_value=$opt_domain ;;
email) fluxer_value=$opt_email ;;
image_tag) fluxer_value=$opt_image_tag ;;
literal) fluxer_value=$fluxer_literal ;;
*) fluxer_fail 5 "Unknown non-secret kind $fluxer_kind for $fluxer_key." ;;
esac
printf '%s=%s\n' "$fluxer_key" "$fluxer_value" >> "$fluxer_env_tmp"
done < "$fluxer_scratch/non-secret-keys"
if [ "$opt_tls" = 'proxy' ]; then
printf 'COMPOSE_FILE=%s\n' 'docker-compose.yml:docker-compose.proxy.yml' >> "$fluxer_env_tmp"
printf 'FLUXER_EDGE_BIND=%s\n' "$opt_edge_bind" >> "$fluxer_env_tmp"
fi
fluxer_secret_keys > "$fluxer_scratch/secret-keys"
while read -r fluxer_key fluxer_kind; do
[ -n "$fluxer_key" ] || continue
case $fluxer_kind in
hex) fluxer_value=$(openssl rand -hex 32) ;;
base64) fluxer_value=$(openssl rand -base64 32) ;;
vapid_public) fluxer_value=$fluxer_vapid_public ;;
vapid_private) fluxer_value=$fluxer_vapid_private ;;
*) fluxer_fail 5 "Unknown secret kind $fluxer_kind for $fluxer_key." ;;
esac
if [ -z "$fluxer_value" ]; then
fluxer_fail 5 "Generated an empty value for $fluxer_key."
fi
printf '%s=%s\n' "$fluxer_key" "$fluxer_value" >> "$fluxer_env_tmp"
done < "$fluxer_scratch/secret-keys"
mv "$fluxer_env_tmp" "$opt_dir/.env"
chmod 600 "$opt_dir/.env"
umask "$fluxer_old_umask"
}
fluxer_stack_ready() {
: > "$fluxer_scratch/not-ready"
$fluxer_engine compose ps -aq > "$fluxer_scratch/ids" 2>/dev/null || return 1
[ -s "$fluxer_scratch/ids" ] || return 1
xargs $fluxer_engine inspect --format "$FLUXER_INSPECT_FORMAT" < "$fluxer_scratch/ids" > "$fluxer_scratch/state" 2>/dev/null || return 1
fluxer_ready=1
fluxer_init_done=0
fluxer_ready_count=0
fluxer_service_count=0
while read -r fluxer_service fluxer_status fluxer_health fluxer_code; do
[ -n "$fluxer_service" ] || continue
fluxer_stack_defines_service "$fluxer_service" || continue
fluxer_service_count=$((fluxer_service_count + 1))
case $fluxer_status in
running)
case $fluxer_health in
healthy|none) fluxer_ready_count=$((fluxer_ready_count + 1)) ;;
*)
printf '%s running (%s)\n' "$fluxer_service" "$fluxer_health" >> "$fluxer_scratch/not-ready"
fluxer_ready=0
;;
esac
;;
exited)
if [ "$fluxer_service" = 'seaweedfs-init' ] && [ "$fluxer_code" = '0' ]; then
fluxer_init_done=1
fluxer_ready_count=$((fluxer_ready_count + 1))
else
printf '%s exited (%s)\n' "$fluxer_service" "$fluxer_code" >> "$fluxer_scratch/not-ready"
fluxer_ready=0
fi
;;
*)
printf '%s %s\n' "$fluxer_service" "$fluxer_status" >> "$fluxer_scratch/not-ready"
fluxer_ready=0
;;
esac
done < "$fluxer_scratch/state"
if [ "$fluxer_ready" -eq 0 ]; then
return 1
fi
if [ "$fluxer_init_done" -eq 1 ]; then
return 0
fi
if fluxer_stack_defines_service 'seaweedfs-init'; then
printf 'seaweedfs-init has not completed\n' >> "$fluxer_scratch/not-ready"
fluxer_service_count=$((fluxer_service_count + 1))
return 1
fi
return 0
}
fluxer_not_ready_detail() {
if [ -s "$fluxer_scratch/not-ready" ]; then
printf 'These services are not ready:\n%s' "$(sed 's/^/ /' "$fluxer_scratch/not-ready")"
else
printf '%s' "$fluxer_engine compose reports no container state for this project."
fi
}
# Readiness comes from Compose state, which is local and authoritative.
#
# By hand:
# docker compose ps
#
# Every service reads running or healthy except seaweedfs-init, which reads
# exited (0) because it is a one-shot bucket initialiser.
fluxer_wait_ready() {
fluxer_waited=0
fluxer_reported=0
fluxer_ready_count=0
fluxer_service_count=0
while [ "$fluxer_waited" -lt "$FLUXER_READY_TIMEOUT" ]; do
if fluxer_stack_ready; then
return 0
fi
sleep "$FLUXER_READY_INTERVAL"
fluxer_waited=$((fluxer_waited + FLUXER_READY_INTERVAL))
if [ "$((fluxer_waited - fluxer_reported))" -ge "$FLUXER_READY_REPORT" ]; then
fluxer_reported=$fluxer_waited
fluxer_say "$fluxer_ready_count of $fluxer_service_count services are ready."
fi
done
return 1
}
fluxer_env_value() {
sed -n "s/^$1=\\(.*\\)\$/\\1/p" "$opt_dir/.env" | head -n 1
}
# The origin browsers use. .env states it outright when FLUXER_PUBLIC_ORIGIN is
# set, and Compose otherwise builds the same string from the scheme, the domain
# and the port, dropping a port that is the default for its scheme.
#
# Compose expands a ${...} reference inside an .env value and this script does
# not, so a FLUXER_PUBLIC_ORIGIN written that way is skipped rather than printed
# back with the braces still in it. The three names below say the same address,
# so the derived string is the right one to fall back to.
#
# By hand:
# grep -E '^FLUXER_(PUBLIC_ORIGIN|PUBLIC_SCHEME|DOMAIN|PUBLIC_PORT)=' .env
fluxer_public_origin() {
fluxer_origin=$(fluxer_env_value FLUXER_PUBLIC_ORIGIN)
case $fluxer_origin in
*'${'*)
printf '%s\n' 'FLUXER_PUBLIC_ORIGIN in .env holds a ${...} reference. This script does not expand those, so the address below comes from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and FLUXER_PUBLIC_PORT instead.' >&2
fluxer_origin=''
;;
esac
if [ -n "$fluxer_origin" ]; then
printf '%s' "${fluxer_origin%/}"
return 0
fi
fluxer_origin_host=$(fluxer_env_value FLUXER_DOMAIN)
if [ -z "$fluxer_origin_host" ]; then
return 0
fi
fluxer_origin_scheme=$(fluxer_env_value FLUXER_PUBLIC_SCHEME)
if [ -z "$fluxer_origin_scheme" ]; then
fluxer_origin_scheme='https'
fi
fluxer_origin_port=$(fluxer_env_value FLUXER_PUBLIC_PORT)
if [ -z "$fluxer_origin_port" ]; then
fluxer_origin_suffix=''
else
case $fluxer_origin_scheme:$fluxer_origin_port in
http:80|https:443) fluxer_origin_suffix='' ;;
*) fluxer_origin_suffix=":$fluxer_origin_port" ;;
esac
fi
printf '%s://%s%s' "$fluxer_origin_scheme" "$fluxer_origin_host" "$fluxer_origin_suffix"
}
# The public probe is informational. A host behind hairpin NAT cannot always
# reach its own hostname, and a false failure there would be worse than no probe.
# It asks the origin .env advertises, so an instance on a non-default port is
# probed where it actually answers.
fluxer_probe() {
fluxer_probe_origin=$1
fluxer_probe_authority=${fluxer_probe_origin#*://}
fluxer_probe_host=${fluxer_probe_authority%%:*}
case $fluxer_probe_origin in
http://*) fluxer_probe_port='80' ;;
*) fluxer_probe_port='443' ;;
esac
case $fluxer_probe_authority in
*:*) fluxer_probe_port=${fluxer_probe_authority##*:} ;;
esac
fluxer_probe_code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$fluxer_probe_origin/_health" 2>/dev/null || true)
if [ -z "$fluxer_probe_code" ]; then
fluxer_probe_code='000'
fi
if [ "$fluxer_probe_code" = '200' ]; then
fluxer_say "$fluxer_probe_origin/_health answers 200."
return 0
fi
fluxer_say "$fluxer_probe_origin/_health answers $fluxer_probe_code from this host. Check the DNS record for $fluxer_probe_host and inbound port $fluxer_probe_port."
}
# The keys a refreshed stack requires that an .env written by an older installer
# does not hold. Only keys with no state anywhere else are here. A value like
# POSTGRES_PASSWORD is matched by a password stored inside the database, so
# minting a new one locks the stack out of its own data and it is not listed.
#
# CHANGE_ME counts as absent. .env.example shipped the relay secret with that
# value for a while, and nothing read it until the API began refusing to start
# on a value under 32 bytes.
fluxer_upgrade_secret_keys() {
cat <<'KEYS'
FLUXER_ERLANG_COOKIE hex
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 base64
KEYS
}
# One key written into .env, through a temporary file that replaces the whole
# file at once.
#
# Appending is not an option. A .env whose last line has no newline takes an
# appended line onto the end of that line instead, which reads as one key whose
# value ends in the name of the next, and destroys the secret that was there.
# awk ends every line it prints, so reading the file and writing it back gives
# the missing newline as a side effect.
#
# The replacement is a rename, so an interrupt leaves the old file whole rather
# than a truncated one, and this runs before the record exists.
fluxer_env_set() {
fluxer_old_umask=$(umask)
umask 077
awk -v fluxer_k="$1" -v fluxer_v="$2" '
index($0, fluxer_k "=") == 1 && !fluxer_done {print fluxer_k "=" fluxer_v; fluxer_done = 1; next}
{print}
END {if (!fluxer_done) print fluxer_k "=" fluxer_v}
' "$opt_dir/.env" > "$fluxer_scratch/env.set"
if [ ! -s "$fluxer_scratch/env.set" ]; then
umask "$fluxer_old_umask"
fluxer_fail 5 "Rewriting $1 into .env produced an empty file. Nothing was written."
fi
if ! grep -q "^$1=" "$fluxer_scratch/env.set"; then
umask "$fluxer_old_umask"
fluxer_fail 5 "Rewriting $1 into .env did not write that key. Nothing was written."
fi
mv "$fluxer_scratch/env.set" "$opt_dir/.env"
chmod 600 "$opt_dir/.env"
umask "$fluxer_old_umask"
}
# Step 0 of an upgrade: mint the values the refreshed stack requires.
#
# Compose stops on a ${NAME:?} it cannot resolve, so a key the running .env does
# not hold fails every command this script runs before it reaches the step that
# would have reported it. Writing the value first is what makes the rest of the
# run possible, and a generated secret is as good as a hand-written one for
# every key listed above.
#
# This runs before the record, so the .env the record saves is the one that
# works and a rollback does not reintroduce the gap.
# The keys the run would write, one per line, without writing any of them. The
# plan and the run read the same list, so a plan cannot describe a run that does
# something else.
fluxer_missing_required_secrets() {
fluxer_upgrade_secret_keys > "$fluxer_scratch/upgrade-keys"
while read -r fluxer_key fluxer_kind; do
[ -n "$fluxer_key" ] || continue
fluxer_current=$(fluxer_env_value "$fluxer_key")
case ${fluxer_current:-} in
''|CHANGE_ME) printf '%s\n' "$fluxer_key" ;;
esac
done < "$fluxer_scratch/upgrade-keys"
}
fluxer_fill_required_secrets() {
fluxer_upgrade_secret_keys > "$fluxer_scratch/upgrade-keys"
while read -r fluxer_key fluxer_kind; do
[ -n "$fluxer_key" ] || continue
fluxer_current=$(fluxer_env_value "$fluxer_key")
case ${fluxer_current:-} in
''|CHANGE_ME) ;;
*) continue ;;
esac
case $fluxer_kind in
hex) fluxer_new=$(openssl rand -hex 32) ;;
base64) fluxer_new=$(openssl rand -base64 32) ;;
*) fluxer_fail 5 "Unknown secret kind $fluxer_kind for $fluxer_key." ;;
esac
[ -n "$fluxer_new" ] || fluxer_fail 5 "Generated an empty value for $fluxer_key."
fluxer_env_set "$fluxer_key" "$fluxer_new"
fluxer_say "Wrote $fluxer_key into .env. The refreshed stack requires it and this instance held no usable value."
done < "$fluxer_scratch/upgrade-keys"
}
fluxer_require_instance() {
if [ ! -e "$opt_dir/.env" ]; then
fluxer_fail 2 "No .env in $opt_dir. That directory holds no instance. Run install.sh with neither --update nor --rollback to set one up."
fi
fluxer_resolve_compose_base
if [ ! -e "$opt_dir/$fluxer_compose_base" ]; then
if [ -n "$fluxer_compose_base_from" ]; then
fluxer_fail 2 "$fluxer_compose_base_from names $fluxer_compose_base first, and $opt_dir/$fluxer_compose_base is not there. Put that file back, or name the file the instance runs on first in COMPOSE_FILE."
fi
fluxer_fail 2 "No compose file in $opt_dir. Compose looks for compose.yaml, compose.yml, docker-compose.yml and docker-compose.yaml there, and that directory holds none of them, so it does not hold an instance."
fi
if [ ! -s "$opt_dir/$fluxer_compose_base" ]; then
fluxer_fail 2 "$opt_dir/$fluxer_compose_base is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again."
fi
if [ "$fluxer_compose_base" != 'docker-compose.yml' ]; then
fluxer_say "Compose loads $fluxer_compose_base in $opt_dir, so the stack's docker-compose.yml is written to that name."
fi
}
# Compose reads COMPOSE_FILE from .env and loads every file it names before it
# answers anything, so one file the directory does not hold fails every docker
# compose command run in it. What Compose prints for that is a single stat line
# that names neither COMPOSE_FILE nor .env.
#
# Two of the files this script downloads sit in .env.example as a COMPOSE_FILE
# line to uncomment, so an instance set up before this script existed can hold
# the line and not the file. An upgrade reads the running images before it
# refreshes the stack files, so such an instance stops on the first step and no
# re-run gets any further.
#
# The line stays. Without the file it names the edge container binds 80 and 443
# and requests its own certificate.
#
# By hand:
# grep COMPOSE_FILE .env
# A value as Compose reads it: no surrounding quotes, no carriage return from an
# editor that writes CRLF, no trailing blanks. Reading it any other way invents a
# filename the operator cannot see and refuses a run that would have worked.
fluxer_env_scalar() {
fluxer_scalar=$(fluxer_env_value "$1" | tr -d '\r')
fluxer_scalar=${fluxer_scalar%"${fluxer_scalar##*[! ]}"}
case $fluxer_scalar in
\"*\") fluxer_scalar=${fluxer_scalar#\"}; fluxer_scalar=${fluxer_scalar%\"} ;;
"'"*"'") fluxer_scalar=${fluxer_scalar#"'"}; fluxer_scalar=${fluxer_scalar%"'"} ;;
esac
printf '%s' "$fluxer_scalar"
}
fluxer_compose_value() {
eval "fluxer_cv=\${$1:-}"
if [ -z "$fluxer_cv" ]; then
fluxer_cv=$(fluxer_env_scalar "$1")
fi
printf '%s' "$fluxer_cv"
}
fluxer_read_compose_setting() {
fluxer_compose_file=${COMPOSE_FILE:-}
fluxer_compose_from="the environment"
if [ -z "$fluxer_compose_file" ]; then
fluxer_compose_file=$(fluxer_env_scalar COMPOSE_FILE)
fluxer_compose_from="$opt_dir/.env"
fi
fluxer_path_sep=${COMPOSE_PATH_SEPARATOR:-}
if [ -z "$fluxer_path_sep" ]; then
fluxer_path_sep=$(fluxer_env_scalar COMPOSE_PATH_SEPARATOR)
fi
if [ -z "$fluxer_path_sep" ]; then
fluxer_path_sep=':'
fi
}
fluxer_resolve_compose_base() {
fluxer_read_compose_setting
fluxer_compose_base_from=''
fluxer_rest=$fluxer_compose_file
while [ -n "$fluxer_rest" ]; do
fluxer_name=${fluxer_rest%%"$fluxer_path_sep"*}
case $fluxer_rest in
*"$fluxer_path_sep"*) fluxer_rest=${fluxer_rest#*"$fluxer_path_sep"} ;;
*) fluxer_rest='' ;;
esac
[ -n "$fluxer_name" ] || continue
fluxer_compose_base=${fluxer_name#./}
fluxer_compose_base=${fluxer_compose_base#"$opt_dir"/}
fluxer_compose_base_from="COMPOSE_FILE from $fluxer_compose_from"
break
done
if [ -z "$fluxer_compose_base_from" ]; then
fluxer_compose_base=$(fluxer_compose_name_in "$opt_dir" || printf '%s' 'docker-compose.yml')
return 0
fi
case $fluxer_compose_base in
*/*) fluxer_fail 2 "$fluxer_compose_base_from names $fluxer_name first, and that file is not directly in $opt_dir. This script refreshes only the files in the directory it acts on, so the upgrade would leave the file Compose loads on the old stack. Move it into $opt_dir and name it there, or upgrade by hand." ;;
esac
}
fluxer_overlay_absence() {
case $1 in
external-object-store.compose.yml) printf '%s' 'Without it the bundled seaweedfs starts again and api, worker and media-proxy wait for it.' ;;
*) printf '%s' "Without $1 the edge container binds 80 and 443 and requests its own certificate." ;;
esac
}
fluxer_require_compose_files() {
fluxer_read_compose_setting
[ -n "$fluxer_compose_file" ] || return 0
fluxer_compose_count=0
fluxer_rest=$fluxer_compose_file
while [ -n "$fluxer_rest" ]; do
case $fluxer_rest in
*"$fluxer_path_sep"*)
fluxer_name=${fluxer_rest%%"$fluxer_path_sep"*}
fluxer_rest=${fluxer_rest#*"$fluxer_path_sep"}
;;
*)
fluxer_name=$fluxer_rest
fluxer_rest=''
;;
esac
[ -n "$fluxer_name" ] || continue
case $fluxer_name in
/*) fluxer_path=$fluxer_name ;;
*) fluxer_path="$opt_dir/$fluxer_name" ;;
esac
fluxer_compose_count=$((${fluxer_compose_count:-0} + 1))
[ ! -e "$fluxer_path" ] || continue
if fluxer_stack_files | grep -qxF "$fluxer_name"; then
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails and this run stops before it changes anything. This script downloads $fluxer_name, and an instance set up before it existed does not hold that file yet. Put it in place and run this again:
curl -fsSL --proto '=https' --tlsv1.2 -o $fluxer_path $FLUXER_RAW_BASE/$opt_ref/$FLUXER_STACK_PATH/$fluxer_name
Leave the COMPOSE_FILE line as it is. $(fluxer_overlay_absence "$fluxer_name")"
fi
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from names $fluxer_name and $fluxer_path is not there, so every $fluxer_engine compose command in $opt_dir fails. This script does not download $fluxer_name. Put that file back, or take it out of the COMPOSE_FILE line."
done
if [ "$fluxer_compose_count" -gt 1 ] &&
! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE_OVERLAY"; then
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from loads $fluxer_compose_count files and this host runs Compose $fluxer_compose_version. Every overlay this script downloads uses the !override or !reset tag, which needs Compose $FLUXER_MIN_COMPOSE_OVERLAY or newer. Upgrade Compose, or load only $fluxer_compose_base."
fi
}
# One read-only Compose query, with what Compose printed on stderr kept.
#
# Compose loads the whole file set and interpolates every variable in it before
# it answers either query below. A file that is not there, a file it cannot
# read and a required variable .env does not set all fail at that point, and the
# stderr text is the only thing that says which of them it was.
#
# The query writes to files rather than through a pipe, because the status of a
# pipeline is the status of its last command and the query is not the last
# command.
fluxer_compose_query() {
fluxer_query_status=0
$fluxer_engine compose config "$1" > "$fluxer_scratch/compose-out" 2> "$fluxer_scratch/compose-err" || fluxer_query_status=$?
return "$fluxer_query_status"
}
# What Compose printed on the last query, indented one level for the caller.
fluxer_compose_error() {
if [ -s "$fluxer_scratch/compose-err" ]; then
sed "s/^/$1/" "$fluxer_scratch/compose-err"
else
printf '%snothing\n' "$1"
fi
}
# The image references the stack resolves to, one per line, deduplicated. Compose
# expands them from docker-compose.yml and .env, so this is the same resolution
# docker compose pull performs. It names the images, not the versions of them.
#
# By hand:
# docker compose config --images
fluxer_compose_images() {
fluxer_compose_query --images || return 1
sort -u "$fluxer_scratch/compose-out"
}
# The image ID each container was actually created from, against the reference
# Compose created it under.
#
# docker image inspect <reference> answers a different question: what that tag
# points at on this host now. The two disagree whenever a newer image already
# sits under a moving tag, which is the state an upgrade that pulled and then
# failed leaves behind, and that is the state the operator re-runs --update from.
# Reading the tag there records the image the stack is about to move to as the
# image it is moving from, and the rollback that follows puts back what is
# already running while reporting success.
#
# By hand:
# docker compose ps -aq | xargs docker inspect --format '{{.Config.Image}} {{.Image}}'
# The text of a captured stream, indented one level for the caller, or a word
# saying there was none. A command that fails without printing is rarer than one
# that prints the reason, and both read the same way here.
fluxer_indent_file() {
if [ -s "$1" ]; then
sed "s/^/$2/" "$1"
else
printf '%snothing\n' "$2"
fi
}
fluxer_running_image_ids() {
if ! $fluxer_engine compose ps -aq > "$fluxer_scratch/containers" 2> "$fluxer_scratch/ps-err"; then
fluxer_fail 2 "$fluxer_engine compose ps failed in $opt_dir, so what is running cannot be read and the record would name no image ID at all. Compose printed:
$(fluxer_indent_file "$fluxer_scratch/ps-err" ' ')"
fi
[ -s "$fluxer_scratch/containers" ] || return 0
if ! xargs $fluxer_engine inspect --format '{{.Config.Image}} {{.Image}}' \
< "$fluxer_scratch/containers" > "$fluxer_scratch/inspected" 2> "$fluxer_scratch/inspect-err"; then
fluxer_fail 2 "$fluxer_engine inspect failed in $opt_dir, so the image ID under each reference cannot be read and a rollback would have nothing to go back to. Docker printed:
$(fluxer_indent_file "$fluxer_scratch/inspect-err" ' ')"
fi
sort -u "$fluxer_scratch/inspected"
}
# The recorded ID for one reference, or nothing when no container uses it.
fluxer_recorded_id_for() {
awk -v fluxer_want="$1" '$1 == fluxer_want {print $2; exit}' "$fluxer_scratch/running"
}
# Step 1 of an upgrade: record what is running before anything moves.
#
# FLUXER_IMAGE_TAG defaults to v1, which tracks the latest compatible release.
# The tag therefore reads v1 before and after the upgrade, and the image ID is
# the only thing that tells two releases apart. That is what makes this record
# the version history the stack does not otherwise keep, and what makes a
# rollback possible on a moving tag.
#
# The reference list comes from Compose and the ID under each reference comes
# from the container running it, for the reason in fluxer_running_image_ids. A
# reference no container uses is recorded as `-`, which a rollback skips,
# because a version that was not running is not a version to go back to.
#
# By hand:
# docker compose images
fluxer_record_state() {
fluxer_running_image_ids > "$fluxer_scratch/running"
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
fluxer_fail 2 "$fluxer_engine compose config --images failed in $opt_dir, so the running version cannot be recorded. Compose printed:
$(fluxer_compose_error ' ')"
fi
if [ ! -s "$fluxer_scratch/refs" ]; then
fluxer_fail 2 "$fluxer_engine compose config --images returned nothing in $opt_dir, so the running version cannot be recorded. The stack files there declare no service with an image."
fi
fluxer_prepare_record
printf '%s\n' "$(fluxer_env_value FLUXER_IMAGE_TAG)" > "$fluxer_record/$FLUXER_TAG_FILE"
: > "$fluxer_record/$FLUXER_IMAGES_FILE"
while read -r fluxer_ref; do
[ -n "$fluxer_ref" ] || continue
fluxer_id=$(fluxer_recorded_id_for "$fluxer_ref")
if [ -z "$fluxer_id" ]; then
fluxer_id='-'
fi
printf '%s %s\n' "$fluxer_ref" "$fluxer_id" >> "$fluxer_record/$FLUXER_IMAGES_FILE"
done < "$fluxer_scratch/refs"
fluxer_say "Recorded $(wc -l < "$fluxer_record/$FLUXER_IMAGES_FILE" | tr -d ' ') image references in $fluxer_record."
}
# .env and the stack files go into the record because nothing else regenerates
# them. .env holds every secret the instance was built with, so the record
# directory is created 700 and the copy is 600, and the record belongs wherever
# the operator already keeps secrets.
fluxer_save_current_files() {
cp -p "$opt_dir/.env" "$fluxer_record/.env"
chmod 600 "$fluxer_record/.env"
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
fluxer_placed="$opt_dir/$(fluxer_placed_name "$fluxer_file")"
if [ -s "$fluxer_placed" ]; then
cp -p "$fluxer_placed" "$fluxer_record/$fluxer_file"
elif [ -e "$fluxer_placed" ]; then
fluxer_fail 7 "$fluxer_placed is empty, so the record would hold a file a rollback could not use. Put the file back before upgrading."
fi
done < "$fluxer_scratch/files"
}
fluxer_postgres_running() {
fluxer_pg_id=$($fluxer_engine compose ps -q postgres 2>/dev/null | head -n 1)
[ -n "$fluxer_pg_id" ] || return 1
[ "$($fluxer_engine inspect --format '{{.State.Status}}' "$fluxer_pg_id" 2>/dev/null)" = 'running' ]
}
# Step 2 of an upgrade, and the part that gates the rest.
#
# api, worker, users-shard and messages-shard apply schema changes while they
# start, and starting an older image does not undo them. The dump taken before
# the pull is the only way back across a schema change.
#
# By hand:
# docker compose exec -T postgres sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' > backups/fluxer.dump
#
# The postgres container's POSTGRES_USER and POSTGRES_DB follow
# FLUXER_POSTGRES_USERNAME and FLUXER_POSTGRES_DATABASE, so the command needs no
# names. Keep -T. Without it Docker attaches a terminal to the command and the
# dump arrives corrupted, which is why the first five bytes are checked against
# the custom-format magic below rather than only the size.
#
# The dump runs against the live stack. pg_dump reads inside one transaction, so
# it sees a consistent database without stopping anything. The volume copy below
# has no equivalent and is why the stack stops there and not here.
#
# The volume copy measures its volume and refuses when the disk is short. This
# step does not, because the size of a custom-format dump is not knowable before
# pg_dump writes it. Point --backup-dir at a filesystem with room for the
# database.
# The bundled data stores are services in the stack file. An operator who points
# the stack at a database or an object store outside it takes those services out,
# and the two backup steps that reach into them then have nothing to reach. That
# is a supported shape rather than a fault, so each step says what it skipped and
# the upgrade goes on. Backing up a store outside the stack belongs to whoever
# runs it.
fluxer_stack_defines_service() {
if [ ! -f "$fluxer_scratch/all-services" ]; then
if ! fluxer_compose_services > "$fluxer_scratch/all-services"; then
rm -f "$fluxer_scratch/all-services"
fluxer_fail 6 "docker compose config --services failed in $opt_dir, so the services this stack defines cannot be read. Compose printed:
$(fluxer_compose_error ' ')"
fi
fi
grep -qxF "$1" "$fluxer_scratch/all-services"
}
# The bundled postgres container takes POSTGRES_USER and POSTGRES_DB from
# FLUXER_POSTGRES_USERNAME and FLUXER_POSTGRES_DATABASE, and the image applies
# them only to an empty data directory. When those names point the apps at a
# database outside the stack, the bundled directory still holds the role and
# database it was first started with, so a dump that reads the container env asks
# for a role that is not there. The bundled service is idle in that shape and the
# dump skips it.
#
# By hand:
# grep -E '^FLUXER_POSTGRES_(HOST|URL)=' .env
fluxer_postgres_external() {
fluxer_pg_host=$(fluxer_compose_value FLUXER_POSTGRES_HOST)
if [ -n "$fluxer_pg_host" ] && [ "$fluxer_pg_host" != postgres ]; then
return 0
fi
fluxer_pg_url=$(fluxer_compose_value FLUXER_POSTGRES_URL)
[ -n "$fluxer_pg_url" ] || return 1
fluxer_pg_url_host=${fluxer_pg_url#*://}
fluxer_pg_url_host=${fluxer_pg_url_host%%[/?]*}
fluxer_pg_url_host=${fluxer_pg_url_host##*@}
fluxer_pg_url_host=${fluxer_pg_url_host%%:*}
[ "$fluxer_pg_url_host" != postgres ]
}
fluxer_dump_postgres() {
if ! fluxer_stack_defines_service postgres; then
fluxer_say 'Skipping the database dump. This stack defines no postgres service, so its database runs outside the stack and only the operator of that database can dump it.'
return 0
fi
if fluxer_postgres_external; then
fluxer_say 'Skipping the database dump. FLUXER_POSTGRES_HOST or FLUXER_POSTGRES_URL points the stack at a database outside it, so the bundled postgres service is idle and only the operator of that database can dump it.'
return 0
fi
if ! fluxer_postgres_running; then
fluxer_say 'Postgres is not running. Starting it for the dump.'
if ! $fluxer_engine compose up -d --wait postgres; then
fluxer_fail 7 "Postgres does not start in $opt_dir, so no dump can be taken."
fi
fi
fluxer_dump_path="$fluxer_record/$FLUXER_DUMP_FILE"
fluxer_say 'Dumping the database.'
if ! $fluxer_engine compose exec -T postgres sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' > "$fluxer_dump_path"; then
rm -f "$fluxer_dump_path"
fluxer_fail 7 'pg_dump failed. The instance is untouched.'
fi
if [ "$(head -c 5 "$fluxer_dump_path")" != 'PGDMP' ]; then
rm -f "$fluxer_dump_path"
fluxer_fail 7 'The dump does not begin with the custom-format header. The instance is untouched.'
fi
fluxer_say "Dumped the database to $fluxer_dump_path."
}
# What the sizing container printed, indented one level for the caller.
fluxer_volume_error() {
if [ -s "$fluxer_scratch/volume-err" ]; then
sed "s/^/$1/" "$fluxer_scratch/volume-err"
else
printf '%snothing\n' "$1"
fi
}
fluxer_volume_size_kb() {
$fluxer_engine run --rm -v "$1:/data:ro" "$FLUXER_HELPER_IMAGE" du -sk /data 2> "$fluxer_scratch/volume-err" |
awk 'NR==1 {print $1}'
}
fluxer_free_kb() {
df -Pk "$1" 2>/dev/null | awk 'NR==2 {print $4}'
}
# Step 2 continued: the volume copy.
#
# A live copy can catch a file mid-write, so the stack stops for it. The stack
# comes back up on the images it was already running before anything else
# happens, so a failure here leaves a working instance rather than a stopped one.
#
# By hand:
# docker compose stop
# docker run --rm -v fluxer_seaweedfs-data:/data -v "$PWD/backups:/backup" alpine tar czf /backup/seaweedfs-data.tgz -C /data .
# docker compose up -d
fluxer_copy_volumes() {
if ! fluxer_stack_defines_service seaweedfs; then
fluxer_say 'Skipping the uploads copy. This stack defines no seaweedfs service, so its objects live outside the stack and only the operator of that store can copy them.'
return 0
fi
fluxer_backup_volumes > "$fluxer_scratch/backup-volumes"
: > "$fluxer_scratch/copy-volumes"
fluxer_copy_any=0
while read -r fluxer_volume; do
[ -n "$fluxer_volume" ] || continue
fluxer_full="${fluxer_project}_${fluxer_volume}"
if ! $fluxer_engine volume inspect "$fluxer_full" >/dev/null 2>&1; then
fluxer_fail 7 "The volume $fluxer_full does not exist, so the uploads cannot be copied. The stack declares that volume, so this is a project name other than $fluxer_project or a volume that was removed. Pass --no-volume-backup to take the database dump alone when the uploads of this instance live somewhere this script cannot reach."
fi
fluxer_size=$(fluxer_volume_size_kb "$fluxer_full")
case ${fluxer_size:-} in
''|*[!0-9]*)
fluxer_fail 7 "Cannot measure the volume $fluxer_full, so the uploads copy cannot be sized. Pass --no-volume-backup to take the database dump alone. Docker printed:
$(fluxer_volume_error ' ')" ;;
esac
fluxer_free=$(fluxer_free_kb "$fluxer_record")
case ${fluxer_free:-} in
''|*[!0-9]*) fluxer_fail 7 "Cannot read the free space on $fluxer_record." ;;
esac
fluxer_need=$((fluxer_size * FLUXER_VOLUME_HEADROOM / 100))
if [ "$fluxer_free" -lt "$fluxer_need" ]; then
fluxer_fail 7 "$fluxer_full holds $((fluxer_size / 1024)) MB and $fluxer_record has $((fluxer_free / 1024)) MB free. Point --backup-dir at a filesystem with room, or pass --no-volume-backup to take the database dump alone."
fi
printf '%s\n' "$fluxer_volume" >> "$fluxer_scratch/copy-volumes"
fluxer_copy_any=1
done < "$fluxer_scratch/backup-volumes"
if [ "$fluxer_copy_any" -eq 0 ]; then
return 0
fi
if [ "$opt_no_volume_compression" -eq 1 ]; then
fluxer_tar_flags='cf'
fluxer_tar_ext='tar'
else
fluxer_tar_flags='czf'
fluxer_tar_ext='tgz'
fi
fluxer_say 'Stopping the stack for a consistent copy of the uploads.'
if ! $fluxer_engine compose stop; then
fluxer_fail 7 "$fluxer_engine compose stop failed in $opt_dir."
fi
while read -r fluxer_volume; do
[ -n "$fluxer_volume" ] || continue
fluxer_full="${fluxer_project}_${fluxer_volume}"
fluxer_say "Copying $fluxer_full."
if ! $fluxer_engine run --rm -v "$fluxer_full:/data:ro" -v "$fluxer_record:/backup" "$FLUXER_HELPER_IMAGE" tar "$fluxer_tar_flags" "/backup/$fluxer_volume.$fluxer_tar_ext" -C /data .; then
$fluxer_engine compose up -d --remove-orphans || true
fluxer_fail 7 "Copying $fluxer_full failed. The stack is started again on the images it was running."
fi
done < "$fluxer_scratch/copy-volumes"
fluxer_say 'Starting the stack again before the upgrade continues.'
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 7 "$fluxer_engine compose up -d failed in $opt_dir after the copy. Read $fluxer_engine compose logs there."
fi
}
fluxer_backup() {
if [ "$opt_skip_backup" -eq 1 ]; then
fluxer_say 'Skipping the backup. --skip-backup-accept-data-loss was given, so a schema change has no way back.'
return 0
fi
fluxer_dump_postgres
if [ "$opt_no_volume_backup" -eq 1 ]; then
fluxer_say 'Skipping the uploads copy. --no-volume-backup was given.'
return 0
fi
fluxer_copy_volumes
}
fluxer_postgres_major() {
fluxer_pg_image=$(sed -n 's/^[[:space:]]*image:[[:space:]]*\(postgres:[^[:space:]]*\).*/\1/p' "$1" | head -n 1)
if [ -z "$fluxer_pg_image" ]; then
fluxer_pg_image=$(sed -n 's/^[[:space:]]*image:[[:space:]]*${FLUXER_POSTGRES_IMAGE:-\([^}]*\)}.*/\1/p' "$1" | head -n 1)
if [ -n "$fluxer_pg_image" ] && [ -n "$(fluxer_compose_value FLUXER_POSTGRES_IMAGE)" ]; then
fluxer_pg_image=$(fluxer_compose_value FLUXER_POSTGRES_IMAGE)
fi
fi
fluxer_pg_image=${fluxer_pg_image%%@*}
case ${fluxer_pg_image##*/} in
*:*) printf '%s\n' "${fluxer_pg_image##*:}" | sed -n 's/^\([0-9][0-9]*\).*/\1/p' ;;
esac
}
# A newer Postgres major does not read the data directory an older major wrote,
# so moving between majors means dumping, removing the volume that holds the
# database, and restoring into an empty directory. Removing that volume is the
# one destructive act in the whole procedure, and it is not something a script
# should do on an operator's behalf while they read scrolling output.
#
# The refreshed file is still in the scratch directory when this runs, so a
# refusal here leaves the instance exactly as it was.
fluxer_guard_postgres_major() {
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/$fluxer_compose_base")
fluxer_new_major=$(fluxer_postgres_major "$fluxer_scratch/docker-compose.yml.part")
if [ -z "$fluxer_old_major" ] || [ -z "$fluxer_new_major" ]; then
return 0
fi
if [ "$fluxer_old_major" = "$fluxer_new_major" ]; then
return 0
fi
fluxer_fail 3 "The refreshed docker-compose.yml pins postgres:$fluxer_new_major and this instance runs postgres:$fluxer_old_major. A major version change goes through a dump, an empty data directory and a restore, which this script does not do because it destroys the volume holding the database. Nothing was changed. The procedure is at https://fluxer.dev/operator/upgrading/"
}
# Which mounted files the refresh changes, and therefore which services need a
# restart rather than a recreate. The comparison happens while the new copies are
# still in the scratch directory, because once they are in place the old bytes
# are gone.
fluxer_note_mounted_changes() {
: > "$fluxer_scratch/restart"
fluxer_mounted_files > "$fluxer_scratch/mounts"
while read -r fluxer_file fluxer_service; do
[ -n "$fluxer_file" ] || continue
if [ ! -e "$opt_dir/$fluxer_file" ]; then
continue
fi
if ! cmp -s "$opt_dir/$fluxer_file" "$fluxer_scratch/$fluxer_file.part"; then
printf '%s %s\n' "$fluxer_file" "$fluxer_service" >> "$fluxer_scratch/restart"
fi
done < "$fluxer_scratch/mounts"
}
# The service names the docker-compose.yml in place defines, one per line.
#
# A rollback puts back the stack files the record holds, and a record taken
# before a service was renamed names the old service. Restarting a service the
# file in place does not define fails, so the name is checked first.
#
# By hand:
# docker compose config --services
fluxer_compose_services() {
fluxer_compose_query --services || return 1
sort -u "$fluxer_scratch/compose-out"
}
fluxer_restart_mounted() {
[ -s "$fluxer_scratch/restart" ] || return 0
if ! fluxer_compose_services > "$fluxer_scratch/services"; then
fluxer_fail 6 "$fluxer_engine compose config --services failed in $opt_dir, so the services that mount a refreshed file cannot be restarted. Compose printed:
$(fluxer_compose_error ' ')"
fi
while read -r fluxer_file fluxer_service; do
[ -n "$fluxer_service" ] || continue
if ! grep -qxF "$fluxer_service" "$fluxer_scratch/services"; then
fluxer_say "Skipping the restart of $fluxer_service, because the $fluxer_compose_base in $opt_dir defines no service by that name."
continue
fi
fluxer_say "Restarting $fluxer_service, because $fluxer_file is mounted into it and up -d does not reload a mounted file."
if ! $fluxer_engine compose restart "$fluxer_service"; then
fluxer_fail 6 "$fluxer_engine compose restart $fluxer_service failed in $opt_dir."
fi
done < "$fluxer_scratch/restart"
}
# A record is created before the upgrade touches anything, so a run that refuses
# or fails after this point still leaves one behind. Such a record describes the
# state the instance is already in, which makes a rollback to it a no-op rather
# than a mistake. Only the newest record is ever used.
fluxer_prepare_record() {
mkdir -p "$opt_backup_dir"
chmod 700 "$opt_backup_dir"
fluxer_record="$opt_backup_dir/$FLUXER_RECORD_PREFIX$(date -u +%Y%m%dT%H%M%SZ)"
if [ -e "$fluxer_record" ]; then
fluxer_fail 3 "$fluxer_record exists already."
fi
mkdir -m 700 "$fluxer_record"
}
# Record names include a UTC stamp, so the shell expands the glob in byte order
# and the last match is the most recent upgrade.
fluxer_newest_record() {
fluxer_newest=''
for fluxer_candidate in "$opt_backup_dir/$FLUXER_RECORD_PREFIX"*; do
[ -d "$fluxer_candidate" ] || continue
fluxer_newest=${fluxer_candidate##*/}
done
printf '%s' "$fluxer_newest"
}
fluxer_verify_stack() {
fluxer_say 'Waiting for every service to report ready.'
if ! fluxer_wait_ready; then
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. $(fluxer_not_ready_detail)
Read $fluxer_engine compose logs in $opt_dir."
fi
fluxer_origin_value=$(fluxer_public_origin)
if [ -n "$fluxer_origin_value" ]; then
fluxer_probe "$fluxer_origin_value"
fi
}
# A plan writes nothing itself. The run it describes writes .env before it reads
# anything when a required key is absent, and saying otherwise would describe a
# different run.
fluxer_plan_footer() {
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say 'This plan wrote nothing. The run it describes writes the keys above into .env before anything else.'
else
fluxer_say 'Nothing outside a temporary directory was written.'
fi
}
fluxer_plan_update() {
fluxer_say 'Plan: upgrade'
fluxer_say " directory $opt_dir"
fluxer_say " ref $opt_ref"
fluxer_say " image tag $(fluxer_env_value FLUXER_IMAGE_TAG) from .env"
fluxer_say " backup dir $opt_backup_dir"
fluxer_missing_required_secrets > "$fluxer_scratch/plan-secrets"
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say ' writes .env the run mints these before it reads anything, because the refreshed stack requires them'
while read -r fluxer_key; do
[ -n "$fluxer_key" ] || continue
fluxer_say " $fluxer_key"
done < "$fluxer_scratch/plan-secrets"
fi
fluxer_running_image_ids > "$fluxer_scratch/running"
if ! fluxer_compose_images > "$fluxer_scratch/refs"; then
fluxer_say ' refusal $fluxer_engine compose config --images fails here, and step 1 of the upgrade reads that list'
fluxer_say ' compose said'
fluxer_compose_error ' '
if [ -s "$fluxer_scratch/plan-secrets" ]; then
fluxer_say ' outcome the run writes the keys above first, which may be what Compose is missing, so this refusal may not stand'
else
fluxer_say ' outcome the run stops on step 1 and changes nothing'
fi
fluxer_plan_footer
return 0
fi
fluxer_say ' running now'
while read -r fluxer_ref; do
[ -n "$fluxer_ref" ] || continue
fluxer_id=$(fluxer_recorded_id_for "$fluxer_ref")
if [ -z "$fluxer_id" ]; then
fluxer_id='no container runs this image'
fi
fluxer_say " $fluxer_ref $fluxer_id"
done < "$fluxer_scratch/refs"
if [ "$opt_skip_backup" -eq 1 ]; then
fluxer_say ' backup none, and a schema change would have no way back'
elif [ "$opt_no_volume_backup" -eq 1 ]; then
fluxer_say ' backup the database dump, .env, and the stack files'
else
if [ "$opt_no_volume_compression" -eq 1 ]; then
fluxer_say ' backup the database dump, the uploads volume uncompressed, .env, and the stack files'
else
fluxer_say ' backup the database dump, the uploads volume, .env, and the stack files'
fi
fluxer_say ' downtime the stack stops for the uploads copy, then again for the recreate'
fi
fluxer_fetch_stack
fluxer_say ' file changes'
fluxer_changed=0
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
fluxer_placed=$(fluxer_placed_name "$fluxer_file")
if [ ! -e "$opt_dir/$fluxer_placed" ]; then
fluxer_say " $fluxer_placed is new"
fluxer_changed=1
elif cmp -s "$opt_dir/$fluxer_placed" "$fluxer_scratch/$fluxer_file.part"; then
fluxer_say " $fluxer_placed is unchanged"
else
fluxer_say " $fluxer_placed changes"
fluxer_changed=1
fi
done < "$fluxer_scratch/files"
if [ "$fluxer_changed" -eq 0 ]; then
fluxer_say " note ref $opt_ref moves no stack file"
fi
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/$fluxer_compose_base")
fluxer_new_major=$(fluxer_postgres_major "$fluxer_scratch/docker-compose.yml.part")
if [ -n "$fluxer_old_major" ] && [ -n "$fluxer_new_major" ] && [ "$fluxer_old_major" != "$fluxer_new_major" ]; then
fluxer_say " refusal postgres moves from $fluxer_old_major to $fluxer_new_major, which this script does not do"
fluxer_say ' outcome the run stops at that refusal and changes nothing'
fluxer_plan_footer
return 0
fi
fluxer_note_mounted_changes
if [ -s "$fluxer_scratch/restart" ]; then
while read -r fluxer_file fluxer_service; do
[ -n "$fluxer_service" ] || continue
fluxer_say " restart $fluxer_service, because $fluxer_file changes and a mounted file survives up -d"
done < "$fluxer_scratch/restart"
fi
fluxer_say ' commands $fluxer_engine compose pull, $fluxer_engine compose up -d'
fluxer_plan_footer
fluxer_say 'Drop --dry-run to run this.'
}
fluxer_plan_rollback() {
fluxer_rollback_name=$(fluxer_newest_record)
if [ -z "$fluxer_rollback_name" ]; then
fluxer_fail 2 "No record in $opt_backup_dir. A rollback needs an upgrade that recorded what was running."
fi
fluxer_rollback_dir="$opt_backup_dir/$fluxer_rollback_name"
fluxer_recorded_tag=$(head -n 1 "$fluxer_rollback_dir/$FLUXER_TAG_FILE" 2>/dev/null || true)
fluxer_current_tag=$(fluxer_env_value FLUXER_IMAGE_TAG)
fluxer_say 'Plan: rollback'
fluxer_say " directory $opt_dir"
fluxer_say " record $fluxer_rollback_dir"
if [ "$fluxer_recorded_tag" = "$fluxer_current_tag" ]; then
fluxer_say " image tag stays $fluxer_current_tag, so the recorded image IDs move back onto it"
else
fluxer_say " image tag $fluxer_current_tag becomes $fluxer_recorded_tag in .env"
fi
fluxer_say ' images'
while read -r fluxer_ref fluxer_id; do
[ -n "$fluxer_ref" ] || continue
if [ "$fluxer_id" = '-' ]; then
fluxer_say " $fluxer_ref was not recorded with an ID"
elif $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
fluxer_say " $fluxer_ref back to $fluxer_id"
else
fluxer_say " $fluxer_ref is gone from this host, so $fluxer_id cannot come back"
fi
done < "$fluxer_rollback_dir/$FLUXER_IMAGES_FILE"
fluxer_say " files restored from $fluxer_rollback_dir"
fluxer_say ' database stays where the new release left it'
fluxer_say 'Nothing is written. Drop --dry-run to run this.'
}
# The full upgrade, in the order that leaves a working instance behind at every
# point it can fail.
fluxer_run_update() {
fluxer_open_scratch "$opt_dir"
fluxer_stack_files > "$fluxer_scratch/files"
fluxer_fill_required_secrets
fluxer_record_state
fluxer_save_current_files
fluxer_backup
fluxer_fetch_stack
fluxer_guard_postgres_major
fluxer_note_mounted_changes
fluxer_place_stack
# The pull runs while the old containers still serve, so the long part of an
# upgrade costs no downtime.
#
# By hand:
# docker compose pull
fluxer_say 'Pulling images.'
if ! $fluxer_engine compose pull; then
fluxer_fail 4 "$fluxer_engine compose pull failed in $opt_dir. The stack files are refreshed and the instance still runs the old images."
fi
# Recreates the containers whose image or configuration changed and leaves
# the rest running.
#
# By hand:
# docker compose up -d --remove-orphans
#
# Behind your own reverse proxy every command names the overlay, which
# COMPOSE_FILE in .env does once. An invocation without it recreates the edge
# from the base file, which binds 80 and 443 and requests its own
# certificate.
#
# api, worker, users-shard and messages-shard each apply the database schema
# while they start, so the upgrade is not finished until all four are back
# up. All four take the same Postgres advisory lock around that work, so
# several of them starting at once is safe.
#
# app-proxy waits for api to report healthy and the edge waits for the
# Gateway, so the hostname returns errors for a minute or two after this
# call. The api healthcheck allows 90 seconds before it counts a failure.
fluxer_say 'Recreating the stack.'
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
fluxer_restart_mounted
fluxer_verify_stack
fluxer_say "Instance upgraded in $opt_dir."
fluxer_say "The record of what it ran before is in $fluxer_record."
fluxer_say "Go back with sh install.sh --rollback --dir $opt_dir."
exit 0
}
# Puts one line of .env back, and proves it touched nothing else.
#
# FLUXER_IMAGE_TAG is the only key this function writes. Every other line,
# which is every secret, is compared byte for byte before the new file replaces
# the old one, so a rewrite that lost or changed a secret cannot land.
fluxer_set_image_tag() {
if ! grep -q '^FLUXER_IMAGE_TAG=' "$opt_dir/.env"; then
fluxer_fail 3 "$opt_dir/.env declares no FLUXER_IMAGE_TAG, so the tag cannot be put back. Set it by hand."
fi
fluxer_old_umask=$(umask)
umask 077
sed "s|^FLUXER_IMAGE_TAG=.*|FLUXER_IMAGE_TAG=$1|" "$opt_dir/.env" > "$fluxer_scratch/env.new"
grep -v '^FLUXER_IMAGE_TAG=' "$opt_dir/.env" > "$fluxer_scratch/env.before" || true
grep -v '^FLUXER_IMAGE_TAG=' "$fluxer_scratch/env.new" > "$fluxer_scratch/env.after" || true
if [ ! -s "$fluxer_scratch/env.before" ] || ! cmp -s "$fluxer_scratch/env.before" "$fluxer_scratch/env.after"; then
umask "$fluxer_old_umask"
fluxer_fail 3 'Rewriting FLUXER_IMAGE_TAG would have changed another line in .env. Nothing was written.'
fi
mv "$fluxer_scratch/env.new" "$opt_dir/.env"
chmod 600 "$opt_dir/.env"
umask "$fluxer_old_umask"
fluxer_say "FLUXER_IMAGE_TAG in .env is $1."
}
# A rollback moves the images and the stack files back. The database stays where
# the new release left it, because api, worker, users-shard and messages-shard
# apply schema work in place while they start and an older image does not undo
# it. Across a release that changed the schema, the dump in the record is the
# only way back, and putting it back is a separate decision an operator makes.
#
# Two shapes, depending on what the upgrade moved:
#
# A pinned tag moved, so the old images still have their own tag. The tag goes
# back into .env and Compose finds them.
#
# By hand: set FLUXER_IMAGE_TAG back, then docker compose up -d
#
# A moving tag such as v1 stayed put and the images under it changed. The
# recorded image IDs are still on the host until a prune removes them, so the
# old ID goes back onto the tag it had.
#
# By hand: docker image tag <recorded id> ghcr.io/fluxerapp/fluxer-api:v1
#
# Neither shape pulls. A pull is what moved the instance forward in the first
# place, and running one here would undo the rollback in the same breath.
fluxer_run_rollback() {
fluxer_rollback_name=$(fluxer_newest_record)
if [ -z "$fluxer_rollback_name" ]; then
fluxer_fail 2 "No record in $opt_backup_dir. A rollback needs an upgrade that recorded what was running."
fi
fluxer_rollback_dir="$opt_backup_dir/$fluxer_rollback_name"
if [ ! -s "$fluxer_rollback_dir/$FLUXER_IMAGES_FILE" ]; then
fluxer_fail 2 "$fluxer_rollback_dir records no images."
fi
fluxer_open_scratch "$opt_dir"
fluxer_stack_files > "$fluxer_scratch/files"
fluxer_say "Rolling back to $fluxer_rollback_dir."
fluxer_recorded_tag=$(head -n 1 "$fluxer_rollback_dir/$FLUXER_TAG_FILE" 2>/dev/null || true)
fluxer_current_tag=$(fluxer_env_value FLUXER_IMAGE_TAG)
fluxer_moved=0
if [ -n "$fluxer_recorded_tag" ] && [ "$fluxer_recorded_tag" != "$fluxer_current_tag" ]; then
case $fluxer_recorded_tag in
''|*' '*|*/*) fluxer_fail 3 "The recorded image tag $fluxer_recorded_tag is not an image tag." ;;
esac
fluxer_set_image_tag "$fluxer_recorded_tag"
fluxer_moved=1
else
while read -r fluxer_ref fluxer_id; do
[ -n "$fluxer_ref" ] || continue
[ "$fluxer_id" != '-' ] || continue
if ! $fluxer_engine image inspect --format '{{.Id}}' "$fluxer_id" >/dev/null 2>&1; then
fluxer_say "$fluxer_ref is gone from this host, so it keeps the image it has now."
continue
fi
if ! $fluxer_engine image tag "$fluxer_id" "$fluxer_ref"; then
fluxer_fail 3 "Cannot put $fluxer_id back on $fluxer_ref."
fi
fluxer_moved=1
done < "$fluxer_rollback_dir/$FLUXER_IMAGES_FILE"
fi
if [ "$fluxer_moved" -eq 0 ]; then
fluxer_fail 2 "Nothing in $fluxer_rollback_dir can be put back. The recorded tag is the one in .env and every recorded image has been removed from this host, which a $fluxer_engine image prune does."
fi
while read -r fluxer_file; do
[ -n "$fluxer_file" ] || continue
if [ -s "$fluxer_rollback_dir/$fluxer_file" ]; then
cp -p "$fluxer_rollback_dir/$fluxer_file" "$opt_dir/$(fluxer_placed_name "$fluxer_file")"
elif [ -e "$fluxer_rollback_dir/$fluxer_file" ]; then
fluxer_fail 3 "$fluxer_rollback_dir/$fluxer_file is empty, so restoring it would replace a working file with nothing. Nothing was restored. Take the file from another record or from the ref the record names."
fi
done < "$fluxer_scratch/files"
fluxer_say "Stack files in $opt_dir are the ones the record holds."
fluxer_say 'Recreating the stack.'
if ! $fluxer_engine compose up -d --remove-orphans; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
# The mounted file came back from the record, so its service restarts.
# Comparing it first would save one restart and cost the reader a reason.
fluxer_mounted_files > "$fluxer_scratch/restart"
fluxer_restart_mounted
fluxer_verify_stack
fluxer_say "Instance rolled back in $opt_dir."
if [ -s "$fluxer_rollback_dir/$FLUXER_DUMP_FILE" ]; then
fluxer_say "The database did not move. Restore it from $fluxer_rollback_dir/$FLUXER_DUMP_FILE only when the release you left changed the schema."
else
fluxer_say "The database did not move. That record holds no dump, because the upgrade ran with --skip-backup-accept-data-loss, so a release that changed the schema has no way back."
fi
exit 0
}
fluxer_preflight
fluxer_validate_options
fluxer_resolve_values
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
fluxer_require_instance
fluxer_resolve_ref
fluxer_require_compose_files
cd "$opt_dir"
fluxer_set_project
if [ "$opt_dry_run" -eq 1 ]; then
# The dry run downloads into a temporary directory so it can name the
# files that actually change, the services that actually restart, and a
# Postgres major that would stop the run. It removes that directory on
# exit and touches nothing in the working directory.
fluxer_open_scratch "${TMPDIR:-/tmp}"
fluxer_stack_files > "$fluxer_scratch/files"
if [ "$opt_rollback" -eq 1 ]; then
fluxer_plan_rollback
else
fluxer_plan_update
fi
exit 0
fi
if [ "$opt_rollback" -eq 1 ]; then
fluxer_run_rollback
fi
fluxer_run_update
fi
fluxer_resolve_ref
if [ "$opt_dry_run" -eq 1 ]; then
fluxer_print_plan
exit 0
fi
mkdir -p "$opt_dir"
cd "$opt_dir"
if [ -e "$opt_dir/.env" ]; then
fluxer_fail 3 "$opt_dir/.env exists. Run with --update to upgrade the instance and keep the secrets."
fi
fluxer_open_scratch "$opt_dir"
fluxer_fetch_stack
fluxer_place_stack
fluxer_check_volumes
fluxer_say 'Generating secrets.'
if ! fluxer_generate_vapid; then
fluxer_fail 5 "Could not generate a VAPID key of the required shape in $FLUXER_VAPID_ATTEMPTS attempts."
fi
fluxer_write_env
fluxer_say "Wrote $opt_dir/.env, readable by you alone."
fluxer_say 'That .env serves https on 443, which is the only layout this script writes. The .env.example beside it says what to change for any other one.'
if [ "$opt_no_start" -eq 1 ]; then
fluxer_say "Start the instance with $fluxer_engine compose up -d in $opt_dir."
fluxer_say 'Open it and create the first admin account. Finish the setup wizard in the same sitting.'
fluxer_say "Secrets live in $opt_dir/.env. Back that file up."
exit 0
fi
fluxer_say 'Starting the stack.'
if ! $fluxer_engine compose up -d; then
fluxer_fail 6 "$fluxer_engine compose up -d failed in $opt_dir. Read $fluxer_engine compose logs there."
fi
fluxer_say 'Waiting for every service to report ready. This takes several minutes on the first start, which pulls eighteen images.'
if ! fluxer_wait_ready; then
fluxer_fail 6 "The stack is not ready after $FLUXER_READY_TIMEOUT seconds. $(fluxer_not_ready_detail)
Read $fluxer_engine compose logs in $opt_dir."
fi
fluxer_ready_origin=$(fluxer_public_origin)
if [ -z "$fluxer_ready_origin" ]; then
fluxer_ready_origin="https://$opt_domain"
fi
fluxer_probe "$fluxer_ready_origin"
fluxer_say "Instance ready at $fluxer_ready_origin"
fluxer_say 'Open it and create the first admin account. Finish the setup wizard in the same sitting.'
fluxer_say "Secrets live in $opt_dir/.env. Back that file up."