diff --git a/.changeset/docker-installer-update.md b/.changeset/docker-installer-update.md new file mode 100644 index 0000000..2bb5d9d --- /dev/null +++ b/.changeset/docker-installer-update.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a one-liner Docker install/update flow that refreshes local runtime assets, preserves persistent data, rebuilds without cache, and recreates the split PI WEB services. diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000..1bb8da0 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,5 @@ +# Keep the local-build runtime context small and avoid sending persistent data. +* +!Dockerfile +!bin/ +!bin/hostexec diff --git a/docker/install.sh b/docker/install.sh new file mode 100755 index 0000000..6800042 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env sh +set -eu + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "pi-web Docker installer: $*" + exit 1 +} + +usage() { + cat <<'EOF' +Usage: docker/install.sh [options] + +Install or update the local-build PI WEB Docker runtime. The installer refreshes +Docker assets in the install directory, writes host-specific .env values, +rebuilds the image without using cache, and recreates the split sessiond/web +services without deleting persistent data. + +Options: + --install-dir DIR Install directory (default: $XDG_DATA_HOME/pi-web-docker + or ~/.local/share/pi-web-docker) + --data-dir DIR Persistent data directory (default: INSTALL_DIR/data) + --bind-address ADDR Host bind address (default: 127.0.0.1) + --port PORT Host port (default: 8504) + --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest) + --pi-version VER npm @earendil-works/pi-coding-agent version pin + (default: latest) + --asset-dir DIR Copy Docker assets from a local docker/ directory + --asset-ref REF Fetch Docker assets from a Git ref (default: main) + --skip-compose Write assets/.env but skip build and service recreate + -h, --help Show this help + +Environment variables with the same names used in .env may also be set before +running the installer, for example: + + PI_WEB_VERSION=1.202606.4 PI_VERSION=0.79.1 docker/install.sh +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --install-dir) + [ "$#" -ge 2 ] || die "--install-dir requires a value" + PI_WEB_DOCKER_HOME=$2 + shift 2 + ;; + --data-dir) + [ "$#" -ge 2 ] || die "--data-dir requires a value" + PI_WEB_DOCKER_DATA_DIR=$2 + shift 2 + ;; + --bind-address) + [ "$#" -ge 2 ] || die "--bind-address requires a value" + PI_WEB_BIND_ADDR=$2 + shift 2 + ;; + --port) + [ "$#" -ge 2 ] || die "--port requires a value" + PI_WEB_PORT=$2 + shift 2 + ;; + --pi-web-version) + [ "$#" -ge 2 ] || die "--pi-web-version requires a value" + PI_WEB_VERSION=$2 + shift 2 + ;; + --pi-version) + [ "$#" -ge 2 ] || die "--pi-version requires a value" + PI_VERSION=$2 + shift 2 + ;; + --asset-dir) + [ "$#" -ge 2 ] || die "--asset-dir requires a value" + PI_WEB_DOCKER_ASSET_DIR=$2 + shift 2 + ;; + --asset-ref) + [ "$#" -ge 2 ] || die "--asset-ref requires a value" + PI_WEB_DOCKER_REF=$2 + shift 2 + ;; + --skip-compose) + PI_WEB_DOCKER_SKIP_COMPOSE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +absolute_dir() { + dir=$1 + mkdir -p "$dir" || return 1 + (cd "$dir" && pwd -P) +} + +absolute_existing_dir() { + dir=$1 + (cd "$dir" && pwd -P) +} + +path_from_base() { + base=$1 + path=$2 + case "$path" in + /*) printf '%s\n' "$path" ;; + *) printf '%s/%s\n' "$base" "$path" ;; + esac +} + +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" +} + +existing_env_value() { + key=$1 + [ -f "$env_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 } + ' "$env_file") || return 1 + strip_wrapping_quotes "$raw" +} + +value_from_env_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + else + printf '%s\n' "$default_value" + fi +} + +value_from_env_or_existing_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + elif existing=$(existing_env_value "$key"); then + printf '%s\n' "$existing" + else + printf '%s\n' "$default_value" + fi +} + +require_non_empty() { + name=$1 + value=$2 + [ -n "$value" ] || die "$name must not be empty" +} + +detect_docker_gid() { + if [ -S /var/run/docker.sock ]; then + if gid=$(stat -c '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + if gid=$(stat -f '%g' /var/run/docker.sock 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + fi + + if command -v getent >/dev/null 2>&1; then + if gid=$(getent group docker | awk -F: 'NR == 1 { print $3 }'); then + if [ -n "$gid" ]; then + printf '%s\n' "$gid" + return 0 + fi + fi + fi + + printf '0\n' +} + +fetch_url() { + url=$1 + target=$2 + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$target" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$target" "$url" + else + die "curl or wget is required to fetch Docker assets" + fi +} + +find_local_asset_dir() { + if [ -f "${0:-}" ]; then + candidate_dir=$(dirname "$0") + if candidate_dir=$(absolute_existing_dir "$candidate_dir" 2>/dev/null); then + if [ -f "$candidate_dir/Dockerfile" ] && [ -f "$candidate_dir/compose.yml" ]; then + printf '%s\n' "$candidate_dir" + return 0 + fi + fi + fi + + return 1 +} + +write_asset() { + rel_path=$1 + mode=$2 + target=$install_dir/$rel_path + temp_target=$target.$$ + mkdir -p "$(dirname "$target")" + + if [ -n "$asset_dir" ]; then + [ -f "$asset_dir/$rel_path" ] || die "missing Docker asset: $asset_dir/$rel_path" + cp "$asset_dir/$rel_path" "$temp_target" + else + fetch_url "$asset_base/$rel_path" "$temp_target" + fi + + chmod "$mode" "$temp_target" + mv "$temp_target" "$target" +} + +compose_cmd() { + 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 +} + +if [ -n "${XDG_DATA_HOME:-}" ]; then + default_data_home=$XDG_DATA_HOME +elif [ -n "${HOME:-}" ]; then + default_data_home=$HOME/.local/share +else + default_data_home= +fi + +default_install_dir= +if [ -n "$default_data_home" ]; then + default_install_dir=$default_data_home/pi-web-docker +fi +install_dir_input=${PI_WEB_DOCKER_HOME:-$default_install_dir} +[ -n "$install_dir_input" ] || die "HOME, XDG_DATA_HOME, or PI_WEB_DOCKER_HOME must be set" +install_dir=$(absolute_dir "$install_dir_input") || die "could not create install directory" +env_file=$install_dir/.env + +if [ "${PI_WEB_DOCKER_ASSET_DIR+x}" = x ]; then + asset_dir=$(absolute_existing_dir "$PI_WEB_DOCKER_ASSET_DIR") || die "asset directory does not exist: $PI_WEB_DOCKER_ASSET_DIR" + asset_base= + log "Using Docker assets from $asset_dir" +elif asset_dir=$(find_local_asset_dir 2>/dev/null); then + asset_base= + log "Using Docker assets from $asset_dir" +else + asset_ref=${PI_WEB_DOCKER_REF:-main} + asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker} + asset_dir= + log "Fetching Docker assets from $asset_base" +fi + +write_asset Dockerfile 0644 +write_asset compose.yml 0644 +write_asset .dockerignore 0644 +write_asset install.sh 0755 +write_asset bin/hostexec 0755 + +pi_web_uid=$(value_from_env_or_default PI_WEB_UID "$(id -u)") +pi_web_gid=$(value_from_env_or_default PI_WEB_GID "$(id -g)") +docker_gid=$(value_from_env_or_default DOCKER_GID "$(detect_docker_gid)") + +raw_data_dir=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_DATA_DIR "$install_dir/data") +data_dir=$(absolute_dir "$(path_from_base "$install_dir" "$raw_data_dir")") || die "could not create data directory" + +pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0.0.1) +pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504) +pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest) +pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest) +pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local) +hostexec_image=$(value_from_env_or_existing_or_default HOSTEXEC_IMAGE alpine:3.22) +pi_web_max_upload_bytes=$(value_from_env_or_existing_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) + +require_non_empty PI_WEB_UID "$pi_web_uid" +require_non_empty PI_WEB_GID "$pi_web_gid" +require_non_empty DOCKER_GID "$docker_gid" +require_non_empty PI_WEB_DOCKER_DATA_DIR "$data_dir" +require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" +require_non_empty PI_WEB_PORT "$pi_web_port" +require_non_empty PI_WEB_VERSION "$pi_web_version" +require_non_empty PI_VERSION "$pi_version" +require_non_empty PI_WEB_IMAGE "$pi_web_image" +require_non_empty HOSTEXEC_IMAGE "$hostexec_image" +require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes" + +umask 077 +temp_env=$env_file.$$ +cat >"$temp_env" </dev/null 2>&1; then + die "docker CLI is required" +fi + +if ! docker info >/dev/null 2>&1; then + die "docker daemon is not reachable by this user" +fi + +cache_bust=${CACHE_BUST:-install-$(date -u +%Y%m%dT%H%M%SZ)} + +log "" +log "WARNING: updating recreates the PI WEB Docker session daemon." +log "Active Pi agent runtimes inside this Docker install can stop; update while sessions are idle." +log "Persistent data under $data_dir is kept. The installer does not run 'docker compose down -v'." +log "" +log "Building $pi_web_image with --pull --no-cache (CACHE_BUST=$cache_bust) ..." +( + cd "$install_dir" + CACHE_BUST=$cache_bust compose_cmd -f compose.yml build --pull --no-cache +) + +log "Recreating split PI WEB Docker services ..." +( + cd "$install_dir" + compose_cmd -f compose.yml up -d --force-recreate --remove-orphans +) + +log "" +log "PI WEB Docker runtime is ready: http://$pi_web_bind_addr:$pi_web_port" +log "Install directory: $install_dir" +log "To update later, re-run this installer." +( + cd "$install_dir" + compose_cmd -f compose.yml ps +)