From 2987b6f282bae068cea9466a1ede4af0eb7e8d6b Mon Sep 17 00:00:00 2001 From: Pi Web Agent Date: Sat, 27 Jun 2026 23:05:40 +0000 Subject: [PATCH] feat(docker): add fail-closed host profiles --- .changeset/docker-host-profiles.md | 5 + docker/README.md | 102 ++++++--- docker/bin/hostexec | 14 ++ docker/compose.dev.yml | 17 +- docker/compose.yml | 17 +- docker/install.sh | 100 +++++--- docker/lib/host-profile.sh | 355 +++++++++++++++++++++++++++++ docker/scripts/docker-compose-dev | 236 +++++++++++++++++++ 8 files changed, 745 insertions(+), 101 deletions(-) create mode 100644 .changeset/docker-host-profiles.md create mode 100644 docker/lib/host-profile.sh create mode 100755 docker/scripts/docker-compose-dev diff --git a/.changeset/docker-host-profiles.md b/.changeset/docker-host-profiles.md new file mode 100644 index 0000000..4137426 --- /dev/null +++ b/.changeset/docker-host-profiles.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add fail-closed Docker host profile detection with Linux and Docker Desktop for Mac Compose overrides, and split Docker dev configuration into a user-editable local env file plus generated Compose inputs. diff --git a/docker/README.md b/docker/README.md index 22e75d8..6e7c5ac 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,12 +13,11 @@ No prebuilt image or registry is required in either mode. The Docker setup is for trusted single-user or trusted-admin environments. It is not a sandbox and it is not suitable for untrusted multi-tenant use. -By design, the runtime containers get deliberate host access so PI WEB agents can work on real server paths: +By design, the runtime containers get deliberate host access so PI WEB agents can work on real host paths: -- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the host. -- `/srv`, `/opt`, and `/home` are mounted read/write. -- `/` is mounted read-only at `/host` for inspection. -- `hostexec` can start a temporary privileged helper container and run explicit commands in the host namespaces. Commands run as the container user by default, and `hostexec --root` can still run explicit administrative commands as root. +- `/var/run/docker.sock` is mounted into the containers. The Docker socket is root-equivalent on the Docker host. +- On native Linux Docker Engine, existing `/home`, `/srv`, and `/opt` paths are mounted read/write, `/` is mounted read-only at `/host` for inspection, and `hostexec` can run explicit commands in the Linux host namespaces. +- On Docker Desktop for Mac, existing `/Users`, `/Volumes`, and `/private` paths are mounted read/write. `hostexec` is disabled because Docker Desktop containers run inside a Linux VM and cannot enter native macOS namespaces. Only install this on machines where the PI WEB user, the selected workspaces, and the browser/API clients are trusted. Review scripts before piping them to `sh` if you do not already trust this repository. @@ -32,10 +31,15 @@ The web port is bound to `127.0.0.1` by default. Do **not** expose PI WEB direct Prerequisites: -- Docker Engine with the Compose plugin (`docker compose`) or `docker-compose`; +- one supported Docker host profile: + - native Linux Docker Engine using the local `/var/run/docker.sock`; or + - Docker Desktop for Mac; +- Docker Compose through the `docker compose` plugin or `docker-compose`; - a user that can talk to the Docker daemon; - `curl` or `wget` for the one-liner installer. +The installer fails closed on unknown or unsupported Docker setups, such as remote Docker contexts, `DOCKER_HOST` overrides outside the supported local Unix socket, rootless/alternate Linux sockets, Docker Desktop for Linux, Colima, or OrbStack. It prints the detected host OS, Docker context, endpoint, `DOCKER_HOST`, socket source, and Docker OS before exiting, and it does not recreate services. + Install or update with the same command: ```bash @@ -96,6 +100,8 @@ Common environment variables written to `.env`: | `PI_WEB_UID`, `PI_WEB_GID` | user/group used by the runtime containers and the image's `pi-web` account | | `DOCKER_GID` | extra group used for Docker socket access | | `PI_WEB_DOCKER_DATA_DIR` | persistent data bind mount | +| `PI_WEB_DOCKER_HOST_PROFILE`, `HOSTEXEC_MODE` | detected host profile and host-command capability toggle | +| `PI_WEB_DOCKER_EXTRA_HOST_PATHS` | optional whitespace-separated existing absolute paths to bind-mount read/write at the same path | | `PI_WEB_BIND_ADDR`, `PI_WEB_PORT` | host bind address and port | | `PI_WEB_VERSION` | npm version/range for `@jmfederico/pi-web` | | `PI_VERSION` | npm version/range for `@earendil-works/pi-coding-agent` | @@ -106,7 +112,9 @@ Common environment variables written to `.env`: | `PI_WEB_IMAGE` | local image tag to build and run | | `HOSTEXEC_IMAGE` | helper image used by `hostexec` | -Host-derived IDs are refreshed on rerun unless you explicitly override them. User-facing values such as data directory, bind address, port, image names, upload limit, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +Host-derived IDs and the Docker host profile are refreshed on rerun unless you explicitly override the IDs. User-facing values such as data directory, bind address, port, image names, upload limit, extra host paths, base image, Node.js settings, extra packages, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. + +The installer also writes a generated `compose.override.yml` in the install directory. Docker Compose loads it automatically for ordinary `docker compose ...` commands run from that directory; re-run the installer instead of editing that generated file by hand. ### Base image and tooling @@ -204,9 +212,11 @@ If you use a reverse proxy, keep the container bound to localhost or a private a ## `hostexec` examples -`hostexec [--root] ` is the only host command bridge provided by this Docker setup. It intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. +`hostexec [--root] ` is the native Linux host command bridge provided by this Docker setup. It is enabled only for the `linux-native-docker` profile and intentionally does not abstract package managers or detect distributions. By default, commands run as the same numeric user/group as the PI WEB container. Use `--root` only for administrative host commands. -Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container: +On Docker Desktop for Mac, `hostexec` exits with a clear disabled message because the Docker daemon and containers run inside a Linux VM, not in native macOS namespaces. Docker CLI and Docker Compose commands still work through the mounted Docker socket. + +Run it from a PI WEB session, a PI WEB terminal, or by execing into the runtime container on native Linux: ```bash hostexec uname -a @@ -223,7 +233,7 @@ cd ~/.local/share/pi-web-docker docker compose exec web hostexec uname -a ``` -`hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. +On native Linux, `hostexec` starts a temporary privileged helper container through the mounted Docker socket, enters the host namespaces with `nsenter`, and runs exactly the command you passed. Treat it like privileged host access even when the final command drops back to the container user. ## Development Docker setup @@ -232,24 +242,42 @@ Use this mode when developing PI WEB from this checkout. It bind-mounts the sour - `sessiond` runs `npm run start:sessiond` as the long-lived owner of Pi agent runtimes; - `web` runs `npm run dev:web` and `npm run dev:client` so API, plugin, and Vite changes can autoreload without restarting `sessiond`. -From the repository root: +From the repository root, use the dev Compose wrapper so the same fail-closed host profile detection is applied as runtime mode: ```bash -export PI_WEB_UID=$(id -u) -export PI_WEB_GID=$(id -g) -export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) -# Optional; this is also the default dev data path. -export PI_WEB_DOCKER_DATA_DIR=${PI_WEB_DOCKER_DATA_DIR:-$HOME/.local/share/pi-web-docker/data} -mkdir -p "$PI_WEB_DOCKER_DATA_DIR" - -docker compose -f docker/compose.dev.yml up --build +./docker/scripts/docker-compose-dev up --build ``` -If you already ran the runtime installer, you can reuse its `.env` so dev mode gets the same UID/GID, Docker group, ports, and data directory: +The wrapper creates `.pi-web/docker-compose-dev.local.env` on first run, writes `.pi-web/docker-compose-dev.generated.env` and `.pi-web/docker-compose-dev.host.generated.yml`, then runs Docker Compose with `docker/compose.dev.yml` plus that generated host override. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the wrapper. + +Values used by the wrapper are resolved in this order: + +1. current shell environment, for this run only; +2. `.pi-web/docker-compose-dev.local.env`; +3. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`; +4. built-in defaults. + +If you already ran the runtime installer, dev mode therefore reuses defaults such as UID/GID, Docker group, data directory, extra host paths, image build inputs, upload limit, and bind address unless you set a more specific value in the shell or `.local.env`. If an older `.pi-web/docker-compose-dev.env` exists, the first run copies its dev bind/port values into `.local.env` so previous local exposure settings are easy to see and edit. + +To expose the dev API and Vite UI beyond localhost persistently, edit `.pi-web/docker-compose-dev.local.env`: + +```dotenv +PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 +PI_WEB_DEV_BIND_ADDR=0.0.0.0 +``` + +For temporary overrides, prefix the wrapper command: ```bash -docker compose --env-file "$HOME/.local/share/pi-web-docker/.env" \ - -f docker/compose.dev.yml up --build +PI_WEB_DEV_API_BIND_ADDR=0.0.0.0 \ +PI_WEB_DEV_BIND_ADDR=0.0.0.0 \ + ./docker/scripts/docker-compose-dev up -d --build +``` + +You can run the dev stack in the background with: + +```bash +./docker/scripts/docker-compose-dev up -d --build ``` Open the Vite UI at . The dev API is published on . @@ -257,16 +285,16 @@ Open the Vite UI at . The dev API is published on `, `/srv/`, or `/opt/`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for `/home/core/`. +For sessions to appear under the same workspace in both modes, use the same project path in PI WEB. On Linux, prefer host-mounted paths such as `/home/core/`, `/srv/`, or `/opt/`. On Mac, prefer paths under `/Users//...`. The dev container also exposes this checkout as `/workspace` so the PI WEB dev server can run from it, but sessions started against `/workspace` are organized under that different working-directory path and will not line up with runtime sessions for the host-mounted path. When `package-lock.json` changes, rebuild the dev image and recreate the `node_modules` volume so the bind-mounted checkout sees the new dependency tree: ```bash -docker compose -f docker/compose.dev.yml down +./docker/scripts/docker-compose-dev down docker volume rm pi-web-dev_node_modules -docker compose -f docker/compose.dev.yml up --build +./docker/scripts/docker-compose-dev up --build ``` ## Local checkout validation @@ -309,11 +337,17 @@ PI_WEB_DOCKER_HOME="$(mktemp -d)" \ sh docker/install.sh ``` -For Compose validation: +For Compose validation after generating host overrides: ```bash -docker compose -f docker/compose.yml config -docker compose -f docker/compose.dev.yml config +tmp_home=$(mktemp -d) +PI_WEB_DOCKER_SKIP_COMPOSE=1 \ +PI_WEB_DOCKER_ASSET_DIR="$PWD/docker" \ +PI_WEB_DOCKER_HOME="$tmp_home" \ +sh docker/install.sh + +docker compose -f "$tmp_home/compose.yml" -f "$tmp_home/compose.override.yml" config +./docker/scripts/docker-compose-dev config docker build --check -f docker/Dockerfile docker docker build --check -f docker/Dockerfile.dev . ``` diff --git a/docker/bin/hostexec b/docker/bin/hostexec index c46a3d0..182cba0 100755 --- a/docker/bin/hostexec +++ b/docker/bin/hostexec @@ -38,6 +38,20 @@ if [ "$#" -eq 0 ]; then exit 64 fi +hostexec_mode="${HOSTEXEC_MODE:-nsenter}" +case "$hostexec_mode" in + nsenter) ;; + disabled|none) + echo "hostexec: disabled for this Docker host profile" >&2 + echo "hostexec: on Docker Desktop for Mac, containers run inside a Linux VM and cannot enter native macOS namespaces" >&2 + exit 69 + ;; + *) + echo "hostexec: unsupported HOSTEXEC_MODE: $hostexec_mode" >&2 + exit 64 + ;; +esac + if ! command -v docker >/dev/null 2>&1; then echo "hostexec: docker CLI not found in this container" >&2 exit 127 diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index fec1abc..4ffb009 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -18,6 +18,7 @@ x-pi-web-dev-environment: &pi-web-dev-environment PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock PI_CODING_AGENT_DIR: /data/pi-agent HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} + HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled} PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} NPM_CONFIG_UPDATE_NOTIFIER: "false" NPM_CONFIG_CACHE: /data/npm-cache @@ -35,22 +36,6 @@ x-pi-web-dev-volumes: &pi-web-dev-volumes source: node_modules target: /workspace/node_modules - *pi-web-dev-data-volume - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - - type: bind - source: /srv - target: /srv - - type: bind - source: /opt - target: /opt - - type: bind - source: /home - target: /home - - type: bind - source: / - target: /host - read_only: true services: data-init: diff --git a/docker/compose.yml b/docker/compose.yml index 9e37247..0ed4d45 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -21,28 +21,13 @@ x-pi-web-environment: &pi-web-environment PI_WEB_SESSIOND_SOCKET: /data/pi-web/sessiond.sock PI_CODING_AGENT_DIR: /data/pi-agent HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22} + HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled} PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864} x-pi-web-volumes: &pi-web-volumes - type: bind source: ${PI_WEB_DOCKER_DATA_DIR:-./data} target: /data - - type: bind - source: /var/run/docker.sock - target: /var/run/docker.sock - - type: bind - source: /srv - target: /srv - - type: bind - source: /opt - target: /opt - - type: bind - source: /home - target: /home - - type: bind - source: / - target: /host - read_only: true services: sessiond: diff --git a/docker/install.sh b/docker/install.sh index cfd902c..5836c62 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -39,6 +39,12 @@ Options: --skip-compose Write assets/.env but skip build and service recreate -h, --help Show this help +Progressive host setup: + The installer supports native Linux Docker Engine and Docker Desktop for Mac. + Unknown Docker hosts fail closed before services are recreated. Set + PI_WEB_DOCKER_EXTRA_HOST_PATHS to a whitespace-separated list of additional + existing absolute directories to bind-mount at the same path in the containers. + Environment variables with the same names used in .env may also be set before running the installer, for example: @@ -223,30 +229,6 @@ dotenv_quote() { printf '"%s"' "$(printf '%s' "$value" | sed 's/[\\"]/\\&/g')" } -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 @@ -292,13 +274,11 @@ write_asset() { } 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 + pi_web_docker_compose "$@" +} + +run_runtime_compose() { + compose_cmd -f compose.yml -f compose.override.yml "$@" } if [ -n "${XDG_DATA_HOME:-}" ]; then @@ -332,12 +312,37 @@ else log "Fetching Docker assets from $asset_base" fi +profile_helper_temp= +cleanup_profile_helper() { + [ -z "$profile_helper_temp" ] || rm -f "$profile_helper_temp" +} +trap cleanup_profile_helper EXIT + +if [ -n "$asset_dir" ]; then + profile_helper=$asset_dir/lib/host-profile.sh + [ -f "$profile_helper" ] || die "missing Docker asset: $profile_helper" +else + profile_helper_temp=${TMPDIR:-/tmp}/pi-web-host-profile.$$ + fetch_url "$asset_base/lib/host-profile.sh" "$profile_helper_temp" + profile_helper=$profile_helper_temp +fi + +# shellcheck source=lib/host-profile.sh +# shellcheck disable=SC1091 +. "$profile_helper" + +if ! pi_web_docker_host_detect_profile; then + pi_web_docker_host_print_detection_failure + die "refusing to install on an unsupported or unknown Docker host setup" +fi + write_asset Dockerfile 0644 write_asset compose.yml 0644 write_asset .dockerignore 0644 write_asset install.sh 0755 write_asset bin/hostexec 0755 write_asset bin/install-opensuse-base 0755 +write_asset lib/host-profile.sh 0644 custom_image_hooks_dir=$install_dir/custom-image.d mkdir -p "$custom_image_hooks_dir" || die "could not create custom image hooks directory: $custom_image_hooks_dir" @@ -347,7 +352,9 @@ fi 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)") +docker_gid=$(value_from_env_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)") +pi_web_host_profile=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE +hostexec_mode=$PI_WEB_DETECTED_HOSTEXEC_MODE 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" @@ -363,10 +370,13 @@ pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTR 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) +pi_web_extra_host_paths=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "") 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_HOST_PROFILE "$pi_web_host_profile" +require_non_empty HOSTEXEC_MODE "$hostexec_mode" 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" @@ -380,6 +390,11 @@ require_non_empty HOSTEXEC_IMAGE "$hostexec_image" require_non_empty PI_WEB_MAX_UPLOAD_BYTES "$pi_web_max_upload_bytes" pi_web_extra_zypper_packages_env=$(dotenv_quote "$pi_web_extra_zypper_packages") +pi_web_extra_host_paths_env=$(dotenv_quote "$pi_web_extra_host_paths") +compose_override_file=$install_dir/compose.override.yml +if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths"; then + die "could not write host-specific Compose override" +fi umask 077 temp_env=$env_file.$$ @@ -393,6 +408,11 @@ PI_WEB_UID=$pi_web_uid PI_WEB_GID=$pi_web_gid DOCKER_GID=$docker_gid +# Detected Docker host profile and host capability toggles. +PI_WEB_DOCKER_HOST_PROFILE=$pi_web_host_profile +HOSTEXEC_MODE=$hostexec_mode +PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths_env + # Persistent data and localhost-only default exposure. PI_WEB_DOCKER_DATA_DIR=$data_dir PI_WEB_BIND_ADDR=$pi_web_bind_addr @@ -417,6 +437,16 @@ mv "$temp_env" "$env_file" log "Wrote Docker assets to $install_dir" log "Wrote runtime environment to $env_file" +log "Wrote host Compose override to $compose_override_file" +log "Selected PI WEB Docker host profile: $pi_web_host_profile" +case "$pi_web_host_profile" in + linux-native-docker) + log "Enabled Linux host mounts and hostexec namespace bridge." + ;; + mac-docker-desktop) + log "Enabled Docker Desktop for Mac project mounts. hostexec is disabled because containers cannot enter native macOS namespaces." + ;; +esac log "Persistent PI WEB Docker data: $data_dir" log "Custom image hooks: $custom_image_hooks_dir" @@ -443,13 +473,13 @@ 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 + CACHE_BUST=$cache_bust run_runtime_compose 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 + run_runtime_compose up -d --force-recreate --remove-orphans ) log "" @@ -458,5 +488,5 @@ log "Install directory: $install_dir" log "To update later, re-run this installer." ( cd "$install_dir" - compose_cmd -f compose.yml ps + run_runtime_compose ps ) diff --git a/docker/lib/host-profile.sh b/docker/lib/host-profile.sh new file mode 100644 index 0000000..5158e36 --- /dev/null +++ b/docker/lib/host-profile.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 + +pi_web_docker_host_yaml_quote() { + value=$1 + escaped=$(printf '%s' "$value" | sed "s/'/''/g") + printf "'%s'" "$escaped" +} + +pi_web_docker_host_socket_path_from_endpoint() { + endpoint=$1 + case "$endpoint" in + unix://*) printf '%s\n' "${endpoint#unix://}" ;; + *) return 1 ;; + esac +} + +pi_web_docker_host_mac_desktop_socket_path() { + [ -n "${HOME:-}" ] || return 1 + printf '%s/.docker/run/docker.sock\n' "$HOME" +} + +pi_web_docker_host_endpoint_is_linux_expected() { + endpoint=$1 + [ "$endpoint" = unix:///var/run/docker.sock ] +} + +pi_web_docker_host_endpoint_is_mac_expected() { + endpoint=$1 + if ! socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$endpoint" 2>/dev/null); then + return 1 + fi + + case "$socket_path" in + /var/run/docker.sock) + return 0 + ;; + esac + + if mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null); then + [ "$socket_path" = "$mac_socket_path" ] && return 0 + fi + + return 1 +} + +pi_web_docker_host_socket_source_for_endpoint() { + endpoint=$1 + pi_web_docker_host_socket_path_from_endpoint "$endpoint" +} + +pi_web_docker_host_detect_docker_gid() { + case "${PI_WEB_DETECTED_DOCKER_HOST_PROFILE:-}" in + mac-docker-desktop) + printf '0\n' + return 0 + ;; + esac + + socket_path=/var/run/docker.sock + if [ -n "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-}" ]; then + if detected_socket_path=$(pi_web_docker_host_socket_path_from_endpoint "$PI_WEB_DETECTED_DOCKER_ENDPOINT" 2>/dev/null); then + socket_path=$detected_socket_path + fi + fi + + if [ -S "$socket_path" ]; then + if gid=$(stat -c '%g' "$socket_path" 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + if gid=$(stat -f '%g' "$socket_path" 2>/dev/null); then + printf '%s\n' "$gid" + return 0 + fi + fi + + 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' +} + +pi_web_docker_host_detect_profile() { + PI_WEB_DETECTED_HOST_OS=$(uname -s 2>/dev/null || printf 'unknown') + PI_WEB_DETECTED_DOCKER_CONTEXT= + PI_WEB_DETECTED_DOCKER_ENDPOINT= + PI_WEB_DETECTED_DOCKER_HOST_ENV=${DOCKER_HOST:-} + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT= + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE= + PI_WEB_DETECTED_DOCKER_OS= + PI_WEB_DETECTED_DOCKER_HOST_PROFILE= + PI_WEB_DETECTED_HOSTEXEC_MODE=disabled + PI_WEB_DOCKER_HOST_PROFILE_ERROR= + + if ! command -v docker >/dev/null 2>&1; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker CLI is required" + return 1 + fi + + PI_WEB_DETECTED_DOCKER_CONTEXT=$(docker context show 2>/dev/null || printf 'unknown') + if [ -n "$PI_WEB_DETECTED_DOCKER_CONTEXT" ] && [ "$PI_WEB_DETECTED_DOCKER_CONTEXT" != unknown ]; then + PI_WEB_DETECTED_DOCKER_ENDPOINT=$(docker context inspect "$PI_WEB_DETECTED_DOCKER_CONTEXT" --format '{{if .Endpoints.docker}}{{.Endpoints.docker.Host}}{{end}}' 2>/dev/null || printf '') + fi + + case "$PI_WEB_DETECTED_HOST_OS" in + Linux) + if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require DOCKER_HOST to be unset or exactly unix:///var/run/docker.sock, not $PI_WEB_DETECTED_DOCKER_HOST_ENV" + return 1 + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_linux_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require the local /var/run/docker.sock Docker context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT" + return 1 + fi + + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=${PI_WEB_DETECTED_DOCKER_HOST_ENV:-$PI_WEB_DETECTED_DOCKER_ENDPOINT} + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock + if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="native Linux installs require a local Docker socket at /var/run/docker.sock" + return 1 + fi + ;; + Darwin) + if [ -n "$PI_WEB_DETECTED_DOCKER_ENDPOINT" ] && ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket context, not $PI_WEB_DETECTED_DOCKER_ENDPOINT" + return 1 + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_HOST_ENV" ]; then + if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_HOST_ENV"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require DOCKER_HOST to be unset or a Docker Desktop local Unix socket, not $PI_WEB_DETECTED_DOCKER_HOST_ENV" + return 1 + fi + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_HOST_ENV + else + PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT=$PI_WEB_DETECTED_DOCKER_ENDPOINT + fi + + if [ -n "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" ]; then + if ! pi_web_docker_host_endpoint_is_mac_expected "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT"; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs require a Docker Desktop local Unix socket, not ${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" + return 1 + fi + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$(pi_web_docker_host_socket_source_for_endpoint "$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT") || return 1 + elif mac_socket_path=$(pi_web_docker_host_mac_desktop_socket_path 2>/dev/null) && [ -S "$mac_socket_path" ]; then + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=$mac_socket_path + else + PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE=/var/run/docker.sock + fi + + if [ ! -S "$PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" ]; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop socket is not accessible at $PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE" + return 1 + fi + ;; + *) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="unsupported host OS: $PI_WEB_DETECTED_HOST_OS" + return 1 + ;; + esac + + if ! docker info >/dev/null 2>&1; then + PI_WEB_DOCKER_HOST_PROFILE_ERROR="docker daemon is not reachable by this user" + return 1 + fi + PI_WEB_DETECTED_DOCKER_OS=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || printf '') + + case "$PI_WEB_DETECTED_HOST_OS" in + Linux) + case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS" in + *desktop-linux*|*"Docker Desktop"*) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="Docker Desktop on Linux is not supported by this installer because it runs containers inside a VM instead of the native Linux host" + return 1 + ;; + esac + + PI_WEB_DETECTED_DOCKER_HOST_PROFILE=linux-native-docker + PI_WEB_DETECTED_HOSTEXEC_MODE=nsenter + ;; + Darwin) + case "$PI_WEB_DETECTED_DOCKER_CONTEXT:$PI_WEB_DETECTED_DOCKER_OS:$PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT" in + *desktop-linux*|*"Docker Desktop"*|*"/.docker/run/docker.sock"*) + PI_WEB_DETECTED_DOCKER_HOST_PROFILE=mac-docker-desktop + PI_WEB_DETECTED_HOSTEXEC_MODE=disabled + ;; + *) + PI_WEB_DOCKER_HOST_PROFILE_ERROR="macOS installs currently require Docker Desktop; detected context '$PI_WEB_DETECTED_DOCKER_CONTEXT' endpoint '${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}'" + return 1 + ;; + esac + ;; + esac + + return 0 +} + +pi_web_docker_host_write_volume() { + source_path=$1 + target_path=$2 + read_only=${3:-false} + + { + printf ' - type: bind\n' + printf ' source: %s\n' "$(pi_web_docker_host_yaml_quote "$source_path")" + printf ' target: %s\n' "$(pi_web_docker_host_yaml_quote "$target_path")" + if [ "$read_only" = true ]; then + printf ' read_only: true\n' + fi + } >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" +} + +pi_web_docker_host_write_existing_volume() { + source_path=$1 + target_path=$2 + read_only=${3:-false} + + if [ -e "$source_path" ]; then + pi_web_docker_host_write_volume "$source_path" "$target_path" "$read_only" + fi +} + +pi_web_docker_host_write_extra_volumes() { + extra_paths=$1 + + for extra_path in $extra_paths; do + case "$extra_path" in + /*) ;; + *) + printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entries must be absolute paths: $extra_path" >&2 + return 1 + ;; + esac + + if [ ! -e "$extra_path" ]; then + printf '%s\n' "PI_WEB_DOCKER_EXTRA_HOST_PATHS entry does not exist: $extra_path" >&2 + return 1 + fi + + pi_web_docker_host_write_volume "$extra_path" "$extra_path" false + done +} + +pi_web_docker_host_write_compose_override() { + target_file=$1 + host_profile=$2 + extra_paths=${3:-} + target_dir=$(dirname "$target_file") + mkdir -p "$target_dir" || return 1 + PI_WEB_DOCKER_HOST_OVERRIDE_TEMP=$target_file.$$ + + case "$host_profile" in + linux-native-docker) hostexec_mode=nsenter ;; + mac-docker-desktop) hostexec_mode=disabled ;; + *) + printf '%s\n' "unsupported PI WEB Docker host profile: $host_profile" >&2 + return 1 + ;; + esac + + cat >"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <&2 + printf '%s\n' "" >&2 + printf '%s\n' "Detected:" >&2 + printf ' host OS: %s\n' "${PI_WEB_DETECTED_HOST_OS:-unknown}" >&2 + printf ' docker context: %s\n' "${PI_WEB_DETECTED_DOCKER_CONTEXT:-unknown}" >&2 + printf ' docker endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_ENDPOINT:-unknown}" >&2 + printf ' DOCKER_HOST: %s\n' "${PI_WEB_DETECTED_DOCKER_HOST_ENV:-unset}" >&2 + printf ' effective endpoint: %s\n' "${PI_WEB_DETECTED_DOCKER_EFFECTIVE_ENDPOINT:-unknown}" >&2 + printf ' docker socket source: %s\n' "${PI_WEB_DETECTED_DOCKER_SOCKET_SOURCE:-unknown}" >&2 + printf ' docker OS: %s\n' "${PI_WEB_DETECTED_DOCKER_OS:-unknown}" >&2 + printf '%s\n' "" >&2 + printf '%s\n' "Supported profiles:" >&2 + printf '%s\n' " - native Linux Docker Engine using /var/run/docker.sock" >&2 + printf '%s\n' " - Docker Desktop for Mac" >&2 + if [ -n "${PI_WEB_DOCKER_HOST_PROFILE_ERROR:-}" ]; then + printf '%s\n' "" >&2 + printf 'Reason: %s\n' "$PI_WEB_DOCKER_HOST_PROFILE_ERROR" >&2 + fi +} + +pi_web_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 + printf '%s\n' "Docker Compose is required (docker compose plugin or docker-compose)" >&2 + return 1 + fi +} diff --git a/docker/scripts/docker-compose-dev b/docker/scripts/docker-compose-dev new file mode 100755 index 0000000..334ebb3 --- /dev/null +++ b/docker/scripts/docker-compose-dev @@ -0,0 +1,236 @@ +#!/usr/bin/env sh +set -eu + +log() { + printf '%s\n' "$*" >&2 +} + +die() { + log "pi-web Docker dev compose: $*" + exit 1 +} + +script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P) +repo_root=$(unset CDPATH; cd "$script_dir/../.." && pwd -P) +dev_config_file=$repo_root/.pi-web/docker-compose-dev.local.env +legacy_dev_env_file=$repo_root/.pi-web/docker-compose-dev.env +generated_env_file=$repo_root/.pi-web/docker-compose-dev.generated.env + +# shellcheck source=../lib/host-profile.sh +# shellcheck disable=SC1091 +. "$repo_root/docker/lib/host-profile.sh" + +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" +} + +dev_config_value() { + env_file_value "$dev_config_file" "$1" +} + +runtime_env_value() { + env_file_value "$runtime_env_file" "$1" +} + +write_initial_dev_config() { + [ ! -e "$dev_config_file" ] || return 0 + + temp_config=$dev_config_file.$$ + previous_umask=$(umask) + umask 077 + cat >"$temp_config" <<'EOF' +# PI WEB Docker dev settings. Safe to edit. +# +# docker/scripts/docker-compose-dev creates this file once and does not +# overwrite it. Put persistent dev Docker settings here. +# +# Precedence for values used by the wrapper: +# 1. current shell environment +# 2. this file +# 3. runtime installer env, usually ~/.local/share/pi-web-docker/.env +# 4. built-in defaults +# +# Generated effective values are written to: +# .pi-web/docker-compose-dev.generated.env +# +# Bind addresses: +# - 127.0.0.1 exposes only to this machine. +# - 0.0.0.0 exposes on all host interfaces. Use only on trusted networks. +# +# Uncomment or add values to persist them. PI_WEB_DEV_API_BIND_ADDR +# controls the web/API server; PI_WEB_DEV_BIND_ADDR controls the Vite UI. +# PI_WEB_DEV_API_BIND_ADDR=127.0.0.1 +# PI_WEB_DEV_BIND_ADDR=127.0.0.1 +# PI_WEB_DEV_API_PORT=8504 +# PI_WEB_DEV_PORT=8505 +# +# Shared Docker/runtime-style defaults may also be set here: +# PI_WEB_DOCKER_DATA_DIR=/absolute/path/to/pi-web-docker/data +# PI_WEB_DOCKER_EXTRA_HOST_PATHS="/absolute/path/one /absolute/path/two" +EOF + umask "$previous_umask" + + if [ -f "$legacy_dev_env_file" ]; then + { + printf '\n%s\n' "# Values copied from the previous generated dev env file." + printf '%s\n' "# Keep, edit, or delete these lines as needed." + for key in PI_WEB_DEV_API_BIND_ADDR PI_WEB_DEV_BIND_ADDR PI_WEB_DEV_API_PORT PI_WEB_DEV_PORT; do + if value=$(env_file_value "$legacy_dev_env_file" "$key"); then + printf '%s=%s\n' "$key" "$value" + fi + done + } >>"$temp_config" + fi + + mv "$temp_config" "$dev_config_file" + log "Created user-editable dev config: $dev_config_file" +} + +value_from_env_or_config_or_runtime_or_default() { + key=$1 + default_value=$2 + eval "is_set=\${$key+x}" + if [ "${is_set:-}" = x ]; then + eval "printf '%s\n' \"\${$key}\"" + elif existing=$(dev_config_value "$key"); then + printf '%s\n' "$existing" + elif existing=$(runtime_env_value "$key"); then + printf '%s\n' "$existing" + else + printf '%s\n' "$default_value" + fi +} + +if ! pi_web_docker_host_detect_profile; then + pi_web_docker_host_print_detection_failure + die "refusing to run Docker Compose for an unsupported or unknown host setup" +fi + +runtime_env_file=${PI_WEB_DOCKER_RUNTIME_ENV_FILE:-} +if [ -z "$runtime_env_file" ] && [ -n "${HOME:-}" ]; then + runtime_env_file=$HOME/.local/share/pi-web-docker/.env +fi + +mkdir -p "$repo_root/.pi-web" || die "could not create .pi-web directory" +write_initial_dev_config + +pi_web_uid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_UID "$(id -u)") +pi_web_gid=$(value_from_env_or_config_or_runtime_or_default PI_WEB_GID "$(id -g)") +docker_gid=$(value_from_env_or_config_or_runtime_or_default DOCKER_GID "$(pi_web_docker_host_detect_docker_gid)") +default_data_dir=${HOME:-$repo_root/.pi-web}/.local/share/pi-web-docker/data +pi_web_data_dir=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir") +pi_web_extra_host_paths=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "") +pi_web_opensuse_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed) +pi_web_nodejs_major=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22) +pi_web_nodejs_repo=$(value_from_env_or_config_or_runtime_or_default PI_WEB_NODEJS_REPO auto) +pi_web_extra_zypper_packages=$(value_from_env_or_config_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "") +pi_web_dev_image=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev) +hostexec_image=$(value_from_env_or_config_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22) +pi_web_max_upload_bytes=$(value_from_env_or_config_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864) +default_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1) +pi_web_dev_api_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr") +pi_web_dev_bind_addr=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr") +pi_web_dev_api_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_API_PORT 8504) +pi_web_dev_port=$(value_from_env_or_config_or_runtime_or_default PI_WEB_DEV_PORT 8505) + +mkdir -p "$pi_web_data_dir" || die "could not create data directory: $pi_web_data_dir" + +env_file=$generated_env_file +override_file=$repo_root/.pi-web/docker-compose-dev.host.generated.yml + +if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths"; then + die "could not write host-specific Compose override" +fi + +umask 077 +temp_env=$env_file.$$ +cat >"$temp_env" <