fix(install): harden release-image resolver — safe build fallback, ghcr digest allowlist, lint + CI coverage
Review found the build fallback could inherit a stale digest pin (compose build aborts), a slash-shaped/non-ghcr lock ref caused a doomed pull, env mode never persisted the pin (host restart used a nonexistent default tag), and the resolver test was unlinted/unrun in CI. Resolver now always leaves a coherent pin, validates ghcr.io/<owner>/<name>@sha256 refs only, exports the mode flag (drops the file-level shellcheck disable), and CI lints + runs the script unit tests.feat/v1-foundation
parent
ace3af5849
commit
4af1633ca9
|
|
@ -29,7 +29,11 @@ jobs:
|
|||
- name: shellcheck
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
|
||||
docker/wg/clusev-wg.sh docker/restart-sentinel/watch.sh install.sh update.sh scripts/*.sh
|
||||
docker/wg/clusev-wg.sh docker/restart-sentinel/watch.sh install.sh update.sh scripts/*.sh tests/scripts/*.sh
|
||||
- name: Script unit tests
|
||||
run: |
|
||||
bash tests/scripts/release-images.test.sh
|
||||
bash tests/scripts/promote-guard.test.sh
|
||||
|
||||
# Build the two prod images and push them to the PRIVATE GHCR, tagged by the version. The promote
|
||||
# job later copies the exact digests to the public GHCR — the image is only public once the release is.
|
||||
|
|
@ -153,7 +157,7 @@ jobs:
|
|||
printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \
|
||||
"${{ github.ref_name }}" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \
|
||||
"${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock
|
||||
git -C pub add release-images.lock
|
||||
git -C pub add -f release-images.lock
|
||||
git -C pub commit -q --amend --no-edit
|
||||
git -C pub tag -f "${{ github.ref_name }}"
|
||||
# Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
chmod +x src/scripts/promote.sh
|
||||
src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG"
|
||||
printf '{ "version": "%s", "app": "%s", "terminal": "%s" }\n' "$TAG" "$APP_DIGEST" "$TERM_DIGEST" > pub/release-images.lock
|
||||
git -C pub add release-images.lock
|
||||
git -C pub add -f release-images.lock
|
||||
git -C pub commit -q --amend --no-edit
|
||||
git -C pub tag -f "$TAG"
|
||||
# Publish AFTER guards, BEFORE the push (publish-then-advertise): a release ref always has an
|
||||
|
|
|
|||
|
|
@ -45,3 +45,7 @@ yarn-error.log
|
|||
/handoff.md
|
||||
/kickoff-prompt.md
|
||||
/docs/
|
||||
|
||||
# The release image pin is written ONLY into the promoted public tree by CI (git add -f there);
|
||||
# a copy must never be committed to the source tree, or a dev install would wrongly switch to pull.
|
||||
/release-images.lock
|
||||
|
|
|
|||
16
install.sh
16
install.sh
|
|
@ -253,17 +253,19 @@ info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet; Zeitzone ${HOST_TZ}"
|
|||
# ── [4/9] image ──────────────────────────────────────────────────────
|
||||
phase 4/9 "Image bauen"
|
||||
set_stage build
|
||||
# Public installs pull the exact promoted image named in release-images.lock; the dev/source tree
|
||||
# builds; staging respects the env-provided pin. The helper only reads + exports — persist the pin
|
||||
# to .env with force_kv so later `$COMPOSE up/migrate` and any manual compose call use it too.
|
||||
# Resolve the prod image source: a public install pulls the exact promoted digests from
|
||||
# release-images.lock, the dev/source tree builds, and staging respects the env-provided pin. The
|
||||
# resolver always leaves a coherent pin (a build never inherits a stale digest); persist it to .env
|
||||
# with force_kv so `$COMPOSE up/migrate` here AND any later manual/host-triggered compose use it too.
|
||||
# shellcheck source=scripts/release-images.sh disable=SC1091
|
||||
. ./scripts/release-images.sh
|
||||
clusev_resolve_release_images "release-images.lock" "$ENV_FILE"
|
||||
clusev_resolve_release_images "release-images.lock"
|
||||
force_kv CLUSEV_IMAGE "$CLUSEV_IMAGE"
|
||||
force_kv CLUSEV_TERMINAL_IMAGE "$CLUSEV_TERMINAL_IMAGE"
|
||||
case "${CLUSEV_RELEASE_MODE:-build}" in
|
||||
env) info "Image aus Umgebung vorgegeben (Pull)" ;;
|
||||
pull) force_kv CLUSEV_IMAGE "$CLUSEV_IMAGE"; force_kv CLUSEV_TERMINAL_IMAGE "$CLUSEV_TERMINAL_IMAGE"
|
||||
info "Release-Images aus release-images.lock (Pull statt Build)" ;;
|
||||
*) : ;;
|
||||
pull) info "Release-Images aus release-images.lock (Pull statt Build)" ;;
|
||||
*) info "Lokaler Build" ;;
|
||||
esac
|
||||
if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi
|
||||
info "Image bereit"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
# Clusev image-source resolver — sourced by install.sh. Defines functions only; sourcing has no
|
||||
# side effects. Decides whether to PULL a digest-pinned promoted image or BUILD locally:
|
||||
# side effects. Chooses whether to PULL a digest-pinned promoted image or BUILD locally, and ALWAYS
|
||||
# leaves a coherent pin in CLUSEV_IMAGE / CLUSEV_TERMINAL_IMAGE / CLUSEV_PULL so a stale digest from
|
||||
# a prior pull can never leak into a later `docker compose build` (which rejects a digest as a tag):
|
||||
# env — CLUSEV_IMAGE already set (staging/CI passed it) → respect it, pull.
|
||||
# pull — a public release-images.lock names valid image refs → pull exactly those digests.
|
||||
# build — no lock (dev/source tree) or an unusable lock → build locally (the tree ships Dockerfile).
|
||||
# The result is exposed via the global CLUSEV_RELEASE_MODE + exported CLUSEV_IMAGE/…/CLUSEV_PULL;
|
||||
# the caller persists the pin to .env. No file writes here (keeps it unit-testable without root).
|
||||
#
|
||||
# CLUSEV_RELEASE_MODE is a plain string flag (env|pull|build) read by the caller across the sourcing
|
||||
# boundary, so its literal assignments are deliberate and its "use" is external to this file:
|
||||
# shellcheck disable=SC2209,SC2034
|
||||
# pull — a public release-images.lock names ghcr.io digest refs → pull exactly those digests.
|
||||
# build — no lock (dev/source tree) or an unusable lock → pin the local default build tags.
|
||||
# The chosen mode is exposed via the exported global CLUSEV_RELEASE_MODE (read by the caller across
|
||||
# the sourcing boundary); the caller persists the pin to .env. No file writes here (unit-testable).
|
||||
|
||||
# clusev_json_str FILE KEY -> value of a top-level "KEY": "VALUE" string. jq if present, else sed.
|
||||
# The lock is our own single-object JSON, so the sed fallback is sufficient + dependency-free.
|
||||
|
|
@ -22,31 +20,35 @@ clusev_json_str() {
|
|||
fi
|
||||
}
|
||||
|
||||
# _clusev_ref_ok REF -> true when REF is a plausible registry ref (non-empty, contains a "/" path).
|
||||
# Catches an empty/garbage lock so it falls back to a local build instead of a doomed `pull`.
|
||||
_clusev_ref_ok() { case "$1" in */*) [ -n "$1" ] ;; *) return 1 ;; esac; }
|
||||
# _clusev_ref_ok REF -> true ONLY for a ghcr.io digest-pinned ref (ghcr.io/<owner>/<name>@sha256:…).
|
||||
# Anything looser — a bare tag, a non-ghcr host, a truncated or empty/garbage value — is rejected so
|
||||
# the caller falls back to a local build instead of a doomed or attacker-directed pull. The promote
|
||||
# CI always emits exactly this shape, so a rejected ref means a corrupt/tampered lock.
|
||||
_clusev_ref_ok() { case "$1" in ghcr.io/*/*@sha256:?*) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# clusev_resolve_release_images [LOCK] [ENVFILE] -> sets CLUSEV_RELEASE_MODE (env|pull|build) and,
|
||||
# on pull/env, exports CLUSEV_PULL (and on pull, CLUSEV_IMAGE/CLUSEV_TERMINAL_IMAGE). ENVFILE is
|
||||
# accepted for signature symmetry with the caller but intentionally NOT written here.
|
||||
# clusev_resolve_release_images [LOCK] -> sets + exports CLUSEV_IMAGE, CLUSEV_TERMINAL_IMAGE,
|
||||
# CLUSEV_PULL and CLUSEV_RELEASE_MODE (env|pull|build). Always leaves a coherent pin.
|
||||
clusev_resolve_release_images() {
|
||||
local lock="${1:-release-images.lock}"
|
||||
CLUSEV_RELEASE_MODE=build
|
||||
# 1) staging/CI already pinned the image via the environment — never override it.
|
||||
local lock="${1:-release-images.lock}" mode appref termref
|
||||
# Local build tags — must match the ${CLUSEV_IMAGE:-…}/${CLUSEV_TERMINAL_IMAGE:-…} defaults in
|
||||
# docker-compose.prod.yml. Pinned on a build so `docker compose build` never inherits a digest.
|
||||
local app_default="clusev-app:prod" term_default="clusev-terminal:prod"
|
||||
if [ -n "${CLUSEV_IMAGE:-}" ]; then
|
||||
CLUSEV_RELEASE_MODE=env
|
||||
# staging/CI already pinned the image via the environment — respect it, never override.
|
||||
export CLUSEV_TERMINAL_IMAGE="${CLUSEV_TERMINAL_IMAGE:-$term_default}"
|
||||
export CLUSEV_PULL="${CLUSEV_PULL:-1}"
|
||||
return 0
|
||||
fi
|
||||
# 2) dev/source tree has no lock — build (unchanged).
|
||||
[ -f "$lock" ] || return 0
|
||||
# 3/4) public tree: pull the exact promoted digests, or build if the lock is unusable.
|
||||
local appref termref
|
||||
appref="$(clusev_json_str "$lock" app)"
|
||||
termref="$(clusev_json_str "$lock" terminal)"
|
||||
if _clusev_ref_ok "$appref" && _clusev_ref_ok "$termref"; then
|
||||
# shellcheck disable=SC2209
|
||||
mode="env"
|
||||
elif [ -f "$lock" ] \
|
||||
&& appref="$(clusev_json_str "$lock" app)" && _clusev_ref_ok "$appref" \
|
||||
&& termref="$(clusev_json_str "$lock" terminal)" && _clusev_ref_ok "$termref"; then
|
||||
# public tree: pull exactly the promoted digests.
|
||||
export CLUSEV_IMAGE="$appref" CLUSEV_TERMINAL_IMAGE="$termref" CLUSEV_PULL=1
|
||||
CLUSEV_RELEASE_MODE=pull
|
||||
mode="pull"
|
||||
else
|
||||
# dev/source tree, or an unusable/tampered lock: build the local default tags (never a digest).
|
||||
export CLUSEV_IMAGE="$app_default" CLUSEV_TERMINAL_IMAGE="$term_default" CLUSEV_PULL=0
|
||||
mode="build"
|
||||
fi
|
||||
return 0
|
||||
export CLUSEV_RELEASE_MODE="$mode"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,51 +2,73 @@
|
|||
# Unit test for scripts/release-images.sh — pure resolver, no root/Docker.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
# shellcheck source=scripts/release-images.sh
|
||||
# shellcheck source=scripts/release-images.sh disable=SC1091
|
||||
. "$here/scripts/release-images.sh"
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
pass=0; fail=0
|
||||
ok() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); printf 'FAIL %s: want=[%s] got=[%s]\n' "$1" "$3" "$2"; fi; }
|
||||
ok() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); printf 'FAIL %s: want=[%s] got=[%s]\n' "$1" "$3" "$2"; fi; }
|
||||
|
||||
reset_env() { unset CLUSEV_IMAGE CLUSEV_TERMINAL_IMAGE CLUSEV_PULL CLUSEV_RELEASE_MODE; }
|
||||
mklock() { printf '{ "version": "v0.11.0", "app": "%s", "terminal": "%s" }\n' "$1" "$2" > "$3"; }
|
||||
|
||||
valid_lock="$tmp/valid.lock"
|
||||
printf '{ "version": "v0.11.0", "app": "ghcr.io/o/clusev@sha256:aaa", "terminal": "ghcr.io/o/clusev-terminal@sha256:bbb" }\n' > "$valid_lock"
|
||||
corrupt_lock="$tmp/corrupt.lock"
|
||||
printf '{ "version": "v0.11.0", "app": "", "terminal": "ghcr.io/o/clusev-terminal@sha256:bbb" }\n' > "$corrupt_lock"
|
||||
valid_lock="$tmp/valid.lock"; mklock "ghcr.io/o/clusev@sha256:aaa" "ghcr.io/o/clusev-terminal@sha256:bbb" "$valid_lock"
|
||||
empty_lock="$tmp/empty.lock"; mklock "" "ghcr.io/o/clusev-terminal@sha256:bbb" "$empty_lock"
|
||||
slash_lock="$tmp/slash.lock"; mklock "hello/world" "ghcr.io/o/clusev-terminal@sha256:bbb" "$slash_lock"
|
||||
notag_lock="$tmp/notag.lock"; mklock "ghcr.io/o/clusev" "ghcr.io/o/clusev-terminal@sha256:bbb" "$notag_lock"
|
||||
evil_lock="$tmp/evil.lock"; mklock "evil.io/o/clusev@sha256:aaa" "ghcr.io/o/clusev-terminal@sha256:bbb" "$evil_lock"
|
||||
|
||||
# 1) no lock -> build
|
||||
# 1) no lock -> build with local default tags, no pull
|
||||
reset_env
|
||||
clusev_resolve_release_images "$tmp/nope.lock" "$tmp/env1"
|
||||
ok "no-lock mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
ok "no-lock pull" "${CLUSEV_PULL:-unset}" "unset"
|
||||
clusev_resolve_release_images "$tmp/nope.lock"
|
||||
ok "no-lock mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
ok "no-lock image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
|
||||
ok "no-lock terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod"
|
||||
ok "no-lock pull" "${CLUSEV_PULL:-}" "0"
|
||||
|
||||
# 2) valid lock -> pull, exports set
|
||||
# 2) valid ghcr digest lock -> pull the exact digests
|
||||
reset_env
|
||||
clusev_resolve_release_images "$valid_lock" "$tmp/env2"
|
||||
ok "valid mode" "${CLUSEV_RELEASE_MODE:-}" "pull"
|
||||
clusev_resolve_release_images "$valid_lock"
|
||||
ok "valid mode" "${CLUSEV_RELEASE_MODE:-}" "pull"
|
||||
ok "valid image" "${CLUSEV_IMAGE:-}" "ghcr.io/o/clusev@sha256:aaa"
|
||||
ok "valid terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "ghcr.io/o/clusev-terminal@sha256:bbb"
|
||||
ok "valid pull" "${CLUSEV_PULL:-}" "1"
|
||||
|
||||
# 3) corrupt lock (empty app) -> build fallback, nothing exported
|
||||
# 3) empty app ref -> build fallback
|
||||
reset_env
|
||||
clusev_resolve_release_images "$corrupt_lock" "$tmp/env3"
|
||||
ok "corrupt mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
ok "corrupt image" "${CLUSEV_IMAGE:-unset}" "unset"
|
||||
clusev_resolve_release_images "$empty_lock"
|
||||
ok "empty mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
ok "empty image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
|
||||
ok "empty pull" "${CLUSEV_PULL:-}" "0"
|
||||
|
||||
# 4) env preset wins over lock
|
||||
# 4) slash-shaped garbage (has "/", no @sha256) -> build fallback (not a doomed pull)
|
||||
reset_env
|
||||
clusev_resolve_release_images "$slash_lock"
|
||||
ok "slash mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
|
||||
# 5) tag-less ghcr ref (no @sha256:) -> build fallback
|
||||
reset_env
|
||||
clusev_resolve_release_images "$notag_lock"
|
||||
ok "notag mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
|
||||
# 6) non-ghcr host, even digest-pinned -> build fallback (registry allowlist)
|
||||
reset_env
|
||||
clusev_resolve_release_images "$evil_lock"
|
||||
ok "evil mode" "${CLUSEV_RELEASE_MODE:-}" "build"
|
||||
ok "evil image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
|
||||
|
||||
# 7) env preset wins over lock; terminal defaults when unset
|
||||
reset_env
|
||||
export CLUSEV_IMAGE="ghcr.io/preset/clusev@sha256:zzz"
|
||||
clusev_resolve_release_images "$valid_lock" "$tmp/env4"
|
||||
ok "env mode" "${CLUSEV_RELEASE_MODE:-}" "env"
|
||||
ok "env image" "${CLUSEV_IMAGE:-}" "ghcr.io/preset/clusev@sha256:zzz"
|
||||
ok "env pull" "${CLUSEV_PULL:-}" "1"
|
||||
clusev_resolve_release_images "$valid_lock"
|
||||
ok "env mode" "${CLUSEV_RELEASE_MODE:-}" "env"
|
||||
ok "env image" "${CLUSEV_IMAGE:-}" "ghcr.io/preset/clusev@sha256:zzz"
|
||||
ok "env terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod"
|
||||
ok "env pull" "${CLUSEV_PULL:-}" "1"
|
||||
|
||||
# 5) json_str: known key + missing key
|
||||
# 8) json_str: known + missing key
|
||||
reset_env
|
||||
ok "json known" "$(clusev_json_str "$valid_lock" app)" "ghcr.io/o/clusev@sha256:aaa"
|
||||
ok "json known" "$(clusev_json_str "$valid_lock" app)" "ghcr.io/o/clusev@sha256:aaa"
|
||||
ok "json missing" "$(clusev_json_str "$valid_lock" nope)" ""
|
||||
|
||||
printf '\n%d passed, %d failed\n' "$pass" "$fail"
|
||||
|
|
|
|||
Loading…
Reference in New Issue