feat: unify Docker entrypoint

This commit is contained in:
Pi Web Agent
2026-06-29 11:27:47 +00:00
parent 4a1136eac2
commit bd28c93cfe
35 changed files with 2316 additions and 118 deletions
+6 -3
View File
@@ -1,9 +1,12 @@
# Keep the local-build runtime context small and avoid sending persistent data.
*
!Dockerfile
!bin/
!bin/hostexec
!bin/install-opensuse-base
!pi-web-docker
!internal/
!internal/bin/
!internal/bin/hostexec
!internal/image/
!internal/image/install-opensuse-base
!custom-image.d/
!custom-image.d/.gitkeep
!custom-image.d/*.sh
+4 -3
View File
@@ -19,7 +19,7 @@ ENV NPM_CONFIG_UPDATE_NOTIFIER=false \
SHELL=/bin/bash \
TERM=xterm-256color
COPY bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
&& install-pi-web-opensuse-base
@@ -55,8 +55,9 @@ COPY --from=package /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=package /usr/local/bin /usr/local/bin
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
COPY bin/hostexec /usr/local/bin/hostexec
RUN chmod 0755 /usr/local/bin/hostexec
COPY internal/bin/hostexec /usr/local/bin/hostexec
COPY pi-web-docker /usr/local/bin/pi-web-docker
RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker
COPY custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
+4 -3
View File
@@ -28,7 +28,7 @@ ENV NODE_ENV=development \
SHELL=/bin/bash \
TERM=xterm-256color
COPY docker/bin/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
COPY docker/internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base
RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \
&& install-pi-web-opensuse-base
@@ -44,8 +44,9 @@ RUN npm ci \
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins
COPY docker/bin/hostexec /usr/local/bin/hostexec
RUN chmod 0755 /usr/local/bin/hostexec
COPY docker/internal/bin/hostexec /usr/local/bin/hostexec
COPY docker/pi-web-docker /usr/local/bin/pi-web-docker
RUN chmod 0755 /usr/local/bin/hostexec /usr/local/bin/pi-web-docker
COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/
RUN bash -euxo pipefail -c '\
+53 -41
View File
@@ -7,7 +7,7 @@ PI WEB has two Docker modes:
- **Runtime/server mode** builds a local image from npm packages and runs split `sessiond` + `web` services. This is for users and servers.
- **Development mode** builds from this checkout and runs the same split shape while letting the web/API/client services autoreload. This is for hacking on PI WEB.
No prebuilt image or registry is required in either mode.
No prebuilt image or registry is required in either mode. The single human-facing Docker entrypoint is `pi-web-docker`: runtime mode is the default, and development mode is explicit with `--dev`.
## Trust model: read this first
@@ -40,13 +40,15 @@ Prerequisites:
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:
The Docker bootstrap does not require Node.js or npm on the host. It only needs a supported Docker/Compose setup plus `curl` or `wget`; Node and PI WEB are installed inside the local Docker image.
Install with the bootstrap one-liner:
```bash
curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh
```
The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data.
The one-liner is idempotent. Each run refreshes Docker assets from the requested Git ref, writes host-specific `.env` values, rebuilds the local image from npm with `--pull --no-cache`, and recreates the split services without deleting persistent data. After installation, use the canonical runtime command in the install directory, for example `~/.local/share/pi-web-docker/pi-web-docker update`.
Defaults:
@@ -57,24 +59,26 @@ Defaults:
Updating recreates the Docker `sessiond` container. Active Pi agent runtimes in this Docker install may stop, so update while sessions are idle. Persisted PI WEB state, Pi config, and session history under the data directory are kept.
Useful runtime commands:
Inside the Docker runtime, the Updates panel uses `pi-web-docker` for status, update, and restart commands. Update and restart commands first start a detached helper container with the same Docker/host mounts and generated Compose environment, including the project name, ports/data paths, helper image, and generated UID/GID/Docker group. The helper then runs Docker Compose, so work continues even when `web`, `sessiond`, or the PI WEB terminal that launched the command exits.
```bash
cd ~/.local/share/pi-web-docker
### Command matrix
docker compose ps
docker compose logs -f web
docker compose logs -f sessiond
docker compose restart web
docker compose restart sessiond
```
From a production/runtime install directory, run `./pi-web-docker <command>`. From a checkout, run `./docker/pi-web-docker --dev <command>` for development mode. Inside PI WEB Docker containers and in the Updates panel, the command name is `pi-web-docker`; development commands include the explicit `--dev` flag, for example `pi-web-docker --dev status`.
To stop the runtime without deleting data:
```bash
cd ~/.local/share/pi-web-docker
docker compose down
```
| Command | Runtime/default | Development | Notes |
| --- | --- | --- | --- |
| `install` | one-liner above or `./pi-web-docker install [installer args]` | Not available | Production bootstrap/install only; accepts the installer options below. |
| `start` | `./pi-web-docker start` | `./docker/pi-web-docker --dev start` | Starts the split `web` and `sessiond` stack. |
| `stop` | `./pi-web-docker stop` | `./docker/pi-web-docker --dev stop` | Stops containers without deleting persistent data. |
| `restart` | `./pi-web-docker restart` | `./docker/pi-web-docker --dev restart` | Restarts `web` and `sessiond`. |
| `restart-web` | `./pi-web-docker restart-web` | `./docker/pi-web-docker --dev restart-web` | Restarts only the web/API service. |
| `restart-sessiond` | `./pi-web-docker restart-sessiond` | `./docker/pi-web-docker --dev restart-sessiond` | Restarts the session daemon; active agent runtimes may stop in that Docker stack. |
| `update` | `./pi-web-docker update` | `./docker/pi-web-docker --dev update` | Rebuilds/recreates the stack. Runtime host updates rerun the installer to refresh Docker assets first. |
| `status` | `./pi-web-docker status` | `./docker/pi-web-docker --dev status` | Shows Docker Compose service status. |
| `logs` | `./pi-web-docker logs [web\|sessiond]` | `./docker/pi-web-docker --dev logs [web\|sessiond\|data-init]` | Follows logs; omitting a target follows all services. |
| `shell` | `./pi-web-docker shell [web\|sessiond]` | `./docker/pi-web-docker --dev shell [web\|sessiond]` | Opens Bash in `web` by default. |
| `doctor` | `./pi-web-docker doctor` | `./docker/pi-web-docker --dev doctor` | Prints static Docker command diagnostics and generated asset paths. |
| `cli` | `./pi-web-docker cli <pi-web args...>` | `./docker/pi-web-docker --dev cli <pi-web args...>` | Proxies the existing `pi-web` CLI in the `web` container. |
Do not run `docker compose down -v` unless you intentionally want to remove Compose-managed volumes. The default persistent PI WEB data is a bind mount, but avoiding `-v` keeps the update/stop flow conservative.
@@ -100,6 +104,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_INSTALL_DIR` | absolute runtime install directory mounted back into the containers for Docker helper commands |
| `PI_WEB_DOCKER_REF` | Git ref used when `pi-web-docker update` refreshes Docker asset templates |
| `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 |
@@ -110,11 +116,12 @@ Common environment variables written to `.env`:
| `PI_WEB_NODEJS_REPO` | Node.js zypper repository URL, `auto`, or `disabled` |
| `PI_WEB_EXTRA_ZYPPER_PACKAGES` | extra openSUSE packages installed during the image build |
| `PI_WEB_IMAGE` | local image tag to build and run |
| `COMPOSE_PROJECT_NAME` | Docker Compose project name used by the runtime and its detached update/restart helpers; defaults to `pi-web` |
| `HOSTEXEC_IMAGE` | helper image used by `hostexec` |
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.
The installer also writes a generated `compose.override.yml` in the install directory. `pi-web-docker` loads the generated `.env` and Compose override explicitly for runtime commands and passes the generated `COMPOSE_PROJECT_NAME` to Docker Compose, so an unrelated ambient Compose project name cannot redirect lifecycle commands. Re-run `pi-web-docker install` or `pi-web-docker update` instead of editing generated files by hand.
### Base image and tooling
@@ -242,22 +249,25 @@ 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, use the dev Compose wrapper so the same fail-closed host profile detection is applied as runtime mode:
From the repository root, use the canonical Docker command so the same fail-closed host profile detection is applied as runtime mode:
```bash
./docker/scripts/docker-compose-dev up --build
./docker/pi-web-docker --dev start
```
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.
The command 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. The generated environment includes the host repository root as `PI_WEB_DOCKER_DEV_REPO_ROOT`, and the generated override mounts that path back into the containers so Docker helper commands can run Compose from the same absolute path. Edit only the `.local.env` file for persistent dev settings; the `.generated.env` and `.host.generated.yml` files are refreshed by the command.
Values used by the wrapper are resolved in this order:
Values used by the command 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.
1. `.pi-web/docker-compose-dev.local.env`;
2. previous generated values in `.pi-web/docker-compose-dev.generated.env`, when present;
3. current shell environment, on first generation only;
4. runtime installer env, usually `$HOME/.local/share/pi-web-docker/.env`;
5. 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.
`COMPOSE_PROJECT_NAME`, `PI_WEB_UID`, and `PI_WEB_GID` are the exceptions to runtime-env reuse. Development mode defaults the Compose project to `pi-web-dev` and defaults the container user/group to the current host user, unless you set values in the shell or `.pi-web/docker-compose-dev.local.env`. This keeps development and runtime stacks from accidentally sharing one Docker Compose project and prevents bind-mounted checkout files from being written as root or as a different runtime service user.
If you already ran the runtime installer, dev mode therefore reuses shared defaults such as 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`:
@@ -266,18 +276,18 @@ 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:
For temporary overrides, prefix the command:
```bash
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
./docker/pi-web-docker --dev start
```
You can run the dev stack in the background with:
```bash
./docker/scripts/docker-compose-dev up -d --build
./docker/pi-web-docker --dev start
```
Open the Vite UI at <http://127.0.0.1:8505>. The dev API is published on <http://127.0.0.1:8504>.
@@ -285,16 +295,18 @@ Open the Vite UI at <http://127.0.0.1:8505>. The dev API is published on <http:/
Useful development commands:
```bash
./docker/scripts/docker-compose-dev ps
./docker/scripts/docker-compose-dev logs -f web
./docker/scripts/docker-compose-dev restart web
./docker/scripts/docker-compose-dev restart sessiond
./docker/scripts/docker-compose-dev down
./docker/pi-web-docker --dev status
./docker/pi-web-docker --dev logs web
./docker/pi-web-docker --dev logs data-init
./docker/pi-web-docker --dev restart-web
./docker/pi-web-docker --dev restart-sessiond
./docker/pi-web-docker --dev update
./docker/pi-web-docker --dev stop
```
Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads.
Restart `sessiond` manually after changes that affect `src/server/sessiond.ts`, daemon ownership, or session-daemon-only code paths. Restarting only `web` is enough for ordinary API/client/plugin development reloads. Commands launched from the Updates panel use the same detached `pi-web-docker` helper as runtime mode, so update/restart work continues after the current PI WEB terminal or container exits. In both modes detached helpers load the generated Docker env and run as the generated `PI_WEB_UID:PI_WEB_GID` with the generated Docker group; development helpers still refuse UID 0 unless `--allow-root` is explicit.
The dev setup intentionally has the same Docker socket and profile-specific host mounts as the runtime setup. The same trust warnings apply.
The dev setup intentionally has the same Docker socket and profile-specific host mounts as the runtime setup. The same trust warnings apply. The command refuses to run development mode as UID 0, or to generate a dev env with `PI_WEB_UID=0`, unless you pass `--allow-root`; use that override only when root-owned checkout writes are intentional.
On startup, a short `data-init` service creates the shared `/data` subdirectories and gives them to `PI_WEB_UID:PI_WEB_GID`. This handles the common Flatcar/Docker case where a missing bind-mount directory is created as root by the Docker daemon. Because the image also builds its `pi-web` account with those IDs, rebuild the image if you change `PI_WEB_UID` or `PI_WEB_GID`.
@@ -321,9 +333,9 @@ For sessions to appear under the same workspace in both modes, use the same proj
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/scripts/docker-compose-dev down
./docker/pi-web-docker --dev stop
docker volume rm pi-web-dev_node_modules
./docker/scripts/docker-compose-dev up --build
./docker/pi-web-docker --dev start
```
## Local checkout validation
@@ -347,7 +359,7 @@ 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/internal/dev/compose config
docker build --check -f docker/Dockerfile docker
docker build --check -f docker/Dockerfile.dev .
```
+8
View File
@@ -19,7 +19,15 @@ x-pi-web-dev-environment: &pi-web-dev-environment
PI_CODING_AGENT_DIR: /data/pi-agent
HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_UID: ${PI_WEB_UID:-1000}
PI_WEB_GID: ${PI_WEB_GID:-1000}
DOCKER_GID: ${DOCKER_GID:-0}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
PI_WEB_DOCKER_RUNTIME: "1"
PI_WEB_DOCKER_MODE: dev
PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}
PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}
NPM_CONFIG_UPDATE_NOTIFIER: "false"
NPM_CONFIG_CACHE: /data/npm-cache
+5
View File
@@ -23,6 +23,11 @@ x-pi-web-environment: &pi-web-environment
HOSTEXEC_IMAGE: ${HOSTEXEC_IMAGE:-alpine:3.22}
HOSTEXEC_MODE: ${HOSTEXEC_MODE:-disabled}
PI_WEB_MAX_UPLOAD_BYTES: ${PI_WEB_MAX_UPLOAD_BYTES:-67108864}
PI_WEB_DOCKER_RUNTIME: "1"
PI_WEB_DOCKER_MODE: runtime
PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}
PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}
x-pi-web-volumes: &pi-web-volumes
- type: bind
+29 -14
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
set -eu
log() {
@@ -278,7 +279,7 @@ compose_cmd() {
}
run_runtime_compose() {
compose_cmd -f compose.yml -f compose.override.yml "$@"
compose_cmd --project-name "$compose_project_name" --env-file .env -f compose.yml -f compose.override.yml "$@"
}
if [ -n "${XDG_DATA_HOME:-}" ]; then
@@ -298,16 +299,22 @@ install_dir_input=${PI_WEB_DOCKER_HOME:-$default_install_dir}
install_dir=$(absolute_dir "$install_dir_input") || die "could not create install directory"
env_file=$install_dir/.env
asset_ref=$(value_from_env_or_existing_or_default PI_WEB_DOCKER_REF main)
asset_base=${PI_WEB_DOCKER_ASSET_BASE:-https://raw.githubusercontent.com/jmfederico/pi-web/$asset_ref/docker}
use_local_asset_dir=1
if [ "${PI_WEB_DOCKER_REFRESH_ASSETS:-0}" = 1 ] || [ "${PI_WEB_DOCKER_REF+x}" = x ] || [ "${PI_WEB_DOCKER_ASSET_BASE+x}" = x ]; then
use_local_asset_dir=0
fi
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
elif [ "$use_local_asset_dir" = 1 ] && local_asset_dir=$(find_local_asset_dir 2>/dev/null) && [ "$local_asset_dir" != "$install_dir" ]; then
asset_dir=$local_asset_dir
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
@@ -319,15 +326,15 @@ cleanup_profile_helper() {
trap cleanup_profile_helper EXIT
if [ -n "$asset_dir" ]; then
profile_helper=$asset_dir/lib/host-profile.sh
profile_helper=$asset_dir/internal/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"
fetch_url "$asset_base/internal/host-profile.sh" "$profile_helper_temp"
profile_helper=$profile_helper_temp
fi
# shellcheck source=lib/host-profile.sh
# shellcheck source=internal/host-profile.sh
# shellcheck disable=SC1091
. "$profile_helper"
@@ -340,9 +347,10 @@ 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
write_asset pi-web-docker 0755
write_asset internal/bin/hostexec 0755
write_asset internal/image/install-opensuse-base 0755
write_asset internal/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"
@@ -368,6 +376,7 @@ pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR
pi_web_nodejs_repo=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_REPO auto)
pi_web_extra_zypper_packages=$(value_from_env_or_existing_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
pi_web_image=$(value_from_env_or_existing_or_default PI_WEB_IMAGE pi-web:local)
compose_project_name=$(value_from_env_or_existing_or_default COMPOSE_PROJECT_NAME pi-web)
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 "")
@@ -378,6 +387,8 @@ 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_DOCKER_INSTALL_DIR "$install_dir"
require_non_empty PI_WEB_DOCKER_REF "$asset_ref"
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"
@@ -386,13 +397,14 @@ require_non_empty PI_WEB_OPENSUSE_IMAGE "$pi_web_opensuse_image"
require_non_empty PI_WEB_NODEJS_MAJOR "$pi_web_nodejs_major"
require_non_empty PI_WEB_NODEJS_REPO "$pi_web_nodejs_repo"
require_non_empty PI_WEB_IMAGE "$pi_web_image"
require_non_empty COMPOSE_PROJECT_NAME "$compose_project_name"
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
if ! pi_web_docker_host_write_compose_override "$compose_override_file" "$pi_web_host_profile" "$pi_web_extra_host_paths" "$install_dir"; then
die "could not write host-specific Compose override"
fi
@@ -413,8 +425,10 @@ 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.
# Persistent data, Docker control root, and localhost-only default exposure.
PI_WEB_DOCKER_DATA_DIR=$data_dir
PI_WEB_DOCKER_INSTALL_DIR=$install_dir
PI_WEB_DOCKER_REF=$asset_ref
PI_WEB_BIND_ADDR=$pi_web_bind_addr
PI_WEB_PORT=$pi_web_port
@@ -428,8 +442,9 @@ PI_WEB_NODEJS_MAJOR=$pi_web_nodejs_major
PI_WEB_NODEJS_REPO=$pi_web_nodejs_repo
PI_WEB_EXTRA_ZYPPER_PACKAGES=$pi_web_extra_zypper_packages_env
# Runtime image names and limits.
# Runtime image names, Compose project, and limits.
PI_WEB_IMAGE=$pi_web_image
COMPOSE_PROJECT_NAME=$compose_project_name
HOSTEXEC_IMAGE=$hostexec_image
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
EOF
@@ -485,7 +500,7 @@ log "Recreating split PI WEB Docker services ..."
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."
log "To update later, run: $install_dir/pi-web-docker update"
(
cd "$install_dir"
run_runtime_compose ps
@@ -11,14 +11,14 @@ die() {
}
script_dir=$(unset CDPATH; cd "$(dirname "$0")" && pwd -P)
repo_root=$(unset CDPATH; cd "$script_dir/../.." && 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 source=../host-profile.sh
# shellcheck disable=SC1091
. "$repo_root/docker/lib/host-profile.sh"
. "$repo_root/docker/internal/host-profile.sh"
strip_wrapping_quotes() {
value=$1
@@ -74,6 +74,10 @@ runtime_env_value() {
env_file_value "$runtime_env_file" "$1"
}
generated_env_value() {
env_file_value "$generated_env_file" "$1"
}
write_initial_dev_config() {
[ ! -e "$dev_config_file" ] || return 0
@@ -83,14 +87,15 @@ write_initial_dev_config() {
cat >"$temp_config" <<'EOF'
# PI WEB Docker dev settings. Safe to edit.
#
# docker/scripts/docker-compose-dev creates this file once and does not
# docker/pi-web-docker --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
# Precedence for values used by docker/pi-web-docker --dev:
# 1. this file
# 2. previous generated values, when present
# 3. current shell environment, on first generation only
# 4. runtime installer env, usually ~/.local/share/pi-web-docker/.env
# 5. built-in defaults
#
# Generated effective values are written to:
# .pi-web/docker-compose-dev.generated.env
@@ -109,6 +114,10 @@ write_initial_dev_config() {
# 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"
#
# PI_WEB_UID and PI_WEB_GID default to the current host user so
# bind-mounted checkout files are not written as root or another user.
# Set them here only if you intentionally want a different container user.
EOF
umask "$previous_umask"
@@ -128,21 +137,73 @@ EOF
log "Created user-editable dev config: $dev_config_file"
}
value_from_env_or_config_or_runtime_or_default() {
value_from_config_or_generated_or_env_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
if existing=$(dev_config_value "$key"); then
printf '%s\n' "$existing"
elif existing=$(runtime_env_value "$key"); then
elif existing=$(generated_env_value "$key"); then
printf '%s\n' "$existing"
else
printf '%s\n' "$default_value"
eval "is_set=\${$key+x}"
if [ "${is_set:-}" = x ]; then
eval "printf '%s\n' \"\${$key}\""
elif existing=$(runtime_env_value "$key"); then
printf '%s\n' "$existing"
else
printf '%s\n' "$default_value"
fi
fi
}
value_from_config_or_generated_or_env_or_default() {
key=$1
default_value=$2
if existing=$(dev_config_value "$key"); then
printf '%s\n' "$existing"
elif existing=$(generated_env_value "$key"); then
printf '%s\n' "$existing"
else
eval "is_set=\${$key+x}"
if [ "${is_set:-}" = x ]; then
eval "printf '%s\n' \"\${$key}\""
else
printf '%s\n' "$default_value"
fi
fi
}
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_unsigned_int() {
name=$1
value=$2
is_unsigned_int "$value" || die "$name must be a numeric Unix id, got: $value"
}
enforce_dev_root_safety() {
uid=$(id -u 2>/dev/null || printf '0')
[ "$uid" != 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to run Docker development mode as root; retry with --allow-root if this is intentional"
}
enforce_non_root_dev_uid() {
[ "${1:-0}" -ne 0 ] || is_truthy "${PI_WEB_DOCKER_ALLOW_ROOT:-0}" || die "refusing to generate Docker development env with PI_WEB_UID=0; retry with --allow-root if this is intentional"
}
enforce_dev_root_safety
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"
@@ -156,42 +217,56 @@ 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)")
host_uid=$(id -u 2>/dev/null || printf '0')
host_gid=$(id -g 2>/dev/null || printf '0')
pi_web_uid=$(value_from_config_or_generated_or_env_or_default PI_WEB_UID "$host_uid")
pi_web_gid=$(value_from_config_or_generated_or_env_or_default PI_WEB_GID "$host_gid")
docker_gid=$(value_from_config_or_generated_or_env_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)
pi_web_data_dir=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_DATA_DIR "$default_data_dir")
pi_web_extra_host_paths=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DOCKER_EXTRA_HOST_PATHS "")
pi_web_opensuse_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed)
pi_web_nodejs_major=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_MAJOR 22)
pi_web_nodejs_repo=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_NODEJS_REPO auto)
pi_web_extra_zypper_packages=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_EXTRA_ZYPPER_PACKAGES "")
pi_web_dev_image=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_IMAGE pi-web:dev)
compose_project_name=$(value_from_config_or_generated_or_env_or_default COMPOSE_PROJECT_NAME pi-web-dev)
hostexec_image=$(value_from_config_or_generated_or_env_or_runtime_or_default HOSTEXEC_IMAGE alpine:3.22)
pi_web_max_upload_bytes=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_MAX_UPLOAD_BYTES 67108864)
default_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_BIND_ADDR 127.0.0.1)
pi_web_dev_api_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_BIND_ADDR "$default_dev_bind_addr")
pi_web_dev_bind_addr=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_BIND_ADDR "$default_dev_bind_addr")
pi_web_dev_api_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_API_PORT 8504)
pi_web_dev_port=$(value_from_config_or_generated_or_env_or_runtime_or_default PI_WEB_DEV_PORT 8505)
require_unsigned_int PI_WEB_UID "$pi_web_uid"
require_unsigned_int PI_WEB_GID "$pi_web_gid"
require_unsigned_int DOCKER_GID "$docker_gid"
enforce_non_root_dev_uid "$pi_web_uid"
case "$pi_web_data_dir" in
/*) ;;
*) die "PI_WEB_DOCKER_DATA_DIR must be an absolute path, got: $pi_web_data_dir" ;;
esac
[ -n "$compose_project_name" ] || die "COMPOSE_PROJECT_NAME must not be empty"
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
if ! pi_web_docker_host_write_compose_override "$override_file" "$PI_WEB_DETECTED_DOCKER_HOST_PROFILE" "$pi_web_extra_host_paths" "$repo_root"; then
die "could not write host-specific Compose override"
fi
umask 077
temp_env=$env_file.$$
cat >"$temp_env" <<EOF
# Generated by docker/scripts/docker-compose-dev. Do not edit by hand.
# Generated by docker/pi-web-docker --dev. Do not edit by hand.
PI_WEB_UID=$pi_web_uid
PI_WEB_GID=$pi_web_gid
DOCKER_GID=$docker_gid
PI_WEB_DOCKER_DATA_DIR=$pi_web_data_dir
PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root
PI_WEB_DOCKER_HOST_PROFILE=$PI_WEB_DETECTED_DOCKER_HOST_PROFILE
HOSTEXEC_MODE=$PI_WEB_DETECTED_HOSTEXEC_MODE
PI_WEB_DOCKER_EXTRA_HOST_PATHS=$pi_web_extra_host_paths
@@ -200,6 +275,7 @@ PI_WEB_NODEJS_MAJOR=$pi_web_nodejs_major
PI_WEB_NODEJS_REPO=$pi_web_nodejs_repo
PI_WEB_EXTRA_ZYPPER_PACKAGES=$pi_web_extra_zypper_packages
PI_WEB_DEV_IMAGE=$pi_web_dev_image
COMPOSE_PROJECT_NAME=$compose_project_name
HOSTEXEC_IMAGE=$hostexec_image
PI_WEB_MAX_UPLOAD_BYTES=$pi_web_max_upload_bytes
PI_WEB_DEV_API_BIND_ADDR=$pi_web_dev_api_bind_addr
@@ -230,6 +306,7 @@ if [ "$#" -eq 0 ]; then
fi
pi_web_docker_compose \
--project-name "$compose_project_name" \
--env-file "$env_file" \
-f "$repo_root/docker/compose.dev.yml" \
-f "$override_file" \
@@ -263,6 +263,7 @@ pi_web_docker_host_write_compose_override() {
target_file=$1
host_profile=$2
extra_paths=${3:-}
control_path=${4:-}
target_dir=$(dirname "$target_file")
mkdir -p "$target_dir" || return 1
PI_WEB_DOCKER_HOST_OVERRIDE_TEMP=$target_file.$$
@@ -278,7 +279,7 @@ pi_web_docker_host_write_compose_override() {
cat >"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <<EOF
# Generated by PI WEB Docker host profile detection. Do not edit by hand.
# Re-run the installer or docker/scripts/docker-compose-dev to refresh this file.
# Re-run the installer or docker/pi-web-docker --dev to refresh this file.
x-pi-web-host-volumes: &pi-web-host-volumes
EOF
@@ -305,6 +306,15 @@ EOF
return 1
fi
if [ -n "$control_path" ]; then
if [ ! -e "$control_path" ]; then
printf '%s\n' "PI WEB Docker control path does not exist: $control_path" >&2
rm -f "$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP"
return 1
fi
pi_web_docker_host_write_volume "$control_path" "$control_path" false
fi
cat >>"$PI_WEB_DOCKER_HOST_OVERRIDE_TEMP" <<EOF
services:
+693
View File
@@ -0,0 +1,693 @@
#!/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
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 so work can continue after 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"
}
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
}
require_runtime_compose_assets() {
root=$1
[ -f "$root/compose.yml" ] || die "runtime compose.yml not found at $root/compose.yml"
[ -f "$root/compose.override.yml" ] || die "runtime compose.override.yml not found at $root/compose.override.yml; run pi-web-docker install first"
[ -f "$root/.env" ] || die "runtime .env not found at $root/.env; run pi-web-docker install first"
}
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 "$@"
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
}
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_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"
printf 'Follow progress with: docker logs -f %s\n' "$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
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