diff --git a/.changeset/opensuse-tumbleweed-docker.md b/.changeset/opensuse-tumbleweed-docker.md new file mode 100644 index 0000000..387bb50 --- /dev/null +++ b/.changeset/opensuse-tumbleweed-docker.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Move the Docker runtime and development images to openSUSE Tumbleweed with Node.js 22, npx, Corepack, and common development tooling, plus zypper-based package customization. diff --git a/README.md b/README.md index b924b0c..3e7dda5 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,9 @@ A Docker runtime is available for trusted local/server installs without using pr curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh ``` -It builds a local image from npm, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. +It builds an openSUSE Tumbleweed based local image from npm with Node.js 22, npx, Corepack, and common development/agent tooling, runs split `sessiond` and `web` services, binds the browser UI to `127.0.0.1:8504` by default, and uses the same command as the update path. The Docker setup intentionally mounts the Docker socket and selected host paths; treat it as root-equivalent host access, do not expose it directly to the public internet, and use an SSH tunnel, VPN, or authenticated reverse proxy for remote access. -See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, custom image hooks for optional CLIs, host command examples, and development Compose usage. +See the [Docker guide](https://github.com/jmfederico/pi-web/blob/main/docker/README.md) for trust warnings, version pinning, openSUSE package customization, custom image hooks for optional CLIs, host command examples, and development Compose usage. PI WEB is also published as a Pi package. Installing it through Pi exposes a `/pi-web` command inside Pi: diff --git a/docker/.dockerignore b/docker/.dockerignore index 872b011..8a7307a 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -3,6 +3,7 @@ !Dockerfile !bin/ !bin/hostexec +!bin/install-opensuse-base !custom-image.d/ !custom-image.d/.gitkeep !custom-image.d/*.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 02bde6d..c411301 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,22 +1,32 @@ # syntax=docker/dockerfile:1.7 -ARG NODE_VERSION=22-bookworm-slim +ARG OPENSUSE_IMAGE=opensuse/tumbleweed ARG DOCKER_CLI_VERSION=29-cli FROM docker:${DOCKER_CLI_VERSION} AS docker-cli -FROM node:${NODE_VERSION} AS package +FROM ${OPENSUSE_IMAGE} AS base + +ARG NODEJS_MAJOR=22 +ARG NODEJS_REPO=auto +ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +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 +RUN chmod 0755 /usr/local/sbin/install-pi-web-opensuse-base \ + && install-pi-web-opensuse-base + +FROM base AS package ARG PI_WEB_VERSION=latest ARG PI_VERSION=latest ARG CACHE_BUST=local -ENV NPM_CONFIG_UPDATE_NOTIFIER=false - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \ - && rm -rf /var/lib/apt/lists/* - RUN set -eux; \ echo "PI WEB Docker build cache bust: ${CACHE_BUST}"; \ npm install -g --omit=dev --no-audit --no-fund \ @@ -24,7 +34,7 @@ RUN set -eux; \ "@earendil-works/pi-coding-agent@${PI_VERSION}"; \ npm cache clean --force -FROM node:${NODE_VERSION} AS runtime +FROM base AS runtime ENV NODE_ENV=production \ NPM_CONFIG_UPDATE_NOTIFIER=false \ @@ -39,18 +49,11 @@ ENV NODE_ENV=production \ SHELL=/bin/bash \ TERM=xterm-256color -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - openssh-client \ - procps \ - tini \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /data/home /data/config /data/pi-web /data/pi-agent /workspace \ - && chown -R node:node /data /workspace +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 bin/hostexec /usr/local/bin/hostexec +RUN chmod 0755 /usr/local/bin/hostexec COPY custom-image.d/ /tmp/pi-web-custom-image.d/ RUN bash -euxo pipefail -c '\ @@ -59,14 +62,11 @@ RUN bash -euxo pipefail -c '\ echo "Running PI WEB custom image hook: ${script}"; \ bash "${script}"; \ done; \ - rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ + rm -rf /tmp/pi-web-custom-image.d; \ + zypper clean --all; \ + rm -rf /var/cache/zypp/* \ ' -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 --chmod=0755 bin/hostexec /usr/local/bin/hostexec - WORKDIR /workspace USER node diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index bcdcd31..a1a9608 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,13 +1,20 @@ # syntax=docker/dockerfile:1.7 -ARG NODE_VERSION=22-bookworm-slim +ARG OPENSUSE_IMAGE=opensuse/tumbleweed ARG DOCKER_CLI_VERSION=29-cli FROM docker:${DOCKER_CLI_VERSION} AS docker-cli -FROM node:${NODE_VERSION} AS dev +FROM ${OPENSUSE_IMAGE} AS dev + +ARG NODEJS_MAJOR=22 +ARG NODEJS_REPO=auto +ARG PI_WEB_EXTRA_ZYPPER_PACKAGES="" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV NODE_ENV=development \ + PATH=/workspace/node_modules/.bin:$PATH \ NPM_CONFIG_UPDATE_NOTIFIER=false \ NPM_CONFIG_CACHE=/data/npm-cache \ HOME=/data/home \ @@ -19,32 +26,23 @@ ENV NODE_ENV=development \ SHELL=/bin/bash \ TERM=xterm-256color -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - g++ \ - git \ - make \ - openssh-client \ - procps \ - python3 \ - tini \ - && rm -rf /var/lib/apt/lists/* +COPY docker/bin/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 WORKDIR /workspace COPY package.json package-lock.json ./ COPY scripts/install-git-hooks.mjs scripts/install-git-hooks.mjs RUN npm ci \ + && ln -sf /workspace/node_modules/.bin/pi /usr/local/bin/pi \ && npm cache clean --force \ - && mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent \ && chmod -R a+rwX /workspace/node_modules /data \ && chmod 0777 /workspace COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker -COPY --chmod=0755 docker/bin/hostexec /usr/local/bin/hostexec +COPY docker/bin/hostexec /usr/local/bin/hostexec +RUN chmod 0755 /usr/local/bin/hostexec COPY docker/custom-image.d/ /tmp/pi-web-custom-image.d/ RUN bash -euxo pipefail -c '\ @@ -53,7 +51,9 @@ RUN bash -euxo pipefail -c '\ echo "Running PI WEB custom image hook: ${script}"; \ bash "${script}"; \ done; \ - rm -rf /tmp/pi-web-custom-image.d /var/lib/apt/lists/* \ + rm -rf /tmp/pi-web-custom-image.d; \ + zypper clean --all; \ + rm -rf /var/cache/zypp/* \ ' EXPOSE 8504 8505 diff --git a/docker/README.md b/docker/README.md index 419128a..d9bfb74 100644 --- a/docker/README.md +++ b/docker/README.md @@ -97,10 +97,27 @@ Common environment variables written to `.env`: | `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` | +| `PI_WEB_OPENSUSE_IMAGE` | openSUSE base image used for the runtime build | +| `PI_WEB_NODEJS_MAJOR` | Node.js major package to install, defaulting to `22` | +| `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 | | `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, and version pins are preserved from an existing `.env` unless you pass a flag or environment override. +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. + +### Base image and tooling + +The Docker runtime and development images are openSUSE Tumbleweed based by default. They install Node.js 22, npm, `npx`, and Corepack through zypper, using the openSUSE Node.js build service repository when needed for the selected architecture. The image also includes common agent/development tools such as Git/Git LFS, GitHub CLI, OpenSSH, Python with pip/virtualenv and headers, native build tooling, `jq`, `ripgrep`, `fd`, `fzf`, `bat`, ShellCheck, archive tools, network utilities, and the Docker CLI. + +Install extra distro packages without writing a hook by setting a whitespace-delimited package list: + +```bash +PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client" \ + curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh +``` + +You can also pass installer flags such as `--opensuse-image`, `--nodejs-major`, `--nodejs-repo`, and `--extra-zypper-packages`, or edit the generated `.env` and rerun the installer. ### Custom image hooks @@ -110,13 +127,20 @@ The runtime image can be extended without changing PI WEB's Dockerfile. Put loca ~/.local/share/pi-web-docker/custom-image.d/ ``` -The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `gh`, `glab`, `kubectl`, or cloud CLIs that you do not want in the default image. +The installer preserves that directory, includes the `*.sh` files in the Docker build context, and runs each script as `root` during the image build in lexical order. Use this for optional tools such as `glab`, `kubectl`, cloud CLIs, or language toolchains that you do not want in the default image. Example: ```bash mkdir -p ~/.local/share/pi-web-docker/custom-image.d -$EDITOR ~/.local/share/pi-web-docker/custom-image.d/10-github-cli.sh +cat >~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +zypper --gpg-auto-import-keys --non-interactive refresh +zypper --non-interactive install --no-recommends glab kubernetes-client +zypper clean --all +EOF +chmod +x ~/.local/share/pi-web-docker/custom-image.d/10-extra-tools.sh curl -fsSL https://raw.githubusercontent.com/jmfederico/pi-web/main/docker/install.sh | sh ``` diff --git a/docker/bin/install-opensuse-base b/docker/bin/install-opensuse-base new file mode 100755 index 0000000..7e0adfe --- /dev/null +++ b/docker/bin/install-opensuse-base @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +nodejs_major=${NODEJS_MAJOR:-22} +nodejs_repo=${NODEJS_REPO:-auto} +extra_zypper_packages=${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} + +nodejs_repo_flavor() { + local rpm_arch + rpm_arch=$(rpm --eval '%{_target_cpu}') + + case "$rpm_arch" in + aarch64|armv6hl|armv7hl) + printf '%s\n' openSUSE_Factory_ARM + ;; + ppc64le) + printf '%s\n' openSUSE_Factory_PowerPC + ;; + riscv64) + printf '%s\n' openSUSE_Factory_RISCV + ;; + s390x) + printf '%s\n' openSUSE_Factory_zSystems + ;; + *) + printf '%s\n' openSUSE_Tumbleweed + ;; + esac +} + +add_nodejs_repo() { + local repo_url + + case "$nodejs_repo" in + ""|disabled|none) + return 0 + ;; + auto) + repo_url="https://download.opensuse.org/repositories/devel:/languages:/nodejs/$(nodejs_repo_flavor)/" + ;; + *) + repo_url=$nodejs_repo + ;; + esac + + zypper --non-interactive removerepo pi-web-nodejs >/dev/null 2>&1 || true + zypper --non-interactive addrepo --refresh "$repo_url" pi-web-nodejs +} + +# The codec repository is not needed for this image and can make noninteractive +# refreshes noisy or brittle when its signing key rolls independently. +zypper --non-interactive modifyrepo --disable repo-openh264 >/dev/null 2>&1 || true + +add_nodejs_repo +zypper --gpg-auto-import-keys --non-interactive refresh + +packages=( + "nodejs${nodejs_major}" + "npm${nodejs_major}" + "corepack${nodejs_major}" + "nodejs${nodejs_major}-devel" + bash + ca-certificates + curl + wget + git + git-lfs + gh + openssh-clients + procps + tini + shadow + gcc-c++ + make + python3 + python3-devel + python3-pip + python3-virtualenv + jq + ripgrep + fd + fzf + bat + ShellCheck + less + file + which + tar + gzip + xz + unzip + zip + zstd + findutils + grep + sed + gawk + patch + diffutils + util-linux + hostname + iproute2 + bind-utils + rsync +) + +extra_packages=() +if [ -n "$extra_zypper_packages" ]; then + # Intentionally split a whitespace-delimited package list supplied as a Docker + # build arg, e.g. PI_WEB_EXTRA_ZYPPER_PACKAGES="go rustup kubernetes-client". + # shellcheck disable=SC2206 + extra_packages=($extra_zypper_packages) +fi + +zypper --non-interactive install --no-recommends "${packages[@]}" "${extra_packages[@]}" + +node --version +npm --version +npx --version +python3 --version +git --version + +if ! getent group node >/dev/null 2>&1; then + groupadd --gid 1000 node +fi + +if ! id node >/dev/null 2>&1; then + useradd --uid 1000 --gid node --create-home --home-dir /home/node --shell /bin/bash node +fi + +mkdir -p /data/home /data/config /data/npm-cache /data/pi-web /data/pi-agent /workspace +chown -R node:node /data /workspace /home/node + +zypper clean --all +rm -rf /var/cache/zypp/* diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index f96cdf7..cf582de 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -3,6 +3,11 @@ name: pi-web-dev x-pi-web-dev-build: &pi-web-dev-build context: .. dockerfile: docker/Dockerfile.dev + args: + OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed} + NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} + NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} + PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} x-pi-web-dev-environment: &pi-web-dev-environment HOME: /data/home diff --git a/docker/compose.yml b/docker/compose.yml index b98ac1c..b35a6bd 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -4,6 +4,10 @@ x-pi-web-build: &pi-web-build context: . dockerfile: Dockerfile args: + OPENSUSE_IMAGE: ${PI_WEB_OPENSUSE_IMAGE:-opensuse/tumbleweed} + NODEJS_MAJOR: ${PI_WEB_NODEJS_MAJOR:-22} + NODEJS_REPO: ${PI_WEB_NODEJS_REPO:-auto} + PI_WEB_EXTRA_ZYPPER_PACKAGES: ${PI_WEB_EXTRA_ZYPPER_PACKAGES:-} PI_WEB_VERSION: ${PI_WEB_VERSION:-latest} PI_VERSION: ${PI_VERSION:-latest} CACHE_BUST: ${CACHE_BUST:-local} diff --git a/docker/install.sh b/docker/install.sh index 27fc2fb..5e26fda 100755 --- a/docker/install.sh +++ b/docker/install.sh @@ -28,6 +28,12 @@ Options: --pi-web-version VER npm @jmfederico/pi-web version pin (default: latest) --pi-version VER npm @earendil-works/pi-coding-agent version pin (default: latest) + --opensuse-image IMAGE openSUSE base image (default: opensuse/tumbleweed) + --nodejs-major MAJOR Node.js major version package to install (default: 22) + --nodejs-repo REPO Node.js zypper repository URL, auto, or disabled + (default: auto) + --extra-zypper-packages LIST + extra openSUSE packages to install during image build --asset-dir DIR Copy Docker assets from a local docker/ directory --asset-ref REF Fetch Docker assets from a Git ref (default: main) --skip-compose Write assets/.env but skip build and service recreate @@ -72,6 +78,26 @@ while [ "$#" -gt 0 ]; do PI_VERSION=$2 shift 2 ;; + --opensuse-image) + [ "$#" -ge 2 ] || die "--opensuse-image requires a value" + PI_WEB_OPENSUSE_IMAGE=$2 + shift 2 + ;; + --nodejs-major) + [ "$#" -ge 2 ] || die "--nodejs-major requires a value" + PI_WEB_NODEJS_MAJOR=$2 + shift 2 + ;; + --nodejs-repo) + [ "$#" -ge 2 ] || die "--nodejs-repo requires a value" + PI_WEB_NODEJS_REPO=$2 + shift 2 + ;; + --extra-zypper-packages) + [ "$#" -ge 2 ] || die "--extra-zypper-packages requires a value" + PI_WEB_EXTRA_ZYPPER_PACKAGES=$2 + shift 2 + ;; --asset-dir) [ "$#" -ge 2 ] || die "--asset-dir requires a value" PI_WEB_DOCKER_ASSET_DIR=$2 @@ -191,6 +217,12 @@ require_non_empty() { [ -n "$value" ] || die "$name must not be empty" } +dotenv_quote() { + value=$1 + [ -n "$value" ] || return 0 + 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 @@ -305,6 +337,7 @@ 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 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" @@ -323,6 +356,10 @@ pi_web_bind_addr=$(value_from_env_or_existing_or_default PI_WEB_BIND_ADDR 127.0. pi_web_port=$(value_from_env_or_existing_or_default PI_WEB_PORT 8504) pi_web_version=$(value_from_env_or_existing_or_default PI_WEB_VERSION latest) pi_version=$(value_from_env_or_existing_or_default PI_VERSION latest) +pi_web_opensuse_image=$(value_from_env_or_existing_or_default PI_WEB_OPENSUSE_IMAGE opensuse/tumbleweed) +pi_web_nodejs_major=$(value_from_env_or_existing_or_default PI_WEB_NODEJS_MAJOR 22) +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) 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) @@ -335,10 +372,15 @@ require_non_empty PI_WEB_BIND_ADDR "$pi_web_bind_addr" require_non_empty PI_WEB_PORT "$pi_web_port" require_non_empty PI_WEB_VERSION "$pi_web_version" require_non_empty PI_VERSION "$pi_version" +require_non_empty PI_WEB_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 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") + umask 077 temp_env=$env_file.$$ cat >"$temp_env" <