488 lines
16 KiB
Bash
Executable File
488 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# POS: docker compose — Docker Compose service manager (ls/up/down/restart/logs/update/config)
|
|
# POS_SUBCMDS: ls installed up down restart logs update config menu
|
|
# POS_CONFIG: compose | compose.env | TS_AUTHKEY=secret:Tailscale auth key for the sidecar | TZ=:Service timezone (default Europe/Amsterdam) | DNS_SERVER=:Custom DNS server (default 9.9.9.9) | SERVICES_BASE=:Deployment root (default /srv)
|
|
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
|
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
|
|
|
SCALE_DIR="${SCALE_DIR:-/usr/local/share/linux_post_install/scale-tail/services}"
|
|
CONFIG_ENV="${CONFIG_ENV:-${HOME}/.config/linux_post_install/compose.env}"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: pos docker compose <command> [args]
|
|
|
|
Bare \`pos docker compose\` on a terminal (or \`pos docker compose menu\`) opens
|
|
an interactive menu wrapping these commands; arguments stay scriptable.
|
|
|
|
Commands:
|
|
ls List all available ScaleTail service templates
|
|
installed List services already deployed on this machine
|
|
up <service> Deploy or start a service
|
|
down <service> Stop and remove a deployed service
|
|
restart <service> Restart a running service
|
|
logs <service> [-f] View logs (use -f to follow)
|
|
update Pull latest ScaleTail templates + refresh all deployed compose files
|
|
config Show current global configuration
|
|
config set KEY=VALUE Set a global default (e.g. TS_AUTHKEY, TZ, SERVICES_BASE)
|
|
config edit Open global config in your editor (\$EDITOR)
|
|
|
|
─── Config Strategy ──────────────────────────────────────────
|
|
|
|
Three layers — each overrides the one above:
|
|
|
|
1. Template defaults
|
|
/usr/local/share/linux_post_install/scale-tail/services/<name>/.env
|
|
|
|
2. Global config (your defaults — set once, applies to all services)
|
|
~/.config/linux_post_install/compose.env
|
|
Keys: TS_AUTHKEY | TZ | DNS_SERVER | SERVICES_BASE
|
|
|
|
3. Per-service config (created on first deploy, preserved forever)
|
|
/srv/<service>/.env (or \$SERVICES_BASE/<service>/.env)
|
|
|
|
On first "up", the service .env is generated from the template
|
|
and filled with values from your global config. After that, the
|
|
per-service .env is never overwritten — even by "update".
|
|
|
|
─── Paths ────────────────────────────────────────────────────
|
|
|
|
Templates: /usr/local/share/linux_post_install/scale-tail/services/
|
|
Deployments: \$SERVICES_BASE/<service>/ (default: /srv/<service>/)
|
|
Global conf: ~/.config/linux_post_install/compose.env
|
|
|
|
─── Quick Start ──────────────────────────────────────────────
|
|
|
|
1. Set your Tailscale auth key (required):
|
|
pos docker compose config set TS_AUTHKEY=tskey-auth-xxxxx
|
|
|
|
2. Deploy a service:
|
|
pos docker compose up jellyfin
|
|
|
|
3. Open https://jellyfin.tail-xxxxx.ts.net
|
|
|
|
Examples:
|
|
pos docker compose up jellyfin
|
|
pos docker compose down actual-budget
|
|
pos docker compose logs home-assistant -f
|
|
pos docker compose config set TZ=Asia/Tokyo
|
|
pos docker compose config set SERVICES_BASE=/data
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
check_deps() {
|
|
command -v docker &>/dev/null || err "docker not found — run 'install.sh --apps' and install Docker first"
|
|
}
|
|
|
|
check_templates() {
|
|
[ -d "$SCALE_DIR" ] || err "ScaleTail templates not found at $SCALE_DIR — run 'install.sh' to set them up"
|
|
}
|
|
|
|
check_service() {
|
|
local svc="$1"
|
|
[ -d "$SCALE_DIR/$svc" ] || err "Unknown service '$svc' — run 'pos docker compose ls' to see available services"
|
|
}
|
|
|
|
load_global_config() {
|
|
mkdir -p "$(dirname "$CONFIG_ENV")"
|
|
[ -f "$CONFIG_ENV" ] && source "$CONFIG_ENV"
|
|
SERVICES_BASE="${SERVICES_BASE:-/srv}"
|
|
}
|
|
|
|
service_dir() {
|
|
local svc="$1"
|
|
echo "$SERVICES_BASE/$svc"
|
|
}
|
|
|
|
###############################################################################
|
|
# Commands
|
|
###############################################################################
|
|
|
|
cmd_ls() {
|
|
check_templates
|
|
local names=()
|
|
for svc in "$SCALE_DIR"/*/; do
|
|
names+=("$(basename "$svc")")
|
|
done
|
|
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
|
|
|
|
echo "${CYAN}Available ScaleTail services:${RESET}"
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
local name
|
|
for name in "${names[@]}"; do
|
|
printf " ${GREEN}%s${RESET}\n" "$name"
|
|
done
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
echo " ${#names[@]} services total"
|
|
}
|
|
|
|
cmd_installed() {
|
|
load_global_config
|
|
local count=0
|
|
|
|
echo "${CYAN}Deployed services:${RESET}"
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
|
|
[ -d "$SERVICES_BASE" ] || { echo " (none — $SERVICES_BASE does not exist)"; echo "${BLUE}────────────────────────────────────────${RESET}"; echo " 0 services deployed"; return; }
|
|
|
|
for svc in "$SERVICES_BASE"/*/; do
|
|
[ -d "$svc" ] || continue
|
|
local name
|
|
name=$(basename "$svc")
|
|
local status=""
|
|
if [ -f "$svc/compose.yaml" ] || [ -f "$svc/compose.yml" ]; then
|
|
status=$(docker compose ls --format json 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
if not isinstance(data, list):
|
|
data = [data]
|
|
for e in data:
|
|
if e.get('Name') == '$name':
|
|
print(e.get('Status', 'unknown'))
|
|
break
|
|
except: pass
|
|
" 2>/dev/null || echo "unknown")
|
|
fi
|
|
printf " ${GREEN}%-28s${RESET} %s\n" "$name" "${status:-unknown}"
|
|
count=$((count + 1))
|
|
done
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
echo " $count services deployed"
|
|
}
|
|
|
|
cmd_up() {
|
|
local svc="$1"
|
|
[ -z "$svc" ] && usage
|
|
check_deps
|
|
check_templates
|
|
check_service "$svc"
|
|
load_global_config
|
|
|
|
local target
|
|
target=$(service_dir "$svc")
|
|
|
|
if [ ! -d "$target" ]; then
|
|
log "Deploying $svc to $target"
|
|
mkdir -p "$target/config" "$target/data"
|
|
if [ -f "$SCALE_DIR/$svc/compose.yaml" ]; then
|
|
cp "$SCALE_DIR/$svc/compose.yaml" "$target/"
|
|
fi
|
|
if [ -f "$SCALE_DIR/$svc/compose.yml" ]; then
|
|
cp "$SCALE_DIR/$svc/compose.yml" "$target/"
|
|
fi
|
|
else
|
|
mkdir -p "$target/config" "$target/data"
|
|
fi
|
|
|
|
local env_file="$target/.env"
|
|
if [ ! -f "$env_file" ]; then
|
|
if [ -f "$SCALE_DIR/$svc/.env" ]; then
|
|
cp "$SCALE_DIR/$svc/.env" "$env_file"
|
|
else
|
|
cat > "$env_file" <<-EOF
|
|
SERVICE=$svc
|
|
IMAGE_URL=
|
|
SERVICEPORT=
|
|
DNS_SERVER=${DNS_SERVER:-9.9.9.9}
|
|
TS_AUTHKEY=${TS_AUTHKEY:-}
|
|
TZ=${TZ:-Europe/Amsterdam}
|
|
EOF
|
|
fi
|
|
|
|
if [ -n "${TS_AUTHKEY:-}" ]; then
|
|
sed -i "s|^TS_AUTHKEY=.*|TS_AUTHKEY=$TS_AUTHKEY|" "$env_file" 2>/dev/null || true
|
|
fi
|
|
if [ -n "${TZ:-}" ]; then
|
|
sed -i "s|^TZ=.*|TZ=$TZ|" "$env_file" 2>/dev/null || true
|
|
fi
|
|
if [ -n "${DNS_SERVER:-}" ]; then
|
|
sed -i "s|^DNS_SERVER=.*|DNS_SERVER=$DNS_SERVER|" "$env_file" 2>/dev/null || true
|
|
fi
|
|
|
|
if grep -q "^TS_AUTHKEY=$" "$env_file" 2>/dev/null; then
|
|
warn "TS_AUTHKEY is not set"
|
|
local key
|
|
read -rp " Enter your Tailscale auth key (or press Enter to skip): " key
|
|
if [ -n "$key" ]; then
|
|
sed -i "s|^TS_AUTHKEY=.*|TS_AUTHKEY=$key|" "$env_file"
|
|
fi
|
|
fi
|
|
log ".env created at $env_file"
|
|
if confirm "Edit .env before starting?" Y; then
|
|
"${EDITOR:-nano}" "$env_file"
|
|
log "Continuing with startup..."
|
|
fi
|
|
fi
|
|
|
|
log "Starting $svc..."
|
|
(cd "$target" && docker compose up -d)
|
|
ok "$svc is running"
|
|
}
|
|
|
|
cmd_down() {
|
|
local svc="$1"
|
|
[ -z "$svc" ] && usage
|
|
check_deps
|
|
load_global_config
|
|
local target
|
|
target=$(service_dir "$svc")
|
|
[ -d "$target" ] || err "$svc is not deployed at $target"
|
|
log "Stopping $svc..."
|
|
(cd "$target" && docker compose down)
|
|
ok "$svc stopped"
|
|
}
|
|
|
|
cmd_restart() {
|
|
local svc="$1"
|
|
[ -z "$svc" ] && usage
|
|
check_deps
|
|
load_global_config
|
|
local target
|
|
target=$(service_dir "$svc")
|
|
[ -d "$target" ] || err "$svc is not deployed"
|
|
log "Restarting $svc..."
|
|
(cd "$target" && docker compose restart)
|
|
ok "$svc restarted"
|
|
}
|
|
|
|
cmd_logs() {
|
|
local svc="$1"
|
|
shift 2>/dev/null || true
|
|
[ -z "$svc" ] && usage
|
|
check_deps
|
|
load_global_config
|
|
local target
|
|
target=$(service_dir "$svc")
|
|
[ -d "$target" ] || err "$svc is not deployed"
|
|
(cd "$target" && exec docker compose logs "$@")
|
|
}
|
|
|
|
cmd_update() {
|
|
check_templates
|
|
|
|
log "Updating ScaleTail templates..."
|
|
sudo git -C "/usr/local/share/linux_post_install/scale-tail" pull
|
|
ok "Templates updated"
|
|
|
|
load_global_config
|
|
local refreshed=0
|
|
|
|
[ -d "$SERVICES_BASE" ] || { ok "$refreshed compose files refreshed (.env preserved)"; return; }
|
|
|
|
for svc in "$SERVICES_BASE"/*/; do
|
|
[ -d "$svc" ] || continue
|
|
local name
|
|
name=$(basename "$svc")
|
|
if [ -f "$SCALE_DIR/$name/compose.yaml" ]; then
|
|
cp "$SCALE_DIR/$name/compose.yaml" "$svc/compose.yaml"
|
|
refreshed=$((refreshed + 1))
|
|
fi
|
|
if [ -f "$SCALE_DIR/$name/compose.yml" ]; then
|
|
cp "$SCALE_DIR/$name/compose.yml" "$svc/compose.yml"
|
|
refreshed=$((refreshed + 1))
|
|
fi
|
|
done
|
|
|
|
ok "$refreshed compose files refreshed (.env preserved)"
|
|
}
|
|
|
|
cmd_config() {
|
|
local action="${1:-show}"
|
|
shift 2>/dev/null || true
|
|
|
|
case "$action" in
|
|
show)
|
|
load_global_config
|
|
echo "${CYAN}Global compose config${RESET}"
|
|
echo "${DIM}File: $CONFIG_ENV${RESET}"
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
if [ -f "$CONFIG_ENV" ]; then
|
|
cat "$CONFIG_ENV"
|
|
else
|
|
echo "(no global config set — using defaults)"
|
|
fi
|
|
echo "${BLUE}────────────────────────────────────────${RESET}"
|
|
echo "SERVICES_BASE=$SERVICES_BASE (deployment directory)"
|
|
echo "${DIM}Set with: pos docker compose config set SERVICES_BASE=/srv${RESET}"
|
|
;;
|
|
set)
|
|
local pair="${1:-}"
|
|
[ -z "$pair" ] && usage
|
|
local key="${pair%%=*}"
|
|
local val="${pair#*=}"
|
|
mkdir -p "$(dirname "$CONFIG_ENV")"
|
|
if [ -f "$CONFIG_ENV" ] && grep -q "^${key}=" "$CONFIG_ENV" 2>/dev/null; then
|
|
sed -i "s|^${key}=.*|${key}=${val}|" "$CONFIG_ENV"
|
|
else
|
|
echo "${key}=${val}" >> "$CONFIG_ENV"
|
|
fi
|
|
ok "Set ${key}=${val}"
|
|
;;
|
|
edit)
|
|
mkdir -p "$(dirname "$CONFIG_ENV")"
|
|
if [ ! -f "$CONFIG_ENV" ]; then
|
|
cat > "$CONFIG_ENV" <<-EOF
|
|
# Linux_post_install — global Docker Compose config
|
|
# Each key is optional; empty values prompt on first deploy.
|
|
TS_AUTHKEY=
|
|
TZ=Europe/Amsterdam
|
|
DNS_SERVER=9.9.9.9
|
|
SERVICES_BASE=/srv
|
|
EOF
|
|
log "Created default config at $CONFIG_ENV"
|
|
fi
|
|
log "Opening $CONFIG_ENV in ${EDITOR:-nano}..."
|
|
"${EDITOR:-nano}" "$CONFIG_ENV"
|
|
log "Config saved"
|
|
;;
|
|
*)
|
|
usage
|
|
;;
|
|
esac
|
|
}
|
|
|
|
###############################################################################
|
|
# Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh)
|
|
###############################################################################
|
|
|
|
menu_list_templates() { # available ScaleTail template names, one per line
|
|
local d
|
|
for d in "$SCALE_DIR"/*/; do
|
|
[ -d "$d" ] || continue
|
|
basename "$d"
|
|
done | LC_ALL=C sort
|
|
}
|
|
|
|
menu_list_deployed() { # deployed stack names under $SERVICES_BASE, one per line
|
|
local d
|
|
[ -d "$SERVICES_BASE" ] || return 0
|
|
for d in "$SERVICES_BASE"/*/; do
|
|
[ -d "$d" ] || continue
|
|
basename "$d"
|
|
done | LC_ALL=C sort
|
|
}
|
|
|
|
# $1 = prompt · $2 = listing function · $3 = empty-state hint → picked name
|
|
menu_pick_stack() {
|
|
local -a items=()
|
|
mapfile -t items < <("$2")
|
|
if [ ${#items[@]} -eq 0 ]; then
|
|
warn "$3"
|
|
return 1
|
|
fi
|
|
local idx
|
|
idx="$(menu_pick "$1" "${items[@]}")" || return 1
|
|
printf '%s\n' "${items[$((idx - 1))]}"
|
|
}
|
|
|
|
menu_up() {
|
|
local svc
|
|
svc="$(menu_pick_stack "Deploy / start which template?" menu_list_templates \
|
|
"No ScaleTail templates found at $SCALE_DIR — run install.sh to set them up")" || return 0
|
|
cmd_up "$svc" # first-deploy .env/TS_AUTHKEY prompts fold in here
|
|
}
|
|
|
|
menu_down() {
|
|
local svc
|
|
load_global_config
|
|
svc="$(menu_pick_stack "Stop and remove which stack?" menu_list_deployed \
|
|
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
|
confirm "Stop and remove stack '$svc' (docker compose down)?" n || { log "Cancelled"; return 0; }
|
|
cmd_down "$svc"
|
|
}
|
|
|
|
menu_restart() {
|
|
local svc
|
|
load_global_config
|
|
svc="$(menu_pick_stack "Restart which stack?" menu_list_deployed \
|
|
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
|
confirm "Restart stack '$svc'?" n || { log "Cancelled"; return 0; }
|
|
cmd_restart "$svc"
|
|
}
|
|
|
|
menu_logs() {
|
|
local svc
|
|
load_global_config
|
|
svc="$(menu_pick_stack "Logs of which stack?" menu_list_deployed \
|
|
"Nothing deployed under $SERVICES_BASE — deploy a template first ('up')")" || return 0
|
|
log "Following logs of '$svc' — Ctrl-C returns to the menu"
|
|
if ! cmd_logs "$svc" -f; then
|
|
log "Returned from logs of '$svc'"
|
|
fi
|
|
}
|
|
|
|
menu_update() {
|
|
load_global_config
|
|
confirm "Update ALL stacks: pull latest ScaleTail templates and overwrite deployed compose files under $SERVICES_BASE (.env preserved)?" n \
|
|
|| { log "Cancelled"; return 0; }
|
|
cmd_update
|
|
}
|
|
|
|
run_menu() {
|
|
menu_guard || exit 1
|
|
while true; do
|
|
local choice
|
|
choice="$(menu_run "Docker Compose (ScaleTail)" \
|
|
"List available service templates" \
|
|
"List deployed stacks (+ status)" \
|
|
"Deploy / start a stack (up)" \
|
|
"Stop and remove a deployed stack (down)" \
|
|
"Restart a deployed stack" \
|
|
"Follow a stack's logs (-f; Ctrl-C returns)" \
|
|
"Update templates + refresh deployed compose files" \
|
|
"Show global config" \
|
|
"Edit global config (\$EDITOR)")" || return 0
|
|
case "$choice" in
|
|
1) cmd_ls ;;
|
|
2) cmd_installed ;;
|
|
3) menu_up ;;
|
|
4) menu_down ;;
|
|
5) menu_restart ;;
|
|
6) menu_logs ;;
|
|
7) menu_update ;;
|
|
8) cmd_config show ;;
|
|
9) cmd_config edit ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
###############################################################################
|
|
# CLI dispatch
|
|
###############################################################################
|
|
|
|
# Menu door: explicit verb, or zero args on a terminal. Everything below —
|
|
# including zero args without a terminal — stays byte-compatible with the
|
|
# pre-menu CLI.
|
|
if [ "${1:-}" = "menu" ]; then
|
|
run_menu
|
|
exit 0
|
|
fi
|
|
if [ $# -eq 0 ] && [ -t 0 ]; then
|
|
run_menu
|
|
exit 0
|
|
fi
|
|
|
|
[ $# -eq 0 ] && usage
|
|
|
|
case "${1:-}" in
|
|
-h|--help) usage ;;
|
|
esac
|
|
|
|
cmd="${1:-}"
|
|
shift
|
|
|
|
case "$cmd" in
|
|
ls) cmd_ls ;;
|
|
installed) cmd_installed ;;
|
|
up) cmd_up "${1:-}" ;;
|
|
down) cmd_down "${1:-}" ;;
|
|
restart) cmd_restart "${1:-}" ;;
|
|
logs) cmd_logs "$@" ;;
|
|
update) cmd_update ;;
|
|
config) cmd_config "$@" ;;
|
|
*) usage ;;
|
|
esac
|