diff --git a/.changeset/docker-helper-inline-logs.md b/.changeset/docker-helper-inline-logs.md new file mode 100644 index 0000000..12d0b24 --- /dev/null +++ b/.changeset/docker-helper-inline-logs.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Stream Docker update/restart helper logs inline after scheduling detached maintenance work. diff --git a/docker/README.md b/docker/README.md index a23d7cb..9f72d59 100644 --- a/docker/README.md +++ b/docker/README.md @@ -59,7 +59,7 @@ 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. -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. +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. After scheduling the helper, the command streams that helper's logs inline and prints the `docker logs -f` command needed to reconnect. The helper still runs independently, so work continues even when `web`, `sessiond`, or the PI WEB terminal that launched the command exits. ### Command matrix @@ -301,7 +301,7 @@ Useful development commands: ./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. 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. +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, stream the helper's logs inline after it starts, and keep update/restart work running 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 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. diff --git a/docker/pi-web-docker b/docker/pi-web-docker index 8cf2498..0880d9d 100755 --- a/docker/pi-web-docker +++ b/docker/pi-web-docker @@ -33,7 +33,8 @@ Commands: cli 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. +independent helper container first, then stream the helper logs inline. The +helper continues running if the terminal or web/sessiond exits. EOF } @@ -598,6 +599,35 @@ cleanup_old_helpers() { done } +stream_detached_helper_logs() { + helper_name=$1 + printf '\n' + printf 'Streaming detached PI WEB Docker helper logs inline.\n' + printf 'If this terminal disconnects, the helper keeps running.\n' + printf 'Reconnect with: docker logs -f %s\n' "$helper_name" + printf '\n' + + if docker logs -f "$helper_name"; then + logs_status=0 + else + logs_status=$? + fi + + if [ "$logs_status" -ne 0 ]; then + log "pi-web-docker: detached helper log streaming stopped with status $logs_status" + log "pi-web-docker: reconnect with: docker logs -f $helper_name" + return "$logs_status" + fi + + helper_status=$(docker inspect --format '{{.State.ExitCode}}' "$helper_name" 2>/dev/null || true) + if is_unsigned_int "$helper_status" && [ "$helper_status" -ne 0 ]; then + log "pi-web-docker: detached helper exited with status $helper_status" + return "$helper_status" + fi + + return 0 +} + start_detached_helper() { action=$1 is_truthy "${PI_WEB_DOCKER_RUNTIME:-}" || die "detached helpers are only available inside the PI WEB Docker runtime" @@ -675,7 +705,7 @@ start_detached_helper() { 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" + stream_detached_helper_logs "$helper_name" } run_detached_action() { diff --git a/src/docker/piWebDockerEntrypoint.test.ts b/src/docker/piWebDockerEntrypoint.test.ts new file mode 100644 index 0000000..bd3147d --- /dev/null +++ b/src/docker/piWebDockerEntrypoint.test.ts @@ -0,0 +1,92 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFile = promisify(execFileCallback); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("pi-web-docker entrypoint", () => { + it("streams detached helper logs inline after scheduling runtime updates", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-entrypoint-")); + try { + const runtimeRoot = join(tempDir, "runtime"); + const binDir = join(tempDir, "bin"); + const dockerCallsPath = join(tempDir, "docker-calls.log"); + await mkdir(runtimeRoot); + await mkdir(binDir); + await writeFile(join(runtimeRoot, ".env"), [ + `PI_WEB_DOCKER_INSTALL_DIR=${runtimeRoot}`, + "COMPOSE_PROJECT_NAME=pi-web-test", + "PI_WEB_UID=1000", + "PI_WEB_GID=1000", + "DOCKER_GID=998", + "PI_WEB_IMAGE=pi-web:test", + "", + ].join("\n")); + + const fakeDockerPath = join(binDir, "docker"); + await writeFile(fakeDockerPath, fakeDockerScript(dockerCallsPath)); + await chmod(fakeDockerPath, 0o755); + + const { stdout, stderr } = await execFile(join(repoRoot, "docker/pi-web-docker"), ["update"], { + env: { + ...process.env, + PATH: `${binDir}:${process.env["PATH"] ?? ""}`, + PI_WEB_DOCKER_RUNTIME: "1", + PI_WEB_DOCKER_MODE: "runtime", + PI_WEB_DOCKER_INSTALL_DIR: runtimeRoot, + PI_WEB_DOCKER_CONTAINER_ID: "current-web-container", + }, + }); + + const output = `${stdout}${stderr}`; + expect(output).toContain("Started detached PI WEB Docker helper: pi-web-docker-update-"); + expect(output).toContain("Streaming detached PI WEB Docker helper logs inline."); + expect(output).toContain("Reconnect with: docker logs -f pi-web-docker-update-"); + expect(output).toContain("helper log: update in progress"); + expect(output).not.toContain("Follow progress with:"); + + const dockerCalls = await readFile(dockerCallsPath, "utf8"); + expect(dockerCalls).toContain("__run-detached update"); + expect(dockerCalls).toMatch(/(?:^|\n)logs -f pi-web-docker-update-\d{14}-\d+(?:\n|$)/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +function fakeDockerScript(dockerCallsPath: string): string { + return `#!/usr/bin/env sh +set -eu +printf '%s\\n' "$*" >> ${shellQuote(dockerCallsPath)} +case "$1" in + ps) + exit 0 + ;; + run) + printf '%s\\n' fake-helper-container-id + ;; + logs) + printf '%s\\n' 'helper log: update in progress' + ;; + inspect) + printf '%s\\n' 0 + ;; + rm) + exit 0 + ;; + *) + printf 'unexpected docker command: %s\\n' "$*" >&2 + exit 42 + ;; +esac +`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 96ebbc2..f356a4d 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -309,7 +309,9 @@ describe("Docker command assets", () => { const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir)); expect(result.stdout).toContain("Started detached PI WEB Docker helper"); - expect(result.stdout).toContain("Follow progress with: docker logs -f pi-web-docker-restart-sessiond-"); + expect(result.stdout).toContain("Streaming detached PI WEB Docker helper logs inline."); + expect(result.stdout).toContain("Reconnect with: docker logs -f pi-web-docker-restart-sessiond-"); + expect(result.stdout).toContain("fake helper log"); const log = await readFile(fakeDocker.logPath, "utf8"); expect(log).toContain("container inspect"); expect(log).toContain("run -d"); @@ -518,6 +520,22 @@ case "\${1:-}" in printf 'fake-helper-container-id\n' exit 0 ;; + logs) + printf 'fake helper log\n' + exit 0 + ;; + inspect) + for arg in "$@"; do + case "$arg" in + *State.ExitCode*) + printf '0\n' + exit 0 + ;; + esac + done + printf '{}\n' + exit 0 + ;; esac printf 'unexpected fake docker args: %s\n' "$*" >&2 exit 9