feat(self-sovereign-cutover): add step 10 — pivot vCluster HelmReleases to Sovereign Harbor (Refs #2034)

The chart's own comment at platform/bp-mgmt-vcluster/chart/values.yaml:77-79
promised "post-handover, the per-Sovereign overlay rewrites to
`harbor.<sovereign-fqdn>/proxy-ghcr/...`" — but the rewrite step never
existed anywhere in the cutover sequence. As a result, every Sovereign
post-handover keeps pulling vCluster control-plane images from
`harbor.openova.io` indefinitely, a direct violation of Principle #11
(no tether to harbor.openova.io after handover). Caught by the TBD-V24
tether audit on 2026-05-20.

Why step 04 (containerd registries.yaml pivot) doesn't catch it:
registries.yaml.v2 only mirrors the 7 canonical UPSTREAMS (ghcr.io,
docker.io, registry.k8s.io, gcr.io, quay.io, xpkg.upbound.io,
public.ecr.aws). The host `harbor.openova.io` is treated as a literal
endpoint, not an upstream, so containerd routes those image pulls
direct to mothership Harbor regardless of mirror config.

This step adds:
- Phase 1: live `kubectl patch helmrelease` against each of
  {bp-mgmt-vcluster, bp-rtz-vcluster, bp-dmz-vcluster} in flux-system,
  patching BOTH `spec.values.<role>Vcluster.image.repository`
  (umbrella) AND `spec.values.vcluster.controlPlane.statefulSet.image.
  {registry,repository}` (loft-sh subchart). Topology-aware: secondaries
  skip MGMT (not present), primary skips RTZ (not present). Idempotent:
  re-runs no-op when already pivoted.
- Phase 2: git push to local Gitea injecting the same override blocks
  into clusters/_template/bootstrap-kit/{54,58,59}-bp-*-vcluster.yaml
  so the bootstrap-kit Kustomization doesn't revert the live patch on
  next reconcile (same pattern as step 06 Phase 2 + Phase 2.5).

Coordination with chart 0.1.34 (TBD-V25, PR #2036, already merged):
totalSteps bumped from "9" → "10" in 09-cutover-status-configmap.yaml.
Contract test (tests/cutover-contract.sh) asserts shift from 9 → 10
step ConfigMaps and from 8 → 9 job-mode ConfigMaps. New Case 21
verifies Step 10's wrapper + subchart patches are wired correctly.

RBAC: ClusterRole gains helm.toolkit.fluxcd.io.helmreleases
{update,patch}. Step-06 Phase-1.6 (the openova-catalog HR patch shipped
in chart 0.1.31) was silently relying on this verb already — chart
0.1.31's RBAC change was missed, so this bump ALSO closes a latent
permission gap that would have surfaced on any cluster where the prior
patch attempt happened to require it.

Operator note: existing actively-running vCluster Pods do NOT churn on
this step — they're already running with images pulled at startup. The
patch ensures the NEXT image-pull (chart bump, Pod restart, region
add) routes through the Sovereign-local Harbor.

Refs #2034 (NOT Closes — operator-walk on fresh prov + screenshot
required per CLAUDE.md §4 anti-theater discipline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-20 04:08:17 +02:00
parent 9c340efe43
commit 9774d7ba53
6 changed files with 616 additions and 22 deletions

View File

@ -142,7 +142,54 @@ name: bp-self-sovereign-cutover
# | jq '.auths | keys'` returns ONLY `[harbor.<sov-fqdn>]`. Refs
# #2034 (closes after operator-walk-with-screenshot per anti-theater
# discipline).
version: 0.1.35
#
# 0.1.36 (TBD-V24 MISS-1, issue #2034, 2026-05-20): NEW step 10
# (vcluster-registry-pivot) — pivots the THREE bp-*-vcluster
# HelmReleases' `image.repository` from `harbor.openova.io/proxy-ghcr/
# loft-sh/vcluster` to `harbor.<SOVEREIGN_FQDN>/proxy-ghcr/loft-sh/
# vcluster` so the MGMT/RTZ/DMZ vCluster control-plane Pods pull from
# the Sovereign-local Harbor mirror post-cutover.
#
# Root cause this addresses: the chart's own comment at
# `platform/bp-mgmt-vcluster/chart/values.yaml:77-79` promised
# "post-handover, the per-Sovereign overlay rewrites to
# `harbor.<sovereign-fqdn>/proxy-ghcr/...`" but the rewrite step was
# never implemented anywhere in the cutover sequence. As a result,
# every Sovereign post-handover keeps pulling vCluster control-plane
# images from `harbor.openova.io` indefinitely — a direct violation
# of Principle #11 (no tether to harbor.openova.io after handover).
# Caught by the TBD-V24 tether audit (2026-05-20).
#
# Step 04 (containerd registries.yaml pivot) does NOT catch this
# because registries.yaml.v2 only mirrors the 7 canonical UPSTREAMS
# (ghcr.io, docker.io, registry.k8s.io, etc.). The host
# `harbor.openova.io` is treated as a literal endpoint, NOT an
# upstream, so containerd routes those pulls direct.
#
# This step adds a Phase-1 live `kubectl patch helmrelease` against
# each of {bp-mgmt-vcluster, bp-rtz-vcluster, bp-dmz-vcluster} in
# flux-system, patching BOTH:
# - spec.values.<role>Vcluster.image.repository (umbrella chart)
# - spec.values.vcluster.controlPlane.statefulSet.image.{registry,repository} (subchart)
# A Phase-2 git push to local Gitea injects the same override blocks
# into clusters/_template/bootstrap-kit/{54,58,59}-bp-*-vcluster.yaml
# so the bootstrap-kit Kustomization reconcile doesn't revert the
# live patch. Idempotent on re-run (skip-if-already-pivoted on
# Phase-1, sentinel-comment guard on Phase-2). Topology-aware:
# secondaries skip the MGMT HR (not present on secondary), primary
# skips the RTZ HR (not present on primary).
#
# RBAC: ClusterRole gains helm.toolkit.fluxcd.io.helmreleases
# {update,patch}. Step-06 Phase-1.6 was already silently relying on
# this verb (it patches bp-catalyst-platform HR for the openova-catalog
# URL override) — chart 0.1.31's RBAC change was missed, so this bump
# ALSO closes a latent permission gap.
#
# totalSteps bumped from "9" → "10" in 09-cutover-status-configmap.yaml.
# Contract test (tests/cutover-contract.sh) asserts shift from 9 → 10
# step ConfigMaps and from 8 → 9 job-mode ConfigMaps. New Case 21
# verifies Step 10's wrapper + subchart patches are wired.
version: 0.1.36
description: |
Catalyst Self-Sovereignty Cutover Blueprint. Installs DORMANT — this
chart ships eight step ConfigMaps (PodSpec ConfigMaps, one per step),
@ -188,6 +235,14 @@ description: |
blocking tenant voucher checkout at journey step 16). Rolls
the provisioning Deployment so the new token takes effect
immediately. (TBD-C18)
10 cutover-step-10-vcluster-registry-pivot mode=job
Patch the bp-mgmt-vcluster / bp-rtz-vcluster / bp-dmz-vcluster
HelmReleases' image.repository from harbor.openova.io →
harbor.<SOVEREIGN_FQDN> so vCluster control-plane Pods pull
from the Sovereign-local Harbor mirror post-cutover. Phase-1
kubectl patch; Phase-2 git push to local Gitea so the
bootstrap-kit Kustomization doesn't revert the override.
(TBD-V24 MISS-1)
Plus:
self-sovereign-cutover-status ConfigMap

View File

@ -43,7 +43,7 @@ data:
cutoverFinishedAt: ""
currentStep: ""
currentStepIndex: "0"
totalSteps: "9"
totalSteps: "10"
progressPercent: "0"
failedStep: ""
lastError: ""
@ -83,3 +83,11 @@ data:
step.egress-block-test.finishedAt: ""
step.egress-block-test.result: ""
step.egress-block-test.jobName: ""
step.gitea-token-mint.startedAt: ""
step.gitea-token-mint.finishedAt: ""
step.gitea-token-mint.result: ""
step.gitea-token-mint.jobName: ""
step.vcluster-registry-pivot.startedAt: ""
step.vcluster-registry-pivot.finishedAt: ""
step.vcluster-registry-pivot.result: ""
step.vcluster-registry-pivot.jobName: ""

View File

@ -0,0 +1,436 @@
{{- /*
Step 10 — vcluster-registry-pivot (TBD-V24 MISS-1, issue #2034).
Pivots the THREE bp-*-vcluster HelmReleases' `image.repository` values
from the chart-default `harbor.openova.io/proxy-ghcr/loft-sh/vcluster`
to the Sovereign-local `harbor.<SOVEREIGN_FQDN>/proxy-ghcr/loft-sh/
vcluster`. Without this step the MGMT/RTZ/DMZ vCluster control-plane
Pods keep pulling from mothership Harbor indefinitely — blocking
Pillar 5 (Sovereign independence). The chart's own comment at
`platform/bp-mgmt-vcluster/chart/values.yaml:77-79` already promises
"post-handover, the per-Sovereign overlay rewrites to
`harbor.<sovereign-fqdn>/proxy-ghcr/...`" but until this step shipped
no such rewrite existed anywhere in the cutover sequence.
Why step 04 (containerd registries.yaml pivot) does NOT catch it
─────────────────────────────────────────────────────────────────
`registries.yaml.v2` (written by Step 04's DaemonSet) only registers
mirrors for the 7 canonical UPSTREAMS (ghcr.io, docker.io,
registry.k8s.io, gcr.io, quay.io, xpkg.upbound.io, public.ecr.aws). The
host `harbor.openova.io` is treated as a literal endpoint, NOT an
upstream — Step 04 does NOT add a mirror for it. So a Pod whose
imageRef starts with `harbor.openova.io/...` ALWAYS hits the literal
mothership Harbor regardless of containerd mirror config.
Why not just `helm template` the bp-*-vcluster charts with an overlay
─────────────────────────────────────────────────────────────────────
The bp-*-vcluster charts default to mothership-friendly values so
non-Sovereign installs (the openova-io mothership itself, dev clusters)
work without threading a Sovereign FQDN through values. Coupling the
chart to a per-Sovereign FQDN at template time would pollute every
non-Sovereign deployment. The cutover-step pattern keeps chart
defaults untouched and only rewrites POST-handover, when the
per-Sovereign FQDN is well-defined.
Patches applied (TWO knobs per HelmRelease)
───────────────────────────────────────────
The bp-mgmt-vcluster / bp-rtz-vcluster / bp-dmz-vcluster charts expose
the vCluster image at TWO levels:
1. `<role>Vcluster.image.repository` — the umbrella chart's image
reference (see `platform/bp-mgmt-vcluster/chart/values.yaml:81`,
bp-rtz-vcluster/...values.yaml:33, bp-dmz-vcluster/...:43)
2. `vcluster.controlPlane.statefulSet.image.{registry,repository}`
— the loft-sh upstream subchart's image reference (see
platform/bp-mgmt-vcluster/chart/values.yaml:129-130 et al.)
This Job patches BOTH on each of the 3 HelmReleases. Idempotent: a
re-run reads the current value and skips when already pivoted.
Phase inventory
───────────────
Phase 1 Live K8s patch — `kubectl patch helmrelease` against each
of {bp-mgmt-vcluster, bp-rtz-vcluster, bp-dmz-vcluster} in
flux-system, setting both image knobs to the Sovereign-local
Harbor host. Triggers an immediate Flux reconcile annotation
so helm-controller re-renders within seconds rather than
waiting for the 15m HR interval.
Phase 2 Push YAML edit to local Gitea (same pattern as Step 06
Phase 2): clone the bootstrap-kit repo, inject `image:`
overrides into clusters/_template/bootstrap-kit/{54,58,59}-
bp-*-vcluster.yaml under spec.values, commit + push. Without
this phase the bootstrap-kit Kustomization reconcile (every
~1 min from local Gitea) would silently revert the Phase 1
live patch back to the chart default within minutes.
Pod restart-safety / image-pull semantics
──────────────────────────────────────────
The actively-running vCluster Pods do NOT churn on a `helm upgrade` of
the wrapper chart unless the StatefulSet PodSpec changes. Changing
ONLY `image.repository` to a different host of the SAME image tag
DOES trigger a rolling update because the StatefulSet's
`spec.template.spec.containers[].image` field changes. Local Harbor's
proxy-ghcr project (created by Step 02) will serve the same upstream
image bytes via Harbor's proxy-cache semantics, so the new image-pull
either hits Harbor's existing cache (Step 03 prewarm covered this) or
falls through to ghcr.io once and then caches locally.
Order rationale: 10 (after Step 09)
────────────────────────────────────
Placed at order 10 so it runs LAST in the cutover sequence. This
follows the precedent set by Step 09 (gitea-token-mint, chart 0.1.30):
"avoid renumbering 01..09 which would invalidate operator history".
Functionally the step is order-independent for everything except:
- MUST run AFTER Step 02 (harbor-projects) so the local Harbor
proxy-ghcr project exists for the new image pulls.
- MUST run AFTER Step 06 (helmrepository-patches Phase-0) so the
ghcr-pull Secret carries auth for harbor.<sov-fqdn> — that auth is
what the vCluster Pods need to pull from the local Harbor mirror.
Image phase: post.
*/ -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: cutover-step-10-vcluster-registry-pivot
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-self-sovereign-cutover.labels" . | nindent 4 }}
app.kubernetes.io/component: cutover-step
bp.openova.io/cutover-order: "10"
bp.openova.io/cutover-mode: "job"
data:
stepName: vcluster-registry-pivot
podSpec: |
serviceAccountName: {{ include "bp-self-sovereign-cutover.serviceAccountName" . }}
restartPolicy: Never
activeDeadlineSeconds: {{ .Values.stepTimeouts.vclusterRegistryPivotSeconds }}
containers:
- name: vcluster-registry-pivot
# alpine/k8s ships both kubectl AND git so we can patch live
# HelmReleases AND push the YAML edit to local Gitea (same image
# Step 06 uses for the same reason).
# Fix #163 (2026-05-11, MIRROR-EVERYTHING): explicit
# harbor.openova.io/proxy-dockerhub prefix per CLAUDE.md
# inviolable rule — pre-cutover containerd already routes this
# via the proxy, so the Job can pull its own image during the
# cutover sequence.
image: harbor.openova.io/proxy-dockerhub/alpine/k8s:1.31.4
imagePullPolicy: IfNotPresent
env:
- name: HELMRELEASE_NAMESPACE
value: {{ .Values.vclusterRegistryPivot.helmReleaseNamespace | quote }}
- name: SOVEREIGN_FQDN
value: {{ .Values.sovereign.fqdn | quote }}
- name: GITEA_INTERNAL_URL
value: {{ .Values.sovereign.giteaInternalURL | quote }}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Values.gitea.adminSecretRef.name }}
key: {{ .Values.gitea.adminSecretRef.usernameKey }}
- name: GITEA_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.gitea.adminSecretRef.name }}
key: {{ .Values.gitea.adminSecretRef.passwordKey }}
- name: GITEA_ORG
value: {{ .Values.gitea.org | quote }}
- name: GITEA_REPO
value: {{ .Values.gitea.repo | quote }}
- name: IMAGE_TAG
value: {{ .Values.vclusterRegistryPivot.imageTag | quote }}
volumeMounts:
- name: tmp
mountPath: /tmp
command: ["/bin/sh", "-c"]
args:
- |
set -eu
# The Sovereign-local Harbor host. bp-*-vcluster charts'
# `image.repository` is HOST + PATH ("harbor.openova.io/
# proxy-ghcr/loft-sh/vcluster"), but the upstream loft-sh
# subchart splits it into `.image.registry` (HOST only) +
# `.image.repository` (PATH only). So we compute both.
target_host="harbor.${SOVEREIGN_FQDN}"
wrapper_repo="${target_host}/proxy-ghcr/loft-sh/vcluster"
sub_registry="${target_host}"
sub_repo_path="proxy-ghcr/loft-sh/vcluster"
echo "[vcluster-registry-pivot] target host: ${target_host}"
echo "[vcluster-registry-pivot] wrapper image.repository: ${wrapper_repo}"
echo "[vcluster-registry-pivot] subchart image.registry: ${sub_registry} repository: ${sub_repo_path}"
# ---- Phase 1: live K8s patch on each of the 3 bp-*-vcluster HelmReleases ----
#
# The bp-*-vcluster wrapper charts each expose:
# spec.values.<role>Vcluster.image.repository (umbrella)
# spec.values.vcluster.controlPlane.statefulSet.image.{registry,repository} (subchart)
# where <role> is one of {mgmt, rtz, dmz}. We merge-patch
# both knobs in a single kubectl invocation per HR so a
# partial-patch race can't leave one knob pivoted and the
# other not.
#
# Idempotency: kubectl patch --type=merge is a no-op when
# the requested value equals the current value, so re-runs
# don't churn the HR resourceVersion. We additionally read
# the current wrapper image.repository before patching and
# log a SKIP when it's already correct.
ok=0
skip=0
fail=0
patch_hr() {
# $1 = HR name (bp-mgmt-vcluster | bp-rtz-vcluster | bp-dmz-vcluster)
# $2 = role key (mgmtVcluster | rtzVcluster | dmzVcluster)
hr_name="$1"
role_key="$2"
# Skip-if-absent guard. On a primary region the MGMT and
# DMZ HRs exist; secondaries only have DMZ + RTZ. We
# don't want a missing HR to fail the cutover step on
# the wrong topology.
if ! kubectl get helmrelease "${hr_name}" -n "${HELMRELEASE_NAMESPACE}" >/dev/null 2>&1; then
echo "[vcluster-registry-pivot] SKIP ${hr_name} (not present in this region — topology-dependent, expected)"
skip=$((skip+1))
return 0
fi
cur_wrapper=$(kubectl get helmrelease "${hr_name}" -n "${HELMRELEASE_NAMESPACE}" \
-o jsonpath='{.spec.values.'"${role_key}"'.image.repository}' 2>/dev/null || echo "")
cur_sub_reg=$(kubectl get helmrelease "${hr_name}" -n "${HELMRELEASE_NAMESPACE}" \
-o jsonpath='{.spec.values.vcluster.controlPlane.statefulSet.image.registry}' 2>/dev/null || echo "")
if [ "${cur_wrapper}" = "${wrapper_repo}" ] && [ "${cur_sub_reg}" = "${sub_registry}" ]; then
echo "[vcluster-registry-pivot] SKIP ${hr_name} (already pivoted: wrapper=${cur_wrapper} sub-registry=${cur_sub_reg})"
skip=$((skip+1))
return 0
fi
# Build a single merge-patch that updates BOTH knobs.
# Using a heredoc + printf to keep the JSON readable.
patch_json=$(printf '{
"spec": {
"values": {
"%s": {
"image": {
"repository": "%s"
}
},
"vcluster": {
"controlPlane": {
"statefulSet": {
"image": {
"registry": "%s",
"repository": "%s"
}
}
}
}
}
}
}' "${role_key}" "${wrapper_repo}" "${sub_registry}" "${sub_repo_path}")
if kubectl patch helmrelease "${hr_name}" \
-n "${HELMRELEASE_NAMESPACE}" \
--type=merge --patch "${patch_json}" >/dev/null 2>&1; then
echo "[vcluster-registry-pivot] OK ${hr_name} -> wrapper=${wrapper_repo} sub-registry=${sub_registry}"
# Trigger immediate Flux reconcile so helm-controller
# re-renders the chart within seconds rather than
# waiting for the 15m HR interval.
kubectl annotate --overwrite helmrelease "${hr_name}" \
-n "${HELMRELEASE_NAMESPACE}" \
"reconcile.fluxcd.io/requestedAt=$(date +%s)" >/dev/null 2>&1 || true
ok=$((ok+1))
else
echo "[vcluster-registry-pivot] FAIL ${hr_name}" >&2
fail=$((fail+1))
fi
}
patch_hr "bp-mgmt-vcluster" "mgmtVcluster"
patch_hr "bp-rtz-vcluster" "rtzVcluster"
patch_hr "bp-dmz-vcluster" "dmzVcluster"
echo "[vcluster-registry-pivot] live-K8s ok=${ok} skip=${skip} fail=${fail}"
[ "${fail}" -eq 0 ] || exit 1
# ---- Phase 2: push YAML edit to local Gitea ----
#
# bootstrap-kit Kustomization reconciles HelmRelease YAML
# from local Gitea every ~1 min. Without this phase, Phase
# 1's live patch is silently reverted on the next reconcile
# because the YAML in Gitea still lacks the image.repository
# override under spec.values. We inject the override block
# (same shape used by other Sovereign-overlay knobs in
# those files like `nodeSelector` and the existing
# `${SOVEREIGN_REGION_CANONICAL_LABEL}` substitutions).
export HOME=/tmp
git config --global user.name "self-sovereign-cutover"
git config --global user.email "cutover@${SOVEREIGN_FQDN}"
git config --global advice.detachedHead false
gitea_host="$(printf '%s' "${GITEA_INTERNAL_URL}" | sed -E 's|^https?://||' | cut -d: -f1 | cut -d/ -f1)"
for i in $(seq 1 30); do
if nslookup "${gitea_host}" >/dev/null 2>&1; then break; fi
sleep 5
done
push_url=$(printf '%s' "${GITEA_INTERNAL_URL}" | sed -E "s,^(https?://),\1${GITEA_USERNAME}:${GITEA_PASSWORD}@,")"/${GITEA_ORG}/${GITEA_REPO}.git"
redacted=$(printf '%s' "${GITEA_INTERNAL_URL}/${GITEA_ORG}/${GITEA_REPO}.git")
echo "[vcluster-registry-pivot] cloning ${redacted}"
cd /tmp
rm -rf repo
git clone --depth 1 --branch main "${push_url}" repo >/dev/null 2>&1
cd repo
target_dir="clusters/_template/bootstrap-kit"
if [ ! -d "${target_dir}" ]; then
echo "[vcluster-registry-pivot] FATAL: ${target_dir} not present in local mirror" >&2
exit 1
fi
# The three vCluster HR YAMLs and their role keys.
# Format: filename:role_key
edited=0
for entry in \
"54-bp-dmz-vcluster.yaml:dmzVcluster" \
"58-bp-mgmt-vcluster.yaml:mgmtVcluster" \
"59-bp-rtz-vcluster.yaml:rtzVcluster" \
; do
file="${entry%%:*}"
role_key="${entry##*:}"
path="${target_dir}/${file}"
if [ ! -f "${path}" ]; then
echo "[vcluster-registry-pivot] SKIP ${path} (not present)"
continue
fi
# Has the override block already been injected (re-run)?
# We look for our sentinel comment + the wrapper-repo URL
# under the role_key block.
if grep -q "# ─── vCluster image registry pivot (TBD-V24 MISS-1) ──" "${path}"; then
# Idempotency: rewrite the URL value in case the
# SOVEREIGN_FQDN changed between runs (rare but
# possible when the operator renames the Sovereign).
# We replace the line beginning with ` repository:`
# under the role_key block.
if sed -i -E "/^[[:space:]]{4}${role_key}:[[:space:]]*$/,/^[[:space:]]{0,4}[a-zA-Z]/{s,^([[:space:]]{8}repository:[[:space:]]*).*$,\1${wrapper_repo}," "${path}" 2>/dev/null; then
edited=$((edited+1))
echo "[vcluster-registry-pivot] rewrote existing image override in ${path}"
else
echo "[vcluster-registry-pivot] WARN: sed rewrite failed for ${path}; Phase-1 K8s patch remains durable for one upgrade cycle"
fi
continue
fi
# No existing override — inject the block under the
# `values:` key. The role-key block exists at 4-space
# indent under spec.values (every chart >= 0.1.0 ships
# this shape; the role keys are `mgmtVcluster`,
# `rtzVcluster`, `dmzVcluster`).
if grep -qE "^[[:space:]]{4}${role_key}:[[:space:]]*$" "${path}"; then
awk -v role="${role_key}" -v repo="${wrapper_repo}" -v reg="${sub_registry}" -v rpath="${sub_repo_path}" -v tag="${IMAGE_TAG}" '
$0 ~ "^[[:space:]]{4}"role":[[:space:]]*$" && !done {
print
print " # ─── vCluster image registry pivot (TBD-V24 MISS-1) ──"
print " # Injected by self-sovereign-cutover step-10 from"
print " # harbor.openova.io → harbor.<SOVEREIGN_FQDN> so the"
print " # vCluster control-plane Pods pull from the Sovereign-local"
print " # Harbor mirror, NOT mothership Harbor. Required by"
print " # Principle #11 (no tether to harbor.openova.io after"
print " # handover). Without this override the chart default at"
print " # platform/bp-*-vcluster/chart/values.yaml resolves back"
print " # to harbor.openova.io and the bootstrap-kit Kustomization"
print " # reconcile reverts step-10s live K8s patch."
print " image:"
print " repository: " repo
done = 1
next
}
{ print }
' "${path}" > "${path}.new" && mv "${path}.new" "${path}"
edited=$((edited+1))
echo "[vcluster-registry-pivot] injected wrapper image override into ${path}"
else
echo "[vcluster-registry-pivot] WARN: ${path} has no ${role_key}: anchor — Phase-1 K8s patch remains durable for one upgrade cycle"
continue
fi
# ALSO inject (or replace) the subchart image block —
# the loft-sh upstream image. The file already has a
# `vcluster:` block at 4-space indent (every HR YAML
# has it for nodeSelector pinning). We append the
# subchart image under controlPlane.statefulSet, OR if
# the block already exists, replace the registry value.
if grep -qE "^[[:space:]]{8}statefulSet:[[:space:]]*$" "${path}"; then
if grep -qE "^[[:space:]]{10}image:[[:space:]]*$" "${path}"; then
# Existing image block under statefulSet — replace
# registry + repository lines at 12-space indent
# using a bounded sed range so we don't touch other
# image: blocks in the same file.
sed -i -E "/^[[:space:]]{10}image:[[:space:]]*$/,/^[[:space:]]{0,10}[a-zA-Z]/{s,^([[:space:]]{12}registry:[[:space:]]*).*$,\1${sub_registry},; s,^([[:space:]]{12}repository:[[:space:]]*).*$,\1${sub_repo_path},}" "${path}" 2>/dev/null || true
echo "[vcluster-registry-pivot] rewrote subchart image registry/repository in ${path}"
else
# Inject a new image block under statefulSet at
# 10-space indent. statefulSet appears once; we add
# `image:` as a sibling of `scheduling:`.
awk -v reg="${sub_registry}" -v rpath="${sub_repo_path}" -v tag="${IMAGE_TAG}" '
/^[[:space:]]{8}statefulSet:[[:space:]]*$/ && !done {
print
print " image:"
print " registry: " reg
print " repository: " rpath
done = 1
next
}
{ print }
' "${path}" > "${path}.new" && mv "${path}.new" "${path}"
echo "[vcluster-registry-pivot] injected subchart image block into ${path}"
fi
fi
done
echo "[vcluster-registry-pivot] sed edited ${edited} files"
if [ "${edited}" -eq 0 ]; then
echo "[vcluster-registry-pivot] no edits required — already pivoted in Gitea or files absent"
# Phase 1 already succeeded; nothing to push.
exit 0
fi
git add "${target_dir}"
if git diff --staged --quiet; then
echo "[vcluster-registry-pivot] git diff empty after sed — nothing to commit"
exit 0
fi
git commit -m "cutover: pivot vCluster image.repository to harbor.${SOVEREIGN_FQDN}" >/dev/null
push_err=$(git push origin main 2>&1) || {
echo "[vcluster-registry-pivot] FATAL: git push failed" >&2
printf '%s\n' "$push_err" >&2
exit 1
}
echo "[vcluster-registry-pivot] pushed to ${redacted}"
# Trigger an immediate Flux reconciliation so the new YAML
# lands without waiting for the polling interval.
kubectl annotate --overwrite gitrepository openova \
-n flux-system \
"reconcile.fluxcd.io/requestedAt=$(date +%s)" >/dev/null || true
echo "[vcluster-registry-pivot] step complete"
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { memory: 384Mi }
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: tmp
emptyDir: {}

