#!/usr/bin/env bash # Clusev image-source resolver — sourced by install.sh. Defines functions only; sourcing has no # 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 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. 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 ONLY for a fully-formed ghcr.io digest ref: # ghcr.io//@sha256:<64 lowercase hex>, where each of / starts with an # alphanumeric and otherwise contains only [A-Za-z0-9._-] — so no slash, whitespace, newline, "=" or # any other shell/dotenv metacharacter can appear ANYWHERE in the ref. Anything looser — a bare tag, a # non-ghcr host, a short/non-hex digest, an extra or relative path segment (…/../…), or an embedded # newline in ANY segment — is rejected so the caller falls back to a local build instead of a doomed # or attacker-directed pull, and no malformed ref can reach force_kv to corrupt .env. The promote CI # always emits exactly this shape, so a rejected ref means a corrupt/tampered lock. _clusev_ref_ok() { [[ "$1" =~ ^ghcr\.io/[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*@sha256:[0-9a-f]{64}$ ]]; } # 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}" 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 # 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}" # 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 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 export CLUSEV_RELEASE_MODE="$mode" }