mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.
Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:
- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
container on the new dist/. This is the one supervisor whose updater does NOT
outlive the restart, which is safe only because the terminal "restarting"
marker is written first.
- node_modules and dist are named volumes over the bind mount, so
container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
`npm run build` is tsc + esbuild and node-pty has no Linux prebuild.
An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.
The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.
Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.
Documented in docs/docker-self-update.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
124 lines
4.6 KiB
Bash
124 lines
4.6 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
env_file="$script_dir/.env"
|
|
compose_file="$script_dir/docker-compose.yaml"
|
|
|
|
if [[ ! -f "$env_file" ]]; then
|
|
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
|
|
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
|
|
exit 1
|
|
fi
|
|
|
|
compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
|
|
appdata_path=$(
|
|
"${compose_command[@]}" config --environment |
|
|
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
|
)
|
|
docker_socket=$(
|
|
"${compose_command[@]}" config --environment |
|
|
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
|
|
)
|
|
|
|
if [[ -z "$appdata_path" ]]; then
|
|
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -d "$appdata_path" ]]; then
|
|
if [[ "$EUID" == '0' ]]; then
|
|
printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2
|
|
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
|
|
exit 1
|
|
fi
|
|
mkdir -p -- "$appdata_path"
|
|
fi
|
|
|
|
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
|
|
:
|
|
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
|
|
:
|
|
else
|
|
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
|
exit 1
|
|
fi
|
|
|
|
export PUID=${owner_ids%%:*}
|
|
export PGID=${owner_ids##*:}
|
|
|
|
if [[ "$PUID" == '0' ]]; then
|
|
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
|
|
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
|
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then
|
|
:
|
|
elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then
|
|
:
|
|
else
|
|
printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2
|
|
exit 1
|
|
fi
|
|
|
|
export DOCKER_SOCKET_GID=${socket_ids##*:}
|
|
|
|
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
|
|
if [[ ! -d "$repo_path" ]]; then
|
|
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
|
|
exit 1
|
|
fi
|
|
export CODEMAN_REPO_PATH="$repo_path"
|
|
|
|
# The in-app updater runs `git checkout` and `npm install` against this checkout
|
|
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
|
|
# ("detected dubious ownership") and the update fails at the first step — so warn
|
|
# here, where the fix is obvious, rather than in a failed update hours later.
|
|
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
|
|
if [[ "$repo_owner" != "$PUID" ]]; then
|
|
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
|
|
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -d "$repo_path/.git" ]]; then
|
|
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
|
fi
|
|
|
|
# Record what the container is about to be built and created FROM. The in-app
|
|
# updater compares these against the release it wants to apply: a release that
|
|
# changes either file cannot be applied by the container restarting itself (a
|
|
# restart reuses the existing image and config), so it is refused and the user
|
|
# is sent back here. Written on every start, so the baseline always describes
|
|
# the container that is actually running. See docs/docker-self-update.md.
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
|
|
elif command -v shasum >/dev/null 2>&1; then
|
|
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
|
|
else
|
|
sha256_of() { printf ''; }
|
|
fi
|
|
|
|
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
|
|
compose_sha=$(sha256_of "$compose_file")
|
|
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
|
|
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
|
|
# dataPath('docker-env-applied.json') as the server inside the container sees it.
|
|
state_dir="$appdata_path/.codeman"
|
|
mkdir -p -- "$state_dir"
|
|
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
|
|
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
|
|
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
|
|
else
|
|
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
|
fi
|
|
|
|
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
|