diff --git a/docs/v1-redeploy-changelog.md b/docs/v1-redeploy-changelog.md index f0ffb61..c964d63 100644 --- a/docs/v1-redeploy-changelog.md +++ b/docs/v1-redeploy-changelog.md @@ -723,5 +723,114 @@ ENV + prose .8.53 -> .12.53 (metal-internal) per D-052. Watch the retrofit --wait=30m output to confirm it reaches internal glance. Non-blocking. +### Phase-05 verify CORRECTION (2026-06-27) -- o-hm0 operstate is UNKNOWN, not UP +First live POST run of phase-05-octavia-verify.sh FALSE-NEGATIVED on o-hm0: the check grepped for +operstate 'UP', but o-hm0 is an OVS INTERNAL port -- the kernel reports operstate UNKNOWN (no +carrier reporting). The live line was: + o-hm0 UNKNOWN fc00:7bab:3c0b:ae43:f816:3eff:fe2a:f741/64 fe80::.../64 +i.e. HEALTHY -- the fc00::/ IPv6-ULA is present, which is the do-doc's actual GATE C signal. +DOCFIX-044 class: the harness fixtures used UP/DOWN and never the real-world UNKNOWN, so the test +validated a fiction. FIXED: accept UP or UNKNOWN, reject only DOWN/absent; fc00::/ ULA is the +positive signal. Harness now drives the real UNKNOWN value (+ an absent case) -> 9/9 green; +shellcheck + bash -n clean. The do-doc itself was CORRECT (GATE C checks the fc00:: ULA + br-int +port, not UP) -- NO DOCFIX. LEARNING: OVS internal ports (o-hm0 etc.) report operstate UNKNOWN; +liveness checks must accept UNKNOWN, never require UP. + +### Step 5.1 EXECUTED -- configure-resources COMPLETE (2026-06-27) +juju run octavia/leader configure-resources --wait=20m: operation 11 / task 12 returned clean (no +wait-timeout). Result: octavia/0 active "Unit is ready"; charm-octavia net/subnet/sec-grp = 1/1/1 +(lb-mgmt-net / lb-mgmt-subnetv6 / lb-mgmt-sec-grp); o-hm0 present, operstate UNKNOWN (normal), +fc00:7bab:3c0b:ae43:f816:3eff:fe2a:f741/64 IPv6-ULA. A transient "ovs-vsctl: no row o-hm0" appeared +mid-run (before o-hm0 was created) and self-cleared -- the expected bring-up transient. lb-mgmt +control plane UP. State = CONTROL-PLANE-DONE; proceeding to Step 5.2 (amphora pipeline). + +### Step 5.2 EXECUTED -- amphora pipeline COMPLETE; PHASE-05 EXIT GATE MET (2026-06-27) +Canonical 5.2 block ran (config gate OK -> stage-and-verify seed -> retrofit -> confirm): + base jammy-amphora-base = 6ed034f7-10b0-4231-adcd-89e36ff79901 (qcow2; sha256 + ec332b438a87c3cc1e38318ced110568392484cc2f33e29c8f4eaf9ecc9de297 verified vs published SHA256SUMS) + retrofit op 15 / task 16 on octavia-diskimage-retrofit/0 + amphora amphora-haproxy-x86_64-ubuntu-22.04-20260627 = c8f9f53a-980b-4224-9949-9e141708dd6f + ACTIVE, tag octavia-amphora (matches octavia amp-image-tag), image-format raw. +DOCFIX-049 connectivity CONFIRMED: the retrofit (use-internal-endpoints=true) built the amphora +successfully, i.e. it reached the INTERNAL glance endpoint (post-D-052 = 10.12.12.53, metal-internal) +over octavia's metal-internal leg. The build succeeding IS the proof; the do-doc's .8.53 is stale +reference text only (DOCFIX-049, consolidation). +Paste cosmetics: the 5.2 block's terminal echo wrapped oddly (closing ')' + tee target + final echo), +but the shell executed in order -- CONFIRM found the active amphora and an independent verify counts +exactly 1 active octavia-amphora image. Not a partial run. + +PHASE-05 EXIT GATE MET (all four): + - octavia/0 active/idle "Unit is ready" + - lb-mgmt net/subnet/sec-grp present (1/1/1) + - o-hm0 up (operstate UNKNOWN -- normal for an OVS internal port -- with fc00::/ ULA) + - ACTIVE amphora image tagged octavia-amphora, tag == octavia amp-image-tag +NOTE: the live verify run still FALSE-NEGATIVED on o-hm0 because the jumphost copy of +phase-05-octavia-verify.sh was the PRE-FIX version (message "not UP with an fc00::/ ULA"); the +corrected script (this session, 9/9 harness) reports PASS. Pull the corrected script (commit/push +-> jumphost pull) for the green confirmation; the gate is met on the evidence regardless. +End-to-end LB build / round-robin / admin-scope failover validation is D-011 criterion 4, deferred +to phase-08 (needs tenant scaffolding). Octavia STANDALONE failover N+1 amphora headroom remains a +Roosevelt sizing note (a cloud at scheduler ceiling cannot self-heal its LBs). + +### Phase-05 amphora pipeline scripted -- phase-05-amphora-pipeline.sh + D-056 PROPOSED (2026-06-27) +Per operator request the Step 5.2 amphora pipeline is now a script, scripts/phase-05-amphora-pipeline.sh, +encapsulating the do-doc 5.2 canonical block. RATIONALE: the inline 5.2 block is large and MANGLED on +paste this session (terminal wrapped the closing ')' + tee target + final echo); a worse wrap could +drop --wait or the subshell close and half-run a mutating pipeline. Scripting removes the command-size- +overrun risk and makes it reviewable/version-controlled. + Behavior identical to the do-doc block: config GATE; idempotent (amphora present -> skip; base + present -> retrofit only; fresh -> download + sha256-verify + upload + retrofit); confirm. + Hardened: env-overridable tunables (jammy/Caracal defaults), explicit -m MODEL on juju config, + preconditions (openstack/juju/sha256sum/OS_AUTH_URL), exit 0/1/2. ASCII + LF; shellcheck clean. + tests/phase-05-amphora/ -- offline LOGIC regression (fake juju/openstack/curl/wget/sha256sum; the + fake retrofit "builds" via a marker so confirm sees the image appear). 6 cases + 1 assertion ALL + PASS: config-gate fails x2; idempotent skip; fresh full path; retrofit-only (proven to NOT + download); checksum-mismatch abort. (Two initial [XX] were line-spanning grep regexes in the + HARNESS, not script faults -- fixed to single-line markers; the script was correct throughout.) + +D-056 (PROPOSED) -- consequential-mutation runbook blocks may be scripted. + Extends the operating model. Prior rule: "scripts own read-only/repeated verification; humans gate + consequential mutations." Refinement: a consequential MUTATION may live in a script WHEN it is fully + IDEMPOTENT + self-gating; the human still gates by CHOOSING to invoke it, and re-running is safe. + Net hardening (removes large-paste overrun; adds review + version control). First instance: + phase-05-amphora-pipeline.sh. CANDIDATES to convert similarly (operator decision): the phase-04 + network-carve create block and the phase-05 Step 5.1 configure-resources block (both multi-line + mutating pastes). Status: PROPOSED -- ratify or amend. + +### Completed-phase mutation blocks scripted -- phase-00/03/04 (D-056 ratified) (2026-06-27) +D-056 RATIFIED by operator (was PROPOSED): consequential mutation blocks may be scripted when fully +idempotent + self-gating. Following a survey of all nine do-docs, the mutation blocks from the +already-completed phases are now scripts (the 06/07/08 blocks will be built as those phases execute): + +scripts/phase-04-network-create.sh -- Step 4.1 provider-ext network + FIP subnet create. Idempotent + (skip-if-exists on network + subnet); provider gateway discovered BY CIDR (DOCFIX-047), never the + do-doc's hardcoded subnet read. CONFIRM gate asserts external/flat/physnet1/not-shared. Exit 0/1/2. + tests/phase-04-create/ -- 4/4 PASS (fresh create, idempotent skip, gateway-gate fail, confirm fail). + +scripts/phase-03-admin-openrc.sh + scripts/extract_admin_password.py -- Step 3.2 admin-openrc build: + vault root CA + keystone admin password + DOCFIX-022 project-scope discovery loop -> 0600 openrc. + SECRET-handling but human-gated; the password stays inside the subshell + the 0600 file (umask 077), + never echoed. The password parser is a tested .py helper (per "Python helpers in their own files"), + not inline python-in-bash. tests/phase-03-openrc/ -- extractor unit 4/4 + integration 4/4 + 0600 + assertion PASS (fake juju/openstack/openssl; real python3/jq; no real secrets in the harness). + +scripts/phase-00-maas-carve.sh -- the MAAS VIP/FIP carve (rebuild foundation; KI-P3-001). Idempotently + reserves the provider + metal-admin API-VIP /26 and the provider FIP pool. Idempotency anchored on + the START ip so a pre-existing .2-.63 OR .2-.100 reserve is left untouched (no overlap-create). Stale + .8.224-.254 is detected + reported; deleted only with DELETE_STALE=1 on an exact start/end match + (destructive, gated). Two correctness fixes vs the do-doc 4b block -- see DOCFIX-050/051. Exit 0/1/2. + tests/phase-00-carve/ -- 8/8 PASS (fresh create x3, idempotent skip, .2-.63 width-drift tolerated, + stale report-only, stale DELETE_STALE=1 delete, provider-subnet-missing fail). + +All four shell scripts: bash -n + shellcheck -S warning clean; ASCII + 0 CR. The .py: ast.parse clean, +ASCII. Invoke as `bash scripts/X.sh` (origin mode 100644; Windows strips +x). + +DOCFIX-050 -- phase-00 do-doc 4b hardcodes `ipranges create subnet=1 ... subnet=2` (PATTERN-1 + violation; same class as DOCFIX-047 but a distinct location). Subnet IDs drift across cutovers; the + script resolves provider/metal subnet ids BY CIDR. Fix the do-doc before Roosevelt. +DOCFIX-051 -- phase-00 do-doc 4b creates the two VIP /26 reservations but NOT the provider FIP pool + (.5.0-.7.254), which 4a lists as "want present". Harmless while the reservation persists across + teardown, but a fresh-MAAS rebuild needs it -- phase-00-maas-carve.sh creates it idempotently. + ### Next-free numbers -Design decision: D-056. Doc fix: DOCFIX-050. +Design decision: D-057. Doc fix: DOCFIX-052. diff --git a/scripts/extract_admin_password.py b/scripts/extract_admin_password.py new file mode 100644 index 0000000..487957f --- /dev/null +++ b/scripts/extract_admin_password.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# scripts/extract_admin_password.py +# Read `juju run keystone/leader get-admin-password --format json` on stdin and print +# the admin password. The action's JSON shape varies by juju version, so search +# recursively for the first non-empty 'admin-password'/'password'/'Stdout' value. +# Prints empty string (and the caller gates) if none found. No secret is logged. +import json, sys + +KEYS = ("admin-password", "password", "Stdout") + +def find(o): + if isinstance(o, dict): + for k in KEYS: + if k in o and o[k]: + return str(o[k]).strip() + for v in o.values(): + r = find(v) + if r: + return r + elif isinstance(o, list): + for v in o: + r = find(v) + if r: + return r + return "" + +def main(): + try: + data = json.load(sys.stdin) + except Exception: + return "" + return find(data) + +if __name__ == "__main__": + print(main()) diff --git a/scripts/phase-00-maas-carve.sh b/scripts/phase-00-maas-carve.sh new file mode 100644 index 0000000..368c525 --- /dev/null +++ b/scripts/phase-00-maas-carve.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# scripts/phase-00-maas-carve.sh +# +# Phase-00 MAAS VIP/FIP address carve (rebuild foundation; KI-P3-001). Idempotently +# reserves the front-loaded API-VIP /26 on provider + metal-admin and the FIP pool on +# provider, so MAAS auto-static can never land a host/container primary on a configured +# VIP. Encapsulates the do-doc carve AS A SCRIPT (D-056): idempotent + self-gating; the +# human gates by invoking it (and separately for the destructive stale-delete). +# +# Two correctness fixes vs the do-doc block: +# DOCFIX-047: subnets resolved BY CIDR (lib-net PATTERN-1), never hardcoded subnet=1/2. +# DOCFIX-048: VIP reserve defaults to .2-.100 (the as-built width), not the do-doc's +# stale .2-.63. Idempotency is anchored on the START ip, so a pre-existing .2-.63 OR +# .2-.100 reserve is left untouched (never an overlap-create). +# +# The stale metal .224-.254 reservation (old scheme) is DETECTED + REPORTED; it is +# deleted only with DELETE_STALE=1 (destructive, gated) and only on an exact start/end match. +# +# Tunables via env: PROVIDER_CIDR METAL_CIDR PROVIDER_VIP_END METAL_VIP_END FIP_START FIP_END DELETE_STALE +# Requires: jumphost; jq; the 'admin' MAAS profile (never 'maas list' -- DOCFIX-016). +# Usage: scripts/phase-00-maas-carve.sh (report + create-if-absent) +# DELETE_STALE=1 scripts/phase-00-maas-carve.sh (also remove the stale .224-.254) +# Exit: 0 carve present/created | 1 error | 2 precondition +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib-net.sh +. "$SCRIPT_DIR/lib-net.sh" + +PROVIDER_CIDR="${PROVIDER_CIDR:-10.12.4.0/22}" +METAL_CIDR="${METAL_CIDR:-10.12.8.0/22}" +PROVIDER_VIP_START="10.12.4.2"; PROVIDER_VIP_END="${PROVIDER_VIP_END:-10.12.4.100}" # DOCFIX-048 (do-doc said .63) +METAL_VIP_START="10.12.8.2"; METAL_VIP_END="${METAL_VIP_END:-10.12.8.100}" +FIP_START="${FIP_START:-10.12.5.0}"; FIP_END="${FIP_END:-10.12.7.254}" +STALE_METAL_START="10.12.8.224"; STALE_METAL_END="10.12.8.254" + +need_jq || exit 2 +command -v maas >/dev/null 2>&1 || { echo "FAIL: maas client not found" >&2; exit 2; } +SUBNETS="$(maas admin subnets read 2>/dev/null || true)" +printf '%s' "$SUBNETS" | jq -e 'type=="array"' >/dev/null 2>&1 \ + || { echo "FAIL: 'maas admin subnets read' not JSON (profile 'admin' logged in?)" >&2; exit 2; } +RANGES="$(maas admin ipranges read 2>/dev/null || true)" +printf '%s' "$RANGES" | jq -e 'type=="array"' >/dev/null 2>&1 \ + || { echo "FAIL: 'maas admin ipranges read' not JSON" >&2; exit 2; } + +FATAL=0 +sid_by_cidr() { printf '%s' "$SUBNETS" | jq -r --arg c "$1" '.[] | select(.cidr==$c) | .id' | head -1; } + +ensure_reserved() { # cidr start end comment + local cidr="$1" start="$2" end="$3" comment="$4" sid existing + sid="$(sid_by_cidr "$cidr")" + [ -n "$sid" ] || { echo "FAIL: no MAAS subnet for cidr $cidr"; FATAL=$((FATAL+1)); return; } + existing="$(printf '%s' "$RANGES" | jq -r --arg s "$sid" --arg a "$start" \ + '.[] | select(.type=="reserved" and (.subnet.id|tostring)==$s and .start_ip==$a) | "\(.start_ip)-\(.end_ip)"' | head -1)" + if [ -n "$existing" ]; then + echo "[SKIP] $cidr: reserved range starting $start already present ($existing)" + else + echo "[..] $cidr: reserving $start-$end (subnet id=$sid)" + maas admin ipranges create type=reserved subnet="$sid" start_ip="$start" end_ip="$end" comment="$comment" >/dev/null + echo "[OK] reserved $start-$end on $cidr" + fi +} + +echo "=== MAAS carve (CIDR-resolved; idempotent) ===" +ensure_reserved "$PROVIDER_CIDR" "$PROVIDER_VIP_START" "$PROVIDER_VIP_END" "OpenStack public API HA VIPs (front-loaded /26)" +ensure_reserved "$METAL_CIDR" "$METAL_VIP_START" "$METAL_VIP_END" "OpenStack internal/admin API HA VIPs (front-loaded /26)" +ensure_reserved "$PROVIDER_CIDR" "$FIP_START" "$FIP_END" "OpenStack Neutron external FIP pool (D-003)" + +echo "=== stale metal $STALE_METAL_START-$STALE_METAL_END (old scheme) ===" +MSID="$(sid_by_cidr "$METAL_CIDR")" +STALE_ID="" +[ -n "$MSID" ] && STALE_ID="$(printf '%s' "$RANGES" | jq -r --arg s "$MSID" --arg a "$STALE_METAL_START" --arg b "$STALE_METAL_END" \ + '.[] | select(.type=="reserved" and (.subnet.id|tostring)==$s and .start_ip==$a and .end_ip==$b) | .id' | head -1)" +if [ -z "$STALE_ID" ]; then + echo "[OK] stale range absent -- nothing to delete" +elif [ "${DELETE_STALE:-0}" = "1" ]; then + echo "[..] deleting stale reserved range id=$STALE_ID ($STALE_METAL_START-$STALE_METAL_END)" + maas admin iprange delete "$STALE_ID" >/dev/null + echo "[OK] stale range id=$STALE_ID deleted" +else + echo "[REPORT] stale range present: id=$STALE_ID $STALE_METAL_START-$STALE_METAL_END" + echo " re-run with DELETE_STALE=1 to remove it (destructive; gated separately)" +fi + +echo "=== final: reserved ranges on provider + metal ===" +RANGES="$(maas admin ipranges read 2>/dev/null || true)" # re-read for post-state +for c in "$PROVIDER_CIDR" "$METAL_CIDR"; do + sid="$(sid_by_cidr "$c")" + [ -n "$sid" ] || { echo " $c: subnet not found"; continue; } + echo " $c (subnet id=$sid):" + printf '%s' "$RANGES" | jq -r --arg s "$sid" '.[] | select(.type=="reserved" and (.subnet.id|tostring)==$s) | " \(.start_ip)-\(.end_ip) [\(.comment // "")]"' +done + +[ "$FATAL" -eq 0 ] || { echo "Summary: ERRORS ($FATAL)"; exit 1; } +echo "Summary: carve complete (idempotent)." diff --git a/scripts/phase-03-admin-openrc.sh b/scripts/phase-03-admin-openrc.sh new file mode 100644 index 0000000..dc1c5cb --- /dev/null +++ b/scripts/phase-03-admin-openrc.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# scripts/phase-03-admin-openrc.sh +# +# Phase-03 Step 3.2: build ~/admin-openrc from live cloud state -- fetch the vault root +# CA, the keystone admin password, discover the admin project (DOCFIX-022: the first +# candidate that issues a SCOPED token wins), and write a 0600 openrc. Encapsulates the +# do-doc block AS A SCRIPT (D-056): deterministic + run every session + carries the +# fiddly scope-discovery loop, so it benefits from being tested and paste-proof. +# +# SECRET-HANDLING: this fetches the admin password (which the operator already retrieves +# manually via juju) and writes it into ~/admin-openrc. The password stays inside the +# subshell + the 0600 file (umask 077 set before write); it is never echoed. The human +# gates by choosing to invoke the script. +# +# Tunables via env: KEYSTONE_VIP ADMIN_DOMAIN PROJECT_CANDIDATES CA RC MODEL +# Requires: jumphost; juju (authed); openstack; python3; jq; openssl. +# Usage: scripts/phase-03-admin-openrc.sh (then: source ~/admin-openrc) +# Exit: 0 openrc written + scoped token verified | 1 fetch/scope/confirm fail | 2 precondition +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXTRACT="$SCRIPT_DIR/extract_admin_password.py" + +KEYSTONE_VIP="${KEYSTONE_VIP:-10.12.4.50}" # keystone PUBLIC endpoint (provider VIP; verify vs bundle on rebuild) +ADMIN_DOMAIN="${ADMIN_DOMAIN:-admin_domain}" +PROJECT_CANDIDATES="${PROJECT_CANDIDATES:-admin admin_domain}" # tried in order; first that SCOPES wins (DOCFIX-022) +CA="${CA:-$HOME/vault-init/vault-ca-root.pem}" +RC="${RC:-$HOME/admin-openrc}" +MODEL="${MODEL:-openstack}" + +# --- preconditions ------------------------------------------------------------------ +for c in juju openstack python3 jq openssl; do + command -v "$c" >/dev/null 2>&1 || { echo "FAIL: $c not found" >&2; exit 2; } +done +[ -f "$EXTRACT" ] || { echo "FAIL: helper not found: $EXTRACT" >&2; exit 2; } +juju whoami >/dev/null 2>&1 || { echo "FAIL: juju not authed (stale macaroon? run 'juju login')" >&2; exit 2; } + +( set -e + mkdir -p "$(dirname "$CA")" + + # 1. Vault root CA -> file (JSON extract; DOCFIX-021 -- human output indents the PEM) + juju run vault/leader get-root-ca -m "$MODEL" --format json \ + | jq -r '[.. | strings | select(test("-----BEGIN CERTIFICATE-----"))][0]' > "$CA" + [ -s "$CA" ] || { echo "FATAL: vault root CA extract empty"; exit 1; } + openssl x509 -in "$CA" -noout -subject -dates + + # 2. Admin password -> var (tested .py extractor; never echoed) + ADMIN_PASS=$(juju run keystone/leader get-admin-password -m "$MODEL" --format json | python3 "$EXTRACT") + [ -n "$ADMIN_PASS" ] || { echo "FATAL: password extract failed"; exit 1; } + + # 3. PROJECT LOOKUP: first candidate that issues a SCOPED token wins (DOCFIX-022) + export OS_AUTH_URL="https://${KEYSTONE_VIP}:5000/v3" OS_USERNAME=admin OS_PASSWORD="$ADMIN_PASS" + export OS_USER_DOMAIN_NAME="$ADMIN_DOMAIN" OS_PROJECT_DOMAIN_NAME="$ADMIN_DOMAIN" + export OS_IDENTITY_API_VERSION=3 OS_REGION_NAME=RegionOne OS_CACERT="$CA" + ADMIN_PROJECT="" + for P in $PROJECT_CANDIDATES; do + if OS_PROJECT_NAME="$P" openstack token issue >/dev/null 2>&1; then ADMIN_PROJECT="$P"; break; fi + done + [ -n "$ADMIN_PROJECT" ] || { echo "FATAL: no candidate project scoped (tried: $PROJECT_CANDIDATES)"; exit 1; } + echo "[OK] admin project = $ADMIN_PROJECT ; password len ${#ADMIN_PASS}" + + # 4. Write ~/admin-openrc (back up any existing; 0600 via umask + chmod) + [ -f "$RC" ] && mv "$RC" "$RC.pre-$(date -u +%Y%m%dT%H%M%SZ)" + umask 077 + cat > "$RC" < $OS_AUTH_URL project=$OS_PROJECT_NAME"; openstack token issue >/dev/null 2>&1 \ + && echo "[OK] scoped token issued" || { echo "VERIFY FAIL: token issue from written openrc"; exit 1; } ) +# shellcheck disable=SC1090 +( source "$RC"; openstack endpoint list -f value -c "Service Name" -c Interface -c URL 2>/dev/null | sort | head ) || true +echo "[OK] admin-openrc ready -- 'source $RC' to use" diff --git a/scripts/phase-04-network-create.sh b/scripts/phase-04-network-create.sh new file mode 100644 index 0000000..b1c6255 --- /dev/null +++ b/scripts/phase-04-network-create.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# scripts/phase-04-network-create.sh +# +# Phase-04 Step 4.1: create the Neutron external provider network + FIP subnet +# (provider-ext / provider-ext-fip). Encapsulates the do-doc create block AS A SCRIPT +# (D-056): consequential + MUTATING but fully IDEMPOTENT and self-gating -- the human +# gates by choosing to invoke it; re-running is safe (skip-if-exists). Pair the +# read-only phase-04-network-verify.sh before (PRE gate) and after (POST/EXIT gate). +# +# DOCFIX-047: the provider gateway is discovered BY CIDR (lib-net PATTERN-1), never via +# the do-doc's hardcoded `maas admin subnet read 1`. +# +# Tunables via env (D-003 defaults): EXT_NET EXT_SUBNET PHYSNET FIP_START FIP_END +# Requires: jumphost; jq; admin-openrc sourced (OS_AUTH_URL); openstack + the 'admin' MAAS profile. +# Usage: source ~/admin-openrc && scripts/phase-04-network-create.sh +# Exit: 0 created or already-present (gate confirmed) | 1 gate/confirm fail | 2 precondition +# ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/lib-net.sh +. "$SCRIPT_DIR/lib-net.sh" + +PROVIDER_CIDR="10.12.4.0/22" # provider-public plane +GW_EXPECT="${PLANE_GW[$PROVIDER_CIDR]}" # 10.12.4.1 -- pinned +EXT_NET="${EXT_NET:-provider-ext}" +EXT_SUBNET="${EXT_SUBNET:-provider-ext-fip}" +PHYSNET="${PHYSNET:-physnet1}" +EXT_CIDR="${EXT_CIDR:-$PROVIDER_CIDR}" +FIP_START="${FIP_START:-10.12.5.0}" +FIP_END="${FIP_END:-10.12.7.254}" + +need_jq || exit 2 +command -v openstack >/dev/null 2>&1 || { echo "FAIL: openstack client not found" >&2; exit 2; } +command -v maas >/dev/null 2>&1 || { echo "FAIL: maas client not found" >&2; exit 2; } +[ -n "${OS_AUTH_URL:-}" ] || { echo "FAIL: OS_AUTH_URL unset -- 'source ~/admin-openrc' first" >&2; exit 2; } + +# DOCFIX-047: provider gateway by CIDR (never 'maas admin subnet read 1') +GW=$(maas admin subnets read 2>/dev/null | jq -r --arg c "$PROVIDER_CIDR" '.[] | select(.cidr==$c) | .gateway_ip') +[ "$GW" = "$GW_EXPECT" ] || { echo "GATE FAIL: MAAS provider gateway='$GW' (expected $GW_EXPECT, by CIDR $PROVIDER_CIDR)"; exit 1; } +echo "[OK] gateway $GW (discovered by CIDR $PROVIDER_CIDR)" + +if openstack network show "$EXT_NET" -f value -c id >/dev/null 2>&1; then + echo "[SKIP] network $EXT_NET exists" +else + openstack network create --external --provider-network-type flat \ + --provider-physical-network "$PHYSNET" "$EXT_NET" -f value -c id + openstack network set --tag role=provider "$EXT_NET" + echo "[OK] network $EXT_NET created + tagged" +fi + +if openstack subnet show "$EXT_SUBNET" -f value -c id >/dev/null 2>&1; then + echo "[SKIP] subnet $EXT_SUBNET exists" +else + openstack subnet create --network "$EXT_NET" --subnet-range "$EXT_CIDR" \ + --gateway "$GW" --no-dhcp --allocation-pool start="$FIP_START",end="$FIP_END" \ + "$EXT_SUBNET" -f value -c id + openstack subnet set --tag role=provider --tag "netbox-iprange=${FIP_START}-${FIP_END}" "$EXT_SUBNET" + echo "[OK] subnet $EXT_SUBNET created + tagged" +fi + +echo "=== CONFIRM ===" +EXT_OK=$(openstack network show "$EXT_NET" -f json | jq -r 'if (."router:external"==true and ."provider:network_type"=="flat" and ."provider:physical_network"=="'"$PHYSNET"'" and .shared==false) then "true" else "false" end') +openstack network show "$EXT_NET" -f json | jq -c '{name, external: ."router:external", type: ."provider:network_type", physnet: ."provider:physical_network", shared, tags}' +openstack subnet show "$EXT_SUBNET" -f json | jq -c '{name, cidr, gateway_ip, enable_dhcp, allocation_pools, tags}' +[ "$EXT_OK" = "true" ] || { echo "CONFIRM FAIL: $EXT_NET attributes not external/flat/$PHYSNET/not-shared"; exit 1; } +echo "[OK] $EXT_NET + $EXT_SUBNET present and correct -- phase-04 create complete" diff --git a/scripts/phase-05-amphora-pipeline.sh b/scripts/phase-05-amphora-pipeline.sh new file mode 100644 index 0000000..51c971d --- /dev/null +++ b/scripts/phase-05-amphora-pipeline.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# scripts/phase-05-amphora-pipeline.sh +# +# D-021 Phase 2: seed the amphora base, then retrofit it into the raw octavia-amphora +# image. This encapsulates the phase-05 Step 5.2 canonical block AS A SCRIPT, to remove +# the large multi-line-paste / command-size-overrun risk (the inline block mangled on +# paste 2026-06-27) and make it a reviewed, version-controlled, per-rebuild artifact. +# +# CONSEQUENTIAL + MUTATING, but fully IDEMPOTENT and self-gating, so the human gates by +# CHOOSING to invoke it; re-running is safe: +# amphora tagged $OTAG already present -> skip seed+build, jump to confirm +# base present (no amphora) -> skip download/upload, retrofit only +# fresh -> download + sha256-verify + upload + retrofit +# +# Tunables via env (jammy / Caracal defaults; override for a different base): +# BASE_IMG_URL BASE_SUM_URL BASE_IMG_FILE BASE_NAME VERSION_NAME PRODUCT_NAME +# RETRO STAGE MODEL RETROFIT_WAIT +# +# Requires: jumphost; admin-openrc sourced (OS_AUTH_URL); openstack + juju; curl/wget/sha256sum. +# Usage: source ~/admin-openrc && scripts/phase-05-amphora-pipeline.sh +# Exit: 0 amphora ACTIVE + tagged (D-021 complete) +# 1 gate / seed / build / confirm failure +# 2 precondition (client missing, openrc not sourced) +# +# NOTE on the retrofit wait: if `juju run ... --wait` times out, the hook keeps running +# on the unit -- do NOT blindly re-invoke. Re-running is safe in that it will not fire a +# second build once the image is tagged (it skips), but check `juju operations` / +# `juju show-operation ` first. ASCII + LF. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +# --- tunables (env-overridable) ----------------------------------------------------- +BASE_IMG_URL="${BASE_IMG_URL:-https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img}" +BASE_SUM_URL="${BASE_SUM_URL:-https://cloud-images.ubuntu.com/jammy/current/SHA256SUMS}" +BASE_IMG_FILE="${BASE_IMG_FILE:-jammy-server-cloudimg-amd64.img}" +BASE_NAME="${BASE_NAME:-jammy-amphora-base}" # NOT amphora-tagged; only the retrofit OUTPUT is +VERSION_NAME="${VERSION_NAME:-$(date -u +%Y%m%d)}" # cosmetic (D-021): feeds the OUTPUT name +PRODUCT_NAME="${PRODUCT_NAME:-com.ubuntu.cloud:server:22.04:amd64}" # cosmetic metadata +RETRO="${RETRO:-octavia-diskimage-retrofit}" +STAGE="${STAGE:-$HOME/amphora-base}" # snap-READABLE (home iface); NOT /tmp (appendix-A L7) +MODEL="${MODEL:-openstack}" +RETROFIT_WAIT="${RETROFIT_WAIT:-30m}" + +# --- preconditions ------------------------------------------------------------------ +command -v openstack >/dev/null 2>&1 || { echo "FAIL: openstack client not found" >&2; exit 2; } +command -v juju >/dev/null 2>&1 || { echo "FAIL: juju client not found" >&2; exit 2; } +command -v sha256sum >/dev/null 2>&1 || { echo "FAIL: sha256sum not found" >&2; exit 2; } +[ -n "${OS_AUTH_URL:-}" ] || { echo "FAIL: OS_AUTH_URL unset -- 'source ~/admin-openrc' first" >&2; exit 2; } + +# ---- Phase 0: config GATE (abort if the cloud is not in the expected state) -------- +UIE=$(juju config -m "$MODEL" "$RETRO" use-internal-endpoints) +IMGFMT=$(juju config -m "$MODEL" "$RETRO" image-format) +RTAG=$(juju config -m "$MODEL" "$RETRO" amp-image-tag) +OTAG=$(juju config -m "$MODEL" octavia amp-image-tag) +[ "$UIE" = true ] || { echo "GATE FAIL: $RETRO use-internal-endpoints=$UIE (need true; retrofit is metal-only)"; exit 1; } +[ "$IMGFMT" = raw ] || { echo "GATE FAIL: $RETRO image-format=$IMGFMT (need raw; Ceph RBD fast-clone)"; exit 1; } +{ [ -n "$RTAG" ] && [ "$RTAG" = "$OTAG" ]; } || { echo "GATE FAIL: amp-image-tag mismatch retrofit='$RTAG' octavia='$OTAG' (LP#1937003)"; exit 1; } +echo "[OK] config gate: use-internal-endpoints=true image-format=raw amp-image-tag=$OTAG" + +# ---- Phase 1: idempotency + seed the base (only if no amphora AND no base) ---------- +AMPH=$(openstack image list --tag "$OTAG" -f value -c ID | head -1) +if [ -n "$AMPH" ]; then + echo "[SKIP] image already tagged $OTAG ($AMPH) -- pipeline complete; jumping to confirm" +else + BASE_ID=$(openstack image list --name "$BASE_NAME" -f value -c ID | head -1) + if [ -z "$BASE_ID" ]; then + mkdir -p "$STAGE"; LOCAL="$STAGE/$BASE_IMG_FILE" + EXP=$(curl -fsSL "$BASE_SUM_URL" | awk -v f="$BASE_IMG_FILE" '$2=="*"f || $2==f {print $1}') + [ -n "$EXP" ] || { echo "GATE FAIL: no published checksum for $BASE_IMG_FILE"; exit 1; } + if [ -f "$LOCAL" ] && [ "$(sha256sum "$LOCAL" | awk '{print $1}')" = "$EXP" ]; then + echo "[OK] staged base present + checksum-valid; skipping download" + else + echo "[..] downloading base to $LOCAL (snap-readable; NOT /tmp)" + wget -q -O "$LOCAL" "$BASE_IMG_URL" + GOT=$(sha256sum "$LOCAL" | awk '{print $1}') + [ "$EXP" = "$GOT" ] || { echo "GATE FAIL: checksum mismatch exp='$EXP' got='$GOT'"; exit 1; } + echo "[OK] checksum verified ($GOT)" + fi + echo "[..] uploading base to glance (qcow2; retrofit props; NO amphora tag on the base)" + BASE_ID=$(openstack image create "$BASE_NAME" \ + --file "$LOCAL" --disk-format qcow2 --container-format bare \ + --property architecture=x86_64 --property os_distro=ubuntu --property os_version=22.04 \ + --property version_name="$VERSION_NAME" --property product_name="$PRODUCT_NAME" \ + -f value -c id) + fi + [ -n "$BASE_ID" ] || { echo "GATE FAIL: base image id empty after seed"; exit 1; } + echo "[OK] base image: $BASE_ID" + + # ---- Phase 2: retrofit (long-running build; bounded wait; tee the result) -------- + echo "[..] running retrofit-image (multi-minute build; --wait=$RETROFIT_WAIT)" + juju run "$RETRO/leader" retrofit-image source-image="$BASE_ID" -m "$MODEL" \ + --wait="$RETROFIT_WAIT" 2>&1 | tee "$HOME/retrofit-image.out" +fi + +# ---- Phase 3: confirm (amphora present + active + tagged == octavia's tag) ---------- +echo "=== CONFIRM: images tagged $OTAG ===" +openstack image list --tag "$OTAG" -f value -c ID -c Name -c Status +ACT=$(openstack image list --tag "$OTAG" -f value -c Status | grep -xc active || true) +[ "$ACT" -ge 1 ] || { echo "CONFIRM FAIL: no ACTIVE image tagged $OTAG"; exit 1; } +echo "[OK] amphora present + active + tagged $OTAG (matches octavia amp-image-tag) -- D-021 complete" diff --git a/scripts/phase-05-octavia-verify.sh b/scripts/phase-05-octavia-verify.sh index 92eb3c5..69f5ad9 100644 --- a/scripts/phase-05-octavia-verify.sh +++ b/scripts/phase-05-octavia-verify.sh @@ -10,7 +10,7 @@ # # then, by octavia/0 workload status: # blocked -> PRE: charm-octavia resources must be EMPTY -> PROCEED (run 5.1 configure-resources) -# active -> POST: lb-mgmt net/subnet/sec-grp present + o-hm0 UP w/ fc00::/ ULA; +# active -> POST: lb-mgmt net/subnet/sec-grp present + o-hm0 (UP/UNKNOWN) w/ fc00::/ ULA; # then amphora image tagged $OTAG ACTIVE? # no -> CONTROL-PLANE-DONE (5.1 done; run 5.2 amphora pipeline) # yes -> PASS (phase-05 EXIT GATE met) @@ -94,10 +94,14 @@ { [ "$n_net" -ge 1 ] && [ "$n_sub" -ge 1 ] && [ "$n_sg" -ge 1 ]; } \ || fail "octavia active but charm-octavia lb-mgmt resources missing (net/sub/sg = $n_net/$n_sub/$n_sg)" OHM0="$(juju exec --unit octavia/0 -m "$MODEL" -- 'ip -br addr show o-hm0' /dev/null || true)" - if printf '%s' "$OHM0" | grep -q 'UP' && printf '%s' "$OHM0" | grep -q 'fc00:'; then - pass "o-hm0 UP with fc00::/ IPv6-ULA" + # o-hm0 is an OVS internal port: kernel operstate reads UNKNOWN (no carrier reporting), + # NOT UP -- so accept UP or UNKNOWN, reject only DOWN/absent. The fc00::/ ULA is the + # actual success signal (the do-doc GATE: an fc00:: IPv6-ULA on a br-int port). + OHM0_STATE="$(printf '%s' "$OHM0" | awk 'NR==1{print $2}')" + if [ -n "$OHM0_STATE" ] && [ "$OHM0_STATE" != "DOWN" ] && printf '%s' "$OHM0" | grep -q 'fc00:'; then + pass "o-hm0 present (state=$OHM0_STATE; UNKNOWN/UP both normal for an OVS internal port) with fc00::/ IPv6-ULA" else - fail "o-hm0 not UP with an fc00::/ ULA addr (got: ${OHM0:-})" + fail "o-hm0 unhealthy: state='${OHM0_STATE:-}' need state!=DOWN and an fc00::/ ULA (got: ${OHM0:-})" fi # 5.2: amphora image AMPH_ACTIVE="$(openstack image list --tag "$OTAG" -f value -c Status 2>/dev/null | grep -xc active || true)" diff --git a/tests/phase-00-carve/fakebin/maas b/tests/phase-00-carve/fakebin/maas new file mode 100644 index 0000000..057f8e8 --- /dev/null +++ b/tests/phase-00-carve/fakebin/maas @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# fake maas: admin {subnets read | ipranges read | ipranges create | iprange delete} +prof="${1:-}"; obj="${2:-}"; act="${3:-}" +if [ "$prof" = admin ] && [ "$obj" = subnets ] && [ "$act" = read ]; then cat "${FIX_SUBNETS_FILE:?}"; exit 0; fi +if [ "$prof" = admin ] && [ "$obj" = ipranges ] && [ "$act" = read ]; then cat "${FIX_RANGES_FILE:?}"; exit 0; fi +if [ "$prof" = admin ] && [ "$obj" = ipranges ] && [ "$act" = create ]; then echo "{}"; exit 0; fi +if [ "$prof" = admin ] && [ "$obj" = iprange ] && [ "$act" = delete ]; then exit 0; fi +exit 0 diff --git a/tests/phase-00-carve/run-tests.sh b/tests/phase-00-carve/run-tests.sh new file mode 100644 index 0000000..b450603 --- /dev/null +++ b/tests/phase-00-carve/run-tests.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# tests/phase-00-carve/run-tests.sh -- offline regression for phase-00-maas-carve.sh. +set -euo pipefail +IFS=$'\n\t' +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS="$(cd "$HERE/../../scripts" && pwd)" +TARGET="$SCRIPTS/phase-00-maas-carve.sh" +BIN="$HERE/fakebin" +command -v jq >/dev/null 2>&1 || { echo "FAIL: jq required" >&2; exit 1; } +[ -f "$TARGET" ] || { echo "FAIL: target missing" >&2; exit 1; } +chmod +x "$BIN"/* 2>/dev/null || true +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +# --- fixtures --- +SUB_BOTH="$WORK/sub_both.json"; printf '[{"id":1,"cidr":"10.12.4.0/22"},{"id":2,"cidr":"10.12.8.0/22"}]\n' > "$SUB_BOTH" +SUB_NOPROV="$WORK/sub_noprov.json"; printf '[{"id":2,"cidr":"10.12.8.0/22"}]\n' > "$SUB_NOPROV" +R_EMPTY="$WORK/r_empty.json"; printf '[]\n' > "$R_EMPTY" +R_ALL="$WORK/r_all.json"; cat > "$R_ALL" <<'JSON' +[ {"id":1,"type":"reserved","start_ip":"10.12.4.2","end_ip":"10.12.4.100","subnet":{"id":1},"comment":"prov vip"}, + {"id":2,"type":"reserved","start_ip":"10.12.8.2","end_ip":"10.12.8.100","subnet":{"id":2},"comment":"metal vip"}, + {"id":3,"type":"reserved","start_ip":"10.12.5.0","end_ip":"10.12.7.254","subnet":{"id":1},"comment":"fip pool"} ] +JSON +R_DRIFT="$WORK/r_drift.json"; cat > "$R_DRIFT" <<'JSON' +[ {"id":1,"type":"reserved","start_ip":"10.12.4.2","end_ip":"10.12.4.63","subnet":{"id":1},"comment":"prov vip (do-doc width)"}, + {"id":2,"type":"reserved","start_ip":"10.12.8.2","end_ip":"10.12.8.100","subnet":{"id":2},"comment":"metal vip"}, + {"id":3,"type":"reserved","start_ip":"10.12.5.0","end_ip":"10.12.7.254","subnet":{"id":1},"comment":"fip pool"} ] +JSON +R_STALE="$WORK/r_stale.json"; cat > "$R_STALE" <<'JSON' +[ {"id":1,"type":"reserved","start_ip":"10.12.4.2","end_ip":"10.12.4.100","subnet":{"id":1},"comment":"prov vip"}, + {"id":2,"type":"reserved","start_ip":"10.12.8.2","end_ip":"10.12.8.100","subnet":{"id":2},"comment":"metal vip"}, + {"id":3,"type":"reserved","start_ip":"10.12.5.0","end_ip":"10.12.7.254","subnet":{"id":1},"comment":"fip pool"}, + {"id":9,"type":"reserved","start_ip":"10.12.8.224","end_ip":"10.12.8.254","subnet":{"id":2},"comment":"STALE old scheme"} ] +JSON + +rc_all=0 +run() { + local want="$1" re="$2" label="$3" subs="$4" ranges="$5"; shift 5 + local rc + set +e + PATH="$BIN:$PATH" FIX_SUBNETS_FILE="$subs" FIX_RANGES_FILE="$ranges" \ + env "$@" bash "$TARGET" >"$WORK/out" 2>&1 + rc=$?; set -e + if [ "$rc" -eq "$want" ] && grep -qE "$re" "$WORK/out"; then + printf ' [OK] %-40s exit %s\n' "$label" "$rc" + else + printf ' [XX] %-40s exit %s (want %s; /%s/)\n' "$label" "$rc" "$want" "$re" + sed 's/^/ /' "$WORK/out"; rc_all=1 + fi +} +echo "=== phase-00-maas-carve.sh (fake maas + real jq) ===" +run 0 'reserved 10.12.4.2-10.12.4.100 on 10.12.4.0/22' "fresh: create provider VIP" "$SUB_BOTH" "$R_EMPTY" +run 0 'reserved 10.12.5.0-10.12.7.254 on 10.12.4.0/22' "fresh: create FIP pool" "$SUB_BOTH" "$R_EMPTY" +run 0 'stale range absent' "fresh: stale absent" "$SUB_BOTH" "$R_EMPTY" +run 0 'starting 10.12.4.2 already present .10.12.4.2-10.12.4.100.' "idempotent: skip all" "$SUB_BOTH" "$R_ALL" +run 0 'starting 10.12.4.2 already present .10.12.4.2-10.12.4.63.' "width drift .2-.63 tolerated" "$SUB_BOTH" "$R_DRIFT" +run 0 'REPORT. stale range present: id=9' "stale present: report-only" "$SUB_BOTH" "$R_STALE" +run 0 'stale range id=9 deleted' "stale present: DELETE_STALE=1" "$SUB_BOTH" "$R_STALE" DELETE_STALE=1 +run 1 'no MAAS subnet for cidr 10.12.4.0/22' "provider subnet missing -> fail" "$SUB_NOPROV" "$R_EMPTY" +echo +[ "$rc_all" -eq 0 ] && echo "ALL PASS" || echo "SOME FAILED" +exit "$rc_all" diff --git a/tests/phase-04-create/fakebin/maas b/tests/phase-04-create/fakebin/maas new file mode 100644 index 0000000..72e6f3d --- /dev/null +++ b/tests/phase-04-create/fakebin/maas @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# fake maas: 'admin subnets read' -> provider subnet with gateway $GW (default 10.12.4.1) +if [ "${1:-}" = admin ] && [ "${2:-}" = subnets ] && [ "${3:-}" = read ]; then + printf '[{"id":1,"cidr":"10.12.4.0/22","gateway_ip":"%s"}]\n' "${GW:-10.12.4.1}" +fi diff --git a/tests/phase-04-create/fakebin/openstack b/tests/phase-04-create/fakebin/openstack new file mode 100644 index 0000000..8505d3f --- /dev/null +++ b/tests/phase-04-create/fakebin/openstack @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# fake openstack: network/subnet show|create|set. Stateful via markers so create->confirm works. +obj="${1:-}"; act="${2:-}"; rest=" $* " +present() { + case "$1" in + net) [ -f "${NET_MARKER:-/nonexistent}" ] || [ "${NET_PRESENT:-0}" = 1 ] ;; + sub) [ -f "${SUB_MARKER:-/nonexistent}" ] || [ "${SUB_PRESENT:-0}" = 1 ] ;; + esac +} +case "$obj $act" in + "network show") + if present net; then + if printf '%s' "$rest" | grep -q -- '-f json'; then + sh=false; [ "${BAD_SHARED:-0}" = 1 ] && sh=true + printf '{"name":"provider-ext","router:external":true,"provider:network_type":"flat","provider:physical_network":"physnet1","shared":%s,"tags":["role=provider"]}\n' "$sh" + else echo "net-id"; fi + exit 0 + fi + exit 1 ;; + "network create") : > "${NET_MARKER:?}"; echo "net-id"; exit 0 ;; + "network set") exit 0 ;; + "subnet show") + if present sub; then + if printf '%s' "$rest" | grep -q -- '-f json'; then + printf '{"name":"provider-ext-fip","cidr":"10.12.4.0/22","gateway_ip":"10.12.4.1","enable_dhcp":false,"allocation_pools":[{"start":"10.12.5.0","end":"10.12.7.254"}],"tags":["role=provider"]}\n' + else echo "sub-id"; fi + exit 0 + fi + exit 1 ;; + "subnet create") : > "${SUB_MARKER:?}"; echo "sub-id"; exit 0 ;; + "subnet set") exit 0 ;; +esac +exit 0 diff --git a/tests/phase-04-create/run-tests.sh b/tests/phase-04-create/run-tests.sh new file mode 100644 index 0000000..c201ddb --- /dev/null +++ b/tests/phase-04-create/run-tests.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# tests/phase-04-create/run-tests.sh -- offline regression for phase-04-network-create.sh. +set -euo pipefail +IFS=$'\n\t' +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS="$(cd "$HERE/../../scripts" && pwd)" +TARGET="$SCRIPTS/phase-04-network-create.sh" +BIN="$HERE/fakebin" +command -v jq >/dev/null 2>&1 || { echo "FAIL: jq required" >&2; exit 1; } +[ -f "$TARGET" ] || { echo "FAIL: target missing" >&2; exit 1; } +chmod +x "$BIN"/* 2>/dev/null || true +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +rc_all=0 +run() { + local want="$1" re="$2" label="$3"; shift 3 + rm -f "$WORK/net" "$WORK/sub" + local rc + set +e + PATH="$BIN:$PATH" OS_AUTH_URL="x" NET_MARKER="$WORK/net" SUB_MARKER="$WORK/sub" \ + env "$@" bash "$TARGET" >"$WORK/out" 2>&1 + rc=$?; set -e + if [ "$rc" -eq "$want" ] && grep -qE "$re" "$WORK/out"; then + printf ' [OK] %-38s exit %s\n' "$label" "$rc" + else + printf ' [XX] %-38s exit %s (want %s; /%s/)\n' "$label" "$rc" "$want" "$re" + sed 's/^/ /' "$WORK/out"; rc_all=1 + fi +} +echo "=== phase-04-network-create.sh (fake maas/openstack + real jq) ===" +run 0 'network provider-ext created.*|create complete' "fresh: create net+subnet" +run 0 'SKIP. network provider-ext exists' "idempotent: both present" NET_PRESENT=1 SUB_PRESENT=1 +run 1 'GATE FAIL: MAAS provider gateway' "gate fail: wrong gateway" GW=10.12.4.254 +run 1 'CONFIRM FAIL' "confirm fail: shared=true" NET_PRESENT=1 SUB_PRESENT=1 BAD_SHARED=1 +echo +[ "$rc_all" -eq 0 ] && echo "ALL PASS" || echo "SOME FAILED" +exit "$rc_all" diff --git a/tests/phase-05-amphora/fakebin/curl b/tests/phase-05-amphora/fakebin/curl new file mode 100644 index 0000000..a7a3044 --- /dev/null +++ b/tests/phase-05-amphora/fakebin/curl @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# fake curl: emit a SHA256SUMS line for the base image file +printf '%s *%s\n' "${PUB_SHA:-deadbeef}" "jammy-server-cloudimg-amd64.img" diff --git a/tests/phase-05-amphora/fakebin/juju b/tests/phase-05-amphora/fakebin/juju new file mode 100644 index 0000000..af94a54 --- /dev/null +++ b/tests/phase-05-amphora/fakebin/juju @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# fake juju for the amphora-pipeline harness. +# config [-m M] -> env-driven value (CFG_* with sane defaults) +# run retrofit-image ... -> "builds" the amphora by touching $MARKER, prints a completed op +sub="${1:-}" +case "$sub" in + config) + args=(); while [ $# -gt 0 ]; do case "$1" in -m) shift 2 ;; *) args+=("$1"); shift ;; esac; done + app="${args[1]:-}"; key="${args[2]:-}" # args = (config app key) + case "$app/$key" in + octavia-diskimage-retrofit/use-internal-endpoints) printf '%s\n' "${CFG_UIE:-true}" ;; + octavia-diskimage-retrofit/image-format) printf '%s\n' "${CFG_IMGFMT:-raw}" ;; + octavia-diskimage-retrofit/amp-image-tag) printf '%s\n' "${CFG_RTAG:-octavia-amphora}" ;; + octavia/amp-image-tag) printf '%s\n' "${CFG_OTAG:-octavia-amphora}" ;; + esac ;; + run) + : > "${MARKER:?MARKER not set}" # the retrofit "builds + tags" the amphora + echo "Running operation 99 with 1 task" + echo " - task 100 on unit-octavia-diskimage-retrofit-0" + echo "task 100 completed" ;; +esac diff --git a/tests/phase-05-amphora/fakebin/openstack b/tests/phase-05-amphora/fakebin/openstack new file mode 100644 index 0000000..a83b1d5 --- /dev/null +++ b/tests/phase-05-amphora/fakebin/openstack @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# fake openstack for the amphora-pipeline harness. Honors `-f value -c ...`. +# image list --tag -> amphora row present iff $MARKER exists OR AMPH_PRESENT=1 +# image list --name -> base row present iff BASE_PRESENT=1 +# image create ... -> echoes a fake base id +cmd="${1:-}"; sub="${2:-}"; shift 2 2>/dev/null || true +if [ "$cmd" = "image" ] && [ "$sub" = "list" ]; then + qtype=""; cols=() + while [ $# -gt 0 ]; do + case "$1" in + --tag) qtype="tag"; shift 2 ;; + --name) qtype="name"; shift 2 ;; + -c) cols+=("$2"); shift 2 ;; + -f) shift 2 ;; + *) shift ;; + esac + done + present=0 + if [ "$qtype" = "tag" ]; then + { [ "${AMPH_PRESENT:-0}" = "1" ] || [ -f "${MARKER:-/nonexistent}" ]; } && present=1 + ID="c8f9f53a-amph"; NAME="amphora-haproxy-x86_64-ubuntu-22.04-test"; STATUS="active" + else + [ "${BASE_PRESENT:-0}" = "1" ] && present=1 + ID="6ed034f7-base"; NAME="jammy-amphora-base"; STATUS="active" + fi + [ "$present" = "1" ] || exit 0 + out="" + for c in "${cols[@]}"; do + case "$c" in ID) v="$ID" ;; Name) v="$NAME" ;; Status) v="$STATUS" ;; *) v="" ;; esac + out="${out:+$out }$v" + done + printf '%s\n' "$out" + exit 0 +elif [ "$cmd" = "image" ] && [ "$sub" = "create" ]; then + echo "6ed034f7-base" # fake base id (the script captures -f value -c id) + exit 0 +fi +exit 0 diff --git a/tests/phase-05-amphora/fakebin/sha256sum b/tests/phase-05-amphora/fakebin/sha256sum new file mode 100644 index 0000000..13b49ea --- /dev/null +++ b/tests/phase-05-amphora/fakebin/sha256sum @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +printf '%s %s\n' "${SHIM_SHA:-deadbeef}" "${1:-stdin}" diff --git a/tests/phase-05-amphora/fakebin/wget b/tests/phase-05-amphora/fakebin/wget new file mode 100644 index 0000000..63613d0 --- /dev/null +++ b/tests/phase-05-amphora/fakebin/wget @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +out=""; while [ $# -gt 0 ]; do case "$1" in -O) out="$2"; shift 2 ;; *) shift ;; esac; done +: > "${out:-/dev/null}" diff --git a/tests/phase-05-amphora/run-tests.sh b/tests/phase-05-amphora/run-tests.sh new file mode 100644 index 0000000..19272f1 --- /dev/null +++ b/tests/phase-05-amphora/run-tests.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# tests/phase-05-amphora/run-tests.sh -- offline logic regression for +# scripts/phase-05-amphora-pipeline.sh. Drives the REAL script through fake +# juju/openstack/curl/wget/sha256sum shims (the retrofit "builds" by touching a marker, +# so the confirm step sees the amphora appear). Tests branching + gates, NOT real I/O. +# No live infra. Needs python3-free bash only. +set -euo pipefail +IFS=$'\n\t' +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS="$(cd "$HERE/../../scripts" && pwd)" +TARGET="$SCRIPTS/phase-05-amphora-pipeline.sh" +BIN="$HERE/fakebin" +[ -f "$TARGET" ] || { echo "FAIL: target missing: $TARGET" >&2; exit 1; } +chmod +x "$BIN"/* 2>/dev/null || true + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +rc_all=0 + +# run