Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions deploy/aks/operator/bin/hosting-deploy
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,53 @@ else
|| hosting::die "could not create namespace ${namespace}"
fi

# ── adopt: every resource the RECORD renders that exists live without Helm ownership ────────────
# 🚨 WHY. An instance created before its record existed carries hand-made objects the chart now
# renders — on memex the SecretProviderClass `memex-kv` (keyVaultSecretClasses[0] on the record),
# applied by a laptop in 2026-08 and never in any release. Helm refuses to upgrade over such an
# object: measured 2026-09-09 01:20Z (Deployments/memex-reconcile-20260909-operator-0b79490),
# UPGRADE FAILED: … SecretProviderClass "memex-kv" in namespace "memex" exists and cannot be
# imported into the current release: invalid ownership metadata
# and --atomic rolled back. The config repo's helm-release lane has an `adopt` action for exactly
# this, but it renders from the COMMITTED OVERLAY, which never carried memex-kv — so what only the
# record renders can only be adopted here, from the record. Adoption is metadata only (two
# annotations + one label per resource), changes no spec and restarts nothing; from then on helm
# owns the object and the next upgrade renders it from the record — which is the design.
#
# Three outcomes per rendered resource, each logged: ABSENT (helm creates it below), OWNED by this
# release (nothing to do), UNOWNED live (adopted). A live object owned by ANOTHER release is a
# refusal, never a takeover — two releases claiming one object is how a teardown deletes the wrong
# instance's Secret.
rendered="$(hosting::read helm template "$release" "$CHART" --namespace "$namespace" "${files[@]}" "${sets[@]}" 2>&1)" \
|| { if hosting::dry; then hosting::log "DRY-RUN: could not template the chart here (${rendered}); adoption is skipped in the rehearsal only"; rendered=""; else hosting::die "could not render the chart for adoption: ${rendered}"; fi; }
adopted=0; owned=0; absent=0
if [ -n "$rendered" ]; then
names="$(printf '%s\n' "$rendered" | hosting::read kubectl -n "$namespace" create --dry-run=client -o name -f - 2>&1)" \
|| hosting::die "could not list the rendered resources for adoption: ${names}"
while IFS= read -r res; do
[ -n "$res" ] || continue
live="$(hosting::read kubectl -n "$namespace" get "$res" -o json 2>/dev/null)" || { absent=$((absent+1)); continue; }
rel="$(printf '%s' "$live" | jq -r '.metadata.annotations["meta.helm.sh/release-name"] // ""')"
mgr="$(printf '%s' "$live" | jq -r '.metadata.labels["app.kubernetes.io/managed-by"] // ""')"
if [ "$rel" = "$release" ] && [ "$mgr" = "Helm" ]; then
owned=$((owned+1)); continue
fi
[ -z "$rel" ] || [ "$rel" = "$release" ] \
|| hosting::die "${res} in ${namespace} is owned by release '${rel}', not '${release}' — refusing to take it over; two releases claiming one object is how a teardown deletes the wrong instance's Secret"
hosting::log "adopting ${res} (live, no Helm ownership)"
hosting::do kubectl -n "$namespace" annotate --overwrite "$res" \
"meta.helm.sh/release-name=${release}" "meta.helm.sh/release-namespace=${namespace}" >/dev/null \
|| hosting::die "could not annotate ${res} for adoption"
hosting::do kubectl -n "$namespace" label --overwrite "$res" "app.kubernetes.io/managed-by=Helm" >/dev/null \
|| hosting::die "could not label ${res} for adoption"
adopted=$((adopted+1))
done <<EOF_NAMES
$names
EOF_NAMES
fi
hosting::log "adoption ${adopted} adopted, ${owned} already owned, ${absent} to be created"
hosting::say adopted "$adopted"

# --atomic: a failed install ROLLS BACK rather than leaving a half-created release that the next
# attempt then has to `helm uninstall` before it can proceed. --wait so "deployed" means the pods
# actually came up; the plan's next step (rollout status) then verifies rather than discovers.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"kind":"ConfigMap","metadata":{"name":"memex-portal-config","labels":{"app.kubernetes.io/managed-by":"Helm"},"annotations":{"meta.helm.sh/release-name":"memex","meta.helm.sh/release-namespace":"memex"}}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"kind":"SecretProviderClass","metadata":{"name":"memex-kv","labels":{},"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{}"}}}
3 changes: 3 additions & 0 deletions deploy/aks/operator/test/fixtures/deploy/names.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
deployment.apps/memex-portal-deployment
configmap/memex-portal-config
secretproviderclass.secrets-store.csi.x-k8s.io/memex-kv
15 changes: 15 additions & 0 deletions deploy/aks/operator/test/fixtures/deploy/rendered.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# What the RECORD renders for the adoption tests (content irrelevant: kubectl's parsing is stubbed
# through names.txt). Four resources: one absent live, one owned by this release, one live with NO
# ownership (the memex-kv shape), and — only in the takeover case — one owned by another release.
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: memex-portal-deployment }
---
apiVersion: v1
kind: ConfigMap
metadata: { name: memex-portal-config }
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata: { name: memex-kv }
53 changes: 53 additions & 0 deletions deploy/aks/operator/test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,59 @@ emits "a dry run still audits (read-only)" "::hosting:: audit_verdict=clean" \
env HOSTING_DRY_RUN=true PATH="$STUBS:$PATH" HOSTING_AUDIT_FIXTURE="$FIXTURES/clean" \
hosting-audit --namespace memex --release memex

# ── hosting-deploy adopts what the RECORD renders and the cluster already holds ─────────────────
# Measured 2026-09-09 01:20Z on memex: the first Reconcile to reach helm failed with
# UPGRADE FAILED: … SecretProviderClass "memex-kv" … exists and cannot be imported into the
# current release: invalid ownership metadata
# because that object was hand-applied before any release and only the record renders it. The
# stubs play a three-resource estate — one absent, one owned, one live without ownership — and
# record every call in order, so the assertions are about WHAT was stamped and WHEN.
echo
echo "── hosting-deploy: adoption before helm ─────────────────────────"
DP_STUBS="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/stubs/deploy" && pwd)"
DP_FIXTURES="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/fixtures/deploy" && pwd)"
_dp_dir="$(mktemp -d)"; cp -R "$DP_FIXTURES/." "$_dp_dir/"; _dp_log="$_dp_dir/calls.log"; : > "$_dp_log"
_dp_vals="$_dp_dir/values.yaml"; printf '# GENERATED from the Hosting/Deployment record by HelmValues\nreplicas:\n portal: 1\n' > "$_dp_vals"
_dp_run() { env PATH="$DP_STUBS:$PATH" HOSTING_CHART=/tmp HOSTING_DEPLOY_FIXTURE="$_dp_dir" HOSTING_DEPLOY_STUB_LOG="$_dp_log" \
hosting-deploy --namespace memex --release memex --database memex --values "$_dp_vals" --image cr.example.test/memex-portal-ai:1 2>&1; }
_dp_out="$(_dp_run)"; _dp_rc=$?
[ "$_dp_rc" -eq 0 ] && ok "deploy succeeds against the stubbed estate" || bad "deploy succeeds against the stubbed estate" "exited ${_dp_rc}: ${_dp_out}"
case "$_dp_out" in *"::hosting:: adopted=1"*) ok "exactly the unowned live resource is adopted (adopted=1)" ;;
*) bad "exactly the unowned live resource is adopted" "said: ${_dp_out}" ;; esac
case "$_dp_out" in *"adoption 1 adopted, 1 already owned, 1 to be created"*) ok "the three outcomes are counted and logged" ;;
*) bad "the three outcomes are counted and logged" "said: ${_dp_out}" ;; esac
_spc='secretproviderclass.secrets-store.csi.x-k8s.io/memex-kv'
if grep -q "^kubectl -n memex annotate --overwrite ${_spc} meta.helm.sh/release-name=memex meta.helm.sh/release-namespace=memex" "$_dp_log" \
&& grep -q "^kubectl -n memex label --overwrite ${_spc} app.kubernetes.io/managed-by=Helm" "$_dp_log"; then
ok "the unowned SecretProviderClass gets this release's ownership annotations and label"
else
bad "the unowned SecretProviderClass gets ownership" "$(cat "$_dp_log")"
fi
grep -q 'annotate --overwrite configmap/\|annotate --overwrite deployment.apps/' "$_dp_log" \
&& bad "an owned or absent resource is never touched" "$(grep 'annotate' "$_dp_log")" \
|| ok "an owned or absent resource is never touched"
_adopt_line="$(grep -n "annotate --overwrite ${_spc}" "$_dp_log" | head -1 | cut -d: -f1)"
_helm_line="$(grep -n '^helm upgrade memex' "$_dp_log" | head -1 | cut -d: -f1)"
if [ -n "$_adopt_line" ] && [ -n "$_helm_line" ] && [ "$_adopt_line" -lt "$_helm_line" ]; then
ok "adoption happens BEFORE helm upgrade"
else
bad "adoption happens BEFORE helm upgrade" "adopt at '${_adopt_line}', helm at '${_helm_line}' in: $(cat "$_dp_log")"
fi
# Idempotent: the stub rewrote the live object with its ownership; a second run adopts nothing.
: > "$_dp_log"; _dp_out="$(_dp_run)"
case "$_dp_out" in *"::hosting:: adopted=0"*) ok "a second run adopts nothing (the estate is now owned)" ;;
*) bad "a second run adopts nothing" "said: ${_dp_out}" ;; esac
# 🚨 Never a takeover: an object owned by ANOTHER release is a refusal, and helm is never reached.
jq '.metadata.labels["app.kubernetes.io/managed-by"]="Helm" | .metadata.annotations["meta.helm.sh/release-name"]="other" | .metadata.annotations["meta.helm.sh/release-namespace"]="memex"' \
"$DP_FIXTURES/live/secretproviderclass.secrets-store.csi.x-k8s.io_memex-kv.json" > "$_dp_dir/live/secretproviderclass.secrets-store.csi.x-k8s.io_memex-kv.json"
: > "$_dp_log"; _dp_out="$(_dp_run)"; _dp_rc=$?
if [ "$_dp_rc" -ne 0 ] && printf '%s' "$_dp_out" | grep -q "owned by release 'other'" && ! grep -q '^helm upgrade' "$_dp_log"; then
ok "an object owned by another release is refused, and helm never runs"
else
bad "an object owned by another release is refused" "rc=${_dp_rc} out: ${_dp_out} log: $(cat "$_dp_log")"
fi
rm -rf "$_dp_dir"

# ── every kubectl verb+resource in bin/ is GRANTED by the operator's ClusterRole ─────────────────
# The manifest lives three directories away from the scripts and is reviewed separately; twice a
# script reached main without its grant (storageclasses for pv-resize — failed the first Reconcile
Expand Down
13 changes: 13 additions & 0 deletions deploy/aks/operator/test/stubs/deploy/helm
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# A stand-in `helm` for hosting-deploy's adoption tests. `template` prints the fixture manifest
# ($HOSTING_DEPLOY_FIXTURE/rendered.yaml — what the RECORD renders); `upgrade` records that it was
# reached, in order, in $HOSTING_DEPLOY_STUB_LOG. It asserts nothing about helm itself.
set -uo pipefail
log="${HOSTING_DEPLOY_STUB_LOG:?the helm stub needs HOSTING_DEPLOY_STUB_LOG}"
fixture="${HOSTING_DEPLOY_FIXTURE:?the helm stub needs HOSTING_DEPLOY_FIXTURE}"
printf 'helm %s\n' "$*" >> "$log"
case "${1:-}" in
template) cat "$fixture/rendered.yaml" ;;
upgrade) echo "Release \"${2:-}\" has been upgraded." ;;
*) echo "helm stub: unexpected invocation: $*" >&2; exit 1 ;;
esac
40 changes: 40 additions & 0 deletions deploy/aks/operator/test/stubs/deploy/kubectl
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# A stand-in `kubectl` for hosting-deploy's adoption tests. Records every invocation in
# $HOSTING_DEPLOY_STUB_LOG. The live estate is $HOSTING_DEPLOY_FIXTURE/live/<name>.json, one file
# per rendered resource name (kind.group/name with '/' → '__'); absent file → NotFound.
Comment on lines +3 to +4
#
# get namespace X → exists
# create --dry-run=client -o name -f - → $FIXTURE/names.txt (kubectl's own parsing is not under test)
# get <res> -o json → the live object, or NotFound
# annotate / label → recorded; the live object is REWRITTEN with the ownership
# so a second pass reads it as owned (adoption is idempotent)
set -uo pipefail
log="${HOSTING_DEPLOY_STUB_LOG:?the kubectl stub needs HOSTING_DEPLOY_STUB_LOG}"
fixture="${HOSTING_DEPLOY_FIXTURE:?the kubectl stub needs HOSTING_DEPLOY_FIXTURE}"
printf 'kubectl %s\n' "$*" >> "$log"
ns=""
if [ "${1:-}" = "-n" ]; then ns="${2:-}"; shift 2; fi
live_file() { printf '%s/live/%s.json' "$fixture" "$(printf '%s' "$1" | tr '/' '_')"; }
case "${1:-}" in
get)
if [ "${2:-}" = "namespace" ]; then echo "NAME STATUS AGE"; exit 0; fi
if [ "${2:-}" = "deploy" ]; then echo "Error from server (NotFound)" >&2; exit 1; fi
f="$(live_file "${2:-}")"
[ -f "$f" ] || { echo "Error from server (NotFound): ${2:-} not found" >&2; exit 1; }
cat "$f" ;;
create)
cat >/dev/null
cat "$fixture/names.txt" ;;
annotate|label)
res="${3:-}"; f="$(live_file "$res")"
[ -f "$f" ] || { echo "Error from server (NotFound): ${res} not found" >&2; exit 1; }
if [ "$1" = "annotate" ]; then
rel=""; rns=""
for arg in "$@"; do case "$arg" in meta.helm.sh/release-name=*) rel="${arg#*=}" ;; meta.helm.sh/release-namespace=*) rns="${arg#*=}" ;; esac; done
jq --arg r "$rel" --arg n "$rns" '.metadata.annotations["meta.helm.sh/release-name"]=$r | .metadata.annotations["meta.helm.sh/release-namespace"]=$n' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
else
jq '.metadata.labels["app.kubernetes.io/managed-by"]="Helm"' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
fi
echo "${res} ${1}d" ;;
*) echo "kubectl stub: unexpected invocation: $*" >&2; exit 1 ;;
esac
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
Name: A Reconcile adopts what the record renders
Category: Fix
Description: Re-applying an instance from its record stopped at helm when the cluster held a hand-made object the record now describes — helm refuses to take over what it never created. The operator now stamps Helm ownership on such objects before the upgrade, one by one and logged, and refuses one that belongs to another release.
Icon: Wrench
Order: -20260909
---

The first record-driven Reconcile of memex to reach helm, on 2026-09-09, failed like this:

```
UPGRADE FAILED: Unable to continue with update: SecretProviderClass "memex-kv" in namespace
"memex" exists and cannot be imported into the current release: invalid ownership metadata
```

`memex-kv` is the Key Vault class that feeds the portal its secrets. It was applied by hand in
August, long before the instance had a record, and no Helm release ever owned it. The record
describes it now — that is the point of the record — so the chart renders it, and helm, correctly,
will not overwrite an object it did not create. `--atomic` rolled the upgrade back; nothing changed.

The config repository's deploy lane already has an `adopt` action for this case, but it renders
the committed overlay, which never carried this object. What only the record renders can only be
adopted from the record.

## What it does now

Before `helm upgrade`, `hosting-deploy` renders the chart from the record and looks at every
resource it would manage. One that does not exist is left for helm to create. One this release
already owns is left alone. One that exists with no owner is adopted: two annotations and one
label, no change to its spec, nothing restarted — and from then on the record renders it like
everything else. One owned by **another** release is a refusal that stops the run before helm:
two releases claiming one object is how a teardown deletes the wrong instance's secret.

Every outcome is logged, and the run reports `adopted=<n>` to the mesh. The operator's test
suite plays a three-object estate through stubs and asserts what was stamped, in what order, that
a second run adopts nothing, and that the takeover case is refused with helm never reached.
Loading