This repository has been archived on 2026-08-23. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

833 lines
26 KiB
Bash
Executable File

#!/usr/bin/env sh
set -eu
log() {
printf '%s\n' "$*" >&2
}
die() {
log "pi-web-docker: $*"
exit 1
}
usage() {
cat <<'EOF'
Usage: pi-web-docker [--dev] [--allow-root] <command> [args...]
Runtime/production mode is the default. Development mode must be selected
explicitly with --dev.
Commands:
install Run the production one-line/bootstrap installer
start Start the PI WEB Docker stack
stop Stop the PI WEB Docker stack without deleting data
restart Restart web and sessiond
restart-web Restart only the web service
restart-sessiond Restart only the session daemon
update Rebuild/update and recreate the Docker stack
(development mode requires a clean Git checkout)
status Show Docker Compose service status
logs [web|sessiond|data-init]
Follow Docker Compose logs
shell [web|sessiond] Open a shell in a service container
doctor Print static Docker command diagnostics
cli <pi-web args...> Run the pi-web CLI in the web container
Update and restart commands launched inside a PI WEB Docker container start an
independent helper container first, then stream the helper logs inline. The
helper continues running if the terminal or web/sessiond exits.
EOF
}
is_truthy() {
case "${1:-}" in
""|0|false|FALSE|False) return 1 ;;
*) return 0 ;;
esac
}
is_unsigned_int() {
case "${1:-}" in
""|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
assert_no_args() {
checked_command=$1
shift
[ "$#" -eq 0 ] || die "$checked_command does not accept positional arguments"
}
assert_at_most_one_arg() {
checked_command=$1
shift
[ "$#" -le 1 ] || die "$checked_command accepts at most one target"
}
entrypoint_dir() {
script_path=${0:-}
case "$script_path" in
*/*) script_dir=$(dirname "$script_path") ;;
*) script_dir=. ;;
esac
unset CDPATH
cd "$script_dir" 2>/dev/null && pwd -P
}
ENTRYPOINT_DIR=$(entrypoint_dir) || die "could not resolve entrypoint directory"
PI_WEB_DOCKER_SELECTED_MODE=runtime
PI_WEB_DOCKER_ALLOW_ROOT=0
while [ "$#" -gt 0 ]; do
case "$1" in
--dev)
PI_WEB_DOCKER_SELECTED_MODE=dev
shift
;;
--allow-root)
PI_WEB_DOCKER_ALLOW_ROOT=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
die "unknown global option: $1"
;;
*)
break
;;
esac
done
command_name=${1:-}
if [ "$#" -gt 0 ]; then
shift
fi
if [ -z "$command_name" ]; then
usage >&2
exit 2
fi
docker_mode() {
case "$PI_WEB_DOCKER_SELECTED_MODE" in
runtime|dev) printf '%s\n' "$PI_WEB_DOCKER_SELECTED_MODE" ;;
*) die "unsupported Docker mode: $PI_WEB_DOCKER_SELECTED_MODE" ;;
esac
}
mode_flag() {
case "$(docker_mode)" in
runtime) return 0 ;;
dev) printf '%s\n' --dev ;;
esac
}
absolute_existing_dir() {
dir=$1
(cd "$dir" && pwd -P)
}
strip_wrapping_quotes() {
value=$1
case "$value" in
\"*\")
case "$value" in
*\") value=${value#\"}; value=${value%\"} ;;
esac
;;
\'*\')
case "$value" in
*\') value=${value#\'}; value=${value%\'} ;;
esac
;;
esac
printf '%s\n' "$value"
}
env_file_value() {
file=$1
key=$2
[ -f "$file" ] || return 1
raw=$(awk -v key="$key" '
function trim(value) {
sub(/^[ \t]+/, "", value)
sub(/[ \t\r]+$/, "", value)
return value
}
/^[ \t]*(#|$)/ { next }
{
line = $0
sub(/^[ \t]*export[ \t]+/, "", line)
name = line
sub(/=.*/, "", name)
name = trim(name)
if (name == key) {
sub(/^[^=]*=/, "", line)
print trim(line)
found = 1
exit
}
}
END { if (!found) exit 1 }
' "$file") || return 1
strip_wrapping_quotes "$raw"
}
runtime_root() {
root=${PI_WEB_DOCKER_INSTALL_DIR:-}
if [ -z "$root" ]; then
root=$ENTRYPOINT_DIR
fi
case "$root" in
/*) ;;
*) die "PI WEB Docker runtime root must be an absolute path: $root" ;;
esac
[ -d "$root" ] || die "PI WEB Docker runtime root does not exist: $root"
printf '%s\n' "$root"
}
dev_root() {
root=${PI_WEB_DOCKER_DEV_REPO_ROOT:-}
if [ -z "$root" ]; then
if [ -f "$ENTRYPOINT_DIR/compose.dev.yml" ] && [ -d "$ENTRYPOINT_DIR/.." ]; then
root=$(absolute_existing_dir "$ENTRYPOINT_DIR/..") || die "could not resolve Docker development repo root"
elif [ -f "$ENTRYPOINT_DIR/docker/compose.dev.yml" ]; then
root=$ENTRYPOINT_DIR
fi
fi
[ -n "$root" ] || die "PI_WEB_DOCKER_DEV_REPO_ROOT must be set or pi-web-docker must run from this checkout's docker/ directory"
case "$root" in
/*) ;;
*) die "PI WEB Docker development repo root must be an absolute path: $root" ;;
esac
[ -d "$root" ] || die "PI WEB Docker development repo root does not exist: $root"
printf '%s\n' "$root"
}
control_root() {
case "$(docker_mode)" in
runtime) runtime_root ;;
dev) dev_root ;;
esac
}
enforce_dev_root_safety() {
[ "$(docker_mode)" = dev ] || return 0
[ "$PI_WEB_DOCKER_ALLOW_ROOT" != 1 ] || return 0
uid=$(id -u 2>/dev/null || printf '0')
[ "$uid" != 0 ] || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional"
}
dev_git_operation() {
git_dir=$1
if [ -f "$git_dir/MERGE_HEAD" ]; then
printf '%s\n' merge
elif [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ] || [ -f "$git_dir/REBASE_HEAD" ]; then
printf '%s\n' rebase
elif [ -f "$git_dir/CHERRY_PICK_HEAD" ]; then
printf '%s\n' cherry-pick
elif [ -f "$git_dir/REVERT_HEAD" ]; then
printf '%s\n' revert
elif [ -d "$git_dir/sequencer" ]; then
printf '%s\n' sequenced-operation
elif [ -f "$git_dir/BISECT_LOG" ]; then
printf '%s\n' bisect
else
return 1
fi
}
require_clean_dev_update_checkout() {
[ "$(docker_mode)" = dev ] || return 0
root=$(dev_root)
require_command git
git_root=$(git -C "$root" rev-parse --show-toplevel 2>/dev/null) \
|| die "Docker development update requires a Git checkout at $root"
git_root=$(absolute_existing_dir "$git_root") \
|| die "could not resolve Git checkout root: $git_root"
[ "$git_root" = "$root" ] \
|| die "Docker development root $root must be the Git checkout root ($git_root)"
git_dir=$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null) \
|| die "could not resolve Git metadata for $root"
operation=$(dev_git_operation "$git_dir" 2>/dev/null || true)
if [ -n "$operation" ]; then
log "pi-web-docker: refusing to update the Docker development stack while a Git $operation is in progress: $root"
checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all 2>/dev/null || true)
if [ -n "$checkout_status" ]; then
log "Checkout status:"
printf '%s\n' "$checkout_status" >&2
fi
die "resolve or abort the Git $operation before rerunning pi-web-docker --dev update"
fi
checkout_status=$(git -C "$root" status --porcelain=v1 --untracked-files=all) \
|| die "could not inspect Git checkout status at $root"
if [ -n "$checkout_status" ]; then
log "pi-web-docker: refusing to update the Docker development stack because the checkout has uncommitted changes: $root"
log "Checkout status:"
printf '%s\n' "$checkout_status" >&2
die "commit, stash, or remove these changes before rerunning pi-web-docker --dev update; no files were changed"
fi
}
enforce_container_mode_match() {
is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || return 0
runtime_mode=${PI_WEB_DOCKER_MODE:-}
[ -n "$runtime_mode" ] || return 0
case "$runtime_mode" in
runtime|dev) ;;
*) die "unsupported PI_WEB_DOCKER_MODE inside PI WEB Docker runtime: $runtime_mode" ;;
esac
selected_mode=$(docker_mode)
[ "$runtime_mode" = "$selected_mode" ] || die "this PI WEB Docker container is in $runtime_mode mode; rerun pi-web-docker with the matching mode flag"
}
docker_compose() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
else
die "Docker Compose is required (docker compose plugin or docker-compose)"
fi
}
is_checkout_runtime_default_root() {
root=$1
[ -z "${PI_WEB_DOCKER_INSTALL_DIR:-}" ] || return 1
[ -f "$root/compose.dev.yml" ] || return 1
[ -f "$root/../package.json" ] || return 1
[ -f "$root/pi-web-docker" ] || return 1
}
runtime_command_hint() {
command=${command_name:-status}
printf '%s\n' "$command"
}
default_runtime_entrypoint_hint() {
if [ -n "${XDG_DATA_HOME:-}" ]; then
printf '%s\n' "$XDG_DATA_HOME/pi-web-docker/pi-web-docker"
elif [ -n "${HOME:-}" ]; then
printf '%s\n' "$HOME/.local/share/pi-web-docker/pi-web-docker"
else
printf '%s\n' '~/.local/share/pi-web-docker/pi-web-docker'
fi
}
die_missing_runtime_asset() {
root=$1
missing_path=$2
if is_checkout_runtime_default_root "$root"; then
command_hint=$(runtime_command_hint)
runtime_entrypoint=$(default_runtime_entrypoint_hint)
log "pi-web-docker: runtime install assets were not found in $root."
log "Missing generated asset: $missing_path"
log ""
log "You appear to be running this checkout's Docker command in runtime mode."
log "For development, use:"
log ""
log " ./docker/pi-web-docker --dev $command_hint"
log ""
log "For an installed runtime, use the installed command, usually:"
log ""
log " $runtime_entrypoint $command_hint"
log ""
log "Or set PI_WEB_DOCKER_INSTALL_DIR to your runtime install directory."
exit 1
fi
die "runtime install asset not found at $missing_path; run pi-web-docker install first"
}
require_runtime_compose_assets() {
root=$1
[ -f "$root/compose.yml" ] || die_missing_runtime_asset "$root" "$root/compose.yml"
[ -f "$root/compose.override.yml" ] || die_missing_runtime_asset "$root" "$root/compose.override.yml"
[ -f "$root/.env" ] || die_missing_runtime_asset "$root" "$root/.env"
}
runtime_compose() {
root=$(runtime_root)
require_runtime_compose_assets "$root"
project_name=$(required_env_file_value "$root/.env" COMPOSE_PROJECT_NAME)
(
cd "$root" || exit 1
docker_compose --project-name "$project_name" --env-file .env -f compose.yml -f compose.override.yml "$@"
)
}
dev_compose() {
root=$(dev_root)
wrapper=$root/docker/internal/dev/compose
[ -x "$wrapper" ] || die "dev Compose helper is not executable at $wrapper"
(
cd "$root" || exit 1
PI_WEB_DOCKER_ALLOW_ROOT=$PI_WEB_DOCKER_ALLOW_ROOT "$wrapper" "$@"
)
}
compose_for_install() {
case "$(docker_mode)" in
runtime) runtime_compose "$@" ;;
dev) dev_compose "$@" ;;
esac
}
entrypoint_installer() {
installer=$ENTRYPOINT_DIR/install.sh
[ -x "$installer" ] || die "installer not found or not executable at $installer"
printf '%s\n' "$installer"
}
runtime_installer() {
root=$1
installer=$root/install.sh
[ -x "$installer" ] || die "runtime installer not found or not executable at $installer; run pi-web-docker install first"
printf '%s\n' "$installer"
}
run_install() {
[ "$(docker_mode)" = runtime ] || die "install is only available in runtime mode; omit --dev"
installer=$(entrypoint_installer)
exec "$installer" "$@"
}
run_start() {
assert_no_args start "$@"
require_command docker
case "$(docker_mode)" in
runtime) runtime_compose up -d ;;
dev) dev_compose up -d --build ;;
esac
}
run_stop() {
assert_no_args stop "$@"
require_command docker
compose_for_install down
}
run_status() {
assert_no_args status "$@"
require_command docker
compose_for_install ps
}
run_restart_web() {
assert_no_args restart-web "$@"
compose_for_install restart web
}
run_restart_sessiond() {
assert_no_args restart-sessiond "$@"
compose_for_install restart sessiond
}
run_restart_all() {
assert_no_args restart "$@"
# Restart web first to mirror native service commands. Detached helpers keep
# running after sessiond restarts, so this is safe when launched from PI WEB.
compose_for_install restart web sessiond
}
run_runtime_host_update() {
root=$(runtime_root)
require_runtime_compose_assets "$root"
installer=$(runtime_installer "$root")
PI_WEB_DOCKER_REFRESH_ASSETS=1
export PI_WEB_DOCKER_REFRESH_ASSETS
exec "$installer" --install-dir "$root"
}
run_update() {
assert_no_args update "$@"
require_clean_dev_update_checkout
case "$(docker_mode)" in
runtime)
if ! is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
run_runtime_host_update
fi
cache_bust=${CACHE_BUST:-pi-web-docker-$(date -u +%Y%m%dT%H%M%SZ)}
log "Building PI WEB runtime image with CACHE_BUST=$cache_bust ..."
CACHE_BUST=$cache_bust runtime_compose build --pull --no-cache
log "Recreating PI WEB runtime services ..."
runtime_compose up -d --force-recreate --remove-orphans
;;
dev)
log "Rebuilding PI WEB development image ..."
dev_compose build --pull
log "Recreating PI WEB development services ..."
dev_compose up -d --force-recreate --remove-orphans
;;
esac
}
validate_logs_target() {
target=${1:-}
case "$target" in
""|web|sessiond) return 0 ;;
data-init)
[ "$(docker_mode)" = dev ] || die "logs data-init is only available with --dev"
return 0
;;
*) die "logs target must be web, sessiond, or data-init" ;;
esac
}
run_logs() {
assert_at_most_one_arg logs "$@"
require_command docker
target=${1:-}
validate_logs_target "$target"
if [ -n "$target" ]; then
compose_for_install logs -f "$target"
else
compose_for_install logs -f
fi
}
validate_shell_target() {
target=${1:-web}
case "$target" in
web|sessiond) printf '%s\n' "$target" ;;
*) die "shell target must be web or sessiond" ;;
esac
}
run_shell() {
assert_at_most_one_arg shell "$@"
require_command docker
target=$(validate_shell_target "${1:-web}")
compose_for_install exec "$target" bash
}
run_doctor() {
assert_no_args doctor "$@"
root=$(control_root)
printf 'PI WEB Docker mode: %s\n' "$(docker_mode)"
printf 'PI WEB Docker root: %s\n' "$root"
case "$(docker_mode)" in
runtime)
[ -f "$root/.env" ] && printf 'Runtime env: %s\n' "$root/.env" || printf 'Runtime env: missing (%s/.env)\n' "$root"
[ -f "$root/compose.yml" ] && printf 'Runtime Compose file: %s\n' "$root/compose.yml" || printf 'Runtime Compose file: missing (%s/compose.yml)\n' "$root"
[ -f "$root/compose.override.yml" ] && printf 'Runtime Compose override: %s\n' "$root/compose.override.yml" || printf 'Runtime Compose override: missing (%s/compose.override.yml)\n' "$root"
[ -x "$root/install.sh" ] && printf 'Runtime installer: %s\n' "$root/install.sh" || printf 'Runtime installer: missing or not executable (%s/install.sh)\n' "$root"
;;
dev)
dev_config=$root/.pi-web/docker-compose-dev.local.env
dev_env=$root/.pi-web/docker-compose-dev.generated.env
dev_override=$root/.pi-web/docker-compose-dev.host.generated.yml
dev_compose_file=$root/docker/compose.dev.yml
dev_wrapper=$root/docker/internal/dev/compose
[ -f "$dev_config" ] && printf 'Dev config: %s\n' "$dev_config" || printf 'Dev config: missing (%s)\n' "$dev_config"
[ -f "$dev_env" ] && printf 'Generated dev env: %s\n' "$dev_env" || printf 'Generated dev env: missing (%s)\n' "$dev_env"
[ -f "$dev_override" ] && printf 'Generated dev Compose override: %s\n' "$dev_override" || printf 'Generated dev Compose override: missing (%s)\n' "$dev_override"
[ -f "$dev_compose_file" ] && printf 'Dev Compose file: %s\n' "$dev_compose_file" || printf 'Dev Compose file: missing (%s)\n' "$dev_compose_file"
[ -x "$dev_wrapper" ] && printf 'Dev Compose helper: %s\n' "$dev_wrapper" || printf 'Dev Compose helper: missing or not executable (%s)\n' "$dev_wrapper"
if [ -f "$dev_env" ]; then
dev_uid=$(env_file_value "$dev_env" PI_WEB_UID 2>/dev/null || true)
dev_gid=$(env_file_value "$dev_env" PI_WEB_GID 2>/dev/null || true)
[ -n "$dev_uid" ] && printf 'Generated dev UID: %s\n' "$dev_uid"
[ -n "$dev_gid" ] && printf 'Generated dev GID: %s\n' "$dev_gid"
fi
;;
esac
if command -v docker >/dev/null 2>&1; then
docker --version || true
if docker compose version >/dev/null 2>&1; then
docker compose version || true
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose --version || true
else
printf '%s\n' 'Docker Compose: not found'
fi
else
printf '%s\n' 'Docker CLI: not found'
fi
}
run_cli() {
[ "$#" -gt 0 ] || die "cli requires pi-web arguments"
require_command docker
compose_for_install exec web pi-web "$@"
}
current_container_ref() {
if [ -n "${PI_WEB_DOCKER_CONTAINER_ID:-}" ]; then
printf '%s\n' "$PI_WEB_DOCKER_CONTAINER_ID"
return 0
fi
hostname_value=$(hostname 2>/dev/null || true)
[ -n "$hostname_value" ] || return 1
if docker container inspect "$hostname_value" >/dev/null 2>&1; then
printf '%s\n' "$hostname_value"
return 0
fi
return 1
}
helper_image() {
env_file=$1
case "$(docker_mode)" in
runtime)
image=$(env_file_value "$env_file" PI_WEB_IMAGE 2>/dev/null || true)
[ -n "$image" ] || image=${PI_WEB_IMAGE:-}
;;
dev)
image=$(env_file_value "$env_file" PI_WEB_DEV_IMAGE 2>/dev/null || true)
[ -n "$image" ] || image=${PI_WEB_DEV_IMAGE:-}
;;
esac
if [ -z "${image:-}" ]; then
image=${PI_WEB_DOCKER_HELPER_IMAGE:-}
fi
if [ -n "${image:-}" ]; then
printf '%s\n' "$image"
return 0
fi
container_ref=$(current_container_ref) || die "could not detect this Docker container; set PI_WEB_DOCKER_HELPER_IMAGE explicitly"
image=$(docker container inspect "$container_ref" --format '{{.Config.Image}}' 2>/dev/null || true)
[ -n "$image" ] && [ "$image" != "<no value>" ] || die "could not detect this container's image; set PI_WEB_DOCKER_HELPER_IMAGE explicitly"
printf '%s\n' "$image"
}
control_env_file() {
root=$1
case "$(docker_mode)" in
runtime) candidate=$root/.env ;;
dev) candidate=$root/.pi-web/docker-compose-dev.generated.env ;;
esac
[ -f "$candidate" ] || die "generated $(docker_mode) Docker env not found at $candidate; run pi-web-docker $(mode_flag || true) status or start from the host first"
printf '%s\n' "$candidate"
}
control_root_env_key() {
case "$(docker_mode)" in
runtime) printf '%s\n' PI_WEB_DOCKER_INSTALL_DIR ;;
dev) printf '%s\n' PI_WEB_DOCKER_DEV_REPO_ROOT ;;
esac
}
required_env_file_value() {
file=$1
key=$2
value=$(env_file_value "$file" "$key" 2>/dev/null || true)
[ -n "$value" ] || die "generated Docker env $file must define $key for detached helpers"
printf '%s\n' "$value"
}
cleanup_old_helpers() {
root=${1:-}
project_name=${2:-}
base_filters="label=pi-web.docker-helper=true"
if [ -n "$root" ] && [ -n "$project_name" ]; then
ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter "label=pi-web.docker-helper.project=$project_name" --filter status=exited 2>/dev/null || true)
elif [ -n "$root" ]; then
ids=$(docker ps -aq --filter "$base_filters" --filter "label=pi-web.docker-helper.root=$root" --filter status=exited 2>/dev/null || true)
else
ids=$(docker ps -aq --filter "$base_filters" --filter status=exited 2>/dev/null || true)
fi
old_ids=$(docker ps -aq --filter label=pi-web.docker-control=true --filter status=exited 2>/dev/null || true)
ids="$ids $old_ids"
for id in $ids; do
[ -n "$id" ] || continue
docker rm "$id" >/dev/null 2>&1 || true
done
}
stream_detached_helper_logs() {
helper_name=$1
printf '\n'
printf 'Streaming detached PI WEB Docker helper logs inline.\n'
printf 'If this terminal disconnects, the helper keeps running.\n'
printf 'Reconnect with: docker logs -f %s\n' "$helper_name"
printf '\n'
if docker logs -f "$helper_name"; then
logs_status=0
else
logs_status=$?
fi
if [ "$logs_status" -ne 0 ]; then
log "pi-web-docker: detached helper log streaming stopped with status $logs_status"
log "pi-web-docker: reconnect with: docker logs -f $helper_name"
return "$logs_status"
fi
helper_status=$(docker inspect --format '{{.State.ExitCode}}' "$helper_name" 2>/dev/null || true)
if is_unsigned_int "$helper_status" && [ "$helper_status" -ne 0 ]; then
log "pi-web-docker: detached helper exited with status $helper_status"
return "$helper_status"
fi
return 0
}
start_detached_helper() {
action=$1
is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached helpers are only available inside the PI WEB Docker runtime"
require_command docker
selected_mode=$(docker_mode)
root=$(control_root)
env_file=$(control_env_file "$root")
root_key=$(control_root_env_key)
env_root=$(required_env_file_value "$env_file" "$root_key")
[ "$env_root" = "$root" ] || die "generated Docker env $env_file has $root_key=$env_root, but selected $selected_mode root is $root"
project_name=$(required_env_file_value "$env_file" COMPOSE_PROJECT_NAME)
helper_uid=$(required_env_file_value "$env_file" PI_WEB_UID)
helper_gid=$(required_env_file_value "$env_file" PI_WEB_GID)
helper_docker_gid=$(required_env_file_value "$env_file" DOCKER_GID)
is_unsigned_int "$helper_uid" || die "generated Docker env must define numeric PI_WEB_UID for detached helpers"
is_unsigned_int "$helper_gid" || die "generated Docker env must define numeric PI_WEB_GID for detached helpers"
is_unsigned_int "$helper_docker_gid" || die "generated Docker env must define numeric DOCKER_GID for detached helpers"
if [ "$selected_mode" = dev ] && [ "$helper_uid" -eq 0 ] && [ "$PI_WEB_DOCKER_ALLOW_ROOT" != 1 ]; then
die "refusing to start a Docker development helper as root; regenerate dev env with a non-root PI_WEB_UID or retry with --allow-root if intentional"
fi
helper_user=$helper_uid:$helper_gid
helper_group_add=$helper_docker_gid
image=$(helper_image "$env_file")
container_ref=$(current_container_ref) || die "could not detect this Docker container; set PI_WEB_DOCKER_CONTAINER_ID to enable detached helpers"
cleanup_old_helpers "$root" "$project_name"
timestamp=$(date -u +%Y%m%d%H%M%S)
helper_name=pi-web-docker-$action-$timestamp-$$
generated_env_keys="PI_WEB_UID PI_WEB_GID DOCKER_GID PI_WEB_DOCKER_HOST_PROFILE HOSTEXEC_MODE PI_WEB_DOCKER_EXTRA_HOST_PATHS PI_WEB_DOCKER_DATA_DIR PI_WEB_DOCKER_INSTALL_DIR PI_WEB_DOCKER_DEV_REPO_ROOT PI_WEB_DOCKER_REF PI_WEB_BIND_ADDR PI_WEB_PORT PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT PI_WEB_VERSION PI_WEB_OPENSUSE_IMAGE PI_WEB_NODEJS_MAJOR PI_WEB_NODEJS_REPO PI_WEB_EXTRA_ZYPPER_PACKAGES PI_WEB_IMAGE PI_WEB_DEV_IMAGE COMPOSE_PROJECT_NAME HOSTEXEC_IMAGE PI_WEB_MAX_UPLOAD_BYTES"
set -- run -d \
--env-file "$env_file" \
--name "$helper_name" \
--label pi-web.docker-helper=true \
--label "pi-web.docker-helper.action=$action" \
--label "pi-web.docker-helper.mode=$selected_mode" \
--label "pi-web.docker-helper.root=$root" \
--label "pi-web.docker-helper.project=$project_name" \
--group-add "$helper_group_add" \
--user "$helper_user" \
--volumes-from "$container_ref" \
--workdir "$root" \
--env PI_WEB_DOCKER_RUNTIME=1 \
--env "PI_WEB_DOCKER_MODE=$selected_mode" \
--env "PI_WEB_DOCKER_ALLOW_ROOT=$PI_WEB_DOCKER_ALLOW_ROOT" \
--env "PI_WEB_DOCKER_HELPER_IMAGE=$image" \
--env "COMPOSE_PROJECT_NAME=$project_name"
# Keep --env-file for traceability, then pass parsed values explicitly so
# helper process env matches Compose dotenv semantics for quoted values.
for key in $generated_env_keys; do
if value=$(env_file_value "$env_file" "$key" 2>/dev/null); then
set -- "$@" --env "$key=$value"
fi
done
case "$selected_mode" in
runtime) set -- "$@" --env "PI_WEB_DOCKER_INSTALL_DIR=$root" ;;
dev) set -- "$@" --env "PI_WEB_DOCKER_DEV_REPO_ROOT=$root" ;;
esac
if [ "${CACHE_BUST+x}" = x ]; then
set -- "$@" --env "CACHE_BUST=$CACHE_BUST"
fi
set -- "$@" "$image" pi-web-docker
flag=$(mode_flag || true)
if [ -n "$flag" ]; then
set -- "$@" "$flag"
fi
if [ "$PI_WEB_DOCKER_ALLOW_ROOT" = 1 ]; then
set -- "$@" --allow-root
fi
set -- "$@" __run-detached "$action"
container_id=$(docker "$@") || die "could not start detached Docker helper"
printf 'Started detached PI WEB Docker helper: %s\n' "$helper_name"
printf 'Container ID: %s\n' "$container_id"
stream_detached_helper_logs "$helper_name"
}
run_detached_action() {
action=${1:-}
[ "$#" -eq 1 ] || die "__run-detached requires exactly one action"
is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached actions only run inside the PI WEB Docker runtime"
require_command docker
log "PI WEB Docker helper running action: $action"
case "$action" in
update) run_update ;;
restart) run_restart_all ;;
restart-web) run_restart_web ;;
restart-sessiond) run_restart_sessiond ;;
*) die "unsupported detached action: $action" ;;
esac
log "PI WEB Docker helper completed action: $action"
}
run_restart_or_update() {
action=$1
shift
assert_no_args "$action" "$@"
if is_truthy "${PI_WEB_DOCKER_RUNTIME:-}"; then
# Fail before scheduling a helper, then recheck inside the helper in
# run_update so a checkout change cannot race the detached operation.
if [ "$action" = update ]; then
require_clean_dev_update_checkout
fi
start_detached_helper "$action"
return 0
fi
case "$action" in
update) run_update ;;
restart) run_restart_all ;;
restart-web) run_restart_web ;;
restart-sessiond) run_restart_sessiond ;;
*) die "unsupported action: $action" ;;
esac
}
case "$command_name" in
help|-h|--help)
usage
;;
install)
run_install "$@"
;;
start|stop|status|logs|shell|doctor|cli|update|restart|restart-web|restart-sessiond|__run-detached)
enforce_dev_root_safety
enforce_container_mode_match
case "$command_name" in
start) run_start "$@" ;;
stop) run_stop "$@" ;;
status) run_status "$@" ;;
logs) run_logs "$@" ;;
shell) run_shell "$@" ;;
doctor) run_doctor "$@" ;;
cli) run_cli "$@" ;;
update|restart|restart-web|restart-sessiond) run_restart_or_update "$command_name" "$@" ;;
__run-detached) run_detached_action "$@" ;;
esac
;;
*)
usage >&2
die "unknown command: $command_name"
;;
esac