From a3cc563883425f9e8eb2dcab5f00a908ff17aa2b Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 3 Jul 2026 21:01:26 +0200 Subject: [PATCH] feat(install): resolve prod image from release-images.lock (pull vs build) --- scripts/release-images.sh | 52 +++++++++++++++++++++++++++ tests/scripts/release-images.test.sh | 53 ++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 scripts/release-images.sh create mode 100644 tests/scripts/release-images.test.sh diff --git a/scripts/release-images.sh b/scripts/release-images.sh new file mode 100644 index 0000000..04cac5e --- /dev/null +++ b/scripts/release-images.sh @@ -0,0 +1,52 @@ +#!/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: +# 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 + +# 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. +clusev_json_str() { + local file="$1" key="$2" + if command -v jq >/dev/null 2>&1; then + jq -er --arg k "$key" '.[$k] // empty' "$file" 2>/dev/null || true + else + sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" 2>/dev/null | head -n1 + 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_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() { + local lock="${1:-release-images.lock}" + CLUSEV_RELEASE_MODE=build + # 1) staging/CI already pinned the image via the environment — never override it. + if [ -n "${CLUSEV_IMAGE:-}" ]; then + CLUSEV_RELEASE_MODE=env + 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 + export CLUSEV_IMAGE="$appref" CLUSEV_TERMINAL_IMAGE="$termref" CLUSEV_PULL=1 + CLUSEV_RELEASE_MODE=pull + fi + return 0 +} diff --git a/tests/scripts/release-images.test.sh b/tests/scripts/release-images.test.sh new file mode 100644 index 0000000..115fa6d --- /dev/null +++ b/tests/scripts/release-images.test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 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 +. "$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; } + +reset_env() { unset CLUSEV_IMAGE CLUSEV_TERMINAL_IMAGE CLUSEV_PULL CLUSEV_RELEASE_MODE; } + +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" + +# 1) no lock -> build +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" + +# 2) valid lock -> pull, exports set +reset_env +clusev_resolve_release_images "$valid_lock" "$tmp/env2" +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 +reset_env +clusev_resolve_release_images "$corrupt_lock" "$tmp/env3" +ok "corrupt mode" "${CLUSEV_RELEASE_MODE:-}" "build" +ok "corrupt image" "${CLUSEV_IMAGE:-unset}" "unset" + +# 4) env preset wins over lock +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" + +# 5) json_str: known key + missing key +reset_env +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" +[ "$fail" -eq 0 ]