948 lines
31 KiB
Bash
948 lines
31 KiB
Bash
#!/usr/bin/env bash
|
||
# Cobble installer and updater.
|
||
#
|
||
# curl -fsSL https://url.moduletrace.com/cobble | sudo bash
|
||
#
|
||
# The script is idempotent: running it again updates Cobble in place and
|
||
# keeps the data, the database and the generated secrets. It installs
|
||
# Docker when it is missing, creates the cobble network and starts the app
|
||
# container with the docker socket mounted, so the app can create the
|
||
# computers it runs.
|
||
#
|
||
# The app serves plain HTTP: put a reverse proxy or tunnel in front of it
|
||
# for HTTPS.
|
||
set -Eeuo pipefail
|
||
|
||
CONTAINER="cobble"
|
||
NETWORK="cobble"
|
||
DEFAULT_PORT="7227"
|
||
DEFAULT_BIND="0.0.0.0"
|
||
# The app container's own limits (the computers' sandboxes are limited
|
||
# separately, per computer).
|
||
DEFAULT_CPUS="1"
|
||
DEFAULT_MEMORY_MB="2048"
|
||
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
||
DEFAULT_VERSION="latest"
|
||
INSTALL_URL="https://url.moduletrace.com/cobble"
|
||
HEALTH_TIMEOUT_SECONDS="${COBBLE_HEALTH_TIMEOUT:-900}"
|
||
BACKUPS_KEPT=5
|
||
|
||
if [ -t 1 ]; then
|
||
C_RESET=$'\033[0m'
|
||
C_BOLD=$'\033[1m'
|
||
C_DIM=$'\033[2m'
|
||
C_UNDERLINE=$'\033[4m'
|
||
C_RED=$'\033[31m'
|
||
C_GREEN=$'\033[32m'
|
||
C_YELLOW=$'\033[33m'
|
||
C_CYAN=$'\033[36m'
|
||
# The ember stone's hue (--color-hue-ember in src/styles/main.css), in
|
||
# true colour where the terminal says it has it, else the nearest of 256.
|
||
case "${COLORTERM:-}" in
|
||
truecolor | 24bit) C_EMBER=$'\033[38;2;228;87;46m' ;;
|
||
*) C_EMBER=$'\033[38;5;166m' ;;
|
||
esac
|
||
# Clickable links (OSC 8) everywhere but the bare Linux console.
|
||
case "${TERM:-}" in
|
||
linux | dumb | "") LINKS=0 ;;
|
||
*) LINKS=1 ;;
|
||
esac
|
||
else
|
||
C_RESET=""
|
||
C_BOLD=""
|
||
C_DIM=""
|
||
C_UNDERLINE=""
|
||
C_RED=""
|
||
C_GREEN=""
|
||
C_YELLOW=""
|
||
C_CYAN=""
|
||
C_EMBER=""
|
||
LINKS=0
|
||
fi
|
||
|
||
say() { printf ' %s›%s %s\n' "$C_EMBER" "$C_RESET" "$*"; }
|
||
ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
||
warn() { printf ' %s!%s %s%s%s\n' "$C_YELLOW" "$C_RESET" "$C_YELLOW" "$*" "$C_RESET" >&2; }
|
||
fail() {
|
||
printf ' %s✗ %s%s\n' "$C_RED" "$*" "$C_RESET" >&2
|
||
exit 1
|
||
}
|
||
|
||
# A URL, underlined and clickable where the terminal can.
|
||
link() {
|
||
if [ "$LINKS" = 1 ]; then
|
||
printf '\033]8;;%s\033\\%s%s%s%s\033]8;;\033\\' "$1" "$C_CYAN" "$C_UNDERLINE" "$1" "$C_RESET"
|
||
else
|
||
printf '%s%s%s%s' "$C_CYAN" "$C_UNDERLINE" "$1" "$C_RESET"
|
||
fi
|
||
}
|
||
|
||
# One line of the closing summary: a dim label, then its value.
|
||
summary_row() { printf ' %s%-12s%s %s\n' "$C_DIM" "$1" "$C_RESET" "$2"; }
|
||
|
||
banner() {
|
||
local s="$C_EMBER" w="$C_BOLD" d="$C_DIM" r="$C_RESET"
|
||
printf '\n'
|
||
printf ' %s ▄▄██████▄▄ %s\n' "$s" "$r"
|
||
printf ' %s ▄████████████%s %s █ █ █ %s\n' "$s" "$r" "$w" "$r"
|
||
printf ' %s ███ ███ ███%s %s ▄▄▄ ▄▄▄ █▄▄▄ █▄▄▄ █ ▄▄▄ %s\n' "$s" "$r" "$w" "$r"
|
||
printf ' %s █████████████%s %s█ █ █ █ █ █ █ █ █▄▄▄█%s\n' "$s" "$r" "$w" "$r"
|
||
printf ' %s ▀████████████%s %s▀▄▄▄ ▀▄▄▄▀ █▄▄▄▀ █▄▄▄▀ █ ▀▄▄▄▄%s\n' "$s" "$r" "$w" "$r"
|
||
printf ' %s ▀▀██████▀▀ %s %sa crew of small AI worker bots%s\n' "$s" "$r" "$d" "$r"
|
||
printf '\n'
|
||
}
|
||
|
||
on_error() {
|
||
local code=$?
|
||
printf ' %s✗ line %s exited with status %s%s\n' "$C_RED" "${BASH_LINENO[0]:-?}" "$code" "$C_RESET" >&2
|
||
exit "$code"
|
||
}
|
||
trap on_error ERR
|
||
|
||
usage() {
|
||
cat <<EOF
|
||
Cobble installer and updater
|
||
|
||
Usage:
|
||
curl -fsSL $INSTALL_URL | sudo bash
|
||
curl -fsSL $INSTALL_URL | sudo bash -s -- [options]
|
||
|
||
Options:
|
||
--home DIR Install directory (default /opt/cobble)
|
||
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
||
--port PORT Host port (default 7227)
|
||
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
||
--cpus N CPU cores the app container may use (default 1)
|
||
--memory MB Memory the app container may use (default 2048)
|
||
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
||
--version TAG Image tag (default latest)
|
||
--no-pull Use the image already on this machine
|
||
--extra-network NAME Join another docker network, e.g. a tunnel's
|
||
--force Recreate the container even when it is current
|
||
--skip-backup Do not back up the database before an update
|
||
--no-docker-install Fail instead of installing Docker when it is missing
|
||
--yes, -y Accept the defaults without asking
|
||
--uninstall Stop and remove the app container
|
||
--purge With --uninstall, also delete the data directory
|
||
--help Show this help
|
||
|
||
HTTPS:
|
||
The app serves plain HTTP on the published port. Put a reverse proxy or
|
||
tunnel in front of it for HTTPS, and point that proxy at
|
||
http://<this machine>:<port>. Phones need HTTPS to install the app and
|
||
receive notifications, so expose Cobble through the proxy rather than
|
||
its bare port.
|
||
|
||
The data directory holds the database, media and every computer's home
|
||
directory. It is kept when the container is removed. Settings live in
|
||
HOME/.env; restart the container after changing them.
|
||
EOF
|
||
}
|
||
|
||
# ---------------------------------------------------------------- arguments
|
||
|
||
HOME_DIR=""
|
||
for ((i = 1; i <= $#; i++)); do
|
||
case "${!i}" in
|
||
--home)
|
||
next=$((i + 1))
|
||
[ "$next" -le $# ] || fail "--home needs a value"
|
||
HOME_DIR="${!next}"
|
||
;;
|
||
--help | -h)
|
||
usage
|
||
exit 0
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [ -z "$HOME_DIR" ]; then
|
||
HOME_DIR="${COBBLE_HOME:-}"
|
||
fi
|
||
|
||
if [ -z "$HOME_DIR" ] && [ "$(uname -s)" = "Darwin" ] && [ -n "${SUDO_USER:-}" ]; then
|
||
user_home="$(dscl . -read "/Users/$SUDO_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
||
[ -n "$user_home" ] && HOME_DIR="$user_home/.cobble"
|
||
fi
|
||
|
||
if [ -z "$HOME_DIR" ]; then
|
||
if [ "$(uname -s)" = "Darwin" ]; then
|
||
HOME_DIR="$HOME/.cobble"
|
||
else
|
||
HOME_DIR="/opt/cobble"
|
||
fi
|
||
fi
|
||
|
||
case "$HOME_DIR" in
|
||
/*) ;;
|
||
*) fail "--home must be an absolute path" ;;
|
||
esac
|
||
|
||
ENV_FILE="$HOME_DIR/.env"
|
||
STATE_FILE="$HOME_DIR/install.conf"
|
||
|
||
if [ -f "$STATE_FILE" ]; then
|
||
# shellcheck disable=SC1090
|
||
. "$STATE_FILE"
|
||
fi
|
||
|
||
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
||
PORT="${COBBLE_PORT:-}"
|
||
BIND="${COBBLE_BIND:-}"
|
||
CPUS="${COBBLE_CPUS:-}"
|
||
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
|
||
IMAGE="${COBBLE_IMAGE:-}"
|
||
VERSION="${COBBLE_VERSION:-}"
|
||
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
||
DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}"
|
||
|
||
ACTION="install"
|
||
FORCE=0
|
||
NO_PULL=0
|
||
NO_DOCKER_INSTALL=0
|
||
SKIP_BACKUP=0
|
||
PURGE=0
|
||
ASSUME_YES=0
|
||
|
||
env_get() {
|
||
local key="$1"
|
||
[ -f "$ENV_FILE" ] || return 0
|
||
sed -nE "s/^[[:space:]]*${key}=(.*)$/\1/p" "$ENV_FILE" | tail -n 1 | sed -e 's/^"//' -e 's/"$//'
|
||
}
|
||
|
||
if [ ! -f "$STATE_FILE" ] && [ -f "$ENV_FILE" ] && [ -z "$PORT" ]; then
|
||
PORT="$(env_get PORT)"
|
||
fi
|
||
|
||
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
||
PORT="${PORT:-$DEFAULT_PORT}"
|
||
BIND="${BIND:-$DEFAULT_BIND}"
|
||
CPUS="${CPUS:-$DEFAULT_CPUS}"
|
||
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
|
||
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
||
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
||
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--home)
|
||
HOME_DIR="${2:-}"
|
||
shift 2
|
||
;;
|
||
--data-dir)
|
||
DATA_DIR="${2:-}"
|
||
shift 2
|
||
;;
|
||
--port)
|
||
PORT="${2:-}"
|
||
shift 2
|
||
;;
|
||
--bind)
|
||
BIND="${2:-}"
|
||
shift 2
|
||
;;
|
||
--cpus)
|
||
CPUS="${2:-}"
|
||
shift 2
|
||
;;
|
||
--memory)
|
||
MEMORY_MB="${2:-}"
|
||
shift 2
|
||
;;
|
||
--image)
|
||
IMAGE="${2:-}"
|
||
shift 2
|
||
;;
|
||
--version)
|
||
VERSION="${2:-}"
|
||
shift 2
|
||
;;
|
||
--extra-network)
|
||
EXTRA_NETWORK="${2:-}"
|
||
shift 2
|
||
;;
|
||
# Accepted from older scripts and docs, then ignored: HTTPS is the
|
||
# proxy's job now, and a value that still pointed at a certificate
|
||
# should not be dropped silently.
|
||
--https | --no-https | --https-regen)
|
||
warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS."
|
||
shift
|
||
;;
|
||
--https-host | --https-cert | --https-key)
|
||
warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS."
|
||
shift 2
|
||
;;
|
||
--yes | -y)
|
||
ASSUME_YES=1
|
||
shift
|
||
;;
|
||
--force)
|
||
FORCE=1
|
||
shift
|
||
;;
|
||
--no-pull)
|
||
NO_PULL=1
|
||
shift
|
||
;;
|
||
--skip-backup)
|
||
SKIP_BACKUP=1
|
||
shift
|
||
;;
|
||
--no-docker-install)
|
||
NO_DOCKER_INSTALL=1
|
||
shift
|
||
;;
|
||
--uninstall)
|
||
ACTION="uninstall"
|
||
shift
|
||
;;
|
||
--purge)
|
||
PURGE=1
|
||
shift
|
||
;;
|
||
--help | -h)
|
||
usage
|
||
exit 0
|
||
;;
|
||
*)
|
||
fail "Unknown option: $1. Use --help."
|
||
;;
|
||
esac
|
||
done
|
||
|
||
[ -n "$HOME_DIR" ] || fail "--home needs a value"
|
||
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
||
[ -n "$PORT" ] || fail "--port needs a value"
|
||
[ -n "$BIND" ] || fail "--bind needs a value"
|
||
[ -n "$CPUS" ] || fail "--cpus needs a value"
|
||
[ -n "$MEMORY_MB" ] || fail "--memory needs a value"
|
||
[ -n "$IMAGE" ] || fail "--image needs a value"
|
||
[ -n "$VERSION" ] || fail "--version needs a value"
|
||
|
||
case "$HOME_DIR" in
|
||
/*) ;;
|
||
*) fail "--home must be an absolute path" ;;
|
||
esac
|
||
|
||
case "$DATA_DIR" in
|
||
/*) ;;
|
||
*) fail "--data-dir must be an absolute path" ;;
|
||
esac
|
||
|
||
case "$PORT" in
|
||
'' | *[!0-9]*) fail "--port must be a number" ;;
|
||
esac
|
||
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || fail "--port must be between 1 and 65535"
|
||
case "$BIND" in
|
||
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
||
esac
|
||
case "$CPUS" in
|
||
'' | *[!0-9.]*) fail "--cpus must be a number" ;;
|
||
esac
|
||
[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0"
|
||
case "$MEMORY_MB" in
|
||
'' | *[!0-9]*) fail "--memory must be a number of megabytes" ;;
|
||
esac
|
||
[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512"
|
||
case "$IMAGE" in
|
||
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
||
esac
|
||
case "$VERSION" in
|
||
'' | *[!A-Za-z0-9._-]*) fail "--version may only contain letters, numbers, dot, dash and underscore" ;;
|
||
esac
|
||
if [ -n "$EXTRA_NETWORK" ]; then
|
||
case "$EXTRA_NETWORK" in
|
||
*[!A-Za-z0-9_.-]*) fail "--extra-network is not a valid network name" ;;
|
||
esac
|
||
fi
|
||
if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then
|
||
fail "--purge only works with --uninstall"
|
||
fi
|
||
|
||
ENV_FILE="$HOME_DIR/.env"
|
||
STATE_FILE="$HOME_DIR/install.conf"
|
||
DATA_DIR="${DATA_DIR%/}"
|
||
BACKUP_DIR="$HOME_DIR/backups"
|
||
# Whole-data archives made in the app (Settings > Backups). They live
|
||
# outside DATA_DIR, so an archive never contains another one.
|
||
BACKUP_ARCHIVE_DIR_HOST="$HOME_DIR/backups-archives"
|
||
|
||
# ---------------------------------------------------------------- platform
|
||
|
||
OS="$(uname -s)"
|
||
case "$OS" in
|
||
Linux | Darwin) ;;
|
||
*) fail "Unsupported operating system: $OS. Cobble runs on Linux and macOS with Docker." ;;
|
||
esac
|
||
|
||
case "$(uname -m)" in
|
||
x86_64 | amd64) ARCH="amd64" ;;
|
||
aarch64 | arm64) ARCH="arm64" ;;
|
||
*) fail "Unsupported architecture: $(uname -m)" ;;
|
||
esac
|
||
|
||
if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
|
||
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
|
||
fi
|
||
|
||
# SELinux hosts need the `z` mount option so docker relabels the bind
|
||
# mount. It is a third colon-separated field (`src:/data:z`), never a
|
||
# suffix on the destination: docker reads `src:/data,z` as the directory
|
||
# "/data,z", which silently leaves the app writing to its own container
|
||
# layer, breaks every update (the container is recreated without its data)
|
||
# and makes computer provisioning fail. Never append to the destination.
|
||
# MARK:selinux-detection
|
||
selinux_enforcing() {
|
||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
|
||
return 0
|
||
fi
|
||
[ -e /sys/fs/selinux/enforce ]
|
||
}
|
||
|
||
MOUNT_OPTS=""
|
||
if [ "$OS" = "Linux" ] && selinux_enforcing; then
|
||
MOUNT_OPTS="z"
|
||
fi
|
||
# END:selinux-detection
|
||
|
||
# MARK:mount-specs
|
||
# A spec is `source:destination[:options]`. Options are comma separated and
|
||
# only count in that third field, so each spec below either opens the field
|
||
# with a colon or adds to one that is already there. A destination is
|
||
# always a plain absolute path.
|
||
ENV_MOUNT="$ENV_FILE:/app/.env:ro${MOUNT_OPTS:+,$MOUNT_OPTS}"
|
||
DATA_MOUNT="$DATA_DIR:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||
# END:mount-specs
|
||
|
||
# ---------------------------------------------------------------- helpers
|
||
|
||
gen_secret() {
|
||
if command -v openssl >/dev/null 2>&1; then
|
||
openssl rand -hex 32
|
||
else
|
||
od -An -N32 -tx1 /dev/urandom | tr -d ' \n'
|
||
fi
|
||
}
|
||
|
||
env_has() {
|
||
[ -f "$ENV_FILE" ] && grep -qE "^[[:space:]]*$1=" "$ENV_FILE"
|
||
}
|
||
|
||
ensure_env() {
|
||
local key="$1" value="$2"
|
||
if ! env_has "$key"; then
|
||
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
|
||
fi
|
||
}
|
||
|
||
ensure_secret() {
|
||
local key="$1" value
|
||
value="$(env_get "$key")"
|
||
if [ -z "$value" ]; then
|
||
set_env "$key" "$(gen_secret)"
|
||
fi
|
||
}
|
||
|
||
unset_env() {
|
||
local key="$1"
|
||
[ -f "$ENV_FILE" ] || return 0
|
||
sed -i.bak -E "/^[[:space:]]*${key}=.*$/d" "$ENV_FILE"
|
||
rm -f "$ENV_FILE.bak"
|
||
}
|
||
|
||
health_url() {
|
||
printf 'http://%s:%s/api/health' "$(health_host)" "$PORT"
|
||
}
|
||
|
||
set_env() {
|
||
local key="$1" value="$2" escaped
|
||
escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')"
|
||
if env_has "$key"; then
|
||
sed -i.bak -E "s|^[[:space:]]*${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
|
||
rm -f "$ENV_FILE.bak"
|
||
else
|
||
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
|
||
fi
|
||
}
|
||
|
||
container_exists() {
|
||
docker container inspect "$CONTAINER" >/dev/null 2>&1
|
||
}
|
||
|
||
container_running() {
|
||
[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" = "true" ]
|
||
}
|
||
|
||
container_image_id() {
|
||
docker inspect -f '{{.Image}}' "$CONTAINER"
|
||
}
|
||
|
||
port_in_use() {
|
||
if [ "$OS" = "Darwin" ]; then
|
||
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1
|
||
elif command -v ss >/dev/null 2>&1; then
|
||
ss -ltnH "sport = :$PORT" 2>/dev/null | grep -q .
|
||
elif command -v netstat >/dev/null 2>&1; then
|
||
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$PORT$"
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
docker_socket_mounted() {
|
||
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
|
||
}
|
||
|
||
mounted_source() {
|
||
docker inspect -f '{{range .Mounts}}{{if eq .Destination "'"$1"'"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
|
||
}
|
||
|
||
container_matches() {
|
||
container_running || return 1
|
||
[ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1
|
||
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$DATA_DIR" ] || return 1
|
||
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/.env"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$ENV_FILE" ] || return 1
|
||
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/backups-archives"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$BACKUP_ARCHIVE_DIR_HOST" ] || return 1
|
||
[ "$(docker_socket_mounted)" = "$DOCKER_SOCKET" ] || return 1
|
||
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
||
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
||
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
||
[ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1
|
||
[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1
|
||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
||
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$NETWORK" || return 1
|
||
if [ -n "$EXTRA_NETWORK" ]; then
|
||
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1
|
||
fi
|
||
# An install from before TLS was removed: its container holds the old
|
||
# certificate mounts and envs, so recreate it rather than report it
|
||
# current.
|
||
if [ -n "$(mounted_source /app/certs/server.crt)" ] ||
|
||
[ -n "$(mounted_source /app/certs/server.key)" ] ||
|
||
[ -n "$(mounted_source /app/certs/ca.crt)" ]; then
|
||
return 1
|
||
fi
|
||
if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -q "^TLS_"; then
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
health_host() {
|
||
case "$BIND" in
|
||
0.0.0.0 | :: | "") printf '127.0.0.1' ;;
|
||
*) printf '%s' "$BIND" ;;
|
||
esac
|
||
}
|
||
|
||
host_ips() {
|
||
if [ "$OS" = "Darwin" ]; then
|
||
ifconfig 2>/dev/null | awk '/^[[:space:]]+inet / && $2 != "127.0.0.1" { print $2 }' | head -n 5 || true
|
||
elif command -v hostname >/dev/null 2>&1; then
|
||
hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 5 || true
|
||
fi
|
||
}
|
||
|
||
wait_for_health() {
|
||
local url start=0
|
||
url="$(health_url)"
|
||
say "Waiting for Cobble to answer at $(link "$url")"
|
||
while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
|
||
if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then
|
||
ok "Cobble is healthy"
|
||
return 0
|
||
fi
|
||
if ! container_running; then
|
||
docker logs --tail 80 "$CONTAINER" 2>&1 || true
|
||
fail "The Cobble container stopped during startup"
|
||
fi
|
||
sleep 3
|
||
start=$((start + 3))
|
||
done
|
||
docker logs --tail 120 "$CONTAINER" 2>&1 || true
|
||
fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s"
|
||
}
|
||
|
||
backup_database() {
|
||
local data_source="$DATA_DIR"
|
||
if container_exists; then
|
||
local mounted
|
||
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
|
||
[ -n "$mounted" ] && data_source="$mounted"
|
||
fi
|
||
|
||
[ -f "$data_source/db/cobble" ] || return 0
|
||
|
||
mkdir -p "$BACKUP_DIR"
|
||
say "Backing up the database"
|
||
|
||
local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||
|
||
local image_id=""
|
||
if container_running; then
|
||
if ! docker exec "$CONTAINER" bunx bun-sqlite backup >/dev/null 2>&1; then
|
||
[ "$SKIP_BACKUP" = 1 ] && {
|
||
warn "Could not back up the database, continuing because of --skip-backup"
|
||
return 0
|
||
}
|
||
fail "Could not back up the database. Use --skip-backup to update anyway."
|
||
fi
|
||
elif container_exists; then
|
||
image_id="$(container_image_id)"
|
||
if ! docker run --rm --entrypoint bunx \
|
||
-v "$backup_mount" -e DB_DIR=/data/db \
|
||
"$image_id" bun-sqlite backup >/dev/null 2>&1; then
|
||
[ "$SKIP_BACKUP" = 1 ] && {
|
||
warn "Could not back up the database, continuing because of --skip-backup"
|
||
return 0
|
||
}
|
||
fail "Could not back up the database. Use --skip-backup to update anyway."
|
||
fi
|
||
else
|
||
return 0
|
||
fi
|
||
|
||
local newest
|
||
newest="$(ls -1t "$data_source"/db/.backups/cobble-* 2>/dev/null | head -n 1 || true)"
|
||
if [ -z "$newest" ]; then
|
||
warn "No database backup file was written"
|
||
return 0
|
||
fi
|
||
|
||
cp -a "$newest" "$BACKUP_DIR/$(basename "$newest")" 2>/dev/null || true
|
||
ok "Backup: $BACKUP_DIR/$(basename "$newest")"
|
||
|
||
local old
|
||
old="$(ls -1t "$BACKUP_DIR"/cobble-* 2>/dev/null | tail -n +$((BACKUPS_KEPT + 1)) || true)"
|
||
if [ -n "$old" ]; then
|
||
printf '%s\n' "$old" | while IFS= read -r file; do
|
||
rm -f "$file"
|
||
done
|
||
fi
|
||
}
|
||
|
||
# Whether the container's /data is really the host's data directory.
|
||
# Older installers wrote the SELinux option onto the destination
|
||
# (`$DATA_DIR:/data,z`), so docker mounted a directory named "/data,z"
|
||
# and the app kept its database in the container's own writable layer.
|
||
# Such a container must have that data lifted out before it is replaced.
|
||
data_mount_broken() {
|
||
container_exists || return 1
|
||
local mounted
|
||
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
|
||
[ "$mounted" != "$DATA_DIR" ]
|
||
}
|
||
|
||
# Copies a container's unmounted /data back onto the host before the
|
||
# container is recreated, so a broken mount is repaired without losing the
|
||
# database, the media or the computers' home directories. Runs only when
|
||
# the container's /data is not the host's directory, and refuses to
|
||
# overwrite a host directory that already holds a database.
|
||
rescue_container_data() {
|
||
data_mount_broken || return 0
|
||
|
||
# Stop the app first: the copy must see a database nothing is writing
|
||
# to, and the container is about to be replaced anyway.
|
||
if container_running; then
|
||
say "Stopping Cobble to copy its data out"
|
||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
local staged
|
||
staged="$(mktemp -d)"
|
||
|
||
if ! docker cp "$CONTAINER:/data/." "$staged/" >/dev/null 2>&1; then
|
||
rm -rf "$staged"
|
||
fail "Could not read the container's data. Its database is still in $CONTAINER; copy it out before updating."
|
||
fi
|
||
|
||
# A container that never wrote anything has nothing to rescue: the app
|
||
# is about to start fresh on the host directory.
|
||
if [ -z "$(ls -A "$staged" 2>/dev/null)" ]; then
|
||
rm -rf "$staged"
|
||
return 0
|
||
fi
|
||
|
||
say "This install's data was inside its container (a broken mount); moving it to $DATA_DIR"
|
||
|
||
if [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
|
||
local aside
|
||
aside="$DATA_DIR.before-rescue-$(date +%s)"
|
||
warn "$DATA_DIR was not empty; the copy is kept at $aside"
|
||
mv "$DATA_DIR" "$aside" || fail "Could not move $DATA_DIR aside"
|
||
fi
|
||
|
||
mkdir -p "$DATA_DIR"
|
||
cp -a "$staged/." "$DATA_DIR/" || {
|
||
rm -rf "$staged"
|
||
fail "Could not copy the rescued data into $DATA_DIR"
|
||
}
|
||
rm -rf "$staged"
|
||
|
||
if [ -f "$DATA_DIR/db/cobble" ]; then
|
||
ok "Rescued the database and data into $DATA_DIR"
|
||
else
|
||
warn "Rescued data has no database file; the install will start empty"
|
||
fi
|
||
}
|
||
|
||
safe_remove_dir() {
|
||
local dir="$1"
|
||
case "$dir" in
|
||
/ | /opt | /home | /root | /var | /usr | /etc | /data | /mnt | /srv | /tmp)
|
||
fail "Refusing to delete $dir"
|
||
;;
|
||
esac
|
||
case "$dir" in
|
||
/*/*) ;;
|
||
*) fail "Refusing to delete $dir" ;;
|
||
esac
|
||
rm -rf "$dir"
|
||
}
|
||
|
||
connect_extra_network() {
|
||
[ -n "$EXTRA_NETWORK" ] || return 0
|
||
docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist"
|
||
docker network connect "$EXTRA_NETWORK" "$CONTAINER" >/dev/null 2>&1 || true
|
||
}
|
||
|
||
run_container() {
|
||
local mounts=(
|
||
-v "$ENV_MOUNT"
|
||
-v "$DATA_MOUNT"
|
||
-v "$BACKUP_ARCHIVE_MOUNT"
|
||
-v "$DOCKER_SOCKET:/var/run/docker.sock"
|
||
)
|
||
local envs=(
|
||
-e MAIN_SERVER=1
|
||
-e "PORT=$PORT"
|
||
-e BACKUP_ARCHIVE_DIR=/backups-archives
|
||
)
|
||
|
||
docker run -d \
|
||
--name "$CONTAINER" \
|
||
--restart unless-stopped \
|
||
--init \
|
||
--network "$NETWORK" \
|
||
-p "$BIND:$PORT:$PORT" \
|
||
--cpus "$CPUS" \
|
||
--memory "${MEMORY_MB}m" \
|
||
"${mounts[@]}" \
|
||
"${envs[@]}" \
|
||
--log-driver json-file \
|
||
--log-opt max-size=10m \
|
||
--log-opt max-file=3 \
|
||
"$IMAGE:$VERSION" >/dev/null
|
||
}
|
||
|
||
# ---------------------------------------------------------------- docker
|
||
|
||
ensure_docker() {
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
if [ "$NO_DOCKER_INSTALL" = 1 ]; then
|
||
fail "Docker is not installed. Install it and rerun, or drop --no-docker-install."
|
||
fi
|
||
if [ "$OS" != "Linux" ]; then
|
||
fail "Docker is not installed. Install Docker Desktop, then rerun."
|
||
fi
|
||
say "Installing Docker"
|
||
local script
|
||
script="$(mktemp)"
|
||
curl -fsSL https://get.docker.com -o "$script"
|
||
sh "$script"
|
||
rm -f "$script"
|
||
fi
|
||
|
||
if ! docker info >/dev/null 2>&1; then
|
||
if [ "$OS" = "Darwin" ]; then
|
||
fail "Docker Desktop is not running. Start it, then rerun."
|
||
fi
|
||
if command -v systemctl >/dev/null 2>&1; then
|
||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||
elif command -v service >/dev/null 2>&1; then
|
||
service docker start >/dev/null 2>&1 || true
|
||
fi
|
||
fi
|
||
|
||
docker info >/dev/null 2>&1 || fail "Docker is installed but not running"
|
||
|
||
if [ ! -S "$DOCKER_SOCKET" ]; then
|
||
fail "No docker socket at $DOCKER_SOCKET"
|
||
fi
|
||
}
|
||
|
||
# ---------------------------------------------------------------- uninstall
|
||
|
||
uninstall() {
|
||
if container_exists; then
|
||
say "Stopping Cobble"
|
||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
local computers
|
||
computers="$(docker ps -aq --filter 'label=cobble.computer_id' 2>/dev/null || true)"
|
||
if [ -n "$computers" ]; then
|
||
say "Removing computer containers"
|
||
while IFS= read -r id; do
|
||
[ -n "$id" ] && docker rm -f "$id" >/dev/null 2>&1 || true
|
||
done <<<"$computers"
|
||
fi
|
||
|
||
if docker network inspect "$NETWORK" >/dev/null 2>&1; then
|
||
local attached
|
||
attached="$(docker network inspect -f '{{len .Containers}}' "$NETWORK" 2>/dev/null || echo 1)"
|
||
if [ "$attached" = "0" ]; then
|
||
docker network rm "$NETWORK" >/dev/null 2>&1 || true
|
||
fi
|
||
fi
|
||
|
||
if [ "$PURGE" = 1 ]; then
|
||
safe_remove_dir "$HOME_DIR"
|
||
if [ "$DATA_DIR" != "$HOME_DIR" ] && [ "$DATA_DIR" != "$HOME_DIR/data" ]; then
|
||
safe_remove_dir "$DATA_DIR"
|
||
fi
|
||
ok "Removed Cobble and its data"
|
||
else
|
||
ok "Cobble stopped. Data kept in $HOME_DIR"
|
||
fi
|
||
exit 0
|
||
}
|
||
|
||
# ---------------------------------------------------------------- main
|
||
|
||
banner
|
||
|
||
command -v curl >/dev/null 2>&1 || fail "curl is required"
|
||
|
||
ensure_docker
|
||
|
||
if [ "$ACTION" = "uninstall" ]; then
|
||
uninstall
|
||
fi
|
||
|
||
mkdir -p "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" "$BACKUP_ARCHIVE_DIR_HOST"
|
||
chmod 755 "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" "$BACKUP_ARCHIVE_DIR_HOST"
|
||
|
||
if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then
|
||
say "Creating the $NETWORK network"
|
||
docker network create "$NETWORK" >/dev/null
|
||
fi
|
||
|
||
if [ -n "$EXTRA_NETWORK" ]; then
|
||
docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist"
|
||
fi
|
||
|
||
if [ ! -f "$ENV_FILE" ]; then
|
||
(umask 077
|
||
cat >"$ENV_FILE" <<EOF
|
||
# Cobble settings. Restart the container after changing them:
|
||
# docker restart $CONTAINER
|
||
PORT=$PORT
|
||
ENCRYPTION_KEY=$(gen_secret)
|
||
ENCRYPTION_SALT=$(gen_secret)
|
||
PAGE_LASTMOD_FILE=/data/sitemap-lastmod.json
|
||
EOF
|
||
)
|
||
fi
|
||
chmod 600 "$ENV_FILE"
|
||
set_env PORT "$PORT"
|
||
ensure_secret ENCRYPTION_KEY
|
||
ensure_secret ENCRYPTION_SALT
|
||
ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
|
||
|
||
# Installs from before TLS was removed may still carry these; the app
|
||
# ignores them, so drop them rather than leave a stale decoration.
|
||
unset_env TLS_CERT_FILE
|
||
unset_env TLS_KEY_FILE
|
||
unset_env TLS_CA_FILE
|
||
unset_env TLS_DISABLED
|
||
unset_env HTTP_REDIRECT_PORT
|
||
|
||
{
|
||
printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR"
|
||
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
||
printf 'COBBLE_PORT=%q\n' "$PORT"
|
||
printf 'COBBLE_BIND=%q\n' "$BIND"
|
||
printf 'COBBLE_CPUS=%q\n' "$CPUS"
|
||
printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB"
|
||
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
||
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
||
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
||
printf 'COBBLE_DOCKER_SOCKET=%q\n' "$DOCKER_SOCKET"
|
||
} >"$STATE_FILE"
|
||
chmod 600 "$STATE_FILE"
|
||
|
||
if ! container_exists && port_in_use; then
|
||
fail "Port $PORT is already in use. Pass --port with a free port."
|
||
fi
|
||
|
||
if [ "$NO_PULL" = 1 ]; then
|
||
docker image inspect "$IMAGE:$VERSION" >/dev/null 2>&1 || fail "Image $IMAGE:$VERSION is not available locally"
|
||
else
|
||
say "Pulling $IMAGE:$VERSION"
|
||
if ! docker pull "$IMAGE:$VERSION" >/dev/null; then
|
||
fail "Could not pull $IMAGE:$VERSION"
|
||
fi
|
||
fi
|
||
TARGET_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$IMAGE:$VERSION")"
|
||
IMAGE_ARCH="$(docker image inspect -f '{{.Architecture}}' "$IMAGE:$VERSION")"
|
||
if [ "$IMAGE_ARCH" != "$ARCH" ]; then
|
||
fail "Image $IMAGE:$VERSION is $IMAGE_ARCH but this machine is $ARCH. Pull the multi-arch image instead of --no-pull."
|
||
fi
|
||
|
||
if [ "$FORCE" = 0 ] && container_matches; then
|
||
connect_extra_network
|
||
if curl -fsS --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then
|
||
ok "Cobble is already up to date"
|
||
say "Healthy at $(link "http://$(health_host):$PORT")"
|
||
exit 0
|
||
fi
|
||
warn "The container is running but not answering; recreating it"
|
||
fi
|
||
|
||
if container_exists; then
|
||
# A container whose /data was never mounted keeps the database in its
|
||
# own layer, so lift it out before anything replaces the container.
|
||
rescue_container_data
|
||
backup_database
|
||
say "Replacing the Cobble container"
|
||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
say "Starting Cobble"
|
||
run_container
|
||
connect_extra_network
|
||
|
||
wait_for_health
|
||
|
||
ok "Cobble is running"
|
||
printf '\n'
|
||
printf ' %sOpen Cobble%s\n' "$C_BOLD" "$C_RESET"
|
||
if [ "$BIND" = "0.0.0.0" ]; then
|
||
while IFS= read -r ip; do
|
||
[ -n "$ip" ] && printf ' %s\n' "$(link "http://$ip:$PORT")"
|
||
done < <(host_ips)
|
||
fi
|
||
printf ' %s\n' "$(link "http://127.0.0.1:$PORT")"
|
||
printf '\n'
|
||
printf ' %sPut a reverse proxy or tunnel in front for HTTPS, and open that\n' "$C_DIM"
|
||
printf ' address instead. The installed app and notifications need it.%s\n' "$C_RESET"
|
||
printf '\n'
|
||
summary_row "First visit" "create the super admin account."
|
||
summary_row "Data" "$DATA_DIR"
|
||
summary_row "Settings" "$ENV_FILE"
|
||
summary_row "Backups" "$BACKUP_DIR"
|
||
summary_row "Archives" "$BACKUP_ARCHIVE_DIR_HOST"
|
||
printf '\n'
|
||
summary_row "Logs" "${C_BOLD}docker logs -f $CONTAINER${C_RESET}"
|
||
summary_row "Update" "${C_BOLD}curl -fsSL${C_RESET} $(link "$INSTALL_URL") ${C_BOLD}| sudo bash${C_RESET}"
|
||
summary_row "Uninstall" "${C_BOLD}curl -fsSL${C_RESET} $(link "$INSTALL_URL") ${C_BOLD}| sudo bash -s -- --uninstall${C_RESET}"
|
||
printf '\n'
|
||
if [ "$BIND" = "0.0.0.0" ]; then
|
||
printf ' %s!%s %sThis port is reachable on your network, without encryption. Put a proxy or tunnel in front of it for HTTPS.%s\n\n' "$C_YELLOW" "$C_RESET" "$C_DIM" "$C_RESET"
|
||
fi
|