Newer
Older
openstack-caracal-ipv4 / clientdocs / scripts / acceptance-run.sh
#!/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