#!/usr/bin/env bash
# acceptance-run.sh -- automated run of the self-service Acceptance Checklist rows
# that your automation (application) credential can prove on its own.
#
# What: builds the checklist's worked sequence with every resource named
# ci-accept-*, checks each row, then tears everything down again in
# reverse order -- teardown is guaranteed by an exit trap, so it runs
# even when a row fails or the script is interrupted.
#
# Rows run here: A1 A4 A5 A6(expected refusal) C1 C2 C3 C4
# D1 D2 E1 E2 F1 F2 G1
# Rows skipped: A2 A3 B1 B2 (need a password login)
# D3 (needs interactive SSH)
# H1 H2 H3 (need the -cluster password login)
# I1 (object storage -- run by hand, CLI or dashboard)
# J1 J2 (request drills through your account contact)
# Manual instructions for the skipped rows are printed at the end.
#
# Why: run once at onboarding to sign off your environment, and re-run any
# time you suspect a problem. "Expected refusal" rows MUST fail -- the
# script counts a refusal as PASS and an unexpected success as FAIL,
# because those refusals are the platform protecting you.
# Safety: creates only resources named ci-accept-* inside your own project,
# and deletes exactly what it created. Waits use the CLI --wait flags
# or 10-second polling, per the integration guide's pacing rules.
#
# Two deliberate adaptations of the checklist text (noted on their rows):
# - D2 boots with a throwaway keypair ci-accept-key created by this run,
# because your handover keypair belongs to the -cluster account and
# keypairs are per-account. The keypair is deleted at teardown.
# - C2/D2 build a dedicated network ci-accept-net (default range
# 192.168.199.0/24, override with CI_ACCEPT_CIDR=<range> if that would
# collide with something you plan to route). It is not attached to any
# router, so it cannot conflict with your handover network.
#
# Auth: your normal OpenStack client environment (OS_CLOUD or the OS_*
# variables), as the application credential. No arguments needed.
# Optional: CI_ACCEPT_IMAGE / CI_ACCEPT_FLAVOR override the automatic
# pick of a shared base image and the smallest machine size.
# Exit: 0 all automated rows passed and teardown is clean | 1 otherwise
# | 2 environment not set up.
set -uo pipefail
command -v openstack >/dev/null 2>&1 || {
echo "SETUP: the 'openstack' client is not installed (pip install python-openstackclient)"; exit 2; }
command -v python3 >/dev/null 2>&1 || { echo "SETUP: python3 is required"; exit 2; }
if [ -z "${OS_CLOUD:-}" ] && [ -z "${OS_AUTH_URL:-}" ]; then
echo "SETUP: no OpenStack environment found."
echo " Either: export OS_CLOUD=<your cloud name from clouds.yaml>"
echo " Or: export the OS_* variables from the CI/Automation Integration Guide."
exit 2
fi
CIDR="${CI_ACCEPT_CIDR:-192.168.199.0/24}"
IMAGE="${CI_ACCEPT_IMAGE:-}"
FLAVOR="${CI_ACCEPT_FLAVOR:-}"
POLL_TRIES=30 # x10s = up to 5 minutes per wait loop
WORK="$(mktemp -d)"
OS_ERR="$WORK/stderr.txt"
FAILED=0; AUTH_OK=0
ROWS_ID=(); ROWS_ST=(); ROWS_NOTE=()
# Structured captures take stdout ONLY; stderr goes to a file so client
# warnings and notices can never corrupt the parsed output.
cap() { # cap <var> <openstack args...>
local __v="$1"; shift
local out rc
out=$(openstack "$@" </dev/null 2>"$OS_ERR"); rc=$?
printf -v "$__v" '%s' "$out"
return "$rc"
}
oerr() { head -1 "$OS_ERR" 2>/dev/null || true; }
jget() { # jget <json> <key> [subkey] -> value (empty + rc 1 when absent)
printf '%s' "$1" | python3 -c '
import json,sys
try:
v=json.load(sys.stdin)
for k in sys.argv[1:]: v=v[k]
if v is None: raise KeyError
print(v)
except Exception:
sys.exit(1)' "${@:2}"
}
row() { # row <id> <PASS|FAIL|SKIP> <note>
ROWS_ID+=("$1"); ROWS_ST+=("$2"); ROWS_NOTE+=("$3")
[ "$2" = "FAIL" ] && FAILED=1
printf ' [%s] %-3s %s\n' "$2" "$1" "$3"
}
# ---- teardown: guaranteed, reverse order, only what this run created -------
MADE_SECRET=""; SECRET_DONE=0
MADE_LB=0; MADE_FIP=""; MADE_VM=0; MADE_KEY=0
MADE_VOL=0; MADE_SG=0; MADE_SUBNET=0; MADE_NET=0; LEAKED_USER=0
TD_FAIL=0; FINISHED=0
td() { # td <label> <openstack args...> -- one teardown deletion, verified
local label="$1"; shift
if openstack "$@" </dev/null >"$WORK/td-out.txt" 2>"$OS_ERR"; then
echo " removed $label"
else
echo " TEARDOWN FAIL: $label -- $(oerr)"
TD_FAIL=1
fi
}
leftover() { # leftover <label> <list args...> -- nothing ci-accept-* may remain
local label="$1"; shift
local out
if ! cap out "$@"; then
echo " TEARDOWN WARN: could not verify $label ($(oerr))"; TD_FAIL=1; return
fi
if printf '%s' "$out" | grep -q 'ci-accept'; then
echo " TEARDOWN FAIL: leftover ci-accept-* still listed in $label"
TD_FAIL=1
fi
}
finish() {
[ "$FINISHED" = 1 ] && return
FINISHED=1
echo
echo "=== teardown (reverse order; runs even after a failure) ==="
if [ "$AUTH_OK" = 1 ]; then
[ -n "$MADE_SECRET" ] && [ "$SECRET_DONE" = 0 ] && td "secret ci-accept-secret" secret delete "$MADE_SECRET"
[ "$MADE_LB" = 1 ] && td "load balancer ci-accept-lb (cascade: member/pool/listener)" \
loadbalancer delete ci-accept-lb --cascade --wait
[ -n "$MADE_FIP" ] && td "floating ip $MADE_FIP (released)" floating ip delete "$MADE_FIP"
[ "$MADE_VM" = 1 ] && td "server ci-accept-vm" server delete ci-accept-vm --wait
[ "$MADE_KEY" = 1 ] && td "keypair ci-accept-key" keypair delete ci-accept-key
[ "$MADE_VOL" = 1 ] && td "volume ci-accept-vol" volume delete ci-accept-vol
[ "$MADE_SG" = 1 ] && td "security group ci-accept-sg" security group delete ci-accept-sg
[ "$MADE_SUBNET" = 1 ] && td "subnet ci-accept-subnet" subnet delete ci-accept-subnet
[ "$MADE_NET" = 1 ] && td "network ci-accept-net" network delete ci-accept-net
if [ "$LEAKED_USER" = 1 ]; then
td "unexpected user ci-accept-probe (should never have been created)" user delete ci-accept-probe
fi
echo " --- leftover check (the checklist's sign-off step) ---"
leftover "server list" server list -f json
leftover "volume list" volume list -f json
leftover "loadbalancer list" loadbalancer list -f json
leftover "network list" network list -f json
leftover "security group list" security group list -f json
else
echo " (nothing was created -- authentication never succeeded)"
fi
[ "$TD_FAIL" = 0 ] && echo " teardown: CLEAN" || echo " teardown: NOT CLEAN (see failures above)"
echo
echo "=== scoreboard ==="
local i npass=0 nfail=0 nskip=0
for i in "${!ROWS_ID[@]}"; do
printf ' %-4s %-4s %s\n' "${ROWS_ID[$i]}" "${ROWS_ST[$i]}" "${ROWS_NOTE[$i]}"
case "${ROWS_ST[$i]}" in PASS) npass=$((npass+1));; FAIL) nfail=$((nfail+1));; *) nskip=$((nskip+1));; esac
done
echo " rows: $npass PASS, $nfail FAIL, $nskip SKIP; teardown $( [ "$TD_FAIL" = 0 ] && echo clean || echo NOT-CLEAN )"
echo
echo "=== rows to run manually (need a password login or a human) ==="
echo " A2: as your -cluster account (password): openstack token issue"
echo " A3: log in to the web dashboard as your -domain-admin account"
echo " B1: as -domain-admin: create a test user in your domain, grant it"
echo " member on your project, then delete it"
echo " B2: as -domain-admin: try to grant admin to one of your users --"
echo " the grant MUST be refused (expected refusal)"
echo " D3: attach a floating ip to a test server and SSH in with your"
echo " keypair (allow TCP/22 in its security group first)"
echo " H1-H3: as your -cluster account (password): show your cluster"
echo " template, create/delete a test cluster, and confirm the same"
echo " create is REFUSED with the application credential"
echo " I1: create a container ci-accept-bucket, upload/download a small"
echo " object, then delete both -- via the CLI (openstack container"
echo " create) or the dashboard's object storage page"
echo " J1/J2: quota-change and escalation drills through your account contact"
echo
if [ "$FAILED" = 0 ] && [ "$TD_FAIL" = 0 ]; then
echo "RESULT: ACCEPTANCE (automated rows) PASS -- teardown clean"
rm -rf "$WORK"
exit 0
fi
echo "RESULT: ACCEPTANCE (automated rows) FAIL"
echo " If a failure looks like a platform fault, send the exact command"
echo " and exact error text to your account contact."
rm -rf "$WORK"
exit 1
}
trap finish EXIT
echo "=== Omega Cloud acceptance run ($(date -u +%Y-%m-%dT%H:%M:%SZ)) ==="
echo " resources will be named ci-accept-*; teardown is automatic"
echo
# ---- A. sign-in and discovery ----------------------------------------------
PROJECT_ID=""
if cap TOK token issue -f json && PROJECT_ID=$(jget "$TOK" project_id); then
AUTH_OK=1
row A1 PASS "token issued, scoped to your project ($PROJECT_ID)"
else
row A1 FAIL "authentication failed: $(oerr) -- check for a revoked/expired credential or a missing CA bundle (OS_CACERT)"
for r in A4 A5 A6 C1 C2 C3 C4 D1 D2 E1 E2 F1 F2 G1; do
row "$r" SKIP "blocked: authentication (A1) failed"
done
exit 1 # trap prints teardown (nothing made) + scoreboard
fi
if cap CAT catalog list -f json; then
MISSING=$(printf '%s' "$CAT" | python3 -c '
import json,sys
need={"identity":["identity"],"compute":["compute"],"image":["image"],
"network":["network"],"block storage":["volumev3","block-storage","volume"],
"load balancer":["load-balancer"],"secrets":["key-manager"]}
try: types={r.get("Type","") for r in json.load(sys.stdin)}
except Exception: print("(unparseable catalog)"); sys.exit(0)
print(" ".join(sorted(k for k,alts in need.items() if not any(a in types for a in alts))))')
if [ -z "$MISSING" ]; then
row A4 PASS "catalog lists every service this checklist uses"
else
row A4 FAIL "catalog is missing: $MISSING"
fi
else
row A4 FAIL "catalog list failed: $(oerr)"
fi
if cap LIM limits show --absolute -f json; then
LIMSUM=$(printf '%s' "$LIM" | python3 -c '
import json,sys
try: d={r.get("Name"): r.get("Value") for r in json.load(sys.stdin)}
except Exception: sys.exit(1)
print("instances %s, cores %s, ram %s MB" % (d.get("maxTotalInstances","?"),
d.get("maxTotalCores","?"), d.get("maxTotalRAMSize","?")))') \
&& row A5 PASS "limits visible ($LIMSUM) -- compare with your agreed quota" \
|| row A5 FAIL "limits output unparseable: $(oerr)"
else
row A5 FAIL "limits show failed: $(oerr)"
fi
# A6 is an EXPECTED REFUSAL: the create MUST fail with a permission error.
if cap PROBE user create ci-accept-probe -f json; then
LEAKED_USER=1
row A6 FAIL "user create unexpectedly SUCCEEDED -- the identity boundary did not hold; report this to your account contact as an incident"
else
if grep -qiE 'forbidden|not authorized|403|policy' "$OS_ERR"; then
row A6 PASS "identity create refused as expected (the boundary is protecting you)"
else
row A6 FAIL "user create failed, but not with a permission error: $(oerr)"
fi
fi
# ---- C. networking ----------------------------------------------------------
# C1: check the handover network/router. Your name prefix is discovered from
# your project name (<prefix>-prod); if that lookup is refused, the row is
# skipped with the manual command instead.
TENANT=""
if cap PRJ project show "$PROJECT_ID" -f json; then
PNAME=$(jget "$PRJ" name || true)
TENANT="${PNAME%-prod}"
fi
if [ -n "$TENANT" ]; then
C1_OK=1; C1_WHY=""
cap HNET network show "${TENANT}-net" -f json || { C1_OK=0; C1_WHY="network ${TENANT}-net: $(oerr)"; }
GWNET=""
if cap HRTR router show "${TENANT}-router" -f json; then
GWNET=$(jget "$HRTR" external_gateway_info network_id || true)
else
C1_OK=0; C1_WHY="${C1_WHY:+$C1_WHY; }router ${TENANT}-router: $(oerr)"
fi
EXT_ID=""
cap EXTNET network show provider-ext -f json && EXT_ID=$(jget "$EXTNET" id || true)
if [ "$C1_OK" = 1 ] && [ -n "$GWNET" ] && [ "$GWNET" = "$EXT_ID" ]; then
row C1 PASS "handover network and router intact; gateway is on provider-ext"
elif [ "$C1_OK" = 1 ]; then
row C1 FAIL "router exists but its external gateway is not provider-ext"
else
row C1 FAIL "$C1_WHY"
fi
else
row C1 SKIP "could not discover your name prefix; run manually: openstack network show <prefix>-net; openstack router show <prefix>-router"
fi
if cap NET network create ci-accept-net -f json; then
MADE_NET=1
if cap SUB subnet create ci-accept-subnet --network ci-accept-net --subnet-range "$CIDR" -f json; then
MADE_SUBNET=1
row C2 PASS "network + subnet created ($CIDR); their deletion is verified at teardown"
else
row C2 FAIL "subnet create failed: $(oerr)"
fi
else
row C2 FAIL "network create failed: $(oerr)"
fi
if cap SG security group create ci-accept-sg -f json; then
MADE_SG=1
if cap SGR security group rule create ci-accept-sg --protocol tcp --dst-port 22 --remote-ip 0.0.0.0/0 -f json; then
row C3 PASS "security group with a TCP/22 rule created; deletion verified at teardown"
else
row C3 FAIL "rule create failed: $(oerr)"
fi
else
row C3 FAIL "security group create failed: $(oerr)"
fi
if cap FIP floating ip create provider-ext --description ci-accept-fip -f json; then
MADE_FIP=$(jget "$FIP" id || true)
FIP_ADDR=$(jget "$FIP" floating_ip_address || true)
if [ -n "$MADE_FIP" ]; then
row C4 PASS "floating ip allocated (${FIP_ADDR:-address hidden}); released at teardown"
else
row C4 FAIL "floating ip created but its id could not be parsed: $(oerr)"
fi
else
row C4 FAIL "floating ip create failed: $(oerr)"
fi
# ---- D. compute -------------------------------------------------------------
NIMG=0; NFLV=0
if cap IMGS image list -f json && cap FLVS flavor list -f json; then
NIMG=$(printf '%s' "$IMGS" | python3 -c 'import json,sys
try: print(len(json.load(sys.stdin)))
except Exception: print(0)')
NFLV=$(printf '%s' "$FLVS" | python3 -c 'import json,sys
try: print(len(json.load(sys.stdin)))
except Exception: print(0)')
if [ "$NIMG" -gt 0 ] && [ "$NFLV" -gt 0 ]; then
[ -n "$IMAGE" ] || IMAGE=$(printf '%s' "$IMGS" | python3 -c '
import json,sys
rows=[r for r in json.load(sys.stdin) if str(r.get("Status","")).lower()=="active"]
rows.sort(key=lambda r: (0 if "ubuntu" in str(r.get("Name","")).lower() else 1, str(r.get("Name",""))))
print(rows[0]["Name"] if rows else "")')
[ -n "$FLAVOR" ] || FLAVOR=$(printf '%s' "$FLVS" | python3 -c '
import json,sys
rows=json.load(sys.stdin)
rows.sort(key=lambda r: (r.get("RAM") or 0, r.get("VCPUs") or 0))
print(rows[0]["Name"] if rows else "")')
row D1 PASS "$NIMG shared images, $NFLV machine sizes visible (using image '$IMAGE', size '$FLAVOR')"
else
row D1 FAIL "image list ($NIMG) or flavor list ($NFLV) came back empty"
fi
else
row D1 FAIL "image/flavor listing failed: $(oerr)"
fi
VM_IP=""
if [ "$MADE_SUBNET" = 1 ] && [ "$MADE_SG" = 1 ] && [ -n "$IMAGE" ] && [ -n "$FLAVOR" ]; then
# The checklist boots with your handover keypair; that keypair belongs to
# the -cluster account and keypairs are per-account, so this run creates
# (and later deletes) its own throwaway keypair instead. The keypair
# output (a private key) is deliberately discarded, never printed.
if openstack keypair create ci-accept-key </dev/null >/dev/null 2>"$OS_ERR"; then
MADE_KEY=1
if cap SRV server create ci-accept-vm --image "$IMAGE" --flavor "$FLAVOR" \
--network ci-accept-net --security-group ci-accept-sg \
--key-name ci-accept-key --wait -f json; then
MADE_VM=1
ST=$(jget "$SRV" status || true)
if [ "$ST" = "ACTIVE" ]; then
cap SRVSHOW server show ci-accept-vm -f json || true
VM_IP=$(printf '%s' "$SRVSHOW" | python3 -c '
import json,sys
try:
for ips in json.load(sys.stdin).get("addresses",{}).values():
for ip in ips: print(ip); sys.exit(0)
except Exception: pass' || true)
row D2 PASS "ci-accept-vm reached ACTIVE (booted with throwaway keypair ci-accept-key -- see header note)"
else
row D2 FAIL "server created but status is '$ST', not ACTIVE"
fi
else
MADE_VM=1 # a failed --wait may still have left the server; teardown handles it
row D2 FAIL "server create failed: $(oerr)"
MADE_VM_FAILED=1
fi
else
row D2 FAIL "keypair create failed: $(oerr)"
fi
else
row D2 SKIP "blocked: needs the C2 network, the C3 security group, and D1 image/size"
fi
# if the create itself failed, do not try to delete a server that never existed
if [ "${MADE_VM_FAILED:-0}" = 1 ]; then
if ! cap CHK server show ci-accept-vm -f json; then MADE_VM=0; fi
fi
# ---- E. block storage -------------------------------------------------------
wait_vol() { # wait_vol <want-status> -> 0 when reached
local want="$1" st="" i
for i in $(seq 1 "$POLL_TRIES"); do
cap VS volume show ci-accept-vol -f json || return 1
st=$(jget "$VS" status || true)
[ "$st" = "$want" ] && return 0
case "$st" in error*) echo " volume entered '$st'"; return 1;; esac
sleep 10 # pacing rule: poll at 10-second intervals or slower
done
echo " volume stuck at '$st' (wanted '$want')"; return 1
}
if cap VOL volume create --size 1 ci-accept-vol -f json; then
MADE_VOL=1
if wait_vol available; then
row E1 PASS "1 GB volume created and reached 'available'"
else
row E1 FAIL "volume never reached 'available': $(oerr)"
fi
else
row E1 FAIL "volume create failed: $(oerr)"
fi
if [ "$MADE_VOL" = 1 ] && [ "$MADE_VM" = 1 ]; then
E2_OK=1
openstack server add volume ci-accept-vm ci-accept-vol </dev/null >/dev/null 2>"$OS_ERR" || E2_OK=0
[ "$E2_OK" = 1 ] && { wait_vol in-use || E2_OK=0; }
if [ "$E2_OK" = 1 ]; then
openstack server remove volume ci-accept-vm ci-accept-vol </dev/null >/dev/null 2>"$OS_ERR" || E2_OK=0
[ "$E2_OK" = 1 ] && { wait_vol available || E2_OK=0; }
fi
if [ "$E2_OK" = 1 ]; then
row E2 PASS "attach -> in-use -> detach -> available all succeeded"
else
row E2 FAIL "attach/detach sequence failed: $(oerr)"
fi
else
row E2 SKIP "blocked: needs both the E1 volume and the D2 server"
fi
# ---- F. load balancers ------------------------------------------------------
if [ "$MADE_SUBNET" = 1 ]; then
if cap LB loadbalancer create --name ci-accept-lb --vip-subnet-id ci-accept-subnet --wait -f json; then
MADE_LB=1
PS=$(jget "$LB" provisioning_status || true)
OPS=$(jget "$LB" operating_status || true)
if [ "$PS" = "ACTIVE" ]; then
row F1 PASS "load balancer ACTIVE (operating status: ${OPS:-unknown}); cascade-deleted at teardown"
else
row F1 FAIL "load balancer finished with provisioning status '$PS', not ACTIVE"
fi
else
F1_ERR="$(oerr)"
MADE_LB=1 # a failed --wait may still have left the balancer; check
if ! cap CHK loadbalancer show ci-accept-lb -f json; then MADE_LB=0; fi
row F1 FAIL "load balancer create failed: $F1_ERR"
fi
else
row F1 SKIP "blocked: needs the C2 subnet"
fi
if [ "$MADE_LB" = 1 ] && [ -n "$VM_IP" ]; then
F2_OK=1
cap LSN loadbalancer listener create --name ci-accept-listener \
--protocol TCP --protocol-port 80 --wait ci-accept-lb -f json || F2_OK=0
[ "$F2_OK" = 1 ] && { cap POOL loadbalancer pool create --name ci-accept-pool \
--lb-algorithm ROUND_ROBIN --listener ci-accept-listener --protocol TCP --wait -f json || F2_OK=0; }
[ "$F2_OK" = 1 ] && { cap MEM loadbalancer member create --name ci-accept-member \
--address "$VM_IP" --protocol-port 80 --subnet-id ci-accept-subnet --wait ci-accept-pool -f json || F2_OK=0; }
if [ "$F2_OK" = 1 ]; then
row F2 PASS "listener (TCP/80), pool, and server member all created"
else
row F2 FAIL "listener/pool/member sequence failed: $(oerr)"
fi
else
row F2 SKIP "blocked: needs the F1 load balancer and the D2 server's address"
fi
# ---- G. secrets and certificates --------------------------------------------
PAYLOAD="ci-accept-check-$(date +%s)"
if cap SEC secret store --name ci-accept-secret --payload "$PAYLOAD" -f json; then
MADE_SECRET=$(jget "$SEC" "Secret href" || true)
if [ -n "$MADE_SECRET" ]; then
GOT=""
cap GOT secret get "$MADE_SECRET" --payload -f value -c Payload || true
if [ "$GOT" = "$PAYLOAD" ]; then
if openstack secret delete "$MADE_SECRET" </dev/null >/dev/null 2>"$OS_ERR"; then
SECRET_DONE=1
row G1 PASS "secret stored, retrieved intact, and deleted (your TLS certificate store works)"
else
row G1 FAIL "secret stored and read back, but delete failed: $(oerr)"
fi
else
row G1 FAIL "retrieved payload did not match what was stored"
fi
else
row G1 FAIL "secret stored but its reference could not be parsed: $(oerr)"
fi
else
row G1 FAIL "secret store failed: $(oerr)"
fi
exit 0 # verdict and final exit code come from the teardown trap