#!/usr/bin/env bash # # Scrydon guided installer. # curl -fsSL https://get.scrydon.com | bash # # Single file by design: it must be curl-able with no checkout. bash 3.2 # compatible (macOS ships 3.2). Set SCRYDON_INSTALL_LIB=1 to source it as a # library without running main. set -uo pipefail # ── Output ─────────────────────────────────────────────────────────────────── Color_Off=''; Red=''; Yellow=''; Green=''; Dim=''; Bold_White=''; Bold_Green='' if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then Color_Off='\033[0m' Red='\033[0;31m' Yellow='\033[0;33m' Green='\033[0;32m' Dim='\033[0;2m' Bold_White='\033[1m' Bold_Green='\033[1;32m' fi error() { echo -e "${Red}error${Color_Off}:" "$@" >&2; exit 1; } warn() { echo -e "${Yellow}warning${Color_Off}:" "$@" >&2; } # shellcheck disable=SC2145 # Intentional: matches the Bun installer helper shape (interpolates $@ in string). info() { echo -e "${Dim}$@ ${Color_Off}"; } # shellcheck disable=SC2145 # Intentional: matches the Bun installer helper shape (interpolates $@ in string). info_bold() { echo -e "${Bold_White}$@ ${Color_Off}"; } # shellcheck disable=SC2145 # Intentional: matches the Bun installer helper shape (interpolates $@ in string). success() { echo -e "${Green}$@ ${Color_Off}"; } # ── Portability ────────────────────────────────────────────────────────────── # Two userlands to survive: GNU (Linux) and BSD (macOS). The differences that # bite are base64 (-d vs -D), sed -i, date -d, grep -P and readlink -f. We route # base64 through openssl (identical on both, and openssl is already required) # and avoid the rest entirely. PLATFORM_OS='' detect_platform() { local uname_out uname_out="$(uname -s 2>/dev/null || echo unknown)" case "${uname_out}" in Linux*) PLATFORM_OS=linux ;; Darwin*) PLATFORM_OS=darwin ;; MINGW*|MSYS*|CYGWIN*) PLATFORM_OS=windows-bash ;; *) PLATFORM_OS=unknown ;; esac # Native Windows outside a POSIX shell cannot run this script at all. if [ "${OS:-}" = "Windows_NT" ] && [ "${PLATFORM_OS}" != "windows-bash" ]; then error "native Windows is not supported.\n" \ " Run this installer from WSL or Git Bash:\n" \ " wsl curl -fsSL https://get.scrydon.com | bash" fi printf '%s' "${PLATFORM_OS}" } # base64url -> bytes. Restores padding and maps the URL alphabet. Uses # `openssl base64` because GNU coreutils wants -d and BSD wants -D. b64url_decode() { local data="$1" pad pad=$(( ${#data} % 4 )) if [ "${pad}" -eq 2 ]; then data="${data}==" elif [ "${pad}" -eq 3 ]; then data="${data}=" elif [ "${pad}" -eq 1 ]; then return 1 fi printf '%s' "${data}" | tr -- '-_' '+/' | openssl base64 -d -A 2>/dev/null } # Minimal JSON scalar reader for jq-free operation. # # DELIBERATELY NAIVE: it matches the first occurrence of "key": anywhere in the # document, ignoring nesting. That is correct for the ONLY documents it reads — # JWT headers and payloads, whose key names (alg, exp, tier, cpuCores, …) are # unique across the whole object. Do not reuse it for arbitrary JSON; use jq. json_string() { printf '%s' "$1" | tr -d '\n' \ | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } json_number() { printf '%s' "$1" | tr -d '\n' \ | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1 } # Like json_string/json_number but for boolean fields. Uses tail -n 1 (last # occurrence) because Go's encoding/json sorts keys alphabetically, so a # field that appears late (e.g. "valid" after "message") is the genuine one. # DELIBERATELY NAIVE: safe only for known-shape documents. Use jq for general JSON. json_bool() { printf '%s' "$1" | tr -d '\n' \ | sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' | tail -n 1 } # ── Kubernetes context ─────────────────────────────────────────────────────── # Which cluster this installs into used to be ambient state: whatever kubectl's # current context happened to be. An operator ran a full install against the # production ops cluster because nothing asked and nothing said. Worse, on that # cluster "scrydon-platform" is a REAL namespace — staging lives there — so a # wrong-context run would have upgraded staging rather than failing safely. # # The chosen context is injected into every kubectl call by shadowing the # binary with a function. `command kubectl` re-resolves through PATH, so test # stubs still work; defining it here means no call site can forget the flag, # and there are 54 of them. SCRYDON_KUBE_CONTEXT="${SCRYDON_KUBE_CONTEXT:-}" SCRYDON_KUBECONFIG="${SCRYDON_KUBECONFIG:-}" kubectl() { if [ -n "${SCRYDON_KUBECONFIG}" ] && [ -n "${SCRYDON_KUBE_CONTEXT}" ]; then command kubectl --kubeconfig "${SCRYDON_KUBECONFIG}" \ --context "${SCRYDON_KUBE_CONTEXT}" "$@" elif [ -n "${SCRYDON_KUBECONFIG}" ]; then command kubectl --kubeconfig "${SCRYDON_KUBECONFIG}" "$@" elif [ -n "${SCRYDON_KUBE_CONTEXT}" ]; then command kubectl --context "${SCRYDON_KUBE_CONTEXT}" "$@" else command kubectl "$@" fi } kube_contexts() { kubectl config get-contexts -o name 2>/dev/null; } kube_current_context(){ kubectl config current-context 2>/dev/null; } # Cluster-facing Helm calls share the same target contract as kubectl. Registry, # pull, template, and version operations deliberately keep using plain `helm`. helm_nato_cluster() { KUBECONFIG="${SCRYDON_KUBECONFIG}" \ HELM_KUBEAPISERVER='' \ HELM_KUBEASGROUPS='' \ HELM_KUBEASUSER='' \ HELM_KUBECAFILE='' \ HELM_KUBECONTEXT="${SCRYDON_KUBE_CONTEXT}" \ HELM_KUBEINSECURE_SKIP_TLS_VERIFY=false \ HELM_KUBETLS_SERVER_NAME='' \ HELM_KUBETOKEN='' \ command helm "$@" } helm_cluster() { if [ -n "${SCRYDON_KUBECONFIG}" ] && [ -n "${SCRYDON_KUBE_CONTEXT}" ]; then if [ "${PROFILE:-}" = "nato-unclassified" ] \ || [ "${PROFILE_EARLY_REQUESTED:-0}" = "1" ]; then helm_nato_cluster --kubeconfig "${SCRYDON_KUBECONFIG}" \ --kube-context "${SCRYDON_KUBE_CONTEXT}" "$@" else command helm --kubeconfig "${SCRYDON_KUBECONFIG}" \ --kube-context "${SCRYDON_KUBE_CONTEXT}" "$@" fi elif [ -n "${SCRYDON_KUBECONFIG}" ]; then command helm --kubeconfig "${SCRYDON_KUBECONFIG}" "$@" elif [ -n "${SCRYDON_KUBE_CONTEXT}" ]; then command helm --kube-context "${SCRYDON_KUBE_CONTEXT}" "$@" else command helm "$@" fi } # Ask, rather than assume. --context (or SCRYDON_KUBE_CONTEXT) pre-answers it; # --non-interactive falls back to the current context so existing automation # keeps working, but the Target banner still states which one that is. select_kube_context() { [ -n "${SCRYDON_KUBE_CONTEXT}" ] && return 0 local current all count current="$(kube_current_context)" all="$(kube_contexts)" count="$(printf '%s' "${all}" | grep -c .)" if [ "${NON_INTERACTIVE}" = "1" ] || [ "${count}" -le 1 ]; then SCRYDON_KUBE_CONTEXT="${current}" return 0 fi info_bold "Which cluster should Scrydon be installed into?" info " kubectl knows about ${count} contexts. This is the single most" info " consequential answer here — everything below applies to it." # shellcheck disable=SC2086 # deliberate word-split into the option list SCRYDON_KUBE_CONTEXT="$(prompt_choice "Kubernetes context" "${current}" ${all})" echo "" } # ── License: Tier 1 (claims) ───────────────────────────────────────────── # Always runs, needs nothing but coreutils + openssl base64. Mirrors the # server's own constants: apps/license-server/jwt.go:16-20. LICENSE_ISSUER='scrydon-license' LICENSE_AUDIENCE='scrydon-platform' LICENSE_ALGORITHM='ML-DSA-65' LICENSE_CLOCK_SKEW=60 LIC_ALG=''; LIC_KID=''; LIC_ISS=''; LIC_AUD=''; LIC_EXP='' LIC_ORG=''; LIC_TIER=''; LIC_CPU=''; LIC_RAM=''; LIC_VRAM='' license_claims() { local jwt="$1" header payload now case "${jwt}" in *.*.*) : ;; *) warn "license is not a three-part JWT"; return 1 ;; esac header="$(b64url_decode "${jwt%%.*}")" || { warn "license header is not valid base64url"; return 1; } local rest="${jwt#*.}" payload="$(b64url_decode "${rest%%.*}")" || { warn "license payload is not valid base64url"; return 1; } LIC_ALG="$(json_string "${header}" alg)" LIC_KID="$(json_string "${header}" kid)" LIC_ISS="$(json_string "${payload}" iss)" LIC_AUD="$(json_string "${payload}" aud)" LIC_EXP="$(json_number "${payload}" exp)" LIC_ORG="$(json_string "${payload}" org)" LIC_TIER="$(json_string "${payload}" tier)" LIC_CPU="$(json_number "${payload}" cpuCores)" LIC_RAM="$(json_number "${payload}" ramGb)" LIC_VRAM="$(json_number "${payload}" vramGb)" [ "${LIC_ALG}" = "${LICENSE_ALGORITHM}" ] || { warn "license algorithm is '${LIC_ALG}', expected ${LICENSE_ALGORITHM}"; return 1; } [ "${LIC_ISS}" = "${LICENSE_ISSUER}" ] || { warn "license issuer is '${LIC_ISS}', expected ${LICENSE_ISSUER}"; return 1; } [ "${LIC_AUD}" = "${LICENSE_AUDIENCE}" ] || { warn "license audience is '${LIC_AUD}', expected ${LICENSE_AUDIENCE}"; return 1; } [ -n "${LIC_EXP}" ] || { warn "license has no exp claim"; return 1; } now="$(date +%s)" if [ "$(( LIC_EXP + LICENSE_CLOCK_SKEW ))" -lt "${now}" ]; then warn "license expired at epoch ${LIC_EXP}" return 1 fi return 0 } license_display() { local when # BSD date wants -r, GNU wants -d @; try both, fall back to the raw epoch. when="$(date -r "${LIC_EXP}" '+%Y-%m-%d' 2>/dev/null \ || date -d "@${LIC_EXP}" '+%Y-%m-%d' 2>/dev/null \ || printf 'epoch %s' "${LIC_EXP}")" info_bold "License" info " organization : ${LIC_ORG}" info " tier : ${LIC_TIER}" info " expires : ${when}" info " entitlements : ${LIC_CPU} CPU cores, ${LIC_RAM} GB RAM, ${LIC_VRAM} GB VRAM" } # ── License: Tier 2 (offline signature) ────────────────────────────────────── # Verifying a JWT against the publicKey carried in the SAME bundle proves # integrity, not authenticity — anyone can mint a keypair and hand over a # self-consistent bundle. So we verify against keys pinned HERE, chosen by the # header's kid. These are public keys; committing them is the point. # # Rotation = add a kid below and republish. An unknown kid degrades to # "cannot verify locally" (2), never to "invalid" (1), so a stale pin never # blocks a legitimate install. MLDSA65_SPKI_PREFIX_B64='MIIHsjALBglghkgBZQMEAxIDggehAA==' # shellcheck disable=SC2034 # documentary constant: SPKI_BYTES = DER_PREFIX(22) + RAW_KEY(1952) MLDSA65_RAW_KEY_BYTES=1952 MLDSA65_SPKI_BYTES=1974 # 22-byte DER prefix + 1952-byte raw key MLDSA65_SIG_BYTES=3309 # ML-DSA-65 signature is always exactly this pinned_key_for_kid() { case "$1" in # test — fixtures only; overridden by the harness, never shipped as valid. test) printf '%s' "${SCRYDON_PINNED_KEY_TEST:-}" ;; # Production signing key, keyId "default" — the value served by # https://license.scrydon.com/api/license/public-key, which is also the # publicKey embedded in every issued bundle. base64url raw ML-DSA-65 public # key (2603 chars), NOT PEM. # # Pinning it is what makes Tier 2 real: before this, every production # licence reported "signature: not verified here" because no key was pinned # for any kid, so a bundle whose publicKey and jwt were BOTH forged would # pass Tier 1 unchallenged. Pinning breaks that: the signature is now # checked against a key the installer carries, not one the bundle supplies. # # Verified before committing, per the runbook: this key verifies a real # licence issued by the production server (license_verify_local -> 0). # Never commit a pin you have not seen verify. default) printf '%s' "F1gudGhrhXZsEF89VjZdYofeRytWKC_pX1rhRabrl3L85xEg5dXtcBtEOBfLg-p4z5ve6gv9qWJfNnIP8ILLNbNN7VqJhoyJsetOAb_e3FG0oPNTUwv5UwnL9Q0h3PNA1My3rP9lQRvfxh9Fx579i54FGXPwKBqJYqVCPewVjAshOrgkWrUuMSIMlKKrlkPa0JK-S9hoK-VpvtRj7gJl9fe9RjDokmL-ZPaRvOTiIBbZC6JR5iYulgaNh79x1so_YdgcIHIK9h383RAu7jj_GJtgBjiDi7FY2Jd4p3_lyX7r-6o_yxFEu6rnA7GZ6BBuYiztG_zlOonIFHZRcd6pBj1G7k_NzmEV7IQnOFs807UVdJfBhU5a7BpjWfCIXGLDu11SM8psbAPZwIrbi4vJyCePfXdRzyBOm7MauQpxnuNAYKFcOkCTriyQ3zb6FQUOdNurg-KaNwvlpALCrifEnwJ7MOCiedYMY3qTIAvav7fVMOc1LBetDMHRtOf8Cd6UOo_cSrjDTyELKZruNBPJL_DL0B4K4CIDuLdY1_nvjM19Qi90KVhMPTBA0BRxgpxvFkYiHZYMdf_QT70wCxZWn8NQo5I4ZOl-8tdRps5uoFin98MiDEl8dE5JMRaLocwOjxwR7paffNPDB229jCUV6BvRAvScY1Ug_KAvsr8y7mPmfAIvuXrtRIPBfi4d3fzaHzlP0OjC-UU7KDFxJHbUVUBZU7iP-N7r3E8iwRGBzJRAYnt-tPnfht8oANZFX3q9IKyXc61iarRx1Cv8MjbAXHFtWL87RTEjhX7Uqdk_dhg-u0ffekztU8_Kl6LuOFw0x2addhOQuuoRBxbYlQndcQeUuB6i6jeTj9MpD9UlyR4w3k33YFI1s2dx9o4YK86uzk0rDM7IOMJ3pYD3vty1Jjxo9-GG5RBJ5iT7Z56YtRB4YYpc4ZnzSFo_JSA9q5e-b7VWMpt8WG-NipcShl_UHuwrYjZdY1pG_TMdzJJZRIABbrt-mGqphlLnzggKijUiZ1PFEaZu35pNVWX5LZdZDhGiVfeEZorLYohmLh9t6wyznB-CbAPY4jqYA3jnmUTaqfOZEsoJzjQ4a7JqqmfFjnX-WPWvvqHTb4SySmpbFZv2yU1W21LZlH5obgFSQQjlmLDveSZnGUu7GVB2CXxy5TW2Dv9cuT6Ox3rBgHrPnJ-YWnmqRALoHiGpstPJGwLIu0VESYLvIonl57y7k6lpKKEj-VeszlYThSijoHtAOWuV3XnE8lYlp8LRkZ56FehdNkGqPvpxGAKdgYutpyWSMg6yCO2lLJ0XD0kqFwKx1_MOcxaaAzeNggRxraDia4PFPXv7VH_6F3sEdHDKayzV7V2ARFdqMCneJ90wTwknxXphiyPZ6qQLFEp7UUbVSPvk3GiEseJx2sRGCFdQxGIny6-jESDw78caiw5GoxnZo_NJKYKM_nNS6c3QpLrJeQEgTl8pR6f4mcebrlctRACOVBKD_7lmTOFKl-7nJAVD5oPsbCCbVF6kx1dmWvbmhVD_82r8-Bwtn5OQ6_7p65mUgrFtGLFiYJWshdKrOsELEiGmnpEh1VkXd94C1nhIp2JTk4zojYiRuPT5DPTutxNP0jTluIhxxQVRMqQWmVm6SgspA0lQCNPPn7O4Qvdwjkdz5Mijbv3Ej9x27YNdsk3QJwdORuevT-46MaJ0_kCLCTE9Lo0bQn3wQ2tt8jNZaPz2uKBhsjVHV8sFvdol3nJFW8-8u7jTerRzF_64lA2vdy6otMfw2P8NBlK3yYyxUVpuvKBd74e6w-yJ1Q0HxvaJ3K2tMvbiM9qO5-2g7bokCDonukvJwrdmYU3F1xkcaqwYxrlFskRgBsVgCIZ1FxLLY7waA3NreLX-2GaMDmKgXKK2c0JY_3RwfbrVdutWdb5jTXRCcEV1CyXpkNWLK4fwnPu_0-NwJYAxLSsoapwXYvAZqM_dnEY69pitKsArGYjSbtMsNSGIajrW1T1TGxt926phC43-uern34HuHoKqM2PVHu5zBXmjefQ9GLA3DxyBWxLahQ5p5qFu4fkbqW9NX9relv0it31o_xBY_cywbx7RiPSWlw_2I26nclHvIL0Wv2x13n9gxliAkP5XwF0Cq-Oo8qCGLJP8ivxoDLIsrUGDbiBLry_Nq4jfjMHhV5VthV85t_gw2eiAesbPxjfvj5R135mVdZ0EBsdX5s0vcu_BLrQjKU6fCoqfks1URQkzfzx5EO9QhLwAdhK4d5rTWSS5ADTw9v8OLyeDcEJA1lxLKxlBPkc-WxUJ-VjM9b2aRtnP5c1zgCUeD30mQSykB-pkYhxkR3XJY3SaRTrExXmsQ2vZDvxJZHzdaehwLx539qybxMgGqjHK9Wi49o8V2nWibqmfJhhfRZVjtJMZkYK3pV-pjUNuVf5WS6VklJ3mfgpXZZJlvdZIoq_IlUz0Z9VRvn1gTSWt_ymPZB2LMU7ERrS9ZbjzJk-pz4CRGakgAnlUhakmixT7fIzwPo_ymJP-nDv31CZKPEb8gxvyiJuLG2XpJqYBQeml_jhxBC087DCDv1Obp7uqWt-OK1eoioy7N4sryQKC68vsAai2c60" ;; *) printf '' ;; esac } find_mldsa_openssl() { local c for c in openssl \ /opt/homebrew/opt/openssl@3/bin/openssl \ /usr/local/opt/openssl@3/bin/openssl \ /usr/bin/openssl /usr/local/bin/openssl; do command -v "${c}" >/dev/null 2>&1 || continue if "${c}" list -signature-algorithms 2>/dev/null | grep -qi 'ML-DSA-65'; then printf '%s' "${c}"; return 0 fi done return 1 } # 0 = verified, 1 = INVALID signature, 2 = cannot verify here license_verify_local() { local jwt="$1" ossl key tmp rc=0 ossl="$(find_mldsa_openssl)" || { info "no ML-DSA-capable OpenSSL found — skipping offline signature check." return 2 } key="$(pinned_key_for_kid "${LIC_KID}")" [ -n "${key}" ] || { info "no pinned key for kid '${LIC_KID}' — skipping offline signature check." return 2 } tmp="$(mktemp -d)" || return 2 # Reassemble the SubjectPublicKeyInfo: fixed 22-byte DER prefix + raw key. # Use ${ossl} for all crypto ops — find_mldsa_openssl located a capable binary; # bare 'openssl' may resolve to LibreSSL or nothing on some hosts. printf '%s' "${MLDSA65_SPKI_PREFIX_B64}" | "${ossl}" base64 -d -A > "${tmp}/prefix.der" b64url_decode "${key}" > "${tmp}/pk.raw" cat "${tmp}/prefix.der" "${tmp}/pk.raw" > "${tmp}/spki.der" printf '%s' "${jwt%.*}" > "${tmp}/msg.bin" # header.payload, signed as-is b64url_decode "${jwt##*.}" > "${tmp}/sig.bin" # Pre-validate fixed sizes so a pkeyutl failure is a real signature failure. # An ML-DSA-65 SPKI is always 1974 bytes; a sig is always 3309 bytes. # Structural failures (bad key, empty sig) return 2, not 1. if [ "$(wc -c < "${tmp}/spki.der" | tr -d ' ')" != "${MLDSA65_SPKI_BYTES}" ]; then rm -rf "${tmp}"; warn "could not assemble a well-formed public key for kid '${LIC_KID}'"; return 2 fi if [ "$(wc -c < "${tmp}/sig.bin" | tr -d ' ')" != "${MLDSA65_SIG_BYTES}" ]; then rm -rf "${tmp}"; warn "license signature is not an ML-DSA-65 signature (wrong length)"; return 2 fi "${ossl}" pkeyutl -verify -pubin -inkey "${tmp}/spki.der" -keyform DER \ -rawin -in "${tmp}/msg.bin" -sigfile "${tmp}/sig.bin" >/dev/null 2>&1 || rc=1 rm -rf "${tmp}" [ "${rc}" -eq 0 ] && return 0 return 1 } # ── License: Tier 3 (online status) ────────────────────────────────────────── # The ONLY tier that catches a revoked or unknown license. Deliberately sends # {"jwt": …} and nothing else: apps/license-server/handlers.go:268 binds the # license to an installation the first time it sees an installId, and a # pre-install probe must never do that. The endpoint is rate limited, so this # runs once per install, never in a loop. SCRYDON_LICENSE_API="${SCRYDON_LICENSE_API:-https://license.scrydon.com/api/license/validate}" license_validate_body() { # Body carries only the jwt — see section header above for why installId is absent. printf '{"jwt":"%s"}' "$1" } # Parse a JSON response from the license service. Tri-state: 0 = valid, # 1 = explicitly rejected (fatal), 2 = response uninterpretable (degrade). # Prefers jq when available; falls back to json_bool (safe for known-shape docs). license_parse_online_response() { local resp="$1" verdict reason if command -v jq >/dev/null 2>&1; then # jq's // operator treats false as falsy; use an explicit conditional instead. verdict="$(printf '%s' "${resp}" | \ jq -r 'if .valid == true then "true" elif .valid == false then "false" else "unknown" end' \ 2>/dev/null)" else verdict="$(json_bool "${resp}" valid)" fi case "${verdict}" in true) return 0 ;; false) reason="$(json_string "${resp}" message)" warn "license rejected by the license service: ${reason:-unknown reason}" return 1 ;; *) info "license service response could not be interpreted — skipping revocation check." return 2 ;; esac } # 0 = valid, 1 = rejected (fatal), 2 = unreachable or uninterpretable (continue) license_verify_online() { local jwt="$1" body resp body="$(license_validate_body "${jwt}")" resp="$(curl -fsS --max-time 15 -X POST \ -H 'Content-Type: application/json' \ -d "${body}" "${SCRYDON_LICENSE_API}" 2>/dev/null)" || { info "license service unreachable — skipping revocation check." return 2 } license_parse_online_response "${resp}" } # ── License: orchestrator ───────────────────────────────────────────────────── # Runs every tier the environment supports and REPORTS WHICH ONES RAN. Never # implies a check happened that did not. license_validate() { local jwt="$1" offline_rc online_rc license_claims "${jwt}" || error "license claims check failed (see above)." license_display license_verify_local "${jwt}"; offline_rc=$? case "${offline_rc}" in 0) success " signature : verified offline against the pinned Scrydon key" ;; 1) error "license signature is INVALID — this bundle was not issued by Scrydon." ;; 2) info " signature : not verified here; the platform verifies it at startup" ;; esac if [ "${OFFLINE_INSTALL:-0}" = "1" ]; then # Air-gapped skips the revocation call, so Tier 2 is the ONLY signature # check that runs. If it could not run either, nothing whatsoever has # verified this bundle and we must not pretend otherwise. # # This is not theoretical. A bundle carries BOTH the jwt and the publicKey, # and the platform verifies one against the other -- so a self-consistent # forgery satisfies it. Omitting `kid` was enough to skip Tier 2 and, with # Tier 3 off, such a bundle was accepted outright (verified: rc 0). # The pinned key is the only check a forger does not control. if [ "${offline_rc}" -eq 2 ]; then error "this licence could not be verified, and an air-gapped install runs no\n" \ " other check — refusing to continue.\n" \ " Either the signing key id ('${LIC_KID:-}') is unknown to this\n" \ " installer version (use a newer installer), or no ML-DSA-65-capable\n" \ " OpenSSL was found on this host (install OpenSSL 3.5+)." fi info " revocation : not checked (air-gapped)" return 0 fi license_verify_online "${jwt}"; online_rc=$? case "${online_rc}" in 0) success " revocation : active, not revoked" ;; 1) error "license is not usable — see the message above." ;; 2) info " revocation : not checked (license service unreachable)" ;; esac return 0 } # ── Prompting ──────────────────────────────────────────────────────────────── # Under `curl | bash` stdin IS the script, so every read must come from # /dev/tty. With no controlling terminal we refuse rather than silently # accepting defaults for things like the hostname. NON_INTERACTIVE="${NON_INTERACTIVE:-0}" require_tty() { # Open /dev/tty to confirm a controlling terminal exists and is actually # usable. A simple [ -r /dev/tty ] only checks permissions; on macOS the # file exists but returns "Device not configured" when there is no tty. { true < /dev/tty; } 2>/dev/null && return 0 error "no terminal available for prompts. Piping into bash keeps stdin busy, so prompts read from /dev/tty. In CI or a non-TTY shell, re-run with --non-interactive plus flags or an existing scrydon-install.conf." } prompt_value() { local label="$1" default="${2:-}" reply='' if [ "${NON_INTERACTIVE}" = "1" ]; then [ -n "${default}" ] || error "--non-interactive: no value for '${label}'" printf '%s' "${default}"; return 0 fi require_tty if [ -n "${default}" ]; then printf '%b' "${Bold_White}${label}${Color_Off} [${default}]: " > /dev/tty else printf '%b' "${Bold_White}${label}${Color_Off}: " > /dev/tty fi IFS= read -r reply < /dev/tty || reply='' # Trim surrounding whitespace. A pasted path routinely carries a leading space # (copying " /tmp/x.yaml" out of a table or a chat message), and an untrimmed # answer failed with "cannot read /tmp/x.yaml" -- note the double space, the # only clue -- which reads as a missing file rather than a bad answer. # None of these prompts (paths, hostnames, class names, namespaces) can carry # a meaningful leading or trailing space. reply="${reply#"${reply%%[![:space:]]*}"}" reply="${reply%"${reply##*[![:space:]]}"}" printf '%s' "${reply:-${default}}" } prompt_secret() { local label="$1" reply='' require_tty printf '%b' "${Bold_White}${label}${Color_Off}: " > /dev/tty stty -echo < /dev/tty 2>/dev/null || true IFS= read -r reply < /dev/tty || reply='' stty echo < /dev/tty 2>/dev/null || true printf '\n' > /dev/tty printf '%s' "${reply}" } prompt_choice() { local label="$1" default="$2"; shift 2 local opts="$*" reply='' if [ "${NON_INTERACTIVE}" = "1" ]; then printf '%s' "${default}"; return 0; fi require_tty printf '%b\n' "${Bold_White}${label}${Color_Off}" > /dev/tty local o for o in ${opts}; do if [ "${o}" = "${default}" ]; then printf ' %b\n' "${Bold_Green}${o}${Color_Off} (default)" > /dev/tty else printf ' %s\n' "${o}" > /dev/tty fi done # Re-prompt rather than erroring. Every caller invokes prompt_choice inside a # command substitution, and `error` is `exit 1` -- which inside $( ) kills only # the SUBSHELL. So a mistyped answer used to print an error, return the empty # string, and let the interview carry on: LOCATION="" silently means "not # airgap" and IMAGE_SOURCE="" silently means "not own", both of which change # the install method without anyone being told. Looping here means this # function can only ever return a listed option. while :; do printf '%b' "choice [${default}]: " > /dev/tty # EOF (piped/exhausted stdin) falls back to the default, matching what # prompt_value already does, instead of looping forever on a closed tty. IFS= read -r reply < /dev/tty || { printf '%s' "${default}"; return 0; } reply="${reply:-${default}}" for o in ${opts}; do [ "${o}" = "${reply}" ] && { printf '%s' "${reply}"; return 0; }; done printf '%b\n' "${Red}'${reply}' is not one of:${Color_Off} ${opts}" > /dev/tty done } # ── Dependencies ───────────────────────────────────────────────────────────── # Report EVERY missing tool at once. One-per-run is a bad experience when a # fresh machine is missing three of them. check_dependencies() { local mode="${1:-connected}" missing='' hint='' local required='curl kubectl helm openssl' [ "${mode}" = "zarf" ] && required="${required} zarf" local c for c in ${required}; do command -v "${c}" >/dev/null 2>&1 || missing="${missing} ${c}" done if [ -n "${missing}" ]; then for c in ${missing}; do case "${c}" in kubectl) hint="https://kubernetes.io/docs/tasks/tools/" ;; helm) hint="https://helm.sh/docs/intro/install/" ;; zarf) hint="https://zarf.dev/install/" ;; *) hint="your package manager" ;; esac echo -e " ${Red}missing${Color_Off}: ${c} — ${hint}" >&2 done error "install the tools above, then re-run." fi command -v jq >/dev/null 2>&1 || \ info "jq not found — license pre-seeding will be skipped (the setup wizard still accepts the bundle)." return 0 } # ── Version selection ──────────────────────────────────────────────────────────── # Published by the release job next to the script, so the picker works with NO # cluster and NO credentials. Resolving "latest" at install time is deliberately # not done: it needs credentials the operator may not have yet, and would make # two runs of the same one-liner install different versions. SCRYDON_VERSIONS_URL="${SCRYDON_VERSIONS_URL:-https://get.scrydon.com/versions.json}" INCLUDE_PRERELEASES="${INCLUDE_PRERELEASES:-0}" CHART_VERSION='' PROFILE="${PROFILE:-}" SITE_VALUES_FILE="${SITE_VALUES_FILE:-}" SITE_VALUES_SHA256="${SITE_VALUES_SHA256:-}" QUALIFICATION_TARGET_RECORD="${QUALIFICATION_TARGET_RECORD:-}" PROFILE_TMPDIR='' PROFILE_CHART_ROOT='' PROFILE_VALUES_FILE='' PROFILE_SITE_COPY='' PROFILE_EFFECTIVE_VALUES='' PROFILE_RENDERED_MANIFEST='' PROFILE_PROJECTION_FILE='' PROFILE_CHART_PREPARED=0 fetch_versions() { local raw if [ -n "${SCRYDON_VERSIONS_FILE:-}" ]; then raw="$(cat "${SCRYDON_VERSIONS_FILE}")" else raw="$(curl -fsS --max-time 15 "${SCRYDON_VERSIONS_URL}" 2>/dev/null)" \ || error "could not fetch the version list from ${SCRYDON_VERSIONS_URL}.\n" \ " Pass --version to skip the lookup." fi # Normalise: collapse all whitespace/newlines so each entry fits on one line, # then split on '{'. This makes all three common shapes (compact single-line, # one-field-per-line, fully-expanded pretty-print) parse identically. # Without the tr -d '\n' the fully-expanded shape (what `jq .` produces) # puts "version" and "status" on different lines, so json_string finds only # the one that happens to share a line with the opening '{', and the other # comes back empty. printf '%s' "${raw}" | tr -d '\n' | tr '{' '\n' | while IFS= read -r line || [ -n "${line}" ]; do case "${line}" in *'"version"'*) printf '%s %s\n' \ "$(json_string "${line}" version)" "$(json_string "${line}" status)" ;; esac done } installable_versions() { local v s fetch_versions | while read -r v s; do [ -n "${v}" ] || continue if [ "${s}" = "prerelease" ] && [ "${INCLUDE_PRERELEASES}" != "1" ]; then continue; fi printf '%s %s\n' "${v}" "${s}" done } default_version() { # No early `awk exit`: it would SIGPIPE the upstream producer, and `pipefail` # would then make this function report 141 on success. Buffer the first match # and emit it in END instead. installable_versions | awk '!f && $2=="stable"{f=$1} END{if(!f) exit 1; print f}' } select_version() { local def list if [ -n "${CHART_VERSION}" ]; then info "version : ${CHART_VERSION} (from --version)"; return 0 fi def="$(default_version)" [ -n "${def}" ] || error "no stable version available; pass --version explicitly." list="$(installable_versions | awk '{print $1}' | tr '\n' ' ')" # shellcheck disable=SC2086 CHART_VERSION="$(prompt_choice "Which version do you want to install?" "${def}" ${list})" local status status="$(installable_versions | awk -v v="${CHART_VERSION}" '$1==v{print $2}')" [ "${status}" = "eol" ] && warn "${CHART_VERSION} is end-of-life and no longer receives fixes." return 0 } # -- Registry credentials ------------------------------------------------------- # Scrydon issues the credential; the customer supplies it. Two accepted forms: # secret-file : a dockerconfigjson Secret manifest used as credential input # token : the issued name + value # Validation uses the OCI Distribution v2 token flow, identical across every # conformant registry. Credentials never appear in the process argument list. SCRYDON_REGISTRY_DEFAULT="${SCRYDON_REGISTRY_DEFAULT:-scrydonops.azurecr.io}" SCRYDON_CHART_REPO="${SCRYDON_CHART_REPO:-scrydon/charts/scrydon}" REG_HOST=''; REG_MODE=''; REG_SECRET_FILE=''; REG_USER=''; REG_PASS='' # Extract host/user/password from a dockerconfigjson blob. # # ONE parser, because there were two and only one of them learned that `auth` # is the canonical field. `auth` is base64 of "user:password"; username and # password are optional conveniences plenty of tooling omits — including this # installer, whose own applied secret carries auth alone. The copy that parsed # the operator-supplied FILE still required the optional pair, so an operator # handed a perfectly normal auth-only pull secret could not install at all. # # Sets REG_HOST / REG_USER / REG_PASS. Returns 1 if it cannot. dockerconfig_credentials() { local cfg="$1" auth decoded REG_HOST="$(printf '%s' "${cfg}" | sed -n 's/.*"auths"[[:space:]]*:[[:space:]]*{[[:space:]]*"\([^"]*\)".*/\1/p')" REG_USER="$(json_string "${cfg}" username)" REG_PASS="$(json_string "${cfg}" password)" if [ -z "${REG_USER}" ] || [ -z "${REG_PASS}" ]; then auth="$(json_string "${cfg}" auth)" [ -n "${auth}" ] || return 1 decoded="$(printf '%s' "${auth}" | openssl base64 -d -A 2>/dev/null)" || return 1 # Only the FIRST colon separates them: a password may contain colons, and # splitting anywhere else hands the registry a truncated secret that fails # as an authentication error rather than a parsing one. case "${decoded}" in *:*) : ;; *) return 1 ;; esac REG_USER="${decoded%%:*}" REG_PASS="${decoded#*:}" fi [ -n "${REG_HOST}" ] && [ -n "${REG_USER}" ] && [ -n "${REG_PASS}" ] } registry_read_secret_file() { local file="$1" b64 cfg entry_count [ -r "${file}" ] || { warn "cannot read ${file}"; return 1; } b64="$(sed -n 's/.*\.dockerconfigjson:[[:space:]]*//p' "${file}" | tr -d ' \r\n')" [ -n "${b64}" ] || { warn "${file} has no .dockerconfigjson entry"; return 1; } cfg="$(printf '%s' "${b64}" | openssl base64 -d -A 2>/dev/null)" \ || { warn "${file}: .dockerconfigjson is not valid base64"; return 1; } # Guard against merged configs with multiple registry entries. # Count "key":{ patterns; subtract 1 for the enclosing "auths":{ itself. # Works for auth-only and username/password formats alike. entry_count="$(printf '%s' "${cfg}" | grep -o '"[^"]*"[[:space:]]*:[[:space:]]*{' | wc -l | tr -d ' ')" entry_count="$((entry_count - 1))" if [ "${entry_count:-0}" -gt 1 ]; then warn "${file}: pull secret contains ${entry_count} registry entries; supply a single-registry secret." return 1 fi # {"auths":{"":{"auth":"..."}}} or the username/password long form. dockerconfig_credentials "${cfg}" \ || { warn "${file}: no usable credential (need auth, or username and password)"; return 1; } REG_SECRET_FILE="${file}" REG_MODE=secret-file return 0 } registry_summary() { # Never prints REG_PASS. Asserted by a test. info " registry : ${REG_HOST}" info " identity : ${REG_USER}" } registry_credentials_prompt() { local form info_bold "Registry access" info "Scrydon issues these during onboarding -- you should have received either a" info "pull-secret file or a token name and value." form="$(prompt_choice "Which did you receive?" secret-file secret-file token)" if [ "${form}" = "secret-file" ]; then local f f="$(prompt_value "Path to the pull-secret file" "")" registry_read_secret_file "${f}" || error "could not read the pull secret from '${f}'." else REG_MODE=token REG_HOST="$(prompt_value "Registry host" "${SCRYDON_REGISTRY_DEFAULT}")" REG_USER="$(prompt_value "Token name" "")" REG_PASS="$(prompt_secret "Token value")" [ -n "${REG_USER}" ] && [ -n "${REG_PASS}" ] || error "a token name and value are both required." fi registry_summary } # OCI Distribution v2: unauthenticated GET /v2/ returns 401 with a # WWW-Authenticate challenge naming the token realm; exchange credentials for a # bearer (via stdin, never argv), then read the chart repository's tag list. # Conformant across Harbor, Artifactory, GHCR, and any other OCI registry. # Turns registry_validate's verdict into the right message. # 0 = fine 1 = credential problem 2 = that version is not in the registry registry_validate_or_die() { local rc=0 registry_validate || rc=$? case "${rc}" in 0) return 0 ;; 2) error "version ${CHART_VERSION} is no longer available in the registry.\n" \ " Release candidates are retained only briefly, so a version the list\n" \ " still advertises may already have aged out. Choose another with\n" \ " --list-versions (add --include-prereleases to see rc builds)." ;; *) error "fix the registry credentials and re-run." ;; esac } registry_validate() { local response rc status challenge realm realm_host service scope token code # Capture response headers. curl exits non-zero on any connection failure. response="$(curl -sS --max-time 15 -D - -o /dev/null \ "https://${REG_HOST}/v2/" 2>/dev/null)"; rc=$? if [ "${rc}" -ne 0 ]; then warn "could not reach registry ${REG_HOST} -- the credential pre-check failed." return 1 fi status="$(printf '%s' "${response}" \ | sed -n 's/^HTTP[/][^ ]* \([0-9]*\).*/\1/p' | head -n 1)" challenge="$(printf '%s' "${response}" | tr -d '\r' \ | sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate: *//p' | head -n 1)" case "${status}" in 401) : ;; 2*) warn "registry ${REG_HOST}: no auth required; skipping credential validation." return 0 ;; *) warn "registry ${REG_HOST}: unexpected status ${status:-unreachable}; cannot validate." return 1 ;; esac [ -n "${challenge}" ] || { warn "registry ${REG_HOST}: 401 with no WWW-Authenticate -- cannot validate." return 1 } realm="$(printf '%s' "${challenge}" | sed -n 's/.*realm="\([^"]*\)".*/\1/p')" service="$(printf '%s' "${challenge}" | sed -n 's/.*service="\([^"]*\)".*/\1/p')" # SSRF guard: require https:// and the realm host must be REG_HOST itself or # a subdomain of it. Every conformant registry co-hosts its auth endpoint; # an unexpected realm points to a MitM or misconfigured intermediary. case "${realm}" in https://*) : ;; *) warn "realm '${realm}' is not HTTPS -- refusing to send credentials there."; return 1 ;; esac realm_host="$(printf '%s' "${realm}" | sed 's|https://\([^/:]*\).*|\1|')" case "${realm_host}" in "${REG_HOST}"|*".${REG_HOST}") : ;; *) warn "realm '${realm}' is not on ${REG_HOST} or a subdomain -- refusing."; return 1 ;; esac scope="repository:${SCRYDON_CHART_REPO}:pull" # Credentials travel via stdin (-K -), never in the process argument list. # Two-pass extraction: prefer access_token (OCI spec) then fall back to token. # Avoids [a-z_]*token which would match refresh_token or id_token first. local resp resp="$(printf 'user = %s:%s\n' "${REG_USER}" "${REG_PASS}" \ | curl -sS -K - "${realm}?service=${service}&scope=${scope}" 2>/dev/null)" token="$(printf '%s' "${resp}" \ | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" [ -n "${token}" ] || \ token="$(printf '%s' "${resp}" \ | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" [ -n "${token}" ] || { warn "the registry rejected these credentials."; return 1; } # Probe the EXACT artifact the install will pull, NOT the tag catalogue. # `tags/list` is a metadata/catalogue operation. A correctly-scoped customer # pull token carries content/read only, so a conformant registry refuses it # even though the pull it authorizes would succeed. Measured against a real # scoped token on 2026-08-25: tags/list -> 401, manifest HEAD -> 200. Because # the caller treats a non-zero return as fatal, probing tags/list rejected # EVERY correctly-scoped customer credential at the gate. if [ -z "${CHART_VERSION:-}" ]; then warn "no chart version selected yet -- skipped the artifact read-back." return 0 fi code="$(curl -sS -I -o /dev/null -w '%{http_code}' \ -H "Authorization: Bearer ${token}" \ -H 'Accept: application/vnd.oci.image.manifest.v1+json' \ -H 'Accept: application/vnd.oci.image.index.v1+json' \ -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \ "https://${REG_HOST}/v2/${SCRYDON_CHART_REPO}/manifests/${CHART_VERSION}" 2>/dev/null)" case "${code}" in 200) success " access : verified -- ${SCRYDON_CHART_REPO}:${CHART_VERSION} is readable"; return 0 ;; 401|403) warn "the credentials authenticated but cannot read ${SCRYDON_CHART_REPO}:${CHART_VERSION}."; return 1 ;; # rc 2, NOT 1: the credential is fine, the VERSION is gone. Callers said # "fix the registry credentials and re-run" for both, which sends an # operator chasing a credential problem that does not exist. Release # candidates age out of the registry quickly, so this is the ordinary # outcome of picking an rc that versions.json still advertises. 404) warn "${SCRYDON_CHART_REPO}:${CHART_VERSION} is not in ${REG_HOST}."; return 2 ;; *) warn "unexpected response ${code} reading ${SCRYDON_CHART_REPO}:${CHART_VERSION}."; return 1 ;; esac } registry_apply_secret() { local ns="$1" auth cfg nato_assert_target # Project every accepted input form through one canonical object. Replaying # an issued manifest would preserve its source namespace and fail when this # credential is replicated into another component namespace. # No credential appears in kubectl argv or a generated file. auth="$(printf '%s:%s' "${REG_USER}" "${REG_PASS}" | openssl base64 -A)" cfg="$(printf '{"auths":{"%s":{"auth":"%s"}}}' "${REG_HOST}" "${auth}" | openssl base64 -A)" printf 'apiVersion: v1\nkind: Secret\nmetadata:\n name: scrydon-registry\n namespace: %s\ntype: kubernetes.io/dockerconfigjson\ndata:\n .dockerconfigjson: %s\n' \ "${ns}" "${cfg}" | kubectl apply -n "${ns}" -f - >/dev/null \ || { warn "could not create the pull secret in ${ns}"; return 1; } return 0 } # ── Interview ──────────────────────────────────────────────────────────────── LOCATION=''; IMAGE_SOURCE=''; BACKEND=''; NAMESPACE='scrydon-platform' ROUTING_HOST=''; ROUTING_MODE='subpath'; GATEWAY_CLASS=''; STORAGE_CLASS=''; BUNDLE_TRAEFIK=false TLS_ISSUER='letsencrypt-prod'; LICENSE_FILE=''; OFFLINE_INSTALL=0 NON_INTERACTIVE="${NON_INTERACTIVE:-0}" # Backend is DERIVED, never asked. An operator should not have to know whether # their situation means helm, a registry mirror, or zarf. derive_backend() { case "${LOCATION}:${IMAGE_SOURCE}" in airgap:*) BACKEND=zarf; OFFLINE_INSTALL=1 ;; *:own) BACKEND=mirror ;; *) BACKEND=connected ;; esac } # Detected from the cluster when one is reachable, asked otherwise. This is what # lets the same script run on a laptop with no access and on a bastion with it. cluster_reachable() { kubectl cluster-info >/dev/null 2>&1; } detect_default_storage_class() { cluster_reachable || return 1 kubectl get storageclass \ -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\n"}{end}' \ 2>/dev/null \ | awk '$2=="true"{print $1; exit}' } detect_gateway_class() { cluster_reachable || return 1 kubectl get gatewayclass -o jsonpath='{.items[0].metadata.name}' 2>/dev/null } # Extract the jwt from the {jwt, publicKey} bundle. jq when present; a targeted # sed otherwise (the jwt is a single-line base64url token with no escapes, so # this is safe — unlike the publicKey, which is why pre-seeding needs jq). license_jwt_from_bundle() { local f="$1" if command -v jq >/dev/null 2>&1; then jq -r '.jwt' "${f}"; return; fi tr -d '\n' < "${f}" | sed -n 's/.*"jwt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' } # Fail if a required value is absent in non-interactive mode. The first argument # is the variable name; the second is the flag to tell the operator to pass. require_value() { eval "local v=\${$1:-}" [ -n "${v}" ] || error "--non-interactive: $1 is required; pass $2" } run_interview() { info_bold "Scrydon installer" echo "" select_version LOCATION="$(prompt_choice "Where are you deploying?" onprem onprem azure airgap)" local default_source=scrydon [ "${LOCATION}" = "airgap" ] && default_source=own IMAGE_SOURCE="$(prompt_choice "Where do images come from?" "${default_source}" scrydon own)" derive_backend info "install method: ${BACKEND} (override with --backend)" echo "" if [ "${BACKEND}" != "zarf" ]; then registry_credentials_prompt registry_validate_or_die echo "" fi LICENSE_FILE="$(prompt_value "Path to your license bundle (empty to use the setup wizard)" "")" if [ -n "${LICENSE_FILE}" ]; then [ -r "${LICENSE_FILE}" ] || error "cannot read the license bundle at '${LICENSE_FILE}'." license_validate "$(license_jwt_from_bundle "${LICENSE_FILE}")" else info "No license supplied — paste your bundle into step 1 of the setup wizard after install." fi echo "" ROUTING_HOST="$(prompt_value "Hostname users will visit" "app.example.com")" ROUTING_MODE="$(prompt_choice "Routing mode" subpath subpath subdomain)" # These three decide whether anyone can actually reach the platform, and each # fails in a way that is easy to miss. Say what the answer does and what a # wrong one costs, and show what is actually on the cluster -- the operator # should not have to already know the answer to be able to give it. # --- GatewayClass --- # Scrydon's edge is the Gateway API, so the cluster needs the # gateway.networking.k8s.io CRDs AND a GatewayClass. Unlike Ingress, neither # ships with a stock cluster, so "none found" is a likely answer here — and # the installer offers to bring one rather than leaving the operator stuck. local gc_found gc_default gc_found="$(kubectl get gatewayclass -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" # detect_gateway_class exits 0 with EMPTY output when the cluster has no # GatewayClass at all, so `|| echo traefik` never fires. Test the value. gc_default="$(detect_gateway_class 2>/dev/null || true)" [ -n "${gc_default}" ] || gc_default=traefik info "Which Gateway API controller routes traffic to Scrydon." if [ -n "${gc_found}" ]; then info " on this cluster: ${gc_found}" else info " none found on this cluster." info " Scrydon can install Traefik as part of this release: it brings the" info " Gateway API CRDs and publishes the 'traefik' GatewayClass. Decline" info " only if you will install a controller yourself first — otherwise the" info " Gateway stays unscheduled and the platform is unreachable." if prompt_yes_no "Install Traefik as part of this release?" yes; then BUNDLE_TRAEFIK=true gc_default=traefik fi fi GATEWAY_CLASS="$(prompt_value "GatewayClass" "${gc_default}")" # --- StorageClass --- local sc_default sc_found sc_found="$(kubectl get storageclass -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" sc_default="$(detect_default_storage_class 2>/dev/null || true)" info "Which StorageClass backs the databases and file storage." [ -n "${sc_found}" ] && info " on this cluster: ${sc_found}" if [ -z "${sc_default}" ]; then info " no DEFAULT class is marked, so this must name one explicitly." info " Get it wrong and every PVC stays Pending — the install hangs, then times out." fi STORAGE_CLASS="$(prompt_value "StorageClass" "${sc_default}")" [ -n "${STORAGE_CLASS}" ] || error "a StorageClass is required; PVCs stay Pending without one." # --- TLS --- local ci_found tls_default=letsencrypt-prod ci_found="$(kubectl get clusterissuer -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" info "How HTTPS certificates are issued." if [ -n "${ci_found}" ]; then info " cert-manager ClusterIssuers found: ${ci_found}" else info " no cert-manager ClusterIssuer found. Leave this EMPTY if you terminate" info " TLS yourself (load balancer, service mesh, existing certificate)." tls_default='' fi TLS_ISSUER="$(prompt_value "cert-manager cluster issuer (empty for BYO/upstream TLS)" "${tls_default}")" if [ "${BACKEND}" = "zarf" ]; then # The package deploys to its own pinned namespace; asking would only offer # an answer zarf_validate_target then refuses. NAMESPACE="${ZARF_PACKAGE_NAMESPACE}" else NAMESPACE="$(prompt_value "Namespace" "scrydon-platform")" fi } write_values_file() { local path="$1" tls_block='' traefik_block='' [ -n "${TLS_ISSUER}" ] && tls_block=" clusterIssuer: ${TLS_ISSUER}" # Bundled controller: the chart installs Traefik, which supplies both the # Gateway API CRDs and the `traefik` GatewayClass. Off unless asked for. [ "${BUNDLE_TRAEFIK}" = "true" ] && traefik_block=$'\ntraefik:\n enabled: true' local tls_enabled if [ -n "${TLS_ISSUER}" ]; then tls_enabled=true; else tls_enabled=false; fi ( umask 077 cat > "${path}" < "${path}" </dev/null || true _SCRYDON_LCF_PREV_CMD='' _SCRYDON_LCF_PREV_TRAP='' fi } load_conf_file() { local path="$1" lineno=0 line prev_trap_str [ -r "${path}" ] || error "cannot read ${path}" # Save any existing EXIT trap command so _scrydon_lcf_cleanup can invoke it # directly. Task 11 installs trap cleanup_license_tmpdir EXIT; a bare # "trap - EXIT" on the success path, or a non-chaining cleanup on the error # path, would silently skip the licence JWT removal. trap -p EXIT returns # "trap -- 'CMD' EXIT"; the test suite asserts this format is consistent # across the bash versions present on the target machine. # strip the wrapper to get the raw command string. prev_trap_str="$(trap -p EXIT)" if [ -n "${prev_trap_str}" ]; then # Full re-executable string — used on the success path to re-register. _SCRYDON_LCF_PREV_TRAP="${prev_trap_str}" # Stripped action (function name) — used in _scrydon_lcf_cleanup for # direct invocation. Trap handlers MUST be plain function names; inline # commands with embedded single quotes are not supported here because # bash's trap -p quoting cannot be reliably reversed for direct eval. _SCRYDON_LCF_PREV_CMD="$(printf '%s' "${prev_trap_str}" | sed "s/^trap -- '//;s/' EXIT$//")" else _SCRYDON_LCF_PREV_TRAP='' _SCRYDON_LCF_PREV_CMD='' fi # shellcheck disable=SC2155 _SCRYDON_LCF_TMP="$(mktemp)" || error "load_conf_file: could not create a temporary file" trap '_scrydon_lcf_cleanup' EXIT # shellcheck disable=SC1003 tr -d '\r' < "${path}" > "${_SCRYDON_LCF_TMP}" # Validate the CR-stripped copy line by line. Every line must be a comment, # blank, or a KEY=value assignment (key starts with uppercase/underscore). # Also reject command substitution in values — $(...), backticks, ${ — # the tampered-file-over-network threat this validator was built for. while IFS= read -r line || [ -n "${line}" ]; do lineno=$((lineno + 1)) case "${line}" in ''|'#'*) : ;; # blank or comment — OK [A-Z_]*=*) # well-formed assignment — check value for command substitution # shellcheck disable=SC2016 # single-quoted patterns are intentional literals case "${line}" in *'$('*|*'`'*|*'${'*) error "load_conf_file: command substitution is not allowed in ${path} (line ${lineno}): ${line}" ;; esac # Allowlist the key — only keys written by write_conf_file are accepted. # A key like LICENSE_TMPDIR matches the shape check but would let a # tampered conf aim the rm -rf defence at an arbitrary path. _lcf_key="${line%%=*}" case " ${SCRYDON_CONF_KEYS} " in *" ${_lcf_key} "*) : ;; *) error "load_conf_file: key '${_lcf_key}' is not a permitted conf key (line ${lineno}): ${line}" ;; esac ;; *) error "load_conf_file: unsafe content at line ${lineno}: ${line}" ;; esac done < "${_SCRYDON_LCF_TMP}" # shellcheck disable=SC1090 # runtime-chosen path is the point . "${_SCRYDON_LCF_TMP}" rm -f "${_SCRYDON_LCF_TMP}"; _SCRYDON_LCF_TMP='' trap - EXIT # Re-register (not execute) the previous EXIT trap so it fires when the # process eventually exits — e.g. Task 11's cleanup_license_tmpdir fires # on process exit, not in the middle of a successful conf load. if [ -n "${_SCRYDON_LCF_PREV_TRAP}" ]; then eval "${_SCRYDON_LCF_PREV_TRAP}" 2>/dev/null || true _SCRYDON_LCF_PREV_TRAP='' _SCRYDON_LCF_PREV_CMD='' fi } # ── NATO UNCLASSIFIED profile contract ────────────────────────────────────── # This path is intentionally stricter than the default installer. It consumes # only the profile stamped into the exact pulled chart, permits a narrow set of # site fields, and binds every cluster operation to the disposable target record # written by scripts/security/nato-aks-qualification.sh. PROFILE_EARLY_REQUESTED=0 NATO_SUBSCRIPTION_ID='' NATO_RUN_ID='' NATO_RESOURCE_GROUP='' NATO_CLUSTER_NAME='' NATO_AKS_RESOURCE_ID='' NATO_AKS_FQDN='' NATO_KUBE_SERVER='' NATO_KUBE_SYSTEM_UID='' NATO_SUBSTRATE_RECORD='' profile_sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } profile_detect_early_request() { local profile_line='' PROFILE_EARLY_REQUESTED=0 case "${PROFILE}" in nato-unclassified) PROFILE_EARLY_REQUESTED=1; return 0 ;; '') : ;; *) error "unknown profile '${PROFILE}'. Supported profile: nato-unclassified" ;; esac [ -n "${CONF_FILE:-}" ] && [ -r "${CONF_FILE}" ] || return 0 profile_line="$(sed -n '/^PROFILE=/p' "${CONF_FILE}" | tail -n 1)" case "${profile_line}" in 'PROFILE="nato-unclassified"') PROFILE_EARLY_REQUESTED=1 ;; 'PROFILE=""'|'') : ;; *) error "${CONF_FILE} contains an unsupported or unsafe PROFILE entry" ;; esac } profile_validate_early_targeting() { [ "${PROFILE_EARLY_REQUESTED}" = "1" ] || return 0 [ -n "${SCRYDON_KUBECONFIG}" ] \ || error "--profile nato-unclassified requires --kubeconfig " [ -n "${SCRYDON_KUBE_CONTEXT}" ] \ || error "--profile nato-unclassified requires --context " [ -n "${QUALIFICATION_TARGET_RECORD}" ] \ || error "--profile nato-unclassified requires --qualification-target " [ -n "${SITE_VALUES_FILE}" ] \ || error "--profile nato-unclassified requires --site-values " [ -n "${CHART_VERSION}" ] \ || error "--profile nato-unclassified requires an explicit --version before cluster access" case "${SCRYDON_KUBECONFIG}" in /*) : ;; *) error "--kubeconfig must be an absolute path for nato-unclassified" ;; esac case "${QUALIFICATION_TARGET_RECORD}" in /*) : ;; *) error "--qualification-target must be an absolute path" ;; esac case "${SITE_VALUES_FILE}" in /*) : ;; *) error "--site-values must be an absolute path for nato-unclassified" ;; esac case "${SCRYDON_KUBECONFIG}" in *:*) error "--kubeconfig must name one file, not a path list" ;; esac [ "${SCRYDON_KUBECONFIG}" != "${HOME:-}/.kube/config" ] \ || error "the normal ~/.kube/config is forbidden for nato-unclassified qualification" [ -f "${SCRYDON_KUBECONFIG}" ] && [ ! -L "${SCRYDON_KUBECONFIG}" ] \ || error "the dedicated kubeconfig must be a regular, non-symlink file" [ -f "${QUALIFICATION_TARGET_RECORD}" ] && [ ! -L "${QUALIFICATION_TARGET_RECORD}" ] \ || error "the qualification target record must be a regular, non-symlink file" [ -f "${SITE_VALUES_FILE}" ] && [ ! -L "${SITE_VALUES_FILE}" ] \ || error "the site values must be a regular, non-symlink file" } profile_require_dependencies() { [ "${PROFILE}" = "nato-unclassified" ] || [ "${PROFILE_EARLY_REQUESTED}" = "1" ] || return 0 local command_name missing='' for command_name in jq yq tar az; do command -v "${command_name}" >/dev/null 2>&1 || missing="${missing} ${command_name}" done [ -z "${missing}" ] \ || error "the nato-unclassified profile requires:${missing}. Install them, then re-run." } profile_valid_ipv4_address() { local address="$1" octet local -a octets IFS=. read -r -a octets <<<"${address}" [ "${#octets[@]}" -eq 4 ] || return 1 for octet in "${octets[@]}"; do [[ "${octet}" =~ ^(0|[1-9][0-9]{0,2})$ ]] || return 1 (( 10#${octet} <= 255 )) || return 1 done } profile_valid_ipv4_cidr() { local cidr="$1" address prefix [[ "${cidr}" == */* ]] || return 1 address="${cidr%/*}" prefix="${cidr##*/}" [[ "${prefix}" =~ ^(0|[1-9]|[12][0-9]|3[0-2])$ ]] || return 1 profile_valid_ipv4_address "${address}" } profile_site_get() { yq -er "$1" "${SITE_VALUES_FILE}" } profile_validate_site_values() { local docs paths path value cidrs cidr docs="$(yq ea -N 'documentIndex' "${SITE_VALUES_FILE}" 2>/dev/null \ | awk '!seen[$0]++{n++} END{print n+0}')" \ || error "--site-values is not valid YAML" [ "${docs}" -eq 1 ] || error "--site-values must contain exactly one YAML document" yq -e 'tag == "!!map"' "${SITE_VALUES_FILE}" >/dev/null 2>&1 \ || error "--site-values must contain one YAML mapping" paths="$(yq -r '.. | select(tag != "!!map" and tag != "!!seq") | path | join(".")' \ "${SITE_VALUES_FILE}")" || error "could not inspect --site-values" [ -n "${paths}" ] || error "--site-values may not be empty" while IFS= read -r path; do if [[ "${path}" =~ ^infra\.db\.backup\.offCluster\.egressCidrs\.[0-9]+$ ]]; then continue fi case "${path}" in auth.authSecret|auth.secrets|auth.secrets.*|platform.secrets|platform.secrets.*|\ analytics.secrets|analytics.secrets.*|cortex.secrets|cortex.secrets.*|\ horizon.secrets|horizon.secrets.*|\ apiOntology.secrets|apiOntology.secrets.*|apiTable.secrets|apiTable.secrets.*|\ agentic.secrets|agentic.secrets.*|runtimePlane.secrets|runtimePlane.secrets.*|\ infra.db.credentials.password|eventBackbone.valkey.password) error "--site-values may reference Secret names, but may not contain inline secret path '${path}'" ;; esac case "${path}" in networkPolicies.licenseApiEgress.enabled|\ networkPolicies.licenseApiEgress.cidr|\ networkPolicies.licenseApiEgress.port|\ networkPolicies.kubernetesApiEgress.enabled|\ networkPolicies.kubernetesApiEgress.cidr|\ networkPolicies.kubernetesApiEgress.port|\ runtimePlane.upstreamDns|\ runtimePlane.isolation.microvm.nodeGroupPrefix|\ infra.db.backup.offCluster.endpoint|\ infra.db.backup.offCluster.container|\ infra.db.backup.offCluster.objectPrefix|\ infra.db.backup.offCluster.credentialSecret.name|\ infra.db.backup.offCluster.credentialSecret.key) : ;; *) error "--site-values path '${path}' is not site-overridable in nato-unclassified" ;; esac done <<<"${paths}" [ "$(profile_site_get '.networkPolicies.licenseApiEgress.enabled')" = "true" ] \ || error "site values must enable the reviewed license API egress rule" [ "$(profile_site_get '.networkPolicies.licenseApiEgress.port')" = "443" ] \ || error "site values must constrain license API egress to TCP/443" value="$(profile_site_get '.networkPolicies.licenseApiEgress.cidr')" profile_valid_ipv4_cidr "${value}" && [ "${value##*/}" = "32" ] \ || error "license API egress must be one reviewed IPv4 /32" [ "$(profile_site_get '.networkPolicies.kubernetesApiEgress.enabled')" = "true" ] \ || error "site values must enable the Kubernetes API egress rule" [ "$(profile_site_get '.networkPolicies.kubernetesApiEgress.port')" = "443" ] \ || error "site values must constrain Kubernetes API egress to TCP/443" value="$(profile_site_get '.networkPolicies.kubernetesApiEgress.cidr')" profile_valid_ipv4_cidr "${value}" && [ "${value##*/}" = "32" ] \ || error "Kubernetes API egress must be one IPv4 /32" value="$(profile_site_get '.runtimePlane.upstreamDns')" if ! [[ "${value}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}:53$ ]] \ || ! profile_valid_ipv4_address "${value%:53}"; then error "runtimePlane.upstreamDns must be a valid IPv4 address on port 53" fi value="$(profile_site_get '.runtimePlane.isolation.microvm.nodeGroupPrefix')" [[ "${value}" =~ ^[a-z0-9][a-z0-9-]{1,30}$ ]] \ || error "the Kata node-group prefix has an invalid shape" value="$(profile_site_get '.infra.db.backup.offCluster.endpoint')" [[ "${value}" =~ ^https://[a-z0-9]{3,24}\.blob\.core\.windows\.net$ ]] \ || error "off-cluster backup endpoint must name one HTTPS Azure Blob account" value="$(profile_site_get '.infra.db.backup.offCluster.container')" [[ "${value}" =~ ^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$ ]] \ || error "off-cluster backup container has an invalid Azure container name" value="$(profile_site_get '.infra.db.backup.offCluster.objectPrefix')" [[ "${value}" =~ ^[a-z0-9][a-z0-9._/-]*$ ]] && [[ "${value}" != *..* ]] \ || error "off-cluster backup objectPrefix has an unsafe shape" value="$(profile_site_get '.infra.db.backup.offCluster.credentialSecret.name')" [[ "${value}" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] \ || error "backup credentialSecret.name must be a Kubernetes DNS name" value="$(profile_site_get '.infra.db.backup.offCluster.credentialSecret.key')" [[ "${value}" =~ ^[A-Za-z0-9._-]+$ ]] \ || error "backup credentialSecret.key has an invalid shape" yq -e '.infra.db.backup.offCluster.egressCidrs | ((tag == "!!seq") and (length > 0) and ([.[] | tag == "!!str"] | all))' \ "${SITE_VALUES_FILE}" >/dev/null 2>&1 \ || error "off-cluster backup egressCidrs must be a non-empty sequence of strings" cidrs="$(yq -er '.infra.db.backup.offCluster.egressCidrs[]' "${SITE_VALUES_FILE}")" \ || error "off-cluster backup requires at least one private-endpoint CIDR" while IFS= read -r cidr; do profile_valid_ipv4_cidr "${cidr}" && [ "${cidr##*/}" = "32" ] \ || error "backup egress CIDR '${cidr}' must be one private IPv4 /32" case "${cidr%/*}" in 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) : ;; *) error "backup egress CIDR '${cidr}' is not a private-endpoint address" ;; esac done <<<"${cidrs}" } profile_validate_inputs() { [ -n "${SITE_VALUES_FILE}" ] || [ -z "${PROFILE}" ] \ || error "--profile nato-unclassified requires --site-values" if [ -n "${SITE_VALUES_FILE}" ] && [ -z "${PROFILE}" ]; then error "--site-values is accepted only with --profile nato-unclassified" fi [ -n "${QUALIFICATION_TARGET_RECORD}" ] || [ -z "${PROFILE}" ] \ || error "--profile nato-unclassified requires --qualification-target" case "${PROFILE}" in '') return 0 ;; nato-unclassified) : ;; *) error "unknown profile '${PROFILE}'. Supported profile: nato-unclassified" ;; esac [ "${BACKEND}" = "connected" ] \ || error "nato-unclassified currently requires the connected OCI backend" [ "${IS_UPGRADE}" = "0" ] \ || error "nato-unclassified qualification requires a fresh release, not an in-place upgrade" profile_require_dependencies profile_validate_early_targeting profile_validate_site_values local actual_site_sha expected_site_sha expected_site_sha="${SITE_VALUES_SHA256}" actual_site_sha="$(profile_sha256_file "${SITE_VALUES_FILE}")" \ || error "could not hash --site-values" [[ "${actual_site_sha}" =~ ^[0-9a-f]{64}$ ]] || error "invalid site-values SHA-256" if [ -n "${expected_site_sha}" ] && [ "${expected_site_sha}" != "${actual_site_sha}" ]; then error "--site-values SHA-256 does not match the reviewed hash in the conf file" fi SITE_VALUES_SHA256="${actual_site_sha}" } nato_is_protected_identifier() { local lower lower="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" case "${lower}" in *app.staging.scrydon.com*|*app.scrydon.com*|*staging*|*production*|\ *scrydon-stage*|*scrydon-prod*|*scrydon-ops*|*scrydon-operations*) return 0 ;; esac [[ "${lower}" =~ (^|[-._/])(prod|production|stage|staging|ops|operations|main)([-._/]|$) ]] } nato_assert_not_protected() { nato_is_protected_identifier "$2" \ && error "$1 '$2' matches a protected production/staging/operations identity" return 0 } nato_az() { case "${1:-}:${2:-}:${3:-}:${4:-}" in account:show:*|group:show:*|aks:show:*|storage:account:show:*|\ network:private-endpoint-connection:list:*|\ storage:account:management-policy:show|\ storage:container:immutability-policy:show) : ;; *) error "the NATO installer Azure wrapper permits read-only qualification checks only" ;; esac command az "$@" --subscription "${NATO_SUBSCRIPTION_ID}" } nato_load_target_record() { local record_candidate expected_group expected_cluster expected_resource_id local normalized_resource_id normalized_expected_resource_id jq -e 'type == "object" and .schema == "scrydon-nato-qualification-target/v1"' \ "${QUALIFICATION_TARGET_RECORD}" >/dev/null \ || error "qualification target record has an invalid schema" NATO_SUBSCRIPTION_ID="$(jq -er '.subscriptionId | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target subscriptionId must be a non-empty string" NATO_RUN_ID="$(jq -er '.runId | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target runId must be a non-empty string" NATO_RESOURCE_GROUP="$(jq -er '.resourceGroup | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target resourceGroup must be a non-empty string" NATO_CLUSTER_NAME="$(jq -er '.clusterName | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target clusterName must be a non-empty string" NATO_AKS_RESOURCE_ID="$(jq -er '.aksResourceId | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target aksResourceId must be a non-empty string" NATO_AKS_FQDN="$(jq -er '.aksFqdn | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target aksFqdn must be a non-empty string" NATO_KUBE_SERVER="$(jq -er '.kubeServer | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target kubeServer must be a non-empty string" NATO_KUBE_SYSTEM_UID="$(jq -er '.kubeSystemUid | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target kubeSystemUid must be a non-empty string" record_candidate="$(jq -er '.candidate | select(type == "string" and length > 0)' "${QUALIFICATION_TARGET_RECORD}")" \ || error "qualification target candidate must be a non-empty string" [[ "${NATO_SUBSCRIPTION_ID}" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] \ || error "qualification target subscriptionId must be an Azure subscription UUID" [[ "${NATO_RUN_ID}" =~ ^[a-z0-9][a-z0-9-]{5,31}$ ]] \ || error "qualification target runId must be 6-32 lowercase letters, digits, or hyphens" [[ "${record_candidate}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \ || error "qualification target candidate must be an exact v-prefixed semantic version" [ "${record_candidate}" = "${CHART_VERSION}" ] \ || error "qualification target candidate does not match ${CHART_VERSION}" expected_group="rg-scrydon-nato-${NATO_RUN_ID}" expected_cluster="aks-scrydon-nato-${NATO_RUN_ID}" [ "${NATO_RESOURCE_GROUP}" = "${expected_group}" ] \ || error "qualification target resource group must be exactly ${expected_group}" [ "${NATO_CLUSTER_NAME}" = "${expected_cluster}" ] \ || error "qualification target cluster must be exactly ${expected_cluster}" [ "${SCRYDON_KUBE_CONTEXT}" = "${expected_cluster}" ] \ || error "qualification target context must be exactly ${expected_cluster}" expected_resource_id="/subscriptions/${NATO_SUBSCRIPTION_ID}/resourceGroups/${expected_group}/providers/Microsoft.ContainerService/managedClusters/${expected_cluster}" normalized_resource_id="$(printf '%s' "${NATO_AKS_RESOURCE_ID}" | tr '[:upper:]' '[:lower:]')" normalized_expected_resource_id="$(printf '%s' "${expected_resource_id}" | tr '[:upper:]' '[:lower:]')" [ "${normalized_resource_id}" = "${normalized_expected_resource_id}" ] \ || error "qualification target AKS resource ID is outside the exact disposable resource group and cluster" [[ "${NATO_AKS_FQDN}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] \ && [[ "${NATO_AKS_FQDN}" == *.* ]] \ || error "qualification target AKS FQDN has an invalid DNS shape" [[ "${NATO_KUBE_SYSTEM_UID}" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] \ || error "qualification target kube-system UID must be a UUID" [ "$(jq -er '.kubeconfig' "${QUALIFICATION_TARGET_RECORD}")" = "${SCRYDON_KUBECONFIG}" ] \ || error "qualification target kubeconfig does not match --kubeconfig" [ "$(jq -er '.kubeContext' "${QUALIFICATION_TARGET_RECORD}")" = "${SCRYDON_KUBE_CONTEXT}" ] \ || error "qualification target context does not match --context" nato_assert_not_protected "resource group" "${NATO_RESOURCE_GROUP}" nato_assert_not_protected "cluster" "${NATO_CLUSTER_NAME}" nato_assert_not_protected "AKS resource ID" "${NATO_AKS_RESOURCE_ID}" nato_assert_not_protected "AKS FQDN" "${NATO_AKS_FQDN}" nato_assert_not_protected "context" "${SCRYDON_KUBE_CONTEXT}" NATO_SUBSTRATE_RECORD="$(dirname "${QUALIFICATION_TARGET_RECORD}")/substrate.json" } nato_assert_target() { [ "${PROFILE}" = "nato-unclassified" ] || [ "${PROFILE_EARLY_REQUESTED}" = "1" ] || return 0 nato_load_target_record local contexts current server uid account aks_json group_json contexts="$(kubectl config get-contexts -o name 2>/dev/null)" \ || error "cannot read the dedicated kubeconfig" [ "$(printf '%s\n' "${contexts}" | awk 'NF{n++} END{print n+0}')" -eq 1 ] \ || error "the NATO kubeconfig must contain exactly one context" [ "${contexts}" = "${SCRYDON_KUBE_CONTEXT}" ] \ || error "the NATO kubeconfig contains an unexpected context" current="$(kubectl config current-context 2>/dev/null)" \ || error "the NATO kubeconfig has no current context" [ "${current}" = "${SCRYDON_KUBE_CONTEXT}" ] \ || error "the NATO kubeconfig current context changed" server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' 2>/dev/null)" \ || error "cannot read the NATO kubeconfig API server" [ "${server}" = "${NATO_KUBE_SERVER}" ] \ || error "the kubeconfig API server no longer matches the target record" case "${server}" in "https://${NATO_AKS_FQDN}"|"https://${NATO_AKS_FQDN}:443") : ;; *) error "the kubeconfig API server does not match the recorded AKS FQDN" ;; esac nato_assert_not_protected "API server" "${server}" account="$(nato_az account show --query id -o tsv 2>/dev/null)" \ || error "cannot inspect the recorded Azure subscription" [ "${account}" = "${NATO_SUBSCRIPTION_ID}" ] \ || error "Azure subscription identity changed" aks_json="$(nato_az aks show --resource-group "${NATO_RESOURCE_GROUP}" \ --name "${NATO_CLUSTER_NAME}" -o json 2>/dev/null)" \ || error "cannot inspect the recorded AKS cluster" [ "$(printf '%s' "${aks_json}" | jq -er '.id')" = "${NATO_AKS_RESOURCE_ID}" ] \ || error "AKS resource ID no longer matches the target record" [ "$(printf '%s' "${aks_json}" | jq -er '.fqdn')" = "${NATO_AKS_FQDN}" ] \ || error "AKS FQDN no longer matches the target record" group_json="$(nato_az group show --name "${NATO_RESOURCE_GROUP}" -o json 2>/dev/null)" \ || error "cannot inspect the qualification resource group" printf '%s' "${group_json}" | jq -e \ --arg run "$(jq -er '.runId' "${QUALIFICATION_TARGET_RECORD}")" \ --arg candidate "${CHART_VERSION}" \ '.tags."qualification-run" == $run and .tags."managed-by" == "scrydon-nato-qualification" and .tags.candidate == $candidate' \ >/dev/null || error "qualification resource-group tags no longer match the target record" uid="$(kubectl get namespace kube-system -o jsonpath='{.metadata.uid}' 2>/dev/null)" \ || error "cannot inspect kube-system identity" [ "${uid}" = "${NATO_KUBE_SYSTEM_UID}" ] \ || error "kube-system namespace UID no longer matches the target record" } profile_require_effective_value() { local path="$1" expected="$2" label="$3" actual actual="$(yq -r "${path}" "${PROFILE_EFFECTIVE_VALUES}")" \ || error "could not inspect effective value ${path}" [ "${actual}" = "${expected}" ] \ || error "nato-unclassified invariant '${label}' expected '${expected}', found '${actual}'" } profile_validate_effective_values() { local digest image sha profile_require_effective_value '.global.requireImageDigests' true 'image digests required' profile_require_effective_value '.admission.imageVerification.enabled' true 'image admission enabled' profile_require_effective_value '.podSecurityStandards.enabled' true 'Pod Security Standards enabled' profile_require_effective_value '.podSecurityStandards.enforce' restricted 'PSS enforce level' profile_require_effective_value '.podSecurityStandards.hardenAppContainers' true 'application container hardening' profile_require_effective_value '.podSecurityStandards.readOnlyRootFilesystem' true 'read-only root filesystem' profile_require_effective_value '.networkPolicies.enabled' true 'chart-wide NetworkPolicies' profile_require_effective_value '.networkPolicies.strictNamespaceIsolation' true 'strict workload network isolation' profile_require_effective_value '.gateway.publicAuthRateLimit.enabled' true 'public auth throttling' profile_require_effective_value '.auth.requireSignedExtensionSources' true 'signed extension sources' profile_require_effective_value '.executionFabric.managedProcess.enabled' false 'managed-process execution disabled' profile_require_effective_value '.runtimePlane.enabled' true 'Runtime Plane enabled' profile_require_effective_value '.runtimePlane.isolation.microvm.renderRuntimeClass' false 'AKS-owned Kata RuntimeClass' profile_require_effective_value '.dapr.enabled' true 'Dapr integration enabled' profile_require_effective_value '.dapr.installControlPlane' false 'bundled Dapr disabled' profile_require_effective_value '.dapr.controlPlaneNamespace' dapr-system 'reviewed Dapr namespace' profile_require_effective_value '.infra.db.backup.enabled' true 'local backup enabled' profile_require_effective_value '.infra.db.backup.offCluster.enabled' true 'off-cluster backup enabled' sha="$(yq -er '.runtimePlane.vendorCandidateSha' "${PROFILE_EFFECTIVE_VALUES}")" [[ "${sha}" =~ ^[0-9a-f]{40}$ ]] && [ "${sha}" != "0000000000000000000000000000000000000000" ] \ || error "the release-stamped vendor candidate SHA is absent or invalid" while IFS= read -r digest; do [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] \ && [ "${digest}" != "sha256:0000000000000000000000000000000000000000000000000000000000000000" ] \ || error "an effective global.imageDigests entry is absent, malformed, or zero" done <<<"$(yq -er '.global.imageDigests[]' "${PROFILE_EFFECTIVE_VALUES}")" for image in runnerImage enforcerImage vendorRunnerImage vendorBrokerImage controllerImage marimoRuntimeImage; do digest="$(yq -er ".runtimePlane.${image}" "${PROFILE_EFFECTIVE_VALUES}")" [[ "${digest}" =~ @sha256:[0-9a-f]{64}$ ]] \ && [[ "${digest}" != *@sha256:0000000000000000000000000000000000000000000000000000000000000000 ]] \ || error "runtimePlane.${image} must be release-stamped to an exact digest" done yq -e '.infra.db.backup.offCluster.endpoint != "https://REPLACE-WITH-STORAGE-ACCOUNT.blob.core.windows.net" and (.infra.db.backup.offCluster.egressCidrs | length) > 0 and .infra.db.backup.offCluster.credentialSecret.name != "" and .runtimePlane.isolation.microvm.nodeGroupPrefix != ""' \ "${PROFILE_EFFECTIVE_VALUES}" >/dev/null \ || error "site values did not replace every mandatory NATO deployment placeholder" } profile_validate_rendered_images() { local images image count=0 grep -Eq 'sha256:0{64}([^0-9a-f]|$)' "${PROFILE_RENDERED_MANIFEST}" \ && error "rendered NATO manifest contains a zero image digest placeholder" images="$(yq -r '.. | select(tag == "!!map" and has("containers")) | (.containers[]?.image, .initContainers[]?.image)' "${PROFILE_RENDERED_MANIFEST}" \ | awk 'NF && $0 != "null" && $0 != "---"')" \ || error "could not enumerate rendered Pod images" [ -n "${images}" ] || error "the NATO chart render produced no Pod images" while IFS= read -r image; do count=$(( count + 1 )) [[ "${image}" =~ @sha256:[0-9a-f]{64}$ ]] \ || error "rendered Pod image is not digest-bound: ${image}" done <<<"${images}" [ "${count}" -gt 0 ] || error "the NATO chart render produced no inspectable Pod images" } cleanup_profile_tmpdir() { [ -n "${PROFILE_TMPDIR}" ] || return 0 local temp_root leaf parent temp_root="${TMPDIR:-/tmp}"; temp_root="${temp_root%/}" leaf="$(basename "${PROFILE_TMPDIR}")" parent="$(dirname "${PROFILE_TMPDIR}")" case "${leaf}" in tmp.*) if [ "${parent}" = "${temp_root}" ] && [ -d "${PROFILE_TMPDIR}" ]; then rm -rf "${PROFILE_TMPDIR}" else warn "unexpected NATO profile temp path '${PROFILE_TMPDIR}'; cleanup skipped" fi ;; *) warn "unexpected NATO profile temp path '${PROFILE_TMPDIR}'; cleanup skipped" ;; esac PROFILE_TMPDIR='' } prepare_profile_chart() { [ "${PROFILE}" = "nato-unclassified" ] || return 0 [ "${PROFILE_CHART_PREPARED}" = "0" ] || return 0 nato_assert_target local chart="oci://${REG_HOST}/${SCRYDON_CHART_REPO}" pull_output archive='' candidate entries local expected_digest="${CHART_DIGEST}" PROFILE_TMPDIR="$(umask 077; mktemp -d)" || error "could not create NATO profile workspace" chmod 700 "${PROFILE_TMPDIR}" mkdir -m 700 "${PROFILE_TMPDIR}/extracted" PROFILE_SITE_COPY="${PROFILE_TMPDIR}/site.nato.yaml" cp "${SITE_VALUES_FILE}" "${PROFILE_SITE_COPY}" || error "could not snapshot --site-values" chmod 600 "${PROFILE_SITE_COPY}" [ "$(profile_sha256_file "${PROFILE_SITE_COPY}")" = "${SITE_VALUES_SHA256}" ] \ || error "--site-values changed after validation" printf '%s' "${REG_PASS}" | helm registry login "${REG_HOST}" \ --username "${REG_USER}" --password-stdin \ --registry-config "${PROFILE_TMPDIR}/registry.json" >/dev/null 2>&1 \ || error "helm could not sign in to the registry for NATO profile inspection" pull_output="$(helm pull "${chart}" --version "${CHART_VERSION}" \ --registry-config "${PROFILE_TMPDIR}/registry.json" \ --destination "${PROFILE_TMPDIR}" 2>&1)" \ || error "could not pull exact chart ${CHART_VERSION} for NATO profile inspection" CHART_DIGEST="$(printf '%s' "${pull_output}" \ | sed -n 's/.*Digest: *\(sha256:[0-9a-f]\{64\}\).*/\1/p' | head -n 1)" [[ "${CHART_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] \ || error "helm did not report an exact OCI chart digest" if [ -n "${expected_digest}" ] && [ "${expected_digest}" != "${CHART_DIGEST}" ]; then error "pulled OCI chart digest does not match the reviewed conf file" fi info " chart digest : ${CHART_DIGEST}" for candidate in "${PROFILE_TMPDIR}"/*.tgz; do [ -f "${candidate}" ] || continue [ -z "${archive}" ] || error "helm pull produced more than one chart archive" archive="${candidate}" done [ -n "${archive}" ] || error "helm pull produced no chart archive" entries="$(tar -tzf "${archive}")" || error "pulled chart archive is unreadable" printf '%s\n' "${entries}" | awk ' /^\// {bad=1} /(^|\/)\.\.($|\/)/ {bad=1} /\\/ {bad=1} $0 !~ /^scrydon(\/|$)/ {bad=1} END {exit bad}' || error "pulled chart archive contains an unsafe path" tar -tvzf "${archive}" \ | awk 'substr($1,1,1) != "-" && substr($1,1,1) != "d" {bad=1} END{exit bad}' \ || error "pulled chart archive contains a link or special entry" tar -xzf "${archive}" -C "${PROFILE_TMPDIR}/extracted" \ || error "could not extract the pulled chart" PROFILE_CHART_ROOT="${PROFILE_TMPDIR}/extracted/scrydon" [ -f "${PROFILE_CHART_ROOT}/Chart.yaml" ] && [ ! -L "${PROFILE_CHART_ROOT}/Chart.yaml" ] \ || error "pulled artifact has no regular Chart.yaml" [ "$(yq -er '.name' "${PROFILE_CHART_ROOT}/Chart.yaml")" = "scrydon" ] \ || error "pulled artifact is not the Scrydon chart" [ "$(yq -er '.version' "${PROFILE_CHART_ROOT}/Chart.yaml")" = "${CHART_VERSION}" ] \ || error "pulled chart version does not match ${CHART_VERSION}" [ "$(yq -er '.appVersion' "${PROFILE_CHART_ROOT}/Chart.yaml")" = "${CHART_VERSION}" ] \ || error "pulled chart appVersion does not match ${CHART_VERSION}" PROFILE_VALUES_FILE="${PROFILE_CHART_ROOT}/values.nato-unclassified.yaml" [ -f "${PROFILE_VALUES_FILE}" ] && [ ! -L "${PROFILE_VALUES_FILE}" ] \ || error "exact chart ${CHART_VERSION} does not contain values.nato-unclassified.yaml" grep -Eq 'sha256:0{64}([^0-9a-f]|$)' "${PROFILE_VALUES_FILE}" \ && error "exact chart ${CHART_VERSION} contains an unstamped zero image digest" PROFILE_EFFECTIVE_VALUES="${PROFILE_TMPDIR}/effective-values.yaml" # shellcheck disable=SC2016 # $item is a yq variable, not a shell variable. yq ea '. as $item ireduce ({}; . * $item)' \ "${PROFILE_CHART_ROOT}/values.yaml" "${PROFILE_VALUES_FILE}" \ "${VALUES_FILE}" "${PROFILE_SITE_COPY}" >"${PROFILE_EFFECTIVE_VALUES}" \ || error "could not merge the NATO profile values" chmod 600 "${PROFILE_EFFECTIVE_VALUES}" profile_validate_effective_values PROFILE_RENDERED_MANIFEST="${PROFILE_TMPDIR}/rendered.yaml" helm template "${RELEASE_NAME}" "${PROFILE_CHART_ROOT}" \ --namespace "${NAMESPACE}" -f "${PROFILE_VALUES_FILE}" \ -f "${VALUES_FILE}" -f "${PROFILE_SITE_COPY}" \ >"${PROFILE_RENDERED_MANIFEST}" \ || error "the exact NATO chart does not render with the reviewed site values" chmod 600 "${PROFILE_RENDERED_MANIFEST}" profile_validate_rendered_images PROFILE_CHART_PREPARED=1 } write_profile_projection() { [ "${PROFILE}" = "nato-unclassified" ] || return 0 PROFILE_PROJECTION_FILE="$(dirname "${QUALIFICATION_TARGET_RECORD}")/install-control-projection.json" ( umask 077 jq -n \ --arg schema 'scrydon-nato-install-control-projection/v1' \ --arg candidate "${CHART_VERSION}" --arg chartDigest "${CHART_DIGEST}" \ --arg profile "${PROFILE}" \ --arg profileSha256 "$(profile_sha256_file "${PROFILE_VALUES_FILE}")" \ --arg siteValuesSha256 "${SITE_VALUES_SHA256}" \ --arg renderedManifestSha256 "$(profile_sha256_file "${PROFILE_RENDERED_MANIFEST}")" \ --arg targetRecord "${QUALIFICATION_TARGET_RECORD}" \ --arg kubeContext "${SCRYDON_KUBE_CONTEXT}" --arg namespace "${NAMESPACE}" \ --arg hostname "${ROUTING_HOST}" \ '{schema:$schema,candidate:$candidate,chartDigest:$chartDigest,profile:$profile, profileSha256:$profileSha256,siteValuesSha256:$siteValuesSha256, renderedManifestSha256:$renderedManifestSha256,targetRecord:$targetRecord, kubeContext:$kubeContext,namespace:$namespace,hostname:$hostname, controls:{requireImageDigests:true,imageVerification:true,podSecurity:"restricted", networkPolicies:true,strictNetworkIsolation:true,publicAuthRateLimit:true,signedPackSources:true, managedProcess:false,runtimePlane:true,microvm:true,bundledDapr:false, localBackup:true,offClusterBackup:true}}' >"${PROFILE_PROJECTION_FILE}" ) chmod 600 "${PROFILE_PROJECTION_FILE}" } nato_preflight_check() { local label="$1" fix="$2" shift 2 PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if "$@" >/dev/null 2>&1; then echo -e " ${Green}ok${Color_Off} ${label}" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) else echo -e " ${Red}FAIL${Color_Off} ${label}" preflight_record_failure "${label}" "${fix}" fi } nato_check_aks_network() { nato_az aks show --resource-group "${NATO_RESOURCE_GROUP}" \ --name "${NATO_CLUSTER_NAME}" -o json \ | jq -e '.networkProfile.networkPlugin == "azure" and .networkProfile.networkPluginMode == "overlay" and .networkProfile.networkPolicy == "calico"' >/dev/null } nato_check_node_contract() { local runtime_class nodes runtime_class="$(kubectl get runtimeclass kata-vm-isolation -o json)" || return 1 printf '%s' "${runtime_class}" | jq -e ' (.handler == "kata-qemu" or .handler == "kata" or .handler == "kata-vm-isolation") and any(.scheduling.tolerations[]?; .key == "scrydon.io/kata" and .value == "true" and .effect == "NoSchedule")' \ >/dev/null || return 1 nodes="$(kubectl get nodes -o json)" || return 1 printf '%s' "${nodes}" | jq -e ' ([.items[] | select(.metadata.labels."kubernetes.azure.com/agentpool" == "system") | select(any(.status.conditions[]; .type == "Ready" and .status == "True"))] | length) >= 3 and ([.items[] | select(.metadata.labels."scrydon.io/kata" == "true") | select(any(.status.conditions[]; .type == "Ready" and .status == "True"))] | length) >= 1' \ >/dev/null } nato_verify_kyverno_webhooks() { jq -e ' [.items[] | select(.metadata.name | test("kyverno")) | . as $configuration | .webhooks[]? | select($configuration.metadata.name != "kyverno-ttl-validating-webhook-cfg") | select(.name != "monitor-webhooks.kyverno.svc")] as $enforcement_hooks | ($enforcement_hooks | length) > 0 and all($enforcement_hooks[]; .failurePolicy == "Fail")' >/dev/null } nato_check_kyverno_registry_credential() { if [ "${DRY_RUN:-0}" = "1" ]; then # The mutation is deliberately not applied during a dry-run. Prove that the # already parsed credential is complete enough to create the canonical # Secret instead of requiring planned state to exist in the live cluster. [ -n "${REG_HOST:-}" ] && [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ] \ || return 1 else kubectl -n kyverno get secret scrydon-registry -o json \ | jq -e '.type == "kubernetes.io/dockerconfigjson" and (.data | has(".dockerconfigjson"))' >/dev/null || return 1 fi [ "$(kubectl auth can-i get secret/scrydon-registry -n kyverno \ --as=system:serviceaccount:kyverno:kyverno-admission-controller)" = "yes" ] } nato_check_kyverno() { local webhooks workloads kubectl get crd clusterpolicies.kyverno.io >/dev/null 2>&1 || return 1 nato_check_kyverno_registry_credential || return 1 workloads="$(kubectl -n kyverno get deployments -o json)" || return 1 printf '%s' "${workloads}" | jq -e ' (.items | length) > 0 and all(.items[]; (.status.readyReplicas // 0) >= (.spec.replicas // 1)) and all(.items[].spec.template.spec.containers[].image; test("@sha256:[0-9a-f]{64}$"))' \ >/dev/null || return 1 webhooks="$(kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations -o json)" \ || return 1 # Kyverno's TTL cleanup and webhook-monitor hooks are non-enforcing support # paths and intentionally use Ignore. Every policy admission hook remains # fail-closed, including any future hook not covered by those two exceptions. printf '%s' "${webhooks}" | nato_verify_kyverno_webhooks } nato_check_dapr() { local values workloads webhook release release="$(helm_cluster list -n dapr-system --filter '^dapr$' -o json)" || return 1 printf '%s' "${release}" | jq -e \ 'length == 1 and .[0].chart == "dapr-1.18.9"' >/dev/null || return 1 values="$(helm_cluster get values dapr -n dapr-system -o json)" || return 1 printf '%s' "${values}" | jq -e ' .global.ha.enabled == true and .global.mtls.enabled == true and .global.logAsJson == true and .global.seccompProfile == "RuntimeDefault" and .global.mtls.controlPlaneTrustDomain == "cluster.local" and .dapr_sidecar_injector.image.name == "ghcr.io/dapr/daprd@sha256:b7f7d296f01f0b4b82bf3c5f087ecf26165ce08caf3e87f94b8c72b9e11873f8" and .dapr_sidecar_injector.webhookFailurePolicy == "Fail" and .dapr_sidecar_injector.sidecarRunAsNonRoot == true and .dapr_sidecar_injector.sidecarReadOnlyRootFilesystem == true and .dapr_sidecar_injector.sidecarDropALLCapabilities == true' >/dev/null || return 1 workloads="$(kubectl -n dapr-system get deployments,statefulsets -o json)" || return 1 printf '%s' "${workloads}" | jq -e ' (.items | length) > 0 and all(.items[]; (.status.readyReplicas // 0) >= (.spec.replicas // 1)) and all(.items[].spec.template.spec.containers[].image; test("@sha256:[0-9a-f]{64}$"))' \ >/dev/null || return 1 webhook="$(kubectl get mutatingwebhookconfiguration dapr-sidecar-injector -o json)" \ || return 1 printf '%s' "${webhook}" | jq -e \ '(.webhooks | length) > 0 and all(.webhooks[]; .failurePolicy == "Fail")' >/dev/null } nato_check_traefik() { local values deployment service release release="$(helm_cluster list -n traefik --filter '^traefik$' -o json)" || return 1 printf '%s' "${release}" | jq -e \ 'length == 1 and .[0].chart == "traefik-41.3.0"' >/dev/null || return 1 values="$(helm_cluster get values traefik -n traefik -o json)" || return 1 printf '%s' "${values}" | jq -e ' .log.format == "json" and .accessLog.enabled == true and .accessLog.format == "json" and .accessLog.fields.headers.defaultMode == "drop" and .core.strictTLSOptions == true and .service.spec.externalTrafficPolicy == "Local" and .image.digest == "sha256:5203c3f39ca70de6790d964624e042463ffbd57715bc82be155cf224c0dd5144"' \ >/dev/null || return 1 deployment="$(kubectl -n traefik get deployment traefik -o json)" || return 1 printf '%s' "${deployment}" | jq -e ' (.status.readyReplicas // 0) >= 3 and all(.spec.template.spec.containers[].image; test("@sha256:[0-9a-f]{64}$"))' \ >/dev/null || return 1 service="$(kubectl -n traefik get service traefik -o json)" || return 1 printf '%s' "${service}" | jq -e '.spec.externalTrafficPolicy == "Local"' >/dev/null } nato_check_cert_manager() { local controller deployments issuer deployments="$(kubectl -n cert-manager get deployments -o json)" || return 1 printf '%s' "${deployments}" | jq -e ' (.items | length) >= 3 and all(.items[]; (.status.readyReplicas // 0) >= 1) and all(.items[].spec.template.spec.containers[].image; test("@sha256:[0-9a-f]{64}$"))' \ >/dev/null || return 1 controller="$(kubectl -n cert-manager get deployment cert-manager -o json)" || return 1 printf '%s' "${controller}" | jq -e ' any(.spec.template.spec.containers[].args[]?; startswith("--acme-http01-solver-image=") and endswith("@sha256:dbc7cc1354f603918e7c5af7f55a0a620537394452c93a565bde75c6f48e8837"))' \ >/dev/null || return 1 issuer="$(kubectl get clusterissuer "${TLS_ISSUER}" -o json)" || return 1 printf '%s' "${issuer}" | jq -e \ 'any(.status.conditions[]?; .type == "Ready" and .status == "True")' >/dev/null } nato_verify_private_endpoint_approval() { jq -e 'any(.[]; ((.properties.privateLinkServiceConnectionState // .privateLinkServiceConnectionState).status) == "Approved")' >/dev/null } nato_check_backup_contract() { [ -f "${NATO_SUBSTRATE_RECORD}" ] && [ ! -L "${NATO_SUBSTRATE_RECORD}" ] || return 1 jq -e --arg candidate "${CHART_VERSION}" --arg target "${QUALIFICATION_TARGET_RECORD}" ' .schema == "scrydon-nato-substrate/v1" and .candidate == $candidate and .targetRecord == $target' "${NATO_SUBSTRATE_RECORD}" >/dev/null || return 1 local storage_id storage_name storage_json private_ip endpoint container secret_name secret_key policy immutability storage_id="$(jq -er '.storageResourceId' "${NATO_SUBSTRATE_RECORD}")" || return 1 private_ip="$(jq -er '.storagePrivateIp' "${NATO_SUBSTRATE_RECORD}")" || return 1 profile_valid_ipv4_address "${private_ip}" || return 1 case "${storage_id}" in "/subscriptions/${NATO_SUBSCRIPTION_ID}/resourceGroups/${NATO_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/"*) : ;; *) return 1 ;; esac nato_assert_not_protected "backup storage resource ID" "${storage_id}" storage_name="${storage_id##*/}" endpoint="$(yq -er '.infra.db.backup.offCluster.endpoint' "${PROFILE_EFFECTIVE_VALUES}")" [ "${endpoint}" = "https://${storage_name}.blob.core.windows.net" ] || return 1 NATO_BACKUP_CIDR="${private_ip}/32" yq -e \ '.infra.db.backup.offCluster.egressCidrs | any_c(. == strenv(NATO_BACKUP_CIDR))' \ "${PROFILE_EFFECTIVE_VALUES}" >/dev/null || return 1 storage_json="$(nato_az storage account show --resource-group "${NATO_RESOURCE_GROUP}" \ --name "${storage_name}" -o json)" || return 1 printf '%s' "${storage_json}" | jq -e ' .publicNetworkAccess == "Disabled" and .minimumTlsVersion == "TLS1_2" and .allowBlobPublicAccess == false' >/dev/null || return 1 nato_az network private-endpoint-connection list --id "${storage_id}" -o json \ | nato_verify_private_endpoint_approval || return 1 policy="$(nato_az storage account management-policy show \ --resource-group "${NATO_RESOURCE_GROUP}" --account-name "${storage_name}" -o json)" \ || return 1 printf '%s' "${policy}" | jq -e ' any(.policy.rules[]?; .enabled == true and .name == "qualification-retention" and .definition.actions.baseBlob.delete.daysAfterModificationGreaterThan >= 90)' \ >/dev/null || return 1 container="$(yq -er '.infra.db.backup.offCluster.container' "${PROFILE_EFFECTIVE_VALUES}")" immutability="$(nato_az storage container immutability-policy show \ --resource-group "${NATO_RESOURCE_GROUP}" --account-name "${storage_name}" \ --container-name "${container}" -o json)" || return 1 printf '%s' "${immutability}" | jq -e \ '.immutabilityPeriodSinceCreationInDays >= 30' >/dev/null || return 1 secret_name="$(yq -er '.infra.db.backup.offCluster.credentialSecret.name' "${PROFILE_EFFECTIVE_VALUES}")" secret_key="$(yq -er '.infra.db.backup.offCluster.credentialSecret.key' "${PROFILE_EFFECTIVE_VALUES}")" kubectl -n scrydon-infra get secret "${secret_name}" -o json \ | jq -e --arg key "${secret_key}" '.data | has($key)' >/dev/null } preflight_nato_profile() { [ "${PROFILE}" = "nato-unclassified" ] || return 0 # A mismatch is a hard safety stop, not an ordinary prerequisite failure. nato_assert_target nato_preflight_check "AKS uses Azure CNI overlay with Calico" \ "recreate the disposable substrate with scripts/security/nato-aks-qualification.sh" \ nato_check_aks_network nato_preflight_check "three Ready system nodes and one Ready Kata node" \ "restore the guarded system/Kata pools and the kata-vm-isolation RuntimeClass contract" \ nato_check_node_contract nato_preflight_check "Kyverno is digest-pinned, Ready, and fail-closed" \ "repair the pinned standalone Kyverno release; do not weaken webhook failurePolicy" \ nato_check_kyverno nato_preflight_check "standalone Dapr matches the hardened control-plane contract" \ "repair the pinned dapr-system release and its fail-closed injector settings" \ nato_check_dapr nato_preflight_check "Traefik preserves client IPs and emits redacted JSON logs" \ "repair the pinned Traefik release with helm/traefik/values.nato-unclassified.yaml" \ nato_check_traefik nato_preflight_check "cert-manager and the selected issuer are Ready" \ "repair cert-manager or the qualification-only ClusterIssuer before installing" \ nato_check_cert_manager nato_preflight_check "private immutable off-cluster backup contract is present" \ "repair the recorded private Blob endpoint, retention policy, and referenced Secret" \ nato_check_backup_contract } # ── Preflight ──────────────────────────────────────────────────────────────── # Every check here maps to a failure that is currently SILENT or only surfaces # after a long, partially-applied install. bash 3.2 has no associative arrays, # so findings accumulate in a newline-delimited string. PREFLIGHT_FAILURES='' PREFLIGHT_WARNINGS='' PREFLIGHT_PASSES=0 PREFLIGHT_CHECKS_ATTEMPTED=0 preflight_reset() { PREFLIGHT_FAILURES=''; PREFLIGHT_WARNINGS=''; PREFLIGHT_PASSES=0; PREFLIGHT_CHECKS_ATTEMPTED=0; } preflight_record_failure() { PREFLIGHT_FAILURES="${PREFLIGHT_FAILURES}${1}"$'\t'"${2}"$'\n' } preflight_fail() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) preflight_record_failure "$1" "$2" } preflight_warn() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) PREFLIGHT_WARNINGS="${PREFLIGHT_WARNINGS}${1}"$'\n' } preflight_check() { local label="$1"; shift PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if "$@" >/dev/null 2>&1; then echo -e " ${Green}ok${Color_Off} ${label}" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) else echo -e " ${Red}FAIL${Color_Off} ${label}" preflight_record_failure "${label}" "see the deployment prerequisites" fi } preflight_report() { echo "" # Zero checks is a vacuous-success trap: fail explicitly so reordering phases is safe. if [ "${PREFLIGHT_CHECKS_ATTEMPTED}" -eq 0 ]; then echo -e "${Red}preflight error${Color_Off} — no checks were executed." return 1 fi if [ -z "${PREFLIGHT_FAILURES}" ]; then local warn_note='' wcount=0 check_word warning_word [ "${PREFLIGHT_PASSES}" -eq 1 ] && check_word="check" || check_word="checks" if [ -n "${PREFLIGHT_WARNINGS}" ]; then wcount="$(printf '%s' "${PREFLIGHT_WARNINGS}" | grep -c .)" [ "${wcount}" -eq 1 ] && warning_word="warning" || warning_word="warnings" warn_note=", ${wcount} ${warning_word}" fi success "preflight passed (${PREFLIGHT_PASSES} ${check_word}${warn_note})" if [ -n "${PREFLIGHT_WARNINGS}" ]; then echo "" printf '%s' "${PREFLIGHT_WARNINGS}" | while IFS= read -r wmsg; do [ -n "${wmsg}" ] || continue echo -e " ${Yellow}warn${Color_Off} ${wmsg}" done fi return 0 fi echo -e "${Red}preflight failed${Color_Off} — nothing has been applied to the cluster." echo "" printf '%s' "${PREFLIGHT_FAILURES}" | while IFS=$'\t' read -r label fix; do [ -n "${label}" ] || continue echo -e " ${Bold_White}${label}${Color_Off}" echo " how to fix: ${fix}" done return 1 } preflight_cluster() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if ! cluster_reachable; then preflight_fail "cluster is reachable" \ "check KUBECONFIG and your VPN, or run this phase on a host that can reach the API server" echo -e " ${Red}FAIL${Color_Off} cluster is reachable" return 1 fi echo -e " ${Green}ok${Color_Off} cluster is reachable ($(kubectl config current-context 2>/dev/null))" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) local server_minor server_minor="$(kubectl version -o json 2>/dev/null \ | sed -n 's/.*"minor"[[:space:]]*:[[:space:]]*"\([0-9]*\).*/\1/p' | tail -n 1)" if [ -n "${server_minor}" ] && [ "${server_minor}" -lt 28 ]; then preflight_fail "Kubernetes >= 1.28" \ "the chart declares kubeVersion >=1.28.0-0; upgrade the cluster" echo -e " ${Red}FAIL${Color_Off} Kubernetes >= 1.28 (found 1.${server_minor})" else echo -e " ${Green}ok${Color_Off} Kubernetes >= 1.28" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) fi } # Restricted clusters are the common case. Without this, a missing permission # surfaces halfway through helm install, leaving a partial release behind. preflight_rbac() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) local verb_res missing='' for verb_res in "create namespace" "create deployment" "create secret" \ "create ingress" "create serviceaccount" "create job"; do # shellcheck disable=SC2086 # word-splitting ${verb_res} is intentional: splits "create namespace" → two args set -- ${verb_res} # namespaces are cluster-scoped; -n is semantically wrong for that one resource if [ "$2" = "namespace" ]; then kubectl auth can-i "$1" "$2" >/dev/null 2>&1 || missing="${missing} $1/$2" else kubectl auth can-i "$1" "$2" -n "${NAMESPACE}" >/dev/null 2>&1 || missing="${missing} $1/$2" fi done if [ -n "${missing}" ]; then preflight_fail "permissions to install" \ "this account cannot:${missing} — ask your cluster administrator for these in ${NAMESPACE}" echo -e " ${Red}FAIL${Color_Off} permissions to install" else echo -e " ${Green}ok${Color_Off} permissions to install" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) fi } preflight_storage() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if kubectl get storageclass "${STORAGE_CLASS}" >/dev/null 2>&1; then echo -e " ${Green}ok${Color_Off} StorageClass '${STORAGE_CLASS}' exists" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) else preflight_fail "StorageClass '${STORAGE_CLASS}' exists" \ "create it or re-run with --storage-class naming a provisioner that exists; PVCs stay Pending forever otherwise" echo -e " ${Red}FAIL${Color_Off} StorageClass '${STORAGE_CLASS}' exists" fi } # Pure verdict helpers, extracted so they are testable without a cluster. nginx_snippet_verdict() { local allow="$1" risk="$2" bad=0 if [ "${allow}" != "true" ]; then echo "allow-snippet-annotations is not true — the chart's Dapr-identity header" echo "stripping would be silently ignored and the install would still report success." bad=1 fi case "${risk}" in Critical) : ;; *) echo "annotations-risk-level is '${risk:-High}'; ConfigurationSnippet is Critical, so the admission webhook will DENY the Ingress and roll the release back." bad=1 ;; esac return "${bad}" } capacity_verdict() { local cpu="$1" ram="$2" # No license yet is the documented path ("paste it into the setup wizard"), so # there is no entitlement to compare against. Still report what was MEASURED: # returning empty printed a bare "info" line with no text, which reads as a # check that broke rather than one that had nothing to compare. if [ -z "${LIC_CPU:-}" ] || [ -z "${LIC_RAM:-}" ]; then echo "cluster capacity is ${cpu} cores, ${ram:-?} GB (no license supplied — entitlement not compared)" return 0 fi # Use awk for comparison so float cpu values (e.g. 0.8 from milli-CPU) work correctly. local exceeds exceeds="$(awk -v c="${cpu}" -v lc="${LIC_CPU}" -v r="${ram:-0}" -v lr="${LIC_RAM}" \ 'BEGIN { print (c > lc || r > lr) ? "yes" : "no" }')" if [ "${exceeds}" = "yes" ]; then echo "cluster capacity (${cpu} cores, ${ram} GB) exceeds the licensed entitlement (${LIC_CPU} cores, ${LIC_RAM} GB)." else echo "cluster capacity (${cpu} cores, ${ram} GB) is within the licensed entitlement." fi return 0 # informational only — never blocks } # Cluster-scoped resources the chart claims. They are NOT in the release # namespace, so `kubectl delete ns scrydon-platform` -- the obvious way to # "start clean" after a failed install, and what our own runbook used to # suggest -- leaves every one of them behind. Helm then refuses to adopt them: # # Error: UPGRADE FAILED: Unable to continue with update: Namespace # "dapr-system" ... exists and cannot be imported into the current release: # invalid ownership metadata; label validation error: missing key # "app.kubernetes.io/managed-by": must be set to "Helm" ... # # So one failed install poisons the cluster for every retry, and the error # names Helm internals rather than the fix. Reported from a real walkthrough. # # Kept in sync with the chart by helm/scrydon/__tests__/cluster-scoped-resources.test.ts # -- that test renders the chart and fails if this list drifts. SCRYDON_CLUSTER_SCOPED_NAMESPACE='dapr-system' SCRYDON_CLUSTER_SCOPED_CLUSTERROLE='dapr-injector dapr-operator-admin dapr-placement dapr-scheduler dapr-secret-reader-cluster dapr-sentry scrydon-license-node-reader' SCRYDON_CLUSTER_SCOPED_CLUSTERROLEBINDING='dapr-injector dapr-operator-admin dapr-operator-secret-reader dapr-placement dapr-scheduler dapr-sentry scrydon-license-node-reader' SCRYDON_CLUSTER_SCOPED_WEBHOOK='dapr-sidecar-injector' # The subset above that belongs to the bundled DAPR SUBCHART. When the cluster # already runs its own Dapr control plane the chart renders none of these, so # they cannot conflict and must not be reported. # # Listed explicitly rather than matched as "dapr-*": clusterrole/ # dapr-secret-reader-cluster and the scrydon-license-node-reader pair are OURS # (part-of: scrydon) and are created either way. A prefix match would have # skipped a resource that really can conflict. SCRYDON_CLUSTER_SCOPED_DAPR_SUBCHART='namespace/dapr-system clusterrole/dapr-injector clusterrole/dapr-operator-admin clusterrole/dapr-placement clusterrole/dapr-scheduler clusterrole/dapr-sentry clusterrolebinding/dapr-injector clusterrolebinding/dapr-operator-admin clusterrolebinding/dapr-operator-secret-reader clusterrolebinding/dapr-placement clusterrolebinding/dapr-scheduler clusterrolebinding/dapr-sentry mutatingwebhookconfiguration/dapr-sidecar-injector' # Emits "kind/nameleftover" or "kind/nameownedreleasenamespace". # # The distinction is load-bearing and was missing: the ops cluster runs a # deliberate standalone `helm install dapr dapr/dapr -n dapr-system` from # 2026-03-19, and reporting it as "resources from an earlier install" told the # operator to DELETE a live production control plane. A resource owned by # another Helm release is never something this installer may propose removing. # # Queried one name at a time on purpose: `kubectl get ` returns a # List but `kubectl get ` returns the OBJECT, so a {range .items[*]} # template silently matches nothing for a single-name kind. orphaned_cluster_resources() { local kind names name rel rns ref skip_dapr=0 [ "${DAPR_INSTALL_CONTROL_PLANE:-}" = "false" ] && skip_dapr=1 for kind in namespace clusterrole clusterrolebinding mutatingwebhookconfiguration; do case "${kind}" in namespace) names="${SCRYDON_CLUSTER_SCOPED_NAMESPACE}" ;; clusterrole) names="${SCRYDON_CLUSTER_SCOPED_CLUSTERROLE}" ;; clusterrolebinding) names="${SCRYDON_CLUSTER_SCOPED_CLUSTERROLEBINDING}" ;; mutatingwebhookconfiguration) names="${SCRYDON_CLUSTER_SCOPED_WEBHOOK}" ;; esac for name in ${names}; do ref="${kind}/${name}" if [ "${skip_dapr}" = "1" ] \ && printf '%s\n' "${SCRYDON_CLUSTER_SCOPED_DAPR_SUBCHART}" | grep -qxF "${ref}"; then continue fi kubectl get "${kind}" "${name}" >/dev/null 2>&1 || continue rel="$(kubectl get "${kind}" "${name}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null)" rns="$(kubectl get "${kind}" "${name}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-namespace}' 2>/dev/null)" [ "${rel}" = "${RELEASE_NAME}" ] && [ "${rns}" = "${NAMESPACE}" ] && continue if [ -n "${rel}" ]; then printf '%s\towned\t%s\t%s\n' "${ref}" "${rel}" "${rns}" elif [ "${kind}" = "namespace" ] && [ -n "$(kubectl -n "${name}" get pods -o name 2>/dev/null | head -1)" ]; then # A namespace created with `--create-namespace` carries NO Helm # ownership even when a live release fills it. dapr-system on the ops # cluster is exactly that: unowned, but holding 7 running pods. Calling # it a deletable leftover pointed the operator at # `kubectl delete ns dapr-system` on production. Emptiness, not # ownership, is what makes a namespace safe to remove. printf '%s\tinuse\t\t\n' "${ref}" else printf '%s\tleftover\t\t\n' "${ref}" fi done done } preflight_orphaned_cluster_resources() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) local all leftovers owned inuse lcount ocount icount all="$(orphaned_cluster_resources)" if [ -z "${all}" ]; then echo -e " ${Green}ok${Color_Off} no conflicting cluster-scoped resources" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) return 0 fi leftovers="$(printf '%s\n' "${all}" | awk -F'\t' '$2=="leftover"{print $1}')" owned="$(printf '%s\n' "${all}" | awk -F'\t' '$2=="owned"{printf "%s (helm release %s in %s)\n", $1, $3, $4}')" inuse="$(printf '%s\n' "${all}" | awk -F'\t' '$2=="inuse"{printf "%s (not empty - has running pods)\n", $1}')" lcount="$(printf '%s' "${leftovers}" | grep -c .)" ocount="$(printf '%s' "${owned}" | grep -c .)" icount="$(printf '%s' "${inuse}" | grep -c .)" echo -e " ${Red}FAIL${Color_Off} no conflicting cluster-scoped resources ($(( lcount + ocount + icount )) found)" [ "${lcount}" -gt 0 ] && printf '%s\n' "${leftovers}" | sed 's/^/ /' [ "${ocount}" -gt 0 ] && printf '%s\n' "${owned}" | sed 's/^/ /' [ "${icount}" -gt 0 ] && printf '%s\n' "${inuse}" | sed 's/^/ /' if [ "${lcount}" -gt 0 ]; then preflight_fail "cluster-scoped resources from an earlier install are in the way (${lcount})" \ "these outlive 'kubectl delete ns ${NAMESPACE}', so Helm cannot adopt them. Either hand them to this release: kubectl annotate / meta.helm.sh/release-name=${RELEASE_NAME} meta.helm.sh/release-namespace=${NAMESPACE} --overwrite && kubectl label / app.kubernetes.io/managed-by=Helm --overwrite -- or, ONLY if you are certain nothing else uses them: kubectl delete /" fi if [ "${icount}" -gt 0 ]; then preflight_fail "a namespace this release would claim is not empty (${icount})" \ "do NOT delete it - it has running pods. It carries no Helm ownership only because it was created with --create-namespace. Adopt it if it really is ours; otherwise install into a cluster that is not already using that name." fi if [ "${ocount}" -gt 0 ]; then # Never propose deleting these. They belong to a live release someone else # installed deliberately. preflight_fail "cluster-scoped resources belong to a DIFFERENT helm release (${ocount})" \ "do NOT delete them — another release owns them and something is probably using them. If that release is a deliberate standalone Dapr install, this installer detects it and skips its own copy; re-run and check for the 'existing Dapr control plane was found' note. Otherwise install into a cluster that does not already own these names." fi } # The chosen class must EXIST as a GatewayClass. A Gateway whose class no # controller claims is never programmed -- helm reports success, every pod is # Ready, and the platform is simply unreachable. That is the worst failure shape # the installer can produce, and it is reachable from a plain typo. preflight_gateway_class() { [ -n "${GATEWAY_CLASS}" ] || return 0 PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if kubectl get gatewayclass "${GATEWAY_CLASS}" >/dev/null 2>&1; then echo -e " ${Green}ok${Color_Off} GatewayClass '${GATEWAY_CLASS}' exists" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) return 0 fi local available available="$(kubectl get gatewayclass -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" echo -e " ${Red}FAIL${Color_Off} GatewayClass '${GATEWAY_CLASS}' exists" preflight_fail "GatewayClass '${GATEWAY_CLASS}' exists" \ "no controller claims a Gateway with an unknown class, so the install would SUCCEED and the platform would be unreachable. Re-run with --gateway-class naming one of: ${available:-}" } # cert-manager: a non-empty issuer makes the chart set ingress.tls.enabled=true # and annotate the Ingress with that cluster-issuer. If cert-manager is absent, # or the issuer does not exist, NOTHING acts on that annotation: no Secret is # ever created, HTTPS never works, and the install still reports success. # An EMPTY issuer is the supported BYO/upstream-TLS path and is not checked. preflight_cert_manager() { [ -n "${TLS_ISSUER}" ] || return 0 PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if ! kubectl get crd clusterissuers.cert-manager.io >/dev/null 2>&1; then echo -e " ${Red}FAIL${Color_Off} cert-manager is installed" preflight_fail "cert-manager is installed" \ "the issuer '${TLS_ISSUER}' needs cert-manager. Install it (https://cert-manager.io/docs/installation/), or re-run with an EMPTY cert-manager issuer to terminate TLS yourself." return 0 fi if ! kubectl get clusterissuer "${TLS_ISSUER}" >/dev/null 2>&1; then local issuers issuers="$(kubectl get clusterissuer -o jsonpath='{range .items[*]}{.metadata.name}{" "}{end}' 2>/dev/null)" echo -e " ${Red}FAIL${Color_Off} ClusterIssuer '${TLS_ISSUER}' exists" preflight_fail "ClusterIssuer '${TLS_ISSUER}' exists" \ "no certificate would ever be issued. Create it, pick one of: ${issuers:-}, or re-run with an EMPTY issuer for BYO TLS." return 0 fi # Present but not Ready is a warning, not a failure: an ACME issuer can still # be completing registration while the chart installs, and it self-heals. local ready ready="$(kubectl get clusterissuer "${TLS_ISSUER}" \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null)" if [ "${ready}" = "True" ]; then echo -e " ${Green}ok${Color_Off} ClusterIssuer '${TLS_ISSUER}' is ready" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) else echo -e " ${Yellow}warn${Color_Off} ClusterIssuer '${TLS_ISSUER}' is not Ready yet" preflight_warn "ClusterIssuer '${TLS_ISSUER}' is not Ready" \ "certificates will not be issued until it is. Check: kubectl describe clusterissuer ${TLS_ISSUER}" fi } preflight_gateway_api_crds() { # A HARD prerequisite, unlike Ingress: the gateway.networking.k8s.io CRDs are # not part of a stock cluster. Without them `helm install` dies on the first # Gateway with `no matches for kind "Gateway"`, after the namespace, secrets # and PVCs already exist. Bundling Traefik supplies them, so only check when # we are not about to install it. PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) if [ "${BUNDLE_TRAEFIK:-false}" = "true" ]; then echo -e " ${Green}ok${Color_Off} Gateway API CRDs arrive with the bundled Traefik" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) return fi if kubectl get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1; then echo -e " ${Green}ok${Color_Off} Gateway API CRDs are installed" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) return fi echo -e " ${Red}FAIL${Color_Off} Gateway API CRDs" preflight_fail "Gateway API CRDs are not installed" \ "install a Gateway API implementation first, or re-run without --gateway-class and let the installer bundle Traefik" } # shellcheck disable=SC2034 # DAPR_INSTALL_CONTROL_PLANE is read by the apply phase (Task 12) DAPR_INSTALL_CONTROL_PLANE='' # The namespace the external control plane runs in; set with the flag above. DAPR_CONTROL_PLANE_NAMESPACE='' # Prints the namespace of a Dapr control plane that is NOT ours; fails when # there is none. # # The old selector was `app.kubernetes.io/name=dapr-operator`, which matches # NOTHING the upstream Dapr chart creates: it labels the operator # `app.kubernetes.io/name=dapr` with `component=operator`. So an existing Dapr # install was never detected, installControlPlane stayed true, and the chart # tried to stand up a SECOND control plane on top of a live one. Found on the # ops cluster, which has run a standalone `helm install dapr dapr/dapr` since # 2026-03-19. Match on the resource NAME, which the upstream chart fixes, plus # the legacy `app` label — not on a label that turned out to be ours alone. external_dapr_control_plane() { # "External" means a control plane THIS RELEASE does not own. # # Ownership is the whole point. Matching dapr-operator by name alone also # matches the one OUR chart installs, so every upgrade would have decided a # control plane already existed, set installControlPlane=false, and helm # would then have deleted the Dapr control plane it owns. Caught by reading # --dry-run output on a cluster we had just installed to. # # The namespace is the operator's own, not its release annotation: that is # where sentry, placement and the injector answer. local found found="$(kubectl get deployment -A \ -o jsonpath='{range .items[*]}{.metadata.name}{"|"}{.metadata.namespace}{"|"}{.metadata.annotations.meta\.helm\.sh/release-name}{"|"}{.metadata.annotations.meta\.helm\.sh/release-namespace}{"\n"}{end}' \ 2>/dev/null \ | while IFS='|' read -r name ns rel rns; do [ "${name}" = "dapr-operator" ] || continue [ "${rel}" = "${RELEASE_NAME}" ] && [ "${rns}" = "${NAMESPACE}" ] && continue printf '%s\n' "${ns}" done | head -n 1)" [ -n "${found}" ] || return 1 printf '%s\n' "${found}" } preflight_dapr() { local ns if ns="$(external_dapr_control_plane)"; then # shellcheck disable=SC2034 # consumed by dapr_control_plane_args DAPR_INSTALL_CONTROL_PLANE=false # shellcheck disable=SC2034 # consumed by dapr_control_plane_args DAPR_CONTROL_PLANE_NAMESPACE="${ns}" if [ "${BACKEND:-}" = "zarf" ]; then # The package deploys its own control plane and takes no chart flags, so # it cannot be pointed at this one; deploying would stand up a second # control plane on top of the live one. preflight_fail "an existing Dapr control plane runs in ${ns}" \ "the air-gapped package installs its own Dapr. Remove the existing one, or use --backend mirror, which reuses it. Nothing has been changed." return 0 fi info " note an existing Dapr control plane was found in ${ns}; the chart will not install a second one" fi return 0 } # Sets DAPR_ARGS to the --set flags that point the chart at an external Dapr # control plane; empty when the chart installs its own. An array rather than # printed lines, so neither install path needs a `read` loop — the mirror # path is pinned never to read input in non-interactive mode. # # `installControlPlane=false` alone is half the answer. With no # `dapr.controlPlaneNamespace` the chart assumes the control plane lives in the # RELEASE namespace, so marimo-document's NetworkPolicy allows Dapr egress # there and drops traffic to dapr-sentry — its daprd dies with # `dial tcp :443: i/o timeout` and holds `helm --wait` open until the # timeout — and dapr/rbac.yaml binds the operator subject in the wrong # namespace. KinD routing run 35869582719 is what that looks like. dapr_control_plane_args() { DAPR_ARGS=() [ "${DAPR_INSTALL_CONTROL_PLANE:-}" = "false" ] || return 0 DAPR_ARGS+=(--set "dapr.installControlPlane=false") [ -n "${DAPR_CONTROL_PLANE_NAMESPACE:-}" ] || return 0 DAPR_ARGS+=(--set "dapr.controlPlaneNamespace=${DAPR_CONTROL_PLANE_NAMESPACE}") } preflight_capacity() { local cpu ram # Detect the m suffix BEFORE stripping so 750m → 0.75 cores, not 750 cores. # Use %.1f so small clusters (e.g. 3x250m = 0.8 cores) are displayed rather than truncated to 0. cpu="$(kubectl get nodes -o jsonpath='{range .items[*]}{.status.allocatable.cpu}{"\n"}{end}' 2>/dev/null \ | awk '{ if ($0 ~ /m$/) { sub(/m$/, "", $0); s += $0/1000 } else { s += $0 } } END { if (NR > 0) printf "%.1f", s }')" # Memory may arrive as Ki, Mi, Gi, or plain bytes with no suffix. # A suffix we genuinely do not recognise produces empty output; plain bytes are handled as bytes. ram="$(kubectl get nodes -o jsonpath='{range .items[*]}{.status.allocatable.memory}{"\n"}{end}' 2>/dev/null \ | awk '{ v = $0 if (v ~ /Ki$/) { sub(/Ki$/, "", v); s += v/1048576 } else if (v ~ /Mi$/) { sub(/Mi$/, "", v); s += v/1024 } else if (v ~ /Gi$/) { sub(/Gi$/, "", v); s += v } else if (v ~ /^[0-9]+$/) { s += v/1073741824 } else { ok = 0 } } BEGIN { ok = 1 } END { if (ok && NR > 0) printf "%d", s }')" # Suppress only when the cluster could not be reached or get nodes was denied (empty string). if [ -z "${cpu}" ]; then info " info capacity check skipped (kubectl get nodes returned nothing — RBAC may restrict node listing; this does not block the install)" return 0 fi info " info $(capacity_verdict "${cpu}" "${ram}")" return 0 } # Single resolution path, shared by the DNS preflight and the post-install # reachability probe, so the two can never disagree about whether a hostname # resolves. getent does not exist on macOS (Linux-only); nslookup is the fallback. # Prints the first address, or nothing. resolve_host() { local h="$1" resolved resolved="$(getent hosts "${h}" 2>/dev/null | awk '{print $1; exit}')" [ -n "${resolved}" ] || resolved="$(nslookup "${h}" 2>/dev/null | awk '/^Address: /{print $2; exit}')" printf '%s' "${resolved}" } host_resolves() { [ -n "$(resolve_host "$1")" ]; } preflight_dns() { local resolved resolved="$(resolve_host "${ROUTING_HOST}")" if [ -z "${resolved}" ]; then preflight_warn "${ROUTING_HOST} does not resolve yet — ACME certificate issuance will fail until DNS points at the ingress" warn "${ROUTING_HOST} does not resolve yet. The install will work, but ACME" warn "certificate issuance fails until DNS points at your ingress." else PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) echo -e " ${Green}ok${Color_Off} ${ROUTING_HOST} resolves to ${resolved}" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) fi return 0 } # Say WHERE this is about to happen, loudly, before anything else. # # The cluster name used to appear as one dim "ok" line among a dozen, and an # operator ran a full install against the production ops cluster without # noticing. kubectl's current context is ambient state that nothing in the # command line reveals — so the installer states it, and --context lets you # assert it. # ── Upgrade path ───────────────────────────────────────────────────────────── # Re-running the installer against an existing release used to re-run the whole # interview and pass ONLY its ~10 answers as -f values. A live release carries # far more than that — staging has 55 user-supplied settings across 16 sections # — so a "version bump" silently reverted branding, email, passkey, storage, # StarRocks sizing and CORS to interview defaults. # # An upgrade therefore asks for the version and nothing else. Everything else is # recovered from the cluster, which is where it already lives. IS_UPGRADE=0 EXISTING_VERSION='' EXISTING_REVISION='' RELEASE_VALUES_FILE='' RECONFIGURE="${RECONFIGURE:-0}" cleanup_release_values() { [ -n "${RELEASE_VALUES_FILE}" ] || return 0 rm -f "${RELEASE_VALUES_FILE}" RELEASE_VALUES_FILE='' return 0 } detect_existing_release() { local line # No cluster, no release to find. Gating here also stops the probe from # reaching out on the unreachable-cluster path, where the installer is meant # to save answers for a bastion rather than talk to anything. cluster_reachable || return 1 line="$(helm_cluster list -n "${NAMESPACE}" --filter "^${RELEASE_NAME}\$" \ -o json 2>/dev/null)" || return 1 # Require a real release entry, not merely non-empty output. helm can print # warnings to stdout, and a stubbed helm prints anything at all — neither is # "a release exists", and mistaking them for one would silently route a fresh # install down the upgrade path and skip the entire interview. case "${line}" in *'"name":"'"${RELEASE_NAME}"'"'*|*'"name": "'"${RELEASE_NAME}"'"'*) : ;; *) return 1 ;; esac EXISTING_VERSION="$(json_string "${line}" app_version)" [ -n "${EXISTING_VERSION}" ] || EXISTING_VERSION="$(json_string "${line}" chart)" EXISTING_REVISION="$(json_string "${line}" revision)" IS_UPGRADE=1 return 0 } # The live values ARE the configuration. Written 0600 and removed on exit: # they contain secrets (STARROCKS_PASSWORD, MARIMO_EDIT_TOKEN and friends). load_release_values() { RELEASE_VALUES_FILE="$(umask 077; mktemp)" helm_cluster get values "${RELEASE_NAME}" -n "${NAMESPACE}" -o yaml \ > "${RELEASE_VALUES_FILE}" 2>/dev/null \ || { cleanup_release_values; return 1; } # `helm get values` prints "null" for a release installed with no overrides. grep -qv '^null$' "${RELEASE_VALUES_FILE}" || { cleanup_release_values; return 1; } return 0 } release_value_count() { [ -n "${RELEASE_VALUES_FILE}" ] || { printf '0'; return 0; } grep -cE '^[[:space:]]*[A-Za-z_][A-Za-z0-9_-]*:[[:space:]]*[^[:space:]]' \ "${RELEASE_VALUES_FILE}" 2>/dev/null || printf '0' } # The registry credential is already in the cluster: the release names its pull # secret in global.imagePullSecrets, and that Secret holds the dockerconfigjson. # Reading it back is why an upgrade does not have to ask for it again. # Read one dotted path out of the recovered values YAML. # # The upgrade path needs the settings preflight validates — storage class, # ingress class, TLS issuer, hostname — and on an upgrade those live in the # RELEASE, not in interview variables that were never filled. Without this the # checks ran against empty strings and failed spuriously ("StorageClass '' # exists"), which would have blocked every upgrade. # # Indentation-walking awk rather than a YAML parser: the installer may not have # jq, and these are four known, shallow, chart-owned paths. values_get() { [ -n "${RELEASE_VALUES_FILE}" ] || return 1 [ -r "${RELEASE_VALUES_FILE}" ] || return 1 awk -v target="$1" ' # Track the key path by INDENTATION, then compare the joined path to the # target. An earlier version advanced a "which component am I looking for" # counter instead, which conflated nesting depth with path position and only # worked when the document happened to be ordered so the components appeared # in sequence. /^[[:space:]]*#/ { next } /^[[:space:]]*$/ { next } { line = $0 match(line, /^[ ]*/) indent = RLENGTH lvl = int(indent / 2) + 1 rest = substr(line, indent + 1) if (rest !~ /^[A-Za-z_][A-Za-z0-9_.-]*:/) next key = rest sub(/:.*$/, "", key) val = rest sub(/^[^:]*:[[:space:]]*/, "", val) path[lvl] = key for (d = lvl + 1; d <= maxlvl; d++) delete path[d] if (lvl > maxlvl) maxlvl = lvl joined = path[1] for (d = 2; d <= lvl; d++) joined = joined "." path[d] if (joined == target && val != "") { gsub(/^"|"$/, "", val) gsub(/^'"'"'|'"'"'$/, "", val) print val exit } } ' "${RELEASE_VALUES_FILE}" } # An upgrade validates the configuration the RELEASE has, not one nobody # entered. Anything already set by a flag wins — an operator changing the # hostname during an upgrade is legitimate. adopt_release_settings() { [ -n "${STORAGE_CLASS}" ] || STORAGE_CLASS="$(values_get global.storageClass)" [ -n "${GATEWAY_CLASS}" ] || GATEWAY_CLASS="$(values_get gateway.className)" [ -n "${ROUTING_HOST}" ] || ROUTING_HOST="$(values_get routing.host)" # tls.clusterIssuer is absent when TLS is terminated upstream; an empty issuer # is the supported BYO path, so an absent key must stay empty rather than # falling back to the letsencrypt-prod default a fresh install would use. TLS_ISSUER="$(values_get gateway.tls.clusterIssuer)" # BACKEND is derived by run_interview and apply_non_interactive_defaults, and # an upgrade runs neither — so it was empty and main died on "unknown backend # ''" AFTER preflight passed and the confirmation was accepted. The release # already tells us: images pulled from somewhere other than the Scrydon # registry mean this install was mirrored. if [ -z "${BACKEND}" ]; then local reg reg="$(values_get global.imageRegistry)" if [ -n "${reg}" ] && [ "${reg}" != "${SCRYDON_REGISTRY_DEFAULT}" ]; then IMAGE_SOURCE=own BACKEND=mirror else IMAGE_SOURCE=scrydon BACKEND=connected fi fi return 0 } recover_registry_credentials() { local name cfg name="$(grep -oE '^[[:space:]]+- name:[[:space:]]*\S+' "${RELEASE_VALUES_FILE}" 2>/dev/null | awk '{print $3}' | head -1)" # Fallback for the plain-list form (`- scrydon-registry`) rather than the # list-of-maps form (`- name: scrydon-registry`) the primary pattern reads. # # This used to end `| tr -d '- '`, whose argument STARTS with a hyphen — tr # parses it as an option and dies with "illegal option". The fallback only # fires when the primary misses, so it errored silently in the one case it # existed to handle. sed does the whole job without the ambiguity. [ -n "${name}" ] || name="$(sed -n '/imagePullSecrets:/,/^[^[:space:]]/p' \ "${RELEASE_VALUES_FILE}" 2>/dev/null \ | sed -n 's/^[[:space:]]*-[[:space:]]*\([A-Za-z0-9._-][A-Za-z0-9._-]*\)[[:space:]]*$/\1/p' \ | head -1)" [ -n "${name}" ] || return 1 cfg="$(kubectl -n "${NAMESPACE}" get secret "${name}" \ -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null)" || return 1 [ -n "${cfg}" ] || return 1 cfg="$(printf '%s' "${cfg}" | openssl base64 -d -A 2>/dev/null)" || return 1 dockerconfig_credentials "${cfg}" || return 1 REG_MODE=recovered REG_SECRET_NAME="${name}" return 0 } # Semver-ish comparison good enough to spot a downgrade. Strips the leading v # and compares numerically field by field; a prerelease suffix on an otherwise # equal version sorts BELOW the release (v1.3.24-rc.1 < v1.3.24). version_is_older() { local a="${1#v}" b="${2#v}" abase bbase apre bpre abase="${a%%-*}"; bbase="${b%%-*}" apre=''; bpre='' [ "${a}" != "${abase}" ] && apre="${a#*-}" [ "${b}" != "${bbase}" ] && bpre="${b#*-}" if [ "${abase}" != "${bbase}" ]; then [ "$(printf '%s\n%s\n' "${abase}" "${bbase}" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "${abase}" ] return $? fi # Same base: a prerelease is older than the plain release. [ -n "${apre}" ] && [ -z "${bpre}" ] && return 0 [ -z "${apre}" ] && [ -n "${bpre}" ] && return 1 [ "${apre}" \< "${bpre}" ] } run_upgrade_interview() { info_bold "Found an existing release" info " release ${RELEASE_NAME} namespace ${NAMESPACE}" info " version ${EXISTING_VERSION} revision ${EXISTING_REVISION}" info " values $(release_value_count) settings carried over from the live release" if recover_registry_credentials; then info " registry recovered from ${REG_SECRET_NAME}" else info " registry could not be recovered — you will be asked for it" fi info " licence already configured on the cluster (nothing to re-supply)" echo "" info "This is an UPGRADE. Nothing is re-asked and nothing is overwritten." info "Re-run with --reconfigure to answer the questions again and replace" info "the configuration with your answers." echo "" adopt_release_settings select_version if [ "${CHART_VERSION}" = "${EXISTING_VERSION}" ]; then success "${RELEASE_NAME} is already on ${CHART_VERSION} — nothing to do." exit 0 fi if version_is_older "${CHART_VERSION}" "${EXISTING_VERSION}"; then warn "${CHART_VERSION} is OLDER than the installed ${EXISTING_VERSION}." warn "Downgrades are not tested: schema migrations do not run backwards." [ "${NON_INTERACTIVE}" = "1" ] || { local ok ok="$(prompt_value "Type 'downgrade' to proceed anyway" "no")" [ "${ok}" = "downgrade" ] || error "aborted; nothing has been changed." } fi # Credentials the cluster could not give us still have to be asked for. [ "${REG_MODE:-}" = "recovered" ] || registry_credentials_prompt registry_validate_or_die echo "" } announce_target() { local ctx ctx="${SCRYDON_KUBE_CONTEXT:-$(kube_current_context)}" [ -n "${ctx}" ] || ctx=unknown # A named context that does not exist must stop here, not surface later as a # confusing connection error from the first kubectl call. if [ -n "${SCRYDON_KUBE_CONTEXT}" ] \ && ! kube_contexts | grep -qxF "${SCRYDON_KUBE_CONTEXT}"; then error "no kubectl context named '${SCRYDON_KUBE_CONTEXT}'.\n" \ " Available: $(kube_contexts | tr '\n' ' ')" fi echo "" info_bold "Target" info_bold " cluster ${ctx}" info " namespace ${NAMESPACE}" info " hostname ${ROUTING_HOST}" echo "" } # Will the NEW version accept the configuration you already have? # # This is the "are any new settings required" check, and it must run # SERVER-side. `helm template` has no cluster access, so every lookup-based # secret comes back empty and the render fails on values that are in fact # present — false positives on exactly the settings people care about. # `--dry-run=server` renders through the apiserver, so lookup works and a # failure means the new chart genuinely needs something this release lacks. preflight_upgrade_renders() { [ "${IS_UPGRADE}" = "1" ] || return 0 PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) local err if err="$(helm_cluster upgrade "${RELEASE_NAME}" "oci://${REG_HOST}/${SCRYDON_CHART_REPO}" \ --version "${CHART_VERSION}" --namespace "${NAMESPACE}" \ -f "${RELEASE_VALUES_FILE}" --dry-run=server 2>&1 >/dev/null)"; then echo -e " ${Green}ok${Color_Off} ${CHART_VERSION} accepts your current configuration" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) return 0 fi echo -e " ${Red}FAIL${Color_Off} ${CHART_VERSION} needs settings your configuration does not have" printf '%s\n' "${err}" | grep -iE "execution error|must (be|contain)|required|Error:" | head -5 | sed 's/^/ /' preflight_fail "${CHART_VERSION} does not render against the current configuration" \ "the message above names what it wants. Supply it with --set, or re-run with --reconfigure to answer the interview again. Nothing has been changed." } preflight_run() { announce_target info_bold "Preflight" preflight_reset preflight_check "helm >= 3.14" helm_at_least_314 preflight_cluster || { preflight_report; return 1; } preflight_nato_profile preflight_rbac preflight_storage # Must run BEFORE the conflict check: if an external Dapr control plane # exists, the chart renders none of the Dapr cluster-scoped resources, so # they cannot conflict and must not be reported. preflight_dapr preflight_orphaned_cluster_resources preflight_gateway_class preflight_gateway_api_crds preflight_cert_manager preflight_capacity preflight_upgrade_renders preflight_dns preflight_report } helm_at_least_314() { local v major minor v="$(helm version --template '{{.Version}}' 2>/dev/null | sed 's/^v//')" major="${v%%.*}"; minor="${v#*.}"; minor="${minor%%.*}" [ -n "${major}" ] || return 1 [ "${major}" -gt 3 ] && return 0 [ "${major}" -eq 3 ] && [ "${minor}" -ge 14 ] } # ── Install ─────────────────────────────────────────────────────────────────── DRY_RUN="${DRY_RUN:-0}" VALUES_FILE="${VALUES_FILE:-values.customer.yaml}" RELEASE_NAME="${RELEASE_NAME:-scrydon}" HAVE_JQ="$(command -v jq >/dev/null 2>&1 && echo 1 || echo 0)" confirm_install() { local ctx ctx="$(kubectl config current-context 2>/dev/null || echo 'unknown')" echo "" if [ "${IS_UPGRADE}" = "1" ]; then info_bold "About to upgrade" info " release : ${RELEASE_NAME}" info " version : ${EXISTING_VERSION} -> ${CHART_VERSION}" info " values : unchanged ($(release_value_count) carried over)" else info_bold "About to install" info " release : ${RELEASE_NAME}" info " version : ${CHART_VERSION}" fi info " cluster : ${ctx}" info " namespace : ${NAMESPACE}" info " method : ${BACKEND}" echo "" [ "${DRY_RUN}" = "1" ] && return 0 # --non-interactive IS the confirmation: the operator committed on the command # line and there is no tty to answer on. Without this branch prompt_value # returns its "no" default and EVERY non-interactive install aborted here -- # the flag could not install at all. Nothing caught it: the KinD gate runs the # installer only with --dry-run (which returns above), and the conf-resume # tests stub confirm_install out entirely. if [ "${NON_INTERACTIVE}" = "1" ]; then info "proceeding without confirmation (--non-interactive)" return 0 fi local reply reply="$(prompt_value "Type 'yes' to proceed" "no")" [ "${reply}" = "yes" ] || error "aborted; nothing was applied." } # Pre-seeding splits the bundle, which needs a real JSON parser: publicKey may # carry escapes. Without jq we skip it — the setup wizard still accepts the # bundle, so this is convenience, not a dependency. LICENSE_TMPDIR='' cleanup_license_tmpdir() { [ -n "${LICENSE_TMPDIR}" ] || return 0 rm -rf "${LICENSE_TMPDIR}" LICENSE_TMPDIR='' return 0 } # A SIGNAL trap that returns normally RESUMES execution -- it does not end the # script. Trapping INT here meant Ctrl+C deleted the temp dir and then dropped # the operator back into the very next interview prompt, with no way to abort # short of closing the terminal. Re-raise instead: restore the default handler # and re-send the signal to ourselves, so the shell dies with the conventional # 128+signal status (130 for INT) that the parent shell and CI expect. # EXIT stays a plain handler -- load_conf_file saves/restores the EXIT trap by # string and must keep seeing exactly "cleanup_license_tmpdir". trap 'cleanup_license_tmpdir; cleanup_release_values; cleanup_profile_tmpdir' EXIT trap 'cleanup_license_tmpdir; cleanup_release_values; cleanup_profile_tmpdir; trap - INT; kill -INT $$' INT trap 'cleanup_license_tmpdir; cleanup_release_values; cleanup_profile_tmpdir; trap - TERM; kill -TERM $$' TERM # HELM_LICENSE_ARGS is an indexed array (bash 3.2 supports indexed arrays; # associative arrays need 4.0). A string form silently truncates paths that # contain spaces when the caller expands flags via word-splitting. HELM_LICENSE_ARGS=() build_helm_license_placeholders() { HELM_LICENSE_ARGS=() [ "${HAVE_JQ}" = "1" ] || return 0 [ -r "${LICENSE_FILE}" ] || return 0 HELM_LICENSE_ARGS=( --set-file "auth.secrets.LICENSE=" ) } build_helm_license_args() { # Remove any directory from a prior call before creating a new one so # a double-call can never orphan licence material in TMPDIR. # Belt-and-braces: only remove if the path is a direct tmp.-prefixed child # of the temp root — a tampered conf could set LICENSE_TMPDIR to an # arbitrary path and trigger this code before the allowlist fix existed. if [ -n "${LICENSE_TMPDIR}" ]; then _bhlargs_root="${TMPDIR:-/tmp}"; _bhlargs_root="${_bhlargs_root%/}" _bhlargs_leaf="$(basename "${LICENSE_TMPDIR}")" _bhlargs_par="$(dirname "${LICENSE_TMPDIR}")" case "${_bhlargs_leaf}" in tmp.*) if [ "${_bhlargs_par}" = "${_bhlargs_root}" ] && [ -d "${LICENSE_TMPDIR}" ]; then rm -rf "${LICENSE_TMPDIR}" else warn "build_helm_license_args: unexpected path '${LICENSE_TMPDIR}'; skipping cleanup" fi ;; *) warn "build_helm_license_args: unexpected path '${LICENSE_TMPDIR}'; skipping cleanup" ;; esac fi HELM_LICENSE_ARGS=() LICENSE_TMPDIR="" [ "${HAVE_JQ}" = "1" ] || return 0 [ -r "${LICENSE_FILE}" ] || return 0 LICENSE_TMPDIR="$(umask 077; mktemp -d)" # Only the licence. The platform checks it against Scrydon's built-in # signing keys; the bundle's publicKey is for this installer's offline check # and is never handed to the platform (it would be ignored in production). jq -r '.jwt' "${LICENSE_FILE}" > "${LICENSE_TMPDIR}/jwt" HELM_LICENSE_ARGS=( --set-file "auth.secrets.LICENSE=${LICENSE_TMPDIR}/jwt" ) } # --dry-run has to show EVERY mutating command, not just the headline one. # It used to print a hand-written copy of the helm line while the live path # executed a separately-written invocation, and said nothing at all about the # namespace, the pull secret, the registry login or the chart pull. So it both # under-reported what an install does and could drift from what it actually # runs. dry_note is the single place a planned command is announced. dry_note() { printf ' %b$%b %s\n' "${Dim}" "${Color_Off}" "$*"; } nato_seed_kyverno_registry_secret() { [ "${PROFILE}" = "nato-unclassified" ] || return 0 if [ "${DRY_RUN}" = "1" ]; then dry_note "kubectl -n kyverno apply -f - <<< ''" return 0 fi nato_assert_target registry_apply_secret kyverno \ || error "could not install the image-verification credential into kyverno." kubectl -n kyverno annotate secret scrydon-registry \ scrydon.io/qualification-run="${NATO_RUN_ID}" \ scrydon.io/qualification-candidate="${CHART_VERSION}" --overwrite >/dev/null } nato_profile_install_namespaces() { printf '%s\n' "${NAMESPACE}" [ "${PROFILE}" = "nato-unclassified" ] || return 0 [ -r "${PROFILE_RENDERED_MANIFEST}" ] \ || error "the validated NATO render is unavailable while preparing namespaces" yq ea -N -r 'select(.kind == "Namespace") | .metadata.name' \ "${PROFILE_RENDERED_MANIFEST}" } nato_assert_install_namespace() { local namespace="$1" [[ "${namespace}" =~ ^scrydon-[a-z0-9]+(-[a-z0-9]+)*$ ]] \ || error "NATO rendered an unsafe namespace '${namespace}'" nato_assert_not_protected "install namespace" "${namespace}" } nato_adopt_component_namespace() { local namespace="$1" [ "${namespace}" != "${NAMESPACE}" ] || return 0 # This is the same exact-release adoption contract used by the guarded AKS # bootstrap for scrydon-infra. It lets Helm update a pre-seeded Namespace # without making an unowned Namespace available to a different release. kubectl label namespace "${namespace}" \ app.kubernetes.io/managed-by=Helm --overwrite >/dev/null kubectl annotate namespace "${namespace}" \ meta.helm.sh/release-name="${RELEASE_NAME}" \ meta.helm.sh/release-namespace="${NAMESPACE}" --overwrite >/dev/null } nato_adopt_component_pull_secret() { local namespace="$1" [ "${namespace}" != "${NAMESPACE}" ] || return 0 kubectl -n "${namespace}" label secret scrydon-registry \ app.kubernetes.io/managed-by=Helm --overwrite >/dev/null kubectl -n "${namespace}" annotate secret scrydon-registry \ meta.helm.sh/release-name="${RELEASE_NAME}" \ meta.helm.sh/release-namespace="${NAMESPACE}" --overwrite >/dev/null } nato_render_owned_image_verification_policy() { NATO_POLICY_RELEASE_NAME="${RELEASE_NAME}" \ NATO_POLICY_RELEASE_NAMESPACE="${NAMESPACE}" \ yq ea -N ' select(.kind == "ClusterPolicy" and .metadata.name == "scrydon-nato-image-verification") | (.spec.rules[] | select(.name == "verify-scrydon-release-signatures") | .verifyImages[]) .imageRegistryCredentials.secrets = ["scrydon-registry"] | .metadata.labels."app.kubernetes.io/managed-by" = "Helm" | .metadata.annotations."meta.helm.sh/release-name" = strenv(NATO_POLICY_RELEASE_NAME) | .metadata.annotations."meta.helm.sh/release-namespace" = strenv(NATO_POLICY_RELEASE_NAMESPACE) ' "${PROFILE_RENDERED_MANIFEST}" } nato_apply_image_verification_policy() { local policy policy_count [ "${PROFILE}" = "nato-unclassified" ] || return 0 policy="$(nato_render_owned_image_verification_policy)" policy_count="$(printf '%s\n' "${policy}" \ | yq ea -N -r 'select(.kind == "ClusterPolicy" and .metadata.name == "scrydon-nato-image-verification") | .metadata.name' \ | awk 'NF{count++} END{print count+0}')" [ "${policy_count}" = "1" ] \ || error "the validated NATO render must contain exactly one image-verification policy" printf '%s\n' "${policy}" | kubectl apply -f - >/dev/null \ || error "could not pre-apply the fail-closed image-verification policy" kubectl wait --for=condition=Ready \ clusterpolicy/scrydon-nato-image-verification --timeout=2m >/dev/null \ || error "the fail-closed image-verification policy did not become Ready" kubectl get clusterpolicy scrydon-nato-image-verification -o json \ | jq -e 'any(.spec.rules[] | select(.name == "verify-scrydon-release-signatures") | .verifyImages[]?.imageRegistryCredentials.secrets[]?; . == "scrydon-registry")' >/dev/null \ || error "the fail-closed image-verification policy lacks its registry credential reference" } ensure_namespace_and_pull_secret() { local namespace namespaces if [ "${DRY_RUN}" = "1" ]; then dry_note "kubectl create namespace ${NAMESPACE} --dry-run=client -o yaml | kubectl apply -f -" [ "${BACKEND}" = "zarf" ] || \ dry_note "kubectl -n ${NAMESPACE} apply -f - <<< ''" if [ "${PROFILE}" = "nato-unclassified" ]; then dry_note "kubectl label namespace ${NAMESPACE} app.kubernetes.io/part-of=scrydon --overwrite" dry_note "# create/adopt every Namespace in the validated render and seed its scrydon-registry Secret" dry_note "kubectl apply -f - <<< ''" fi return 0 fi nato_assert_target namespaces="$(nato_profile_install_namespaces | awk 'NF && !seen[$0]++')" for namespace in ${namespaces}; do [ "${PROFILE}" != "nato-unclassified" ] || nato_assert_install_namespace "${namespace}" kubectl create namespace "${namespace}" --dry-run=client -o yaml \ | kubectl apply -f - >/dev/null if [ "${PROFILE}" = "nato-unclassified" ]; then kubectl label namespace "${namespace}" \ app.kubernetes.io/part-of=scrydon --overwrite >/dev/null nato_adopt_component_namespace "${namespace}" fi [ "${BACKEND}" = "zarf" ] && continue registry_apply_secret "${namespace}" \ || error "could not install the registry pull secret into ${namespace}." [ "${PROFILE}" != "nato-unclassified" ] \ || nato_adopt_component_pull_secret "${namespace}" done nato_apply_image_verification_policy } CHART_DIGEST='' record_chart_digest() { local chart="$1" out # helm pull reports "Digest: sha256:…" on stderr for OCI charts. out="$(helm pull "${chart}" --version "${CHART_VERSION}" \ --destination "$(mktemp -d)" 2>&1)" || return 0 CHART_DIGEST="$(printf '%s' "${out}" | sed -n 's/.*Digest: *\(sha256:[0-9a-f]*\).*/\1/p' | head -n 1)" [ -n "${CHART_DIGEST}" ] && info " chart digest : ${CHART_DIGEST}" return 0 } # The Dapr control plane and the application pods are created by the SAME helm # apply. Injection happens once, at admission, so a pod scheduled before the # sidecar-injector webhook is serving gets no sidecar and NEVER acquires one. # Observed on a first install: api-platform and agentic-realtime sat at 0/1 # indefinitely while `helm --wait` blocked toward its 30-minute timeout. # # Helm cannot order this away: hooks run BEFORE the subchart that provides the # injector, so a "wait for the injector" pre-install hook would deadlock on a # first install — the thing it waits for does not exist yet. # # Restarting the affected Deployment recreates its pods through a now-serving # webhook. That is already the documented remediation; this does it # automatically instead of leaving the operator to discover it after a # half-hour stall. Each Deployment is restarted AT MOST ONCE — a pod that is # still sidecar-less after its own restart is a different fault, and looping on # it would just churn the cluster. dapr_injection_watchdog() { local ns="$1" pid="$2" interval="${3:-15}" stale rs dep fixed='' while kill -0 "${pid}" 2>/dev/null; do sleep "${interval}" kill -0 "${pid}" 2>/dev/null || break stale="$(kubectl -n "${ns}" get pods \ -o jsonpath='{range .items[*]}{.metadata.annotations.dapr\.io/enabled}{"|"}{range .spec.containers[*]}{.name}{","}{end}{"|"}{.metadata.ownerReferences[0].name}{"\n"}{end}' \ 2>/dev/null | awk -F'|' '$1=="true" && $2 !~ /(^|,)daprd(,|$)/ && $3!="" {print $3}' | sort -u)" [ -n "${stale}" ] || continue for rs in ${stale}; do dep="$(kubectl -n "${ns}" get rs "${rs}" -o jsonpath='{.metadata.ownerReferences[0].name}' 2>/dev/null)" [ -n "${dep}" ] || continue case " ${fixed} " in *" ${dep} "*) continue ;; esac warn "${dep} started without its Dapr sidecar — restarting it so injection can run." nato_assert_target kubectl -n "${ns}" rollout restart "deploy/${dep}" >/dev/null 2>&1 fixed="${fixed} ${dep}" done done } install_connected() { local chart="oci://${REG_HOST}/${SCRYDON_CHART_REPO}" local extra version_args value_args atomic_args extra=() version_args=(--version "${CHART_VERSION}") value_args=(-f "${VALUES_FILE}") atomic_args=() dapr_control_plane_args extra+=("${DAPR_ARGS[@]+"${DAPR_ARGS[@]}"}") if [ "${PROFILE}" = "nato-unclassified" ]; then [ "${PROFILE_CHART_PREPARED}" = "1" ] \ || error "the exact NATO chart was not prepared before install" chart="${PROFILE_CHART_ROOT}" version_args=() value_args=(-f "${PROFILE_VALUES_FILE}" -f "${VALUES_FILE}" -f "${PROFILE_SITE_COPY}") atomic_args=(--atomic) fi # Narrates in dry-run, acts otherwise — same call either way. ensure_namespace_and_pull_secret if [ "${DRY_RUN}" = "1" ]; then build_helm_license_placeholders # no disk I/O, no temp files if [ "${PROFILE}" = "nato-unclassified" ]; then dry_note "# exact OCI chart ${CHART_VERSION}@${CHART_DIGEST} was pulled into an isolated temporary directory for validation" else dry_note "helm registry login ${REG_HOST} --username ${REG_USER} --password-stdin # secret via stdin, never argv" dry_note "helm pull ${chart} --version ${CHART_VERSION} # to record the chart digest" fi else if [ "${PROFILE}" != "nato-unclassified" ]; then printf '%s' "${REG_PASS}" | helm registry login "${REG_HOST}" \ --username "${REG_USER}" --password-stdin >/dev/null 2>&1 \ || error "helm could not sign in to the registry." record_chart_digest "${chart}" fi build_helm_license_args # creates temp files exactly once, on the live path fi # ONE definition of the install command, printed by dry-run and executed by # the live path. Previously each branch wrote its own copy, so a flag added to # the real invocation would never appear in what --dry-run promised. # "${arr[@]+"${arr[@]}"}" is the bash 3.2 safe empty-array expansion: under # set -u a bare "${arr[@]}" errors when the array is empty. local helm_cmd helm_cmd=(helm upgrade --install "${RELEASE_NAME}" "${chart}" ${SCRYDON_KUBECONFIG:+--kubeconfig "${SCRYDON_KUBECONFIG}"} ${SCRYDON_KUBE_CONTEXT:+--kube-context "${SCRYDON_KUBE_CONTEXT}"} "${version_args[@]+"${version_args[@]}"}" --namespace "${NAMESPACE}" "${value_args[@]+"${value_args[@]}"}" "${extra[@]+"${extra[@]}"}" "${HELM_LICENSE_ARGS[@]+"${HELM_LICENSE_ARGS[@]}"}" "${atomic_args[@]+"${atomic_args[@]}"}" --wait --timeout 30m) if [ "${DRY_RUN}" = "1" ]; then dry_note "${helm_cmd[*]}" dry_note "kubectl -n ${NAMESPACE} rollout restart deploy/ # only if a pod starts without its Dapr sidecar" return 0 fi # helm runs in the background so the watchdog can repair sidecar-less pods # while --wait is still blocking on them. Without that the two deadlock: # helm waits for pods that can never become Ready. nato_assert_target if [ "${PROFILE}" = "nato-unclassified" ]; then helm_nato_cluster "${helm_cmd[@]:1}" & else "${helm_cmd[@]}" & fi local helm_pid=$! dapr_injection_watchdog "${NAMESPACE}" "${helm_pid}" wait "${helm_pid}" \ || error "the install failed. Run 'helm -n ${NAMESPACE} status ${RELEASE_NAME}' and see\n" \ " https://docs.scrydon.com/deployment/operations/troubleshooting" } zarf_package_version() { # zarf-package-scrydon-amd64-1.3.2.tar.zst -> 1.3.2 # Contract: prints nothing and returns 1 if the filename does not match the # canonical pattern, so callers can detect an unrecognised file early rather # than silently passing an empty string to --version. local ver ver="$(basename "$1" | sed -n 's/^zarf-package-scrydon-[^-]*-\(.*\)\.tar\.zst$/\1/p')" [ -n "${ver}" ] || return 1 printf '%s\n' "${ver}" } # Check the package against the SHA256SUMS carried across the gap beside it. # The release signs the package with cosign KEYLESS (Sigstore), which needs the # public transparency log, so that verification happens on the connected side # before the file crosses. Inside, the checksum proves this is the same file. verify_zarf_package_checksum() { local pkg="$1" sums expected actual sums="$(dirname "${pkg}")/SHA256SUMS" if [ ! -r "${sums}" ]; then warn "no SHA256SUMS next to the package; its checksum was NOT verified. Carry SHA256SUMS across with the package." return 0 fi expected="$(awk -v f="$(basename "${pkg}")" '{ name = $2; sub(/^\*/, "", name) } name == f { print $1; exit }' "${sums}")" [ -n "${expected}" ] || error "SHA256SUMS does not list $(basename "${pkg}") — do not deploy this file." actual="$(profile_sha256_file "${pkg}")" [ "${actual}" = "${expected}" ] \ || error "package checksum does NOT match SHA256SUMS — do not deploy this file." success " package : checksum verified against SHA256SUMS" } # The air-gapped package pins its own target: zarf.yaml's scrydon-deploy # component deploys release `scrydon` into `scrydon-platform`, and # `zarf package deploy` takes no flag for either. setup.test.sh pins these two # against zarf.yaml, because nothing else compares them. ZARF_PACKAGE_NAMESPACE='scrydon-platform' ZARF_PACKAGE_RELEASE_NAME='scrydon' # Refuse a zarf install aimed anywhere but the package's own target. Accepting # --namespace / RELEASE_NAME here did not move the install — the package went to # its pinned target regardless — but everything around it used the requested # one: the pull secret landed in the wrong namespace, verification looked in the # wrong place, and on a RE-RUN preflight_dapr compared the package's own # dapr-operator to the wrong release, called it external, and refused the # upgrade with "Remove the existing one". zarf_validate_target() { [ "${BACKEND:-}" = "zarf" ] || return 0 [ "${NAMESPACE}" = "${ZARF_PACKAGE_NAMESPACE}" ] \ || error "the air-gapped package always deploys into namespace '${ZARF_PACKAGE_NAMESPACE}'; '${NAMESPACE}' was requested. Drop --namespace (or set it to ${ZARF_PACKAGE_NAMESPACE}), or use --backend mirror to choose a namespace." [ "${RELEASE_NAME}" = "${ZARF_PACKAGE_RELEASE_NAME}" ] \ || error "the air-gapped package always deploys release '${ZARF_PACKAGE_RELEASE_NAME}'; RELEASE_NAME='${RELEASE_NAME}' was requested. Unset RELEASE_NAME, or use --backend mirror to choose a release name." return 0 } install_zarf() { [ -n "${ZARF_PACKAGE}" ] || error "--package is required for an air-gapped install." # Deploy-time variables declared in zarf.yaml and wired to chart values there. # An empty --tls-issuer ("terminate TLS yourself") keeps the package default. local zarf_vars=(--set-variables "DOMAIN=${ROUTING_HOST}") [ -n "${TLS_ISSUER}" ] && zarf_vars+=(--set-variables "TLS_ISSUER=${TLS_ISSUER}") if [ "${DRY_RUN}" = "1" ]; then info "zarf package deploy ${ZARF_PACKAGE} --confirm ${zarf_vars[*]}" return 0 fi [ -r "${ZARF_PACKAGE}" ] || error "cannot read the package at '${ZARF_PACKAGE}'." verify_zarf_package_checksum "${ZARF_PACKAGE}" ensure_namespace_and_pull_secret zarf package deploy "${ZARF_PACKAGE}" --confirm "${zarf_vars[@]}" \ || error "zarf deploy failed; see ${DOCS_TROUBLESHOOTING}" } # ── Mirror backend (customer registry) ─────────────────────────────────────── # # Usage: setup.sh --backend mirror --host app.example.com \ # [--package zarf-package-scrydon-amd64-1.3.2.tar.zst] # # Both --set global.imageRegistry and --set dapr.global.registry MUST be # passed. Setting only global.imageRegistry leaves the five Dapr control-plane # images plus the injected daprd sidecar pointing at an unreachable upstream — # the injector never becomes Ready and no pod can start its sidecar. Helm # subchart values are static YAML, so the parent global cannot reach them. # # Image mirroring: # When a Zarf package is supplied and `zarf package mirror-resources` is # available (the gate in the Task 17 brief), that path is used. Until the # gate is verified against the pinned Zarf version the fallback prints clear # instructions to use the existing distribution/airgap/scripts/load-images.sh. # The retirement of that crane bundle is deferred pending gate verification. # # FUTURE (once `zarf package mirror-resources` is confirmed): # Replace the ZARF_PACKAGE branch below with: # zarf package mirror-resources "${ZARF_PACKAGE}" \ # --registry-url "${REG_HOST}" \ # --registry-push-username "${REG_USER}" \ # --registry-push-password "${REG_PASS}" --confirm \ # || error "mirroring images failed." # and delete the load-images.sh instruction block. install_mirror() { local chart="oci://${REG_HOST}/${SCRYDON_CHART_REPO}" # Both flags, always. See the function header comment for why one is not enough. local flags="--set global.imageRegistry=${REG_HOST} --set dapr.global.registry=${REG_HOST}/dapr" # And the external control plane, when preflight found one — the same flags # the connected backend passes. dapr_control_plane_args [ "${#DAPR_ARGS[@]}" -eq 0 ] || flags="${flags} ${DAPR_ARGS[*]}" if [ "${DRY_RUN}" = "1" ]; then info "helm upgrade --install ${RELEASE_NAME} ${chart} --version ${CHART_VERSION}" \ "--namespace ${NAMESPACE} -f ${VALUES_FILE} ${flags} --wait" return 0 fi if [ -n "${ZARF_PACKAGE}" ]; then # FUTURE: replace this block with `zarf package mirror-resources` once the # gate in task-17-brief.md is verified against the pinned Zarf version. # Until then, print the manual step and let the operator proceed assuming # the images are already in their registry. info "A Zarf package was supplied. Mirror its images into ${REG_HOST} before" info "continuing. Run the following on a host that has both the package and" info "Docker/crane access to the registry:" info "" info " bash distribution/airgap/scripts/load-images.sh \\" info " --package ${ZARF_PACKAGE} \\" info " --registry ${REG_HOST}" info "" if [ "${NON_INTERACTIVE}" = "1" ]; then info "Non-interactive mode — continuing with images assumed present." else info "Press Enter to continue assuming images are present, or Ctrl-C to abort." read -r _ \n %s#auth-server-unreachable-in-the-platform-ui\n' "${NAMESPACE}" "${DOCS_TROUBLESHOOTING}" ;; ingress) printf 'The gateway has no address or the certificate is not ready yet.\n %s\n' "${DOCS_TROUBLESHOOTING}" ;; unreachable) printf 'The release is healthy in-cluster but nothing answers on the hostname.\n' printf 'The three causes seen in practice, in order:\n' printf ' 1. The GatewayClass is not Accepted, or a listener reports PortUnavailable —\n' printf ' gateway.listenerPorts must match your controller (Traefik: 8000/8443, not 80/443).\n' printf ' Check: kubectl -n %s get gateway -o wide; kubectl get gatewayclass.\n' "${NAMESPACE}" printf ' 2. The cloud load balancer health probe targets a path the controller 404s,\n' printf ' so every backend is marked unhealthy. On AKS set\n' printf ' service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path=/ping\n' printf ' on the controller Service.\n' printf ' 3. DNS for the hostname points somewhere other than the gateway address.\n' printf ' %s\n' "${DOCS_TROUBLESHOOTING}" ;; *) printf 'See the install troubleshooting runbook:\n %s\n' "${DOCS_TROUBLESHOOTING}" ;; esac } # Every check verify_install had could pass while the platform was unreachable: # pods Running, sidecars injected, migrations applied, and an ingress that has an # ADDRESS -- none of which means anything ANSWERS on the hostname. Observed live # on AKS: the load balancer health probe targeted "/", ingress-nginx 404s there, # every backend was marked unhealthy, and the installer still printed "Scrydon is # installed. Finish setup at: https://...". An unclaimed IngressClass produces the # same shape. Only an HTTP request to the real URL can see it. # # Returns the HTTP status on stdout. 0 = something answered, 1 = nothing did. # Retries because LB programming and controller reload legitimately take a minute. platform_probe() { local url="$1" attempts="${2:-10}" delay="${3:-6}" i=0 code='' while [ "${i}" -lt "${attempts}" ]; do code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 "${url}" 2>/dev/null)" case "${code}" in # 000 = no answer at all; 5xx = reached something that is not ready yet. 000|502|503|504) : ;; *) printf '%s' "${code}"; return 0 ;; esac i=$(( i + 1 )) [ "${i}" -lt "${attempts}" ] && sleep "${delay}" done printf '%s' "${code}" return 1 } verify_install() { info_bold "Verifying" local not_ready sidecarless not_ready="$(kubectl -n "${NAMESPACE}" get pods --no-headers 2>/dev/null \ | awk '$3!="Running" && $3!="Completed" {print $1}')" if [ -n "${not_ready}" ]; then warn "these pods are not Running:" printf '%s\n' "${not_ready}" | sed 's/^/ /' triage_hint generic else success " all pods Running" fi # Only pods ANNOTATED for injection can have a sidecar. Flagging every "1/1" # pod reported Postgres, Valkey, OPA, SeaweedFS, StarRocks and the entire Dapr # control plane as broken on a perfectly healthy install -- none of which ever # get a sidecar. That noise also buried the one case that matters: an app pod # that genuinely missed injection because it was scheduled before the # sidecar-injector webhook was serving (observed on a first install, where the # chart brings up Dapr and the apps in the same release). sidecarless="$(kubectl -n "${NAMESPACE}" get pods \ -o jsonpath='{range .items[*]}{.metadata.name}{"|"}{.metadata.annotations.dapr\.io/enabled}{"|"}{range .spec.containers[*]}{.name}{","}{end}{"\n"}{end}' 2>/dev/null \ | awk -F'|' '$2=="true" && $3 !~ /(^|,)daprd(,|$)/ && $1 != "" {print $1}')" if [ -n "${sidecarless}" ]; then warn "these pods have no Dapr sidecar:" printf '%s\n' "${sidecarless}" | sed 's/^/ /' triage_hint no-sidecar else success " Dapr sidecars injected" fi if kubectl -n "${NAMESPACE}" get job -l app=auth-bootstrap \ -o jsonpath='{.items[*].status.succeeded}' 2>/dev/null | grep -q 1; then success " database migrations completed" elif [ -z "$(kubectl -n "${NAMESPACE}" get job -l app=auth-bootstrap -o name 2>/dev/null)" ]; then # The Job sets ttlSecondsAfterFinished: 300, so Kubernetes reaps it five # minutes after it finishes -- routinely BEFORE a 30-minute install returns. # Its absence is the normal case, not a failure, and reporting "the database # schema is missing" on a healthy install is a bad first impression. # The schema is still proven: every auth-dependent pod carries a # wait-for-auth-schema init container, so the pods counted as Running above # could not have started unless the migration had applied. success " database migrations completed (bootstrap Job already reaped by its 300s TTL)" else warn "the auth bootstrap job has not succeeded." triage_hint migrations fi local addr addr="$(kubectl -n "${NAMESPACE}" get gateway -o jsonpath='{.items[0].status.addresses[0].value}' 2>/dev/null)" if [ -n "${addr}" ]; then success " gateway address ${addr}"; else warn "the gateway has no address yet."; triage_hint ingress; fi # The reachability gate. Skipped (not failed) when the hostname does not # resolve from HERE -- installing from a bastion whose DNS view differs from # the users' is legitimate, and claiming a failure we cannot observe would be # as wrong as claiming a success we cannot observe. local setup_url="https://${ROUTING_HOST}/platform/setup" code reachable if ! host_resolves "${ROUTING_HOST}"; then reachable=skipped warn "${ROUTING_HOST} does not resolve from this machine — cannot confirm the platform is reachable." elif code="$(platform_probe "${setup_url}")"; then reachable=yes success " reachable https://${ROUTING_HOST}/ answered HTTP ${code}" else reachable=no echo -e " ${Red}FAIL${Color_Off} nothing answered on https://${ROUTING_HOST}/ (last: ${code:-no response})" fi echo "" if [ "${reachable}" = "no" ]; then # NOT ${IS_UPGRADE:+...}: the variable is 0 or 1, and "0" is non-empty, so # :+ fires for a fresh install too and prints "upgraded0". if [ "${IS_UPGRADE}" = "1" ]; then warn "Scrydon was upgraded, but nothing answers on https://${ROUTING_HOST}/." else warn "Scrydon is installed, but nothing answers on https://${ROUTING_HOST}/." fi info "The release is healthy inside the cluster — the break is between your" info "ingress and the outside world, so the platform is unusable as it stands." echo "" triage_hint unreachable return 0 fi if [ "${IS_UPGRADE}" = "1" ]; then success "Scrydon upgraded to ${CHART_VERSION}." else success "Scrydon is installed." fi if [ "${reachable}" = "skipped" ]; then info "Reachability was not verified from here — confirm the URL below yourself." fi info_bold "Finish setup at: https://${ROUTING_HOST}/platform/setup" # Key off whether a license was actually pre-seeded, NOT off whether jq is # installed. Keying on HAVE_JQ told every operator who happened to have jq -- # including one who supplied no license at all -- to skip the license step, # and then the wizard demanded a bundle they had just been told to skip. # These conditions mirror build_helm_license_args exactly: it needs BOTH jq # and a readable bundle to pass the license through to helm. if [ -n "${LICENSE_FILE}" ] && [ -r "${LICENSE_FILE}" ] && [ "${HAVE_JQ}" = "1" ]; then info "Your license is pre-seeded, so start at the admin-account step." elif [ -n "${LICENSE_FILE}" ] && [ -r "${LICENSE_FILE}" ]; then info "jq is not installed, so the license could not be pre-seeded --" info "paste your { jwt, publicKey } bundle into step 1 of the wizard." else info "No license was supplied -- paste your { jwt, publicKey } bundle into step 1." fi } # ── CLI ─────────────────────────────────────────────────────────────────────── CONF_FILE='' # ZARF_PACKAGE is set by --package and consumed by install_zarf (Task 17). # Declared here to satisfy static analysis. ZARF_PACKAGE='' usage() { cat <<'USAGE' Scrydon installer curl -fsSL https://get.scrydon.com | bash Options: --version Install this version instead of prompting --profile Installer-owned control profile (nato-unclassified) --site-values Reviewed site overlay for nato-unclassified --qualification-target Immutable target.json from the guarded AKS bootstrap --list-versions Print the installable versions and exit --include-prereleases Offer rc builds in the version picker --conf Skip the interview; use saved answers --backend connected | mirror | zarf (default: derived) --location onprem | azure | airgap --namespace Target namespace (default: scrydon-platform) --host Hostname users will visit --storage-class StorageClass for persistent volumes --bundle-traefik Install Traefik with the release: it brings the Gateway API CRDs and publishes the 'traefik' GatewayClass. Use on a cluster that has no Gateway API controller yet. --gateway-class GatewayClass name (required with --non-interactive when the cluster has no GatewayClass to detect) --tls-issuer cert-manager ClusterIssuer for HTTPS. Pass an empty value ("") when you terminate TLS yourself. --context Install into this kubectl context. Without it the installer ASKS, defaulting to your current context. --kubeconfig Explicit kubeconfig; mandatory for nato-unclassified --reconfigure On an existing release, re-run the full interview and REPLACE its configuration instead of upgrading in place. Discards settings the interview does not ask about. --license Path to the { jwt, publicKey } bundle --pull-secret Path to the issued pull-secret manifest --package Zarf package (air-gapped installs). A SHA256SUMS file beside it is checked before deploying --values Where to write generated values (default: values.customer.yaml; this is output, not the site overlay) --non-interactive Never prompt; every needed value must come from flags or --conf --dry-run Run preflight and print commands; change nothing --help Show this message USAGE exit 0 } # ACTION holds the name of an immediate-exit command (list-versions, help) that # must be deferred until after the full parse loop so that flags like # --include-prereleases that precede or follow it are already applied. ACTION='' parse_args() { while [ $# -gt 0 ]; do case "$1" in --version) CHART_VERSION="$2"; shift 2 ;; --profile) PROFILE="$2"; shift 2 ;; --site-values) SITE_VALUES_FILE="$2"; shift 2 ;; --qualification-target) QUALIFICATION_TARGET_RECORD="$2"; shift 2 ;; --list-versions) ACTION=list_versions; shift ;; --include-prereleases) INCLUDE_PRERELEASES=1; shift ;; --conf) CONF_FILE="$2"; shift 2 ;; --backend) BACKEND="$2"; shift 2 ;; --location) LOCATION="$2"; shift 2 ;; --namespace) NAMESPACE="$2"; shift 2 ;; --host) ROUTING_HOST="$2"; shift 2 ;; --storage-class) STORAGE_CLASS="$2"; shift 2 ;; --gateway-class) GATEWAY_CLASS="$2"; shift 2 ;; --bundle-traefik) BUNDLE_TRAEFIK=true; shift ;; --context) SCRYDON_KUBE_CONTEXT="$2"; shift 2 ;; --kubeconfig) SCRYDON_KUBECONFIG="$2"; shift 2 ;; --reconfigure) RECONFIGURE=1; shift ;; # Accepts an EMPTY value on purpose: --tls-issuer "" is how an operator # who terminates TLS themselves opts out of the cert-manager preflight. --tls-issuer) TLS_ISSUER="$2"; shift 2 ;; --license) LICENSE_FILE="$2"; shift 2 ;; --pull-secret) registry_read_secret_file "$2" || error "cannot read pull secret '$2'"; shift 2 ;; --package) export ZARF_PACKAGE="$2"; shift 2 ;; --values) VALUES_FILE="$2"; shift 2 ;; --non-interactive) NON_INTERACTIVE=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --help|-h) ACTION=help; shift ;; *) error "unknown option '$1' (try --help)" ;; esac done # Act on deferred immediate-exit commands now that all flags are applied. case "${ACTION}" in list_versions) installable_versions; exit 0 ;; help) usage ;; esac # A backend given explicitly wins; otherwise derive it once the interview or # the conf file has supplied location and image source. [ -n "${BACKEND}" ] && [ "${BACKEND}" = "zarf" ] && OFFLINE_INSTALL=1 return 0 } # The interview derives several values that no flag expresses. The # --non-interactive path used to skip ALL of them, so it wrote an empty ingress # className and an empty --version: # # ingress: className: <- binds to no controller; the platform is # unreachable, yet helm reports success # helm ... --version "" <- the operator cannot pin a version, which # is the one thing the picker exists for # # Nothing caught it: the KinD gate drives this exact path and only asserts the # values file is non-empty and carries the host. Both entry points now derive # through this one function so they cannot drift apart again. apply_non_interactive_defaults() { # Honours --version when given; otherwise resolves the newest stable release. select_version [ -n "${IMAGE_SOURCE}" ] || IMAGE_SOURCE=scrydon [ -n "${BACKEND}" ] || derive_backend if [ -z "${GATEWAY_CLASS}" ]; then # Guessing here is worse than stopping: a wrong class produces a Gateway no # controller schedules, which fails silently at request time rather than at # install time. detect_gateway_class exits 0 with EMPTY output when the # cluster has no GatewayClass at all, so test the value, not the status. GATEWAY_CLASS="$(detect_gateway_class 2>/dev/null || true)" if [ -z "${GATEWAY_CLASS}" ] && [ "${BUNDLE_TRAEFIK}" = "true" ]; then GATEWAY_CLASS=traefik fi [ -n "${GATEWAY_CLASS}" ] || error \ "--non-interactive: no GatewayClass found on the cluster.\n" \ " Pass --gateway-class (e.g. traefik), or --bundle-traefik to install one." fi # The interview validates the LICENCE before install; the flags-only path did # not, so --non-interactive --license accepted an expired, revoked or forged # bundle without a word -- no claims check, no signature check, no revocation # check -- and passed it straight to helm. Automated installs deserve the same # gate a human gets. if [ -n "${LICENSE_FILE}" ]; then [ -r "${LICENSE_FILE}" ] || error "cannot read the license bundle at '${LICENSE_FILE}'." license_validate "$(license_jwt_from_bundle "${LICENSE_FILE}")" fi # The interview validates the credential before install; the flags-only path # skipped that too, so a bad pull secret first surfaced as an opaque # 'helm registry login' failure. case "${BACKEND}" in connected|mirror) [ -n "${REG_MODE:-}" ] && { registry_validate_or_die; } ;; esac return 0 } main() { detect_platform >/dev/null parse_args "$@" profile_detect_early_request profile_validate_early_targeting check_dependencies "${BACKEND:-connected}" profile_require_dependencies # Qualification is allowed to inspect only the immutable recorded target. # Do this before select_kube_context/detect_existing_release can make their # first cluster-facing read; every client invocation is already pinned to the # dedicated kubeconfig and context parsed above. if [ "${PROFILE_EARLY_REQUESTED}" = "1" ]; then nato_assert_target fi # Before ANY cluster read: which cluster? Every kubectl call below goes # through the wrapper that pins this context. select_kube_context # Install or upgrade? Decide BEFORE gathering answers, because an upgrade # gathers almost none. --namespace selects which release is meant. if [ "${RECONFIGURE}" != "1" ] && [ -z "${CONF_FILE:-}" ] \ && detect_existing_release && load_release_values; then [ "${PROFILE_EARLY_REQUESTED}" != "1" ] \ || error "nato-unclassified qualification requires a fresh cluster release" run_upgrade_interview elif [ -n "${CONF_FILE:-}" ]; then load_conf_file "${CONF_FILE}" # F2: re-read the trusted pull-secret file after the conf loads so a # tampered conf cannot redirect REG_HOST/REG_USER to an attacker-controlled # registry. parse_args runs before load_conf_file, so REG_SECRET_FILE holds # the path from --pull-secret (if supplied); re-reading restores the trusted # values that the conf may have overwritten. Order matters: the conf is # untrusted input that could cross an insecure channel; the secret file is # the operator-supplied authoritative source. if [ -n "${REG_SECRET_FILE:-}" ]; then registry_read_secret_file "${REG_SECRET_FILE}" \ || error "could not re-read the pull secret from '${REG_SECRET_FILE}' after loading conf." fi # F1: require a registry credential before any install attempt when the # backend needs one. Without this check the installer runs silently until # helm registry login fails with a cryptic empty-password error. case "${BACKEND:-connected}" in connected|mirror) if [ "${DRY_RUN:-0}" != "1" ] && [ -z "${REG_MODE:-}" ]; then error "the registry credential is required but was not supplied.\n" \ " On the bastion host, pass --pull-secret (the pull-secret\n" \ " manifest issued by your account team) alongside --conf:\n" \ " curl -fsSL https://get.scrydon.com | bash -s -- \\\\\n" \ " --conf scrydon-install.conf --pull-secret " fi ;; esac elif [ "${NON_INTERACTIVE}" = "1" ]; then # In non-interactive mode without --conf, values come entirely from flags. # require_value will error for any missing required value. require_value ROUTING_HOST --host require_value NAMESPACE --namespace require_value STORAGE_CLASS --storage-class apply_non_interactive_defaults else run_interview fi profile_validate_inputs zarf_validate_target if [ "${IS_UPGRADE}" = "1" ]; then # The live release's values are the configuration. Regenerating them from # an interview that never ran would blank everything it does not ask about. VALUES_FILE="${RELEASE_VALUES_FILE}" else write_values_file "${VALUES_FILE}" write_conf_file "scrydon-install.conf" fi if [ "${PROFILE}" = "nato-unclassified" ]; then nato_assert_target fi if ! cluster_reachable; then echo "" success "Answers saved." info "No cluster is reachable from here, which is expected for a restricted" info "environment. Copy these three files to a host that CAN reach the cluster:" info " ${VALUES_FILE}" info " scrydon-install.conf" [ "${PROFILE}" = "nato-unclassified" ] && info " ${SITE_VALUES_FILE}" info " (the manifest issued by your account team)" info "then run, on that host:" info_bold " curl -fsSL https://get.scrydon.com | bash -s -- --conf scrydon-install.conf --pull-secret " exit 0 fi if [ "${PROFILE}" = "nato-unclassified" ]; then prepare_profile_chart nato_seed_kyverno_registry_secret # Persist the immutable chart digest only after the exact artifact has been # pulled and validated. The unrestricted site file itself is never copied. write_conf_file "scrydon-install.conf" write_profile_projection fi preflight_run || exit 1 confirm_install case "${BACKEND}" in connected) install_connected ;; mirror) install_mirror ;; zarf) install_zarf ;; *) error "unknown backend '${BACKEND}'" ;; esac [ "${DRY_RUN}" = "1" ] && { info "dry run complete; nothing was applied."; exit 0; } verify_install if [ "${PROFILE}" = "nato-unclassified" ]; then info "NATO install control projection: ${PROFILE_PROJECTION_FILE}" fi return 0 } if [ "${SCRYDON_INSTALL_LIB:-0}" != "1" ]; then main "$@" fi