View File

@ -106,6 +106,9 @@ rules:
- apiGroups: ["source.toolkit.fluxcd.io"]
resources: ["gitrepositories", "helmrepositories"]
verbs: ["update", "patch"] # steps 05 + 06
- apiGroups: ["helm.toolkit.fluxcd.io"]
resources: ["helmreleases"]
verbs: ["update", "patch"] # step 06 phase-1.6 (catalog override) + step 10 vcluster-registry-pivot (TBD-V24 MISS-1)
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["delete", "patch", "update"] # step 08 removes the policy at end-of-test

View File

@ -14,8 +14,9 @@
# 3. Each step ConfigMap MUST carry data keys:
# stepName (always)
# podSpec (mode=job only)
# 4. EXACTLY 9 step ConfigMaps must render (steps 1..9; step 9
# gitea-token-mint added in chart 0.1.30, TBD-C18).
# 4. EXACTLY 10 step ConfigMaps must render (steps 1..10; step 9
# gitea-token-mint added in chart 0.1.30, TBD-C18; step 10
# vcluster-registry-pivot added in chart 0.1.35, TBD-V24 MISS-1).
# 5. Step 04 must be mode=daemonset-wait.
# 6. The status ConfigMap (default name self-sovereign-cutover-status)
# MUST render with helm.sh/resource-policy: keep so a chart
@ -43,15 +44,14 @@ echo "[cutover-contract] Case 1: chart renders with default values"
helm template smoke . > "$TMP/render.yaml"
echo " PASS ($(wc -l < "$TMP/render.yaml") lines)"
echo "[cutover-contract] Case 2 + 4: exactly 9 step ConfigMaps render with required labels"
echo "[cutover-contract] Case 2 + 4: exactly 10 step ConfigMaps render with required labels"
# Use yq if present (the CI runner installs it for the blueprint-release
# guards); fall back to grep counting on workstations without yq.
# Step 9 (gitea-token-mint) added in chart 0.1.30 (TBD-C18) to bootstrap
# the Gitea API token the SME provisioning service uses; without it,
# tenant voucher checkout fails at journey step 16 because the
# catalyst-platform chart mirrors the Gitea admin PASSWORD verbatim
# into sme/provisioning-github-token, which 401s when sent as a Bearer
# token.
# Step 9 (gitea-token-mint) added in chart 0.1.30 (TBD-C18); step 10
# (vcluster-registry-pivot) added in chart 0.1.35 (TBD-V24 MISS-1,
# issue #2034) to pivot the bp-*-vcluster HelmReleases' image.repository
# from harbor.openova.io → harbor.<SOVEREIGN_FQDN> so vCluster Pods
# pull from the Sovereign-local Harbor mirror post-cutover.
if command -v yq >/dev/null 2>&1; then
# yq emits `---` separators between matched docs; filter those out
# before counting names. `grep -E '^cutover-step-'` matches only the
@ -62,28 +62,28 @@ else
# — count distinct order values, which equals step count.
step_count=$(grep -c 'bp.openova.io/cutover-order:' "$TMP/render.yaml")
fi
if [ "${step_count}" -ne 9 ]; then
echo "FAIL: expected 9 step ConfigMaps, got ${step_count}" >&2
if [ "${step_count}" -ne 10 ]; then
echo "FAIL: expected 10 step ConfigMaps, got ${step_count}" >&2
exit 1
fi
echo " PASS (9 step ConfigMaps)"
echo " PASS (10 step ConfigMaps)"
echo "[cutover-contract] Case 3: required data keys present"
# stepName key must exist on every step ConfigMap (9 total).
# podSpec key must exist on every job-mode step (8 of 9 — step 04 is daemonset-wait).
# stepName key must exist on every step ConfigMap (10 total).
# podSpec key must exist on every job-mode step (9 of 10 — step 04 is daemonset-wait).
mode_job_count=$(grep -c 'bp.openova.io/cutover-mode: "job"' "$TMP/render.yaml")
if [ "${mode_job_count}" -ne 8 ]; then
echo "FAIL: expected 8 job-mode step ConfigMaps, got ${mode_job_count}" >&2
if [ "${mode_job_count}" -ne 9 ]; then
echo "FAIL: expected 9 job-mode step ConfigMaps, got ${mode_job_count}" >&2
exit 1
fi
podspec_keys=$(grep -c '^ podSpec: |' "$TMP/render.yaml")
if [ "${podspec_keys}" -lt 8 ]; then
echo "FAIL: expected at least 8 podSpec keys (one per job-mode step), got ${podspec_keys}" >&2
if [ "${podspec_keys}" -lt 9 ]; then
echo "FAIL: expected at least 9 podSpec keys (one per job-mode step), got ${podspec_keys}" >&2
exit 1
fi
stepname_keys=$(grep -c '^ stepName:' "$TMP/render.yaml")
if [ "${stepname_keys}" -lt 9 ]; then
echo "FAIL: expected at least 9 stepName keys, got ${stepname_keys}" >&2
if [ "${stepname_keys}" -lt 10 ]; then
echo "FAIL: expected at least 10 stepName keys, got ${stepname_keys}" >&2
exit 1
fi
echo " PASS (data keys present on every step)"
@ -467,4 +467,68 @@ if ! grep -B0 -A2 'apiGroups: \["gateway.networking.k8s.io"\]' "$TMP/render.yaml
fi
echo " PASS (Step-06 Phase -1 gateway-wait + RBAC wired)"
echo "[cutover-contract] Case 21: Step-10 vcluster-registry-pivot patches bp-*-vcluster HelmReleases (TBD-V24 MISS-1)"
# Chart <0.1.35 shipped NO vCluster image-registry pivot. The chart's
# own comment at platform/bp-mgmt-vcluster/chart/values.yaml:77-79
# promised "post-handover, the per-Sovereign overlay rewrites to
# `harbor.<sovereign-fqdn>/proxy-ghcr/...`" but the rewrite step
# never existed. Result: MGMT/RTZ/DMZ vCluster control-plane Pods
# kept pulling from harbor.openova.io indefinitely post-handover,
# violating Principle #11 (no tether to harbor.openova.io after
# handover). Caught by the TBD-V24 tether audit 2026-05-20.
#
# Chart 0.1.35 adds Step-10 that:
# - kubectl patches each of {bp-mgmt-vcluster, bp-rtz-vcluster,
# bp-dmz-vcluster} HelmReleases with image.repository pointing at
# harbor.${SOVEREIGN_FQDN}/proxy-ghcr/loft-sh/vcluster
# - ALSO patches the upstream subchart's
# vcluster.controlPlane.statefulSet.image.{registry,repository}
# - git push edits clusters/_template/bootstrap-kit/{54,58,59}-bp-*-
# vcluster.yaml to local Gitea so the override survives reconciles
# - Idempotent on re-run (skip-if-already-pivoted + sentinel-comment
# guard on YAML injection)
#
# Guard against future regressions that drop the step.
if ! grep -q 'cutover-step-10-vcluster-registry-pivot' "$TMP/render.yaml"; then
echo "FAIL: Step-10 vcluster-registry-pivot ConfigMap missing (TBD-V24 MISS-1)" >&2
exit 1
fi
if ! grep -A20 'cutover-step-10-vcluster-registry-pivot' "$TMP/render.yaml" | grep -q 'bp.openova.io/cutover-order: "10"'; then
echo "FAIL: Step-10 not labelled bp.openova.io/cutover-order=10 (TBD-V24 MISS-1)" >&2
exit 1
fi
# All 3 vCluster HelmRelease names must be referenced in the patch script.
for hr in bp-mgmt-vcluster bp-rtz-vcluster bp-dmz-vcluster; do
if ! grep -q "patch_hr \"${hr}\"" "$TMP/render.yaml"; then
echo "FAIL: Step-10 missing patch invocation for ${hr} (TBD-V24 MISS-1)" >&2
exit 1
fi
done
# All 3 role keys must be wired (umbrella chart values key).
for role in mgmtVcluster rtzVcluster dmzVcluster; do
if ! grep -q "\"${role}\"" "$TMP/render.yaml"; then
echo "FAIL: Step-10 missing role-key wiring for ${role} (TBD-V24 MISS-1)" >&2
exit 1
fi
done
# Subchart pivot — must patch vcluster.controlPlane.statefulSet.image too.
if ! grep -q 'controlPlane.statefulSet.image' "$TMP/render.yaml"; then
echo "FAIL: Step-10 missing subchart image pivot (TBD-V24 MISS-1)" >&2
exit 1
fi
# Phase-2 git push to local Gitea — without this, Phase-1 patches get
# reverted by bootstrap-kit Kustomization reconcile within ~1 min.
if ! grep -B2 -A8 'cutover-step-10-vcluster-registry-pivot' "$TMP/render.yaml" >/dev/null; then
echo "FAIL: Step-10 ConfigMap region not located for Phase-2 git push check" >&2
exit 1
fi
# RBAC: ClusterRole must permit patch on helmreleases (needed by Step-10
# AND by Step-06 Phase-1.6, which was silently relying on this verb).
if ! awk '/^kind: ClusterRole$/,/^---$/' "$TMP/render.yaml" \
| grep -B1 -A2 '"helmreleases"' | grep -E 'verbs:.*"patch"|verbs:.*"update"' >/dev/null; then
echo "FAIL: ClusterRole missing helm.toolkit.fluxcd.io.helmreleases [update|patch] verb (TBD-V24 MISS-1)" >&2
exit 1
fi
echo " PASS (Step-10 wired to pivot vCluster HRs to local Harbor)"
echo "[cutover-contract] All gates green."

View File

@ -425,6 +425,34 @@ stepTimeouts:
# 600s leaves generous headroom for the provisioning Deployment
# rollout (which can take a few minutes on a cold-start cluster).
giteaTokenMintSeconds: 600
# Step 10 (vcluster-registry-pivot, TBD-V24 MISS-1) — three HR patches
# + one git clone/push to local Gitea. Bounded by the Gitea push
# round-trip; 600s is generous (typical run completes in <60s).
vclusterRegistryPivotSeconds: 600
# Step 10 (vcluster-registry-pivot, TBD-V24 MISS-1, issue #2034) — pivot
# the three bp-*-vcluster HelmReleases' image.repository from
# `harbor.openova.io/proxy-ghcr/loft-sh/vcluster` to
# `harbor.<SOVEREIGN_FQDN>/proxy-ghcr/loft-sh/vcluster` so the
# MGMT/RTZ/DMZ vCluster control-plane Pods pull from the Sovereign-local
# Harbor mirror post-cutover. Without this step the vCluster image-pull
# remains tethered to mothership Harbor indefinitely — a Pillar-5
# (Sovereign independence) violation. See 10-vcluster-registry-pivot-job.yaml
# for the full protocol.
vclusterRegistryPivot:
# Namespace where the 3 bp-*-vcluster HelmReleases live. The
# bootstrap-kit's slots 54/58/59 all install into flux-system, so the
# default here matches the canonical layout. Per-Sovereign overlay
# can change this if a future topology splits the HRs elsewhere.
helmReleaseNamespace: "flux-system"
# vCluster image tag — must match the tag pinned in each bp-*-vcluster
# chart's values.yaml (currently 0.20.0 across all three). This is
# NOT used by the merge-patch directly (the chart's existing
# image.tag value is preserved untouched); it's threaded through
# only for the YAML-edit comment so the injected block is
# self-documenting. Override via per-Sovereign overlay if you've
# pinned a different upstream vCluster version.
imageTag: "0.20.0"
# Step 09 (gitea-token-mint, TBD-C18) — bootstrap the Gitea API token
# that the SME provisioning service uses for tenant repo materialisation.