Companion to argus-appliance issue #88. update.sh no longer trusts the version manifest alone -- before pulling or restarting any service, it independently resolves each image's live registry digest and verifies it matches what the release pipeline recorded, then cosign-verifies the signature against the committed public key. Aborts (installation untouched) on any mismatch, missing tool, or ambiguity.
201 lines
9.0 KiB
Bash
Executable File
201 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# ARGUS Enterprise Web Security Platform — updater.
|
|
#
|
|
# cd /opt/argus && ./update.sh
|
|
# (or, if you don't have it locally yet)
|
|
# curl -fsSL https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/update.sh | bash
|
|
#
|
|
# Requires: docker (with the buildx plugin, bundled by default in current
|
|
# Docker/docker.io packages) and cosign (https://docs.sigstore.dev/cosign/
|
|
# installation/) on PATH. Both are used only to VERIFY what's about to be
|
|
# installed, before anything is pulled or restarted -- see below.
|
|
#
|
|
# ADR-0051 (argus-appliance repo): this pulls a specific, pinned version --
|
|
# never :latest. :latest is three independently-mutable registry tags with
|
|
# no cross-image transaction; a version resolved from latest-version.json
|
|
# is a single atomically-committed pointer that the release pipeline only
|
|
# ever writes AFTER every image it names has finished promoting AND passed
|
|
# scripts/verify-release.sh's full release-gate (argus-appliance repo's
|
|
# release.yml `release-gate` job) -- so the mere presence of a version in
|
|
# this manifest already means it passed CI-side verification. This script
|
|
# does NOT re-trust that alone: 2026-08-15 incident (see argus-appliance's
|
|
# docs/adr/0051-...md incident note) established that neither this script
|
|
# nor the Appliance GUI may ever treat a version as installable without
|
|
# independently re-verifying it here, at the point of actual install --
|
|
# a bug in the gate, a compromised pointer, or a torn registry push must
|
|
# still be caught here, not merely trusted upstream.
|
|
set -euo pipefail
|
|
|
|
INSTALL_DIR="${ARGUS_INSTALL_DIR:-/opt/argus}"
|
|
MANIFEST_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/latest-version.json"
|
|
COSIGN_PUBKEY_URL="https://git-cloud.weboria.eu/weboria/argus-installer/raw/branch/main/argus-images.pub"
|
|
IMAGE_PREFIX="git-cloud.weboria.eu/weboria/argus"
|
|
|
|
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
|
info() { printf ' %s\n' "$1"; }
|
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
|
fail() { printf ' \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
|
|
|
|
bold "ARGUS Updater"
|
|
echo
|
|
|
|
[ -f "$INSTALL_DIR/docker-compose.yml" ] || fail "No ARGUS installation found at $INSTALL_DIR (set ARGUS_INSTALL_DIR if you installed elsewhere)."
|
|
cd "$INSTALL_DIR"
|
|
|
|
API_HOST_PORT="$(grep -oP '^API_HOST_PORT=\K.*' .env 2>/dev/null || true)"
|
|
API_HOST_PORT="${API_HOST_PORT:-9080}"
|
|
|
|
BEFORE_VERSION="$(curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")"
|
|
info "Current version: ${BEFORE_VERSION}"
|
|
|
|
bold "Resolving target version..."
|
|
MANIFEST="$(curl -fsS "$MANIFEST_URL" 2>/dev/null)" \
|
|
|| fail "Could not reach the update manifest ($MANIFEST_URL). Nothing was changed -- your installation is untouched. Check your network connection and try again."
|
|
|
|
TARGET_VERSION="$(printf '%s' "$MANIFEST" | grep -oP '"version"\s*:\s*"\K[^"]+' || true)"
|
|
[ -n "$TARGET_VERSION" ] || fail "The update manifest was malformed (no 'version' field). Nothing was changed -- your installation is untouched."
|
|
info "Target version: ${TARGET_VERSION}"
|
|
|
|
if [ "$BEFORE_VERSION" = "$TARGET_VERSION" ]; then
|
|
ok "Already up to date (${TARGET_VERSION})."
|
|
exit 0
|
|
fi
|
|
|
|
# ------------------------------------------------------------------
|
|
# Fail-safe re-verification, BEFORE anything is pulled or restarted.
|
|
#
|
|
# The manifest's mere existence already implies it passed the CI-side
|
|
# release gate (see header comment) -- but this script does not stop
|
|
# there. It independently re-derives, from the registry and the committed
|
|
# public key alone (no Gitea credentials needed or used), that what it's
|
|
# about to install is really what the release pipeline promoted: the
|
|
# live image digest must match what the manifest recorded, AND that
|
|
# digest must carry a valid cosign signature. Any failure, missing tool,
|
|
# missing data, or ambiguity aborts here -- nothing is pulled, nothing is
|
|
# restarted, the running installation is untouched.
|
|
# ------------------------------------------------------------------
|
|
bold "Verifying ${TARGET_VERSION} before installing anything..."
|
|
|
|
command -v docker >/dev/null 2>&1 || fail "docker not found on PATH -- cannot verify the release. Nothing was changed."
|
|
command -v cosign >/dev/null 2>&1 || fail "cosign not found on PATH (see https://docs.sigstore.dev/cosign/installation/) -- signature verification is required before install, not optional. Nothing was changed."
|
|
|
|
COSIGN_PUBKEY_FILE="$(mktemp)"
|
|
trap 'rm -f "$COSIGN_PUBKEY_FILE"' EXIT
|
|
curl -fsS "$COSIGN_PUBKEY_URL" -o "$COSIGN_PUBKEY_FILE" 2>/dev/null \
|
|
|| fail "Could not fetch the image signing public key ($COSIGN_PUBKEY_URL). Nothing was changed -- your installation is untouched."
|
|
[ -s "$COSIGN_PUBKEY_FILE" ] || fail "Fetched signing public key was empty. Nothing was changed."
|
|
|
|
manifest_image_digest() {
|
|
printf '%s' "$MANIFEST" | python3 -c "
|
|
import json, sys
|
|
try:
|
|
print(json.load(sys.stdin).get('images', {}).get('$1', ''))
|
|
except Exception:
|
|
print('')
|
|
" 2>/dev/null
|
|
}
|
|
|
|
VERIFY_FAILED=false
|
|
for svc_component in "api:api" "ui:ui" "nginx:proxy"; do
|
|
svc="${svc_component%%:*}"
|
|
component="${svc_component##*:}"
|
|
IMG="${IMAGE_PREFIX}-${component}:${TARGET_VERSION}"
|
|
|
|
RECORDED_DIGEST="$(manifest_image_digest "$component")"
|
|
if [ -z "$RECORDED_DIGEST" ] || [ "$RECORDED_DIGEST" = "null" ]; then
|
|
info " ✗ ${svc}: manifest has no recorded digest for ${TARGET_VERSION} -- cannot verify"
|
|
VERIFY_FAILED=true
|
|
continue
|
|
fi
|
|
|
|
ACTUAL_DIGEST="$(docker buildx imagetools inspect "$IMG" --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"' || true)"
|
|
if [ -z "$ACTUAL_DIGEST" ] || [ "$ACTUAL_DIGEST" = "null" ]; then
|
|
info " ✗ ${svc}: could not resolve ${IMG} in the registry"
|
|
VERIFY_FAILED=true
|
|
continue
|
|
fi
|
|
if [ "$ACTUAL_DIGEST" != "$RECORDED_DIGEST" ]; then
|
|
info " ✗ ${svc}: registry digest ${ACTUAL_DIGEST} does not match manifest-recorded ${RECORDED_DIGEST}"
|
|
VERIFY_FAILED=true
|
|
continue
|
|
fi
|
|
|
|
if ! cosign verify --key "$COSIGN_PUBKEY_FILE" "${IMAGE_PREFIX}-${component}@${ACTUAL_DIGEST}" >/dev/null 2>&1; then
|
|
info " ✗ ${svc}: signature verification failed for ${ACTUAL_DIGEST}"
|
|
VERIFY_FAILED=true
|
|
continue
|
|
fi
|
|
|
|
info " ✓ ${svc}: digest ${ACTUAL_DIGEST} matches manifest and signature verified"
|
|
done
|
|
|
|
if [ "$VERIFY_FAILED" = "true" ]; then
|
|
fail "Release verification failed for ${TARGET_VERSION} -- treating this update as unavailable. Nothing was pulled or changed. This is not a transient error to retry blindly; investigate before trying again."
|
|
fi
|
|
ok "Release ${TARGET_VERSION} independently verified (digest + signature) for every service."
|
|
|
|
# Pinned, not :latest -- see the header comment. Exported only for this
|
|
# script's own `docker compose` invocations below, not written to .env, so
|
|
# a later manual `docker compose pull` by the operator still defaults to
|
|
# :latest exactly as it always has.
|
|
export ARGUS_VERSION="$TARGET_VERSION"
|
|
|
|
bold "Pulling ${TARGET_VERSION} (pinned, not :latest)..."
|
|
docker compose pull
|
|
|
|
bold "Restarting..."
|
|
docker compose up -d
|
|
echo
|
|
|
|
bold "Waiting for ARGUS to become healthy..."
|
|
READY=false
|
|
for _ in $(seq 1 60); do
|
|
if curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" >/dev/null 2>&1; then
|
|
READY=true
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
|
|
if [ "$READY" != "true" ]; then
|
|
fail "ARGUS did not come back healthy within 5 minutes. Check: docker compose -f $INSTALL_DIR/docker-compose.yml logs"
|
|
fi
|
|
|
|
AFTER_VERSION="$(curl -fsS "http://127.0.0.1:${API_HOST_PORT}/health" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")"
|
|
|
|
# The API being healthy only proves the API container itself came up on
|
|
# some version -- it says nothing about whether the UI and proxy images
|
|
# actually match. A torn pull (network hiccup mid-pull, disk full on one
|
|
# image, etc.) would otherwise be reported as a clean success. Check every
|
|
# service's actual running image tag against the target explicitly.
|
|
bold "Verifying every service is running ${TARGET_VERSION}..."
|
|
INCONSISTENT=false
|
|
for svc in api ui nginx; do
|
|
CONTAINER="$(docker compose ps -q "$svc" 2>/dev/null || true)"
|
|
if [ -z "$CONTAINER" ]; then
|
|
info " ✗ ${svc}: container not found"
|
|
INCONSISTENT=true
|
|
continue
|
|
fi
|
|
RUNNING_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || echo "")"
|
|
RUNNING_TAG="${RUNNING_IMAGE##*:}"
|
|
if [ "$RUNNING_TAG" = "$TARGET_VERSION" ]; then
|
|
info " ✓ ${svc}: ${RUNNING_TAG}"
|
|
else
|
|
info " ✗ ${svc}: ${RUNNING_TAG:-unknown} (expected ${TARGET_VERSION})"
|
|
INCONSISTENT=true
|
|
fi
|
|
done
|
|
|
|
if [ "$INCONSISTENT" = "true" ]; then
|
|
fail "Update did not complete cleanly -- not every service is running ${TARGET_VERSION}. Your services were NOT automatically rolled back; investigate (docker compose ps, docker compose logs) before retrying."
|
|
fi
|
|
|
|
if [ "$AFTER_VERSION" != "$TARGET_VERSION" ]; then
|
|
fail "Every service's image is ${TARGET_VERSION}, but the API's own /health still reports ${AFTER_VERSION} -- it may not have finished restarting. Check: docker compose -f $INSTALL_DIR/docker-compose.yml logs api"
|
|
fi
|
|
|
|
ok "ARGUS is healthy and every service is confirmed running ${AFTER_VERSION}"
|
|
info "Updated: ${BEFORE_VERSION} → ${AFTER_VERSION}"
|