fix(operator,chart): adopt pre-existing aif-ui-config ConfigMap into Helm ownership - #218
fix(operator,chart): adopt pre-existing aif-ui-config ConfigMap into Helm ownership#218leomiraanda wants to merge 10 commits into
Conversation
…m ownership The operator's self-heal (syncUIConfigMap) and the UI's Settings-save create path could each leave aif-ui-config unowned by Helm, which then permanently blocks the next `helm install`/`upgrade` of aif-ui-server with "invalid ownership metadata" (SUSEAI-1039). Both writers now stamp the same Helm ownership label/annotations the chart's own template sets. The operator additionally pre-adopts the ConfigMap right before every Helm install attempt (not just after a successful one), closing the gap where a leftover unowned object blocks the very first install too. Scoped to the operator-orchestrated install path; a standalone `helm install`/`upgrade` of aif-ui-server with no operator involved is not covered — Helm's own ownership check runs before any chart hook could intervene.
Test coverage matrixTwo axes matter: who's installing (operator-orchestrated vs. standalone
Bottom line: every path through the aif-operator (rows 1-8) is fixed and verified. The one confirmed gap is any |
syncUIConfigMap's own doc comment already claimed the ConfigMap "is intentionally not deleted when the CR is removed", but nothing enforced that: configmap.yaml had no helm.sh/resource-policy: keep, so a plain helm uninstall (including the operator's own finalizer path) deleted it along with the rest of the release, silently losing any custom operator-connection/catalog settings on a reinstall. Adding the resource-policy annotation makes the ConfigMap survive uninstall with its ownership labels/annotations intact, so a same-identity reinstall adopts it with zero conflict and zero manual intervention — verified live end-to-end (uninstall -> reinstall keeps the exact same object, no ownership error). If the release identity changes instead (different release name), Helm's ownership check still correctly rejects the mismatch; that case is handled by the aif-operator's own pre-install adoption step (SUSEAI-1039, PR #218), not by this chart.
Two goroutines hammering the ConfigMap with no pacing exhausted the shared k8sClient's default client-side QPS/burst well before the 2-second race window ended, and golang.org/x/time/rate's Wait() rejected proactively once it could predict a wait would outlast the remaining deadline. That surfaced as dozens of client-side throttling errors the test misread as genuine correctness failures. Give the race a dedicated client with client-side rate limiting fully disabled (flowcontrol.NewFakeAlwaysRateLimiter), and tolerate the one request that can still get cancelled right as the window's own context deadline fires — an artifact of the fixed test window, not a signal about the writers under test. Verified stable across 10+ consecutive runs after the fix (previously failed roughly every other run).
…amping Code review on #218 found three real gaps in the hand-rolled ownership stamping this fix originally added, all stemming from duplicating logic Helm's own SDK already provides: - The UI's release-name is hardcoded ("aif-ui-server"); the operator derives it from the chart URL. They only agreed for the default chart path. - The UI's create path unconditionally stamped Helm ownership even on the git-sourced branch its own comment names, unlike the operator's equivalent code, which correctly guards on source kind. - A chart-URL change inside the operator's own reconcile loop silently transferred the ConfigMap's ownership to the new release instead of failing the way a bare `helm install` under a mismatched name does — untested, and undocumented as a difference from that bare-install behavior. Also fixes a Critical, independently confirmed bug: saveOperatorConfig's PUT is a full-object replace that never carried forward the existing object's labels/annotations, so the very next Settings save after the ConfigMap became Helm-owned stripped that ownership right back off. The test asserting the PUT "does not touch ownership metadata" never mocked an existing object that actually had any, so it could not have caught this. This replaces all of that hand-rolled stamping with Helm's own TakeOwnership (install.go/upgrade.go, vendored v3.21.1), which this codebase already uses for the identical error via Fleet's equivalent flag (blueprint.go:230-239, NVIDIA NIM charts templating their own pre-delivered ngc-secret). Set on all three of the operator's Helm action sites — install, upgrade, and the dry-run render EnsureRelease uses to decide whether an upgrade is even needed, since Helm's ownership check runs before the DryRun branch and would otherwise block the real upgrade from ever being attempted. adoptUIConfigMap and the ownership-stamping half of syncUIConfigMap are gone — Helm itself now adopts the ConfigMap regardless of who created it or what it currently says, using the correct current release identity every time, so there is no separate logic left to have a wrong name, a missing guard, or a silent transfer. syncUIConfigMap keeps only its Data self-heal. The UI's create path stops stamping ownership entirely; its update path now carries forward whatever labels/ annotations already exist instead of a PUT silently clearing them — general correctness, independent of this redesign. Verified against the live cluster two ways: a standalone program using the same vendored Helm SDK directly (install fails on a pre-existing unowned ConfigMap without TakeOwnership; with it, Helm patches rather than replaces the object, correctly stamping the real release's identity and merging data instead of clobbering it), and the actual operator binary end to end (locally-built image loaded into the cluster node, unowned ConfigMap recreated by hand, next reconcile logs "Helm release not found, installing" -> "installed successfully" with the same object uid throughout and correct ownership stamped). Traded off deliberately: TakeOwnership applies to every resource in a chart's manifest, not just this one ConfigMap — broader than the hand-rolled fix it replaces. This operator only ever installs one known extension chart today, so the risk is accepted; worth revisiting if that stops being true.
…nstalls TakeOwnership is only wired into the operator's own Helm SDK calls (SUSEAI-1039); a standalone helm install/upgrade of this chart has no automatic fix since Helm's ownership check runs before any chart hook could intervene. Document the manual workaround instead.
…-adoption # Conflicts: # charts/aif-ui/README.md # operator/internal/infra/helm/action.go
Drop saveOperatorConfig fix and its test: the UI settings write path was removed on main.
The one-chart claim it relied on is a deployment convention, not enforced - the chart comes from the CR.
Test coverage matrix — revised after the
|
| # | Then | Now | What changed |
|---|---|---|---|
| 1 | ✅ via adoptUIConfigMap |
✅ | Conclusion holds, mechanism doesn't — TakeOwnership on install does the adoption. Re-verified live against the current revision (see the PR description's Testing section). |
| 2 | ✅ syncUIConfigMap re-stamps ownership on every reconcile |
Detail below. | |
| 3 | ✅ CM deleted with the release, nothing left over | ✅ | Premise inverted by resource-policy: keep: uninstall now keeps the object. Reinstall adopts it — verified same uid/resourceVersion throughout. Outcome unchanged, but the row no longer tests what it says. |
| 4 | ➖ N/A — left unowned by design | ➖ | Still accurate, and now on a firmer basis: the self-heal never writes ownership metadata at all. |
| 5 | ✅ same code path as #1 | ✅ | Rationale stale (adoptUIConfigMap → TakeOwnership), conclusion holds. |
| 6 | ✅ UI-save / operator-install race | ➖ cannot occur | There is no UI writer. |
| 7 | ➖ N/A — UI skips the write when a CR exists | ➖ | Moot: the UI never writes, CR or no CR. |
| 8 | ✅ saveOperatorConfig POST-create stamps ownership |
➖ scenario removed | Detail below. |
| 9 | ❌ standalone helm install |
❌ | Unchanged. Now has a documented --take-ownership workaround (charts/aif-ui/README.md). |
| 10 | ❌ standalone helm upgrade |
❌ | Unchanged, same workaround. |
| 11 | Detail below. | ||
| 12 | (new) | useStaticCatalog is not preserved across a self-heal. Detail below. |
Row 2 — the self-heal no longer re-stamps ownership
syncUIConfigMap now deliberately touches only Data. So if the ConfigMap is deleted, the self-heal recreates it unowned, and nothing re-adopts it on its own: decideRelease selects its action "from storage state alone, before any chart is pulled" (operator/internal/infra/helm/action.go:369) and never inspects live cluster objects. A deleted-and-recreated ConfigMap therefore produces no upgrade, and the object can stay unowned indefinitely.
Benign on every path I can trace — the UI reads Data and doesn't care about ownership, the next genuine upgrade adopts via TakeOwnership, and uninstall doesn't need ownership thanks to resource-policy: keep. But it is a real behavioural change from "re-stamped every 60s" to "unowned until something else triggers an upgrade," and the original row asserts the opposite.
Row 8 — not broken, deleted
main removed the advanced settings section, so Settings.vue only reads now (loadOperatorConfig, getOperatorNamespace). Consequence worth being explicit about: an unmanaged install can no longer correct wrong operator coordinates from the UI — that now requires chart values and a helm upgrade. Upstream product decision, not a change this PR makes, but the capability existed when the matrix was written.
Row 11 — risk changed shape rather than going away
The wrong-release-stamping code is gone, but TakeOwnership makes the multi-CR case concrete rather than theoretical: two InstallAIExtension CRs means two releases, and each install/upgrade adopts aif-ui-config away from the other, ping-ponging ownership. Now called out in the PR description's Trade-off section.
Row 12 (new) — useStaticCatalog lost on self-heal
main added a third key to the chart's configmap.yaml, but syncUIConfigMap writes only operatorNamespace and operatorService. On update this is harmless — CreateOrUpdate merges, so the key survives. If the ConfigMap is deleted outright, the self-heal recreates it without useStaticCatalog and the UI falls back to its true default. That matches the chart default, so it's invisible in the common case, but an admin who installed with appCatalog.useStaticCatalog=false is silently reverted to the static catalog until the next helm upgrade.
Same trigger as row 2, but the only one of the two with user-visible impact. Out of scope here — the self-heal has no source for that value today, so fixing it is design work. Tracking with SUSEAI-910, which covers the same "objects in cattle-ui-plugin-system surviving in a degraded state" territory.
Bottom line
Unchanged from the original: every operator-orchestrated path is fixed and verified, and the one confirmed gap is a by-hand helm install/upgrade outside the operator (rows 9-10), which Helm's own ownership check makes impossible to fix from inside the chart. What's new is that rows 6-8 can no longer happen at all, and rows 2 and 12 are latent-but-benign consequences of the self-heal no longer owning anything.
Summary
Fixes SUSEAI-1039:
helm install/upgradeof theaif-ui-serverrelease can fail with:The operator's
syncUIConfigMapself-heal recreatesaif-ui-configif it's missing, on every successfulInstallAIExtensionreconcile — and it creates it unowned by Helm. Once that happens, the next Helm install/upgrade ofaif-ui-serverpermanently fails and theInstallAIExtensionCR gets stuck inFailed, retrying the same doomed install every reconcile.This PR bundles two complementary fixes for that problem.
Fix 1 — adopt any pre-existing ConfigMap via Helm's native
TakeOwnershipAn earlier version of this fix hand-rolled the ownership stamping (mirroring the label/annotations the chart's
configmap.yamlsets). Code review on this PR found three real gaps in that approach — the UI's hardcoded release name vs. the operator's chart-URL-derived one only agreeing on the default path, the UI's create path stamping ownership even on the git-sourced branch its own comment named, and a chart-URL change silently transferring ownership instead of failing like a mismatched-name bare install would.Replaced all of that with Helm's own
TakeOwnership(vendored v3.21.1'sinstall.go/upgrade.go), which this codebase already uses for the identical problem via Fleet's equivalent flag (blueprint.go, NVIDIA NIM charts templating their own pre-deliveredngc-secret). Set on all three of the operator's Helm action sites inoperator/internal/infra/helm/action.go— install, upgrade, and the dry-run renderEnsureReleaseuses to decide whether an upgrade is even needed (Helm's ownership check runs before theDryRunbranch, so it would otherwise block the real upgrade from ever being attempted).adoptUIConfigMapand the ownership-stamping half ofsyncUIConfigMapare gone — Helm itself now adopts the ConfigMap regardless of who created it or what it currently says, using the correct current release identity every time.syncUIConfigMapkeeps only itsDataself-heal.Fix 2 — make
aif-ui-configsurvivehelm uninstallsyncUIConfigMap's own doc comment already claimed the ConfigMap "is intentionally not deleted when the CR is removed," but nothing enforced that —configmap.yamlhad nohelm.sh/resource-policy: keep, so a plain uninstall (including the operator's own finalizer path) deleted it along with the rest of the release, silently losing custom operator-connection/catalog settings on reinstall.Added
helm.sh/resource-policy: keep. Helm's uninstall now leaves the object behind with its ownership labels/annotations intact, so a same-identity reinstall adopts it with no extra help needed — and Fix 1'sTakeOwnershipnow covers every other case too (identity change, or an object created unowned entirely), not just this one.Dropped after merging
mainThis branch originally carried a third fix:
saveOperatorConfig'sPUTwas a full-object replace that never carried forward the existing object's labels/annotations, so the next Settings save after the ConfigMap became Helm-owned stripped that ownership straight back off.mainhas since removed the UI's Settings write path entirely (getOperatorConfig,saveOperatorConfig,invalidateOperatorConfigand the extension-check helpers are all gone fromui/pkg/aif-ui/utils/operator-config.ts). There is no longer a function to fix, so that change and its Vitest suite were dropped in the merge rather than carried forward. The UI is no longer a writer ofaif-ui-configat all, which is why the summary above names only one.Testing
ui_configmap_ownership_test.go(renamed to reflect scope:syncUIConfigMapno longer touches ownership) now pins that the self-heal only ever touchesData, regardless of pre-existing labels/annotations — including recreating an unowned ConfigMap without adding any ownership metadata.TakeOwnership; with it, Helm patches rather than replaces the object, correctly stamping the real release's identity and merging data instead of clobbering it.Helm release not found, installing→installed successfullywith the same objectuidthroughout and correct ownership stamped.helm uninstall→ Helm explicitly reportsThese resources were kept due to the resource policy: [ConfigMap] aif-ui-config; a same-name reinstall then adopts with the exact sameuid/resourceVersionthroughout — never touched, not deleted+recreated.Known limitations (not fixed by this PR)
Standalone
helmCLI runs. A barehelm install/helm upgrade aif-ui-serverwith no operator involved still isn't covered automatically —TakeOwnershipis set on the operator's own Helm SDK calls, not on the chart itself. This can't be made automatic from inside the chart: Helm's ownership check runs before any pre-install/pre-upgrade hook could execute (confirmed against the vendored v3.21.1 source — the ownership check computes its adoption set beforeperformInstall, which is what firesHookPreInstall). SinceTakeOwnershipis the same flag Helm exposes as--take-ownershiponhelm install/upgrade(v3.15+), documented this as a manual workaround instead — see the new "Standalone install/upgrade" section incharts/aif-ui/README.md.useStaticCatalogis not preserved by the self-heal.mainadded a third key to the chart'sconfigmap.yaml, butsyncUIConfigMapwrites onlyoperatorNamespaceandoperatorService. On update that's harmless (CreateOrUpdatemerges, so the key survives), but if the ConfigMap is deleted outright, the self-heal recreates it withoutuseStaticCatalogand the UI falls back to itstruedefault. That matches the chart default, so it's invisible in the common case — but an admin who installed withappCatalog.useStaticCatalog=falsegets silently reverted to the static catalog until the nexthelm upgrade. Out of scope here: the self-heal has no source for that value today, so fixing it is design work. Tracking it with SUSEAI-910, which covers the same "objects incattle-ui-plugin-systemsurvive in a degraded state" territory.Trade-off
TakeOwnershipapplies to every resource in a chart's manifest, not just this one ConfigMap, and adopting an object owned by another Helm release transfers deletion rights to this one. In practice we only ever pointInstallAIExtensionat the aif-ui chart, whose objects are release-scoped apart fromaif-ui-config— but that's a deployment convention, not an invariant: the chart comes from the CR (spec.source.helm.chartURL, orspec.extension.nameon the ClusterRepo path). Accepted deliberately, since the narrower alternative is the hand-rolled stamping this replaced. Worth revisiting if we ever install more than the one chart.Test plan
go build ./...,go vet,gofmtclean on touched fileshelm lint/helm templatecleanmake test), including updated specsvitest run) andtsctypecheck passmainmerged into the branch; conflicts resolved, obsolete UI fix dropped (see above)