#!/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 } # ── 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 keys are appended here as they are issued. Populate before # the first public release: see Task 14. *) 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 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='' 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 ready-made dockerconfigjson Secret manifest # 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='' 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":{"":{"username":"...","password":"..."}}} 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)" REG_SECRET_FILE="${file}" REG_MODE=secret-file [ -n "${REG_HOST}" ] && [ -n "${REG_USER}" ] || { warn "${file}: could not read host/username"; return 1; } 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. 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 ;; 404) warn "${SCRYDON_CHART_REPO}:${CHART_VERSION} does not exist on ${REG_HOST}."; return 1 ;; *) warn "unexpected response ${code} reading ${SCRYDON_CHART_REPO}:${CHART_VERSION}."; return 1 ;; esac } registry_apply_secret() { local ns="$1" auth cfg if [ "${REG_MODE}" = "secret-file" ]; then kubectl apply -n "${ns}" -f "${REG_SECRET_FILE}" >/dev/null \ || { warn "could not apply the pull secret into ${ns}"; return 1; } else # Build the dockerconfigjson in-process. No credential appears in kubectl argv. 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; } fi return 0 } # ── Interview ──────────────────────────────────────────────────────────────── LOCATION=''; IMAGE_SOURCE=''; BACKEND=''; NAMESPACE='scrydon-platform' ROUTING_HOST=''; ROUTING_MODE='subpath'; INGRESS_CLASS=''; STORAGE_CLASS='' 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_ingress_class() { cluster_reachable || return 1 kubectl get ingressclass -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 || error "fix the registry credentials and re-run." 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)" INGRESS_CLASS="$(prompt_value "Ingress class" "$(detect_ingress_class || echo traefik)")" local sc_default sc_default="$(detect_default_storage_class || echo '')" if [ -z "${sc_default}" ] && [ "${LOCATION}" = "onprem" ]; then info "No default StorageClass detected. The chart ships no cloud default, so" info "this must name your provisioner (e.g. ceph-rbd, vsphere-csi, local-path)." fi STORAGE_CLASS="$(prompt_value "StorageClass" "${sc_default}")" [ -n "${STORAGE_CLASS}" ] || error "a StorageClass is required; PVCs stay Pending without one." TLS_ISSUER="$(prompt_value "cert-manager cluster issuer (empty for BYO/upstream TLS)" "letsencrypt-prod")" NAMESPACE="$(prompt_value "Namespace" "scrydon-platform")" } write_values_file() { local path="$1" tls_block='' [ -n "${TLS_ISSUER}" ] && tls_block=" clusterIssuer: ${TLS_ISSUER}" 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 } # ── 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_fail() { PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) PREFLIGHT_FAILURES="${PREFLIGHT_FAILURES}${1}"$'\t'"${2}"$'\n' } 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_fail "${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 } preflight_ingress_nginx() { # Only relevant when the chosen ingress class is nginx. case "${INGRESS_CLASS}" in *nginx*) : ;; *) return 0 ;; esac PREFLIGHT_CHECKS_ATTEMPTED=$(( PREFLIGHT_CHECKS_ATTEMPTED + 1 )) local allow risk msg allow="$(kubectl get configmap -A -l app.kubernetes.io/name=ingress-nginx \ -o jsonpath='{.items[0].data.allow-snippet-annotations}' 2>/dev/null)" risk="$(kubectl get configmap -A -l app.kubernetes.io/name=ingress-nginx \ -o jsonpath='{.items[0].data.annotations-risk-level}' 2>/dev/null)" if msg="$(nginx_snippet_verdict "${allow}" "${risk}" 2>&1)"; then echo -e " ${Green}ok${Color_Off} ingress-nginx snippet annotations are enabled" PREFLIGHT_PASSES=$(( PREFLIGHT_PASSES + 1 )) else printf '%s\n' "${msg}" | sed 's/^/ /' echo -e " ${Red}FAIL${Color_Off} ingress-nginx snippet annotations" preflight_fail "ingress-nginx snippet annotations" \ "helm upgrade ingress-nginx … --set controller.config.allow-snippet-annotations=true --set-string controller.config.annotations-risk-level=Critical (or use the traefik routing mode, which needs neither)" fi } # shellcheck disable=SC2034 # DAPR_INSTALL_CONTROL_PLANE is read by the apply phase (Task 12) DAPR_INSTALL_CONTROL_PLANE='' preflight_dapr() { if kubectl get deployment -A -l app.kubernetes.io/name=dapr-operator \ -o name 2>/dev/null | grep -q .; then # shellcheck disable=SC2034 # set for use by the apply phase (Task 12) DAPR_INSTALL_CONTROL_PLANE=false info " note an existing Dapr control plane was found; the chart will not install a second one" fi return 0 } 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 } preflight_dns() { local resolved # getent does not exist on macOS (it is Linux-only); nslookup is the macOS fallback. resolved="$(getent hosts "${ROUTING_HOST}" 2>/dev/null | awk '{print $1; exit}')" [ -n "${resolved}" ] || resolved="$(nslookup "${ROUTING_HOST}" 2>/dev/null | awk '/^Address: /{print $2; exit}')" 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 } preflight_run() { info_bold "Preflight" preflight_reset preflight_check "helm >= 3.14" helm_at_least_314 preflight_cluster || { preflight_report; return 1; } preflight_rbac preflight_storage preflight_ingress_nginx preflight_dapr preflight_capacity 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 "" info_bold "About to install" info " release : ${RELEASE_NAME}" info " version : ${CHART_VERSION}" 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 EXIT trap 'cleanup_license_tmpdir; trap - INT; kill -INT $$' INT trap 'cleanup_license_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=" --set-file "auth.secrets.LICENSE_PUBLIC_KEY=" ) } 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)" jq -r '.jwt' "${LICENSE_FILE}" > "${LICENSE_TMPDIR}/jwt" jq -r '.publicKey' "${LICENSE_FILE}" > "${LICENSE_TMPDIR}/pubkey" HELM_LICENSE_ARGS=( --set-file "auth.secrets.LICENSE=${LICENSE_TMPDIR}/jwt" --set-file "auth.secrets.LICENSE_PUBLIC_KEY=${LICENSE_TMPDIR}/pubkey" ) } ensure_namespace_and_pull_secret() { [ "${DRY_RUN}" = "1" ] && return 0 kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml \ | kubectl apply -f - >/dev/null [ "${BACKEND}" = "zarf" ] && return 0 registry_apply_secret "${NAMESPACE}" \ || error "could not install the registry pull secret into ${NAMESPACE}." } 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 } install_connected() { local chart="oci://${REG_HOST}/${SCRYDON_CHART_REPO}" local extra extra=() [ -n "${DAPR_INSTALL_CONTROL_PLANE:-}" ] && \ extra=(--set "dapr.installControlPlane=${DAPR_INSTALL_CONTROL_PLANE}") if [ "${DRY_RUN}" = "1" ]; then build_helm_license_placeholders # no disk I/O # shellcheck disable=SC2145 # info uses $@ intentionally info "helm upgrade --install ${RELEASE_NAME} ${chart}" \ "--version ${CHART_VERSION}" \ "--namespace ${NAMESPACE}" \ "-f ${VALUES_FILE}" \ "${extra[@]+"${extra[@]}"}" \ "${HELM_LICENSE_ARGS[@]+"${HELM_LICENSE_ARGS[@]}"}" \ "--wait --timeout 30m" return 0 fi ensure_namespace_and_pull_secret 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}" build_helm_license_args # creates temp files exactly once on the live path # "${arr[@]+"${arr[@]}"}" is the bash 3.2 safe empty-array expansion: # under set -u a bare "${arr[@]}" errors when the array is empty. helm upgrade --install "${RELEASE_NAME}" "${chart}" \ --version "${CHART_VERSION}" \ --namespace "${NAMESPACE}" \ -f "${VALUES_FILE}" \ "${extra[@]+"${extra[@]}"}" \ "${HELM_LICENSE_ARGS[@]+"${HELM_LICENSE_ARGS[@]}"}" \ --wait --timeout 30m \ || error "the install failed. Run 'helm -n ${NAMESPACE} status ${RELEASE_NAME}' and see\n" \ " https://docs.scrydon.com/deployment/operations/troubleshooting" } # ── Zarf backend (air-gapped) ───────────────────────────────────────────────── # # Usage: setup.sh --backend zarf --package zarf-package-scrydon-amd64-1.3.2.tar.zst \ # [--cosign-key pub.key] --host app.example.com # # The package filename encodes the version. If a cosign key is supplied the # package is inspected (signature verified) before deployment; otherwise the # installer warns honestly that the signature was NOT checked. 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}" } install_zarf() { [ -n "${ZARF_PACKAGE}" ] || error "--package is required for an air-gapped install." if [ "${DRY_RUN}" = "1" ]; then info "zarf package deploy ${ZARF_PACKAGE} --confirm --set DOMAIN=${ROUTING_HOST}" return 0 fi [ -r "${ZARF_PACKAGE}" ] || error "cannot read the package at '${ZARF_PACKAGE}'." if [ -n "${ZARF_COSIGN_KEY}" ]; then zarf package inspect "${ZARF_PACKAGE}" --key "${ZARF_COSIGN_KEY}" >/dev/null \ || error "package signature verification FAILED — do not deploy this file." success " package : signature verified" else warn "no cosign key supplied (--cosign-key); the package signature was NOT verified." fi ensure_namespace_and_pull_secret zarf package deploy "${ZARF_PACKAGE}" --confirm --set "DOMAIN=${ROUTING_HOST}" \ || 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" 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 ingress has no address or the certificate is not ready yet.\n %s\n' "${DOCS_TROUBLESHOOTING}" ;; *) printf 'See the install troubleshooting runbook:\n %s\n' "${DOCS_TROUBLESHOOTING}" ;; esac } 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 ingress -o jsonpath='{.items[0].status.loadBalancer.ingress[0].ip}' 2>/dev/null)" if [ -n "${addr}" ]; then success " ingress address ${addr}"; else warn "the ingress has no address yet."; triage_hint ingress; fi echo "" success "Scrydon is installed." 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 and ZARF_COSIGN_KEY are set by --package/--cosign-key and # consumed by install_zarf (Task 17). Declared here to satisfy static analysis. ZARF_PACKAGE='' ZARF_COSIGN_KEY='' usage() { cat <<'USAGE' Scrydon installer curl -fsSL https://get.scrydon.com | bash Options: --version Install this version instead of prompting --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 --ingress-class Ingress class (required with --non-interactive when the cluster has no IngressClass to detect) --license Path to the { jwt, publicKey } bundle --pull-secret Path to the issued pull-secret manifest --package Zarf package (air-gapped installs) --cosign-key Public key used to verify the Zarf package --values Where to write generated values (default: values.customer.yaml) --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 ;; --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 ;; --ingress-class) INGRESS_CLASS="$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 ;; --cosign-key) export ZARF_COSIGN_KEY="$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 "${INGRESS_CLASS}" ]; then # Guessing here is worse than stopping: a wrong class produces an Ingress no # controller claims, which fails silently at request time rather than at # install time. detect_ingress_class exits 0 with EMPTY output when the # cluster has no IngressClass at all, so test the value, not the status. INGRESS_CLASS="$(detect_ingress_class 2>/dev/null || true)" [ -n "${INGRESS_CLASS}" ] || error \ "--non-interactive: no IngressClass found on the cluster.\n" \ " Pass --ingress-class (e.g. nginx, traefik)." 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 || error "fix the registry credentials and re-run."; } ;; esac return 0 } main() { detect_platform >/dev/null parse_args "$@" check_dependencies "${BACKEND:-connected}" if [ -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 write_values_file "${VALUES_FILE}" write_conf_file "scrydon-install.conf" 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" 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 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 [ "${SCRYDON_INSTALL_LIB:-0}" != "1" ]; then main "$@" fi