#!/usr/bin/env bash
# tenant-onboard.sh -- Option-3 multi-tenant onboarding (D-066), Omega Cloud v1
# STATUS: DRAFT 2026-07-02. Stages 0-5 validated live (tenant acme). Stage 6 gate CLEARED:
# D-067 (barbican<->Vault) FIXED + validated live 2026-07-02 (see D-067 amendment / BUNDLEFIX-007).
# KEYPAIR FIX (2026-07-02 pre-first-clean-room-run): nova keypairs are USER-scoped and magnum
# validates + boots the keypair in the CLUSTER-CREATOR/trustor context (magnum 18.0.0
# attr_validator.validate_keypair via cluster.py:545; trust app-cred impersonates the trustor).
# So the keypair is created by -cluster (stage3), and the template (stage5) omits --keypair
# (keypair_id optional, default=None); stage6 supplies it. A -svc-owned key would 400 at stage6.
#
# Model (D-066): operator creates domain + manager; manager creates project + -cluster (password,
# trust-capable, cluster lifecycle) + -svc (unrestricted app cred, non-trust automation). Cluster
# create MUST be password (keystone blocks app-cred trust creation; see appendix-D/D-066).
#
# Hardening conventions (learned 2026-07-02): validate raw output WHOLE (never extract-then-check);
# whitelist-write secrets to 0600 files, never echo; dynamic ID resolution; CA threaded; verify
# before mutate. This is an EXECUTED script (bash tenant-onboard.sh ...), so exit-on-error is correct.
set -uo pipefail
# ---- inputs ----
CLIENT="${1:-}"; STAGE="${2:-all}"
TENANT_CIDR="${TENANT_CIDR:-}" # required for stage 4 (e.g. 10.20.24.0/24); must not collide
KEYSTONE_VIP="${KEYSTONE_VIP:-10.12.4.50}"
CA="${OS_CACERT:-$HOME/vault-init/vault-ca-root.pem}"
OUT="$HOME/tenant-${CLIENT}" # 0600 credential handover dir
AUTH_URL="https://${KEYSTONE_VIP}:5000/v3"
die(){ echo "FATAL: $*" >&2; exit 1; }
[ -n "$CLIENT" ] || die "usage: tenant-onboard.sh <client> [stage0|1|2|3|4|5|6|all]"
[ -s "$CA" ] || die "OS_CACERT not found: $CA"
openssl x509 -in "$CA" -noout -checkend 0 >/dev/null 2>&1 || die "CA expired/unreadable: $CA"
umask 077; mkdir -p "$OUT"; chmod 700 "$OUT"
is_id(){ [[ "$1" =~ ^[0-9a-f]{32}$ ]]; } # keystone id
newpw(){ python3 -c 'import secrets;print(secrets.token_urlsafe(24))'; }
# ---- admin context helper (operator) ----
admin_env(){ for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done; source ~/admin-openrc; }
# ---- named-identity password context (subshell-scoped by callers) ----
stage0(){ # operator preflight (read-only)
admin_env; local fail=0
echo "== stage0: preflight =="
juju status keystone -m openstack --format=yaml 2>/dev/null | python3 -c 'import sys,yaml;m=yaml.safe_load(sys.stdin)["applications"]["keystone"]["units"]["keystone/0"].get("workload-status",{}).get("message","");print("keystone:",m);sys.exit(0 if m.startswith("PO:") else 1)' || { echo " keystone override not PO: active"; fail=1; }
for R in manager member load-balancer_member; do is_id "$(openstack role show "$R" -f value -c id </dev/null 2>&1)" || { echo " role $R MISSING"; fail=1; }; done
local img; img=$(openstack image list --public -f value -c ID -c Name </dev/null 2>&1 | awk '/kube/{print $1;exit}')
is_id "${img//-/}" 2>/dev/null || [[ "$img" =~ ^[0-9a-f-]{36}$ ]] || { echo " no public kube image"; fail=1; }
is_id "$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1)" && { echo " domain $CLIENT already EXISTS -- decide reuse/clean"; fail=1; } || true
[ "$fail" = 0 ] && echo " PREFLIGHT PASS" || die "preflight failed"
}
stage1(){ # operator: domain + manager + quota
admin_env
echo "== stage1: operator provisions domain + manager =="
openstack domain create --description "Client: $CLIENT" "$CLIENT" </dev/null >/dev/null 2>&1 || true
local DOM; DOM=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1); is_id "$DOM" || die "domain create failed"
local MPW; MPW=$(newpw)
openstack user create --domain "$DOM" --password "$MPW" --description "$CLIENT domain manager (D-051/D-064)" "${CLIENT}-domain-admin" </dev/null >/dev/null 2>&1 || true
local MUID; MUID=$(openstack user show "${CLIENT}-domain-admin" --domain "$DOM" -f value -c id </dev/null 2>&1); is_id "$MUID" || die "manager create failed"
openstack role add --domain "$DOM" --user "$MUID" manager </dev/null 2>&1 || true
openstack role assignment list --user "$MUID" --names -f value -c Role </dev/null 2>&1 | grep -qw manager || die "manager grant failed"
local MF="$OUT/${CLIENT}-domain-admin-cred.txt"; : > "$MF"; chmod 600 "$MF"
printf 'domain=%s\ndomain_id=%s\nusername=%s-domain-admin\nuser_id=%s\npassword=%s\nauth_url=%s\n' "$CLIENT" "$DOM" "$CLIENT" "$MUID" "$MPW" "$AUTH_URL" > "$MF"; chmod 600 "$MF"
echo " domain=$DOM manager=$MUID cred -> $MF"
echo " (set project quota after stage2 creates ${CLIENT}-prod, or pre-create the project as operator)"
}
stage2(){ # manager self-service: project + -cluster + -svc + grants
admin_env
local DOM; DOM=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1); is_id "$DOM" || die "no domain"
local MF="$OUT/${CLIENT}-domain-admin-cred.txt"; [ -s "$MF" ] || die "run stage1 first"
echo "== stage2: manager self-services project + -cluster + -svc + grants (D-064 G3) =="
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_USERNAME="${CLIENT}-domain-admin" OS_USER_DOMAIN_ID="$DOM" OS_DOMAIN_ID="$DOM"
export OS_PASSWORD="$(awk -F= '/^password=/{print $2}' "$MF")"
[ "$(openstack token issue -f value -c domain_id </dev/null 2>&1)" = "$DOM" ] || { echo "manager auth FAIL"; exit 1; }
openstack project create --domain "$DOM" --description "$CLIENT production" "${CLIENT}-prod" </dev/null >/dev/null 2>&1 || true
local PID; PID=$(openstack project show "${CLIENT}-prod" --domain "$DOM" -f value -c id </dev/null 2>&1); [[ "$PID" =~ ^[0-9a-f]{32}$ ]] || { echo "project FAIL"; exit 1; }
# -cluster (password, trust-capable) and -svc (app-cred automation)
for U in cluster svc; do
local PW; PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')
openstack user create --domain "$DOM" --password "$PW" --description "$CLIENT $U" "${CLIENT}-$U" </dev/null >/dev/null 2>&1 || true
local UID2; UID2=$(openstack user show "${CLIENT}-$U" --domain "$DOM" -f value -c id </dev/null 2>&1); [[ "$UID2" =~ ^[0-9a-f]{32}$ ]] || { echo "user $U FAIL"; exit 1; }
for R in member load-balancer_member; do openstack role add --project "$PID" --user "$UID2" "$R" </dev/null 2>&1 || true; done
local F="$OUT/${CLIENT}-$U-cred.txt"; umask 077; : > "$F"; chmod 600 "$F"
printf 'username=%s-%s\nuser_id=%s\nuser_domain_id=%s\nproject_id=%s\nauth_url=%s\npassword=%s\n' "$CLIENT" "$U" "$UID2" "$DOM" "$PID" "$AUTH_URL" "$PW" > "$F"; chmod 600 "$F"
echo " ${CLIENT}-$U=$UID2 (member+load-balancer_member) cred -> $F"
done
# anti-escalation self-check (must be DENIED)
local SU; SU=$(openstack user show "${CLIENT}-svc" --domain "$DOM" -f value -c id </dev/null 2>&1)
openstack role add --project "$PID" --user "$SU" admin </dev/null >/dev/null 2>&1 || true
if openstack role assignment list --project "$PID" --user "$SU" --names -f value -c Role </dev/null 2>&1 | grep -qw admin; then echo " *** ESCALATION: manager granted admin -- STOP ***"; exit 1; else echo " anti-escalation OK (admin grant denied)"; fi
) || die "stage2 failed"
}
stage3(){ # -svc mints unrestricted app cred; -cluster owns the keypair (trustor-owned)
admin_env
local DOM; DOM=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1)
local SF="$OUT/${CLIENT}-svc-cred.txt"; local PID; PID=$(awk -F= '/^project_id=/{print $2}' "$SF")
echo "== stage3: -svc mints unrestricted app cred + keypair =="
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_USERNAME="${CLIENT}-svc" OS_USER_DOMAIN_ID="$DOM" OS_PROJECT_ID="$PID"
export OS_PASSWORD="$(awk -F= '/^password=/{print $2}' "$SF")"
[ "$(openstack token issue -f value -c project_id </dev/null 2>&1)" = "$PID" ] || { echo "svc auth FAIL"; exit 1; }
local ACF="$OUT/${CLIENT}-svc-appcred.txt"; umask 077; : > "$ACF"; chmod 600 "$ACF"
openstack application credential create "${CLIENT}-svc-cred" --unrestricted --description "$CLIENT non-trust automation" -f shell </dev/null > "$ACF" 2>&1
grep -qE '^id=' "$ACF" || { echo "appcred FAIL"; cat "$ACF"; exit 1; }; chmod 600 "$ACF"
echo " app cred -> $ACF (unrestricted; secret len $(awk -F'"' '/^secret=/{print length($2)}' "$ACF"))"
) || die "stage3 (svc app cred) failed"
# keypair as -CLUSTER: magnum validates it in the cluster-creator nova context at cluster
# create, and node boot resolves it as the trustor -- both are the -cluster identity.
local CF="$OUT/${CLIENT}-cluster-cred.txt"; [ -s "$CF" ] || die "run stage2 first (no cluster cred)"
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_USERNAME="${CLIENT}-cluster" OS_USER_DOMAIN_ID="$DOM" OS_PROJECT_ID="$PID"
export OS_PASSWORD="$(awk -F= '/^password=/{print $2}' "$CF")"
[ "$(openstack token issue -f value -c project_id </dev/null 2>&1)" = "$PID" ] || { echo "cluster-user auth FAIL"; exit 1; }
local KF="$OUT/${CLIENT}-key.pem"; umask 077; openstack keypair create "${CLIENT}-key" </dev/null > "$KF" 2>&1
head -1 "$KF" | grep -q 'PRIVATE KEY' && { chmod 600 "$KF"; echo " keypair -> $KF (owner: ${CLIENT}-cluster)"; } || { echo "keypair FAIL"; cat "$KF"; exit 1; }
) || die "stage3 (cluster keypair) failed"
}
stage4(){ # tenant L3 via app cred
[ -n "$TENANT_CIDR" ] || die "set TENANT_CIDR (e.g. 10.20.24.0/24)"
admin_env
openstack subnet list -f value -c Subnet </dev/null 2>&1 | grep -qw "$TENANT_CIDR" && die "CIDR $TENANT_CIDR in use"
local ACF="$OUT/${CLIENT}-svc-appcred.txt"
echo "== stage4: tenant L3 (net/subnet/router/ext-gw) via app cred =="
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_TYPE=v3applicationcredential OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_APPLICATION_CREDENTIAL_ID="$(awk -F'"' '/^id=/{print $2}' "$ACF")"
export OS_APPLICATION_CREDENTIAL_SECRET="$(awk -F'"' '/^secret=/{print $2}' "$ACF")"
[[ "$(openstack token issue -f value -c project_id </dev/null 2>&1)" =~ ^[0-9a-f]{32}$ ]] || { echo "appcred auth FAIL"; exit 1; }
openstack network create "${CLIENT}-net" </dev/null >/dev/null 2>&1 && echo " net ok"
openstack subnet create "${CLIENT}-subnet" --network "${CLIENT}-net" --subnet-range "$TENANT_CIDR" --dns-nameserver 8.8.8.8 </dev/null >/dev/null 2>&1 && echo " subnet ok"
openstack router create "${CLIENT}-router" </dev/null >/dev/null 2>&1 && echo " router ok"
openstack router set "${CLIENT}-router" --external-gateway provider-ext </dev/null 2>&1 && echo " ext-gw ok" || echo " *** ext-gw FAILED (operator may need to attach) ***"
openstack router add subnet "${CLIENT}-router" "${CLIENT}-subnet" </dev/null 2>&1 && echo " interface ok"
) || die "stage4 failed"
}
stage5(){ # tenant template (image by UUID)
admin_env
local IMG; IMG=$(openstack image list --public -f value -c ID -c Name </dev/null 2>&1 | awk '/kube/{print $1;exit}')
[[ "$IMG" =~ ^[0-9a-f-]{36}$ ]] || die "kube image uuid resolve failed"
local ACF="$OUT/${CLIENT}-svc-appcred.txt"
echo "== stage5: tenant cluster template (image by UUID) =="
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_TYPE=v3applicationcredential OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_APPLICATION_CREDENTIAL_ID="$(awk -F'"' '/^id=/{print $2}' "$ACF")"
export OS_APPLICATION_CREDENTIAL_SECRET="$(awk -F'"' '/^secret=/{print $2}' "$ACF")"
openstack coe cluster template show "${CLIENT}-k8s" -f value -c uuid </dev/null >/dev/null 2>&1 && openstack coe cluster template delete "${CLIENT}-k8s" </dev/null 2>&1 || true
# NO --keypair here: template is -svc-created but the key is -cluster-owned; keypair_id is
# optional (magnum default=None) and stage6 supplies --keypair in the -cluster context.
openstack coe cluster template create "${CLIENT}-k8s" --image "$IMG" --external-network provider-ext \
--master-flavor gp.mid --flavor capi.node --coe kubernetes --network-driver calico \
--docker-storage-driver overlay2 --master-lb-enabled --floating-ip-enabled \
--fixed-network "${CLIENT}-net" --fixed-subnet "${CLIENT}-subnet" </dev/null 2>&1
[[ "$(openstack coe cluster template show "${CLIENT}-k8s" -f value -c uuid </dev/null 2>&1)" =~ ^[0-9a-f-]{36}$ ]] && echo " template ${CLIENT}-k8s ok" || { echo "template FAIL"; exit 1; }
) || die "stage5 failed"
}
stage6(){ # cluster create as -cluster PASSWORD [GATED ON D-067]
echo "== stage6: cluster create as ${CLIENT}-cluster (PASSWORD) =="
echo " NOTE: D-067 RESOLVED 2026-07-02 (vault external binding -> metal-internal; BUNDLEFIX-007)."
admin_env
local DOM; DOM=$(openstack domain show "$CLIENT" -f value -c id </dev/null 2>&1)
local CF="$OUT/${CLIENT}-cluster-cred.txt"; local PID; PID=$(awk -F= '/^project_id=/{print $2}' "$CF")
( for v in $(env|awk -F= '/^OS_/{print $1}'); do unset "$v"; done
export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3 OS_CACERT="$CA"
export OS_USERNAME="${CLIENT}-cluster" OS_USER_DOMAIN_ID="$DOM" OS_PROJECT_ID="$PID"
export OS_PASSWORD="$(awk -F= '/^password=/{print $2}' "$CF")"
[ "$(openstack token issue -f value -c project_id </dev/null 2>&1)" = "$PID" ] || { echo "cluster-user auth FAIL"; exit 1; }
openstack coe cluster create "${CLIENT}-cluster" --cluster-template "${CLIENT}-k8s" --keypair "${CLIENT}-key" --master-count 1 --node-count 1 </dev/null 2>&1
sleep 15
openstack coe cluster show "${CLIENT}-cluster" -f value -c uuid -c status -c status_reason </dev/null 2>&1 | sed 's/^/ /'
) || die "stage6 failed"
}
case "$STAGE" in
stage0|0) stage0 ;;
stage1|1) stage1 ;;
stage2|2) stage2 ;;
stage3|3) stage3 ;;
stage4|4) stage4 ;;
stage5|5) stage5 ;;
stage6|6) stage6 ;;
all) stage0; stage1; stage2; stage3; stage4; stage5; echo "== stages 0-5 done. run stage6 (cluster) explicitly: tenant-onboard.sh $CLIENT 6 ==" ;;
*) die "unknown stage: $STAGE" ;;
esac
echo "handover creds in: $OUT (0600)"