{{- /*
Step 06 — helmrepository-patches.

Three phases run in this Job:

  Phase 0  Merge harbor.<sov-fqdn> auth into the flux-system/ghcr-pull
           dockerconfigjson Secret so source-controller can pull from the
           per-Sovereign Harbor mirror once the URLs are pivoted in
           Phase 1. Without this, every HelmRepository pivoted to
           oci://harbor.<sov-fqdn>/openova-io 401s on the very first
           reconcile because ghcr-pull only carries auth for
           ghcr.io / harbor.openova.io. Caught manually on omantel
           2026-05-10 (session 5c468708) — bp-guacamole / bp-netbird /
           bp-dmz-vcluster all stuck Reconciling until a manual
           `kubectl patch secret ghcr-pull` was run. Idempotent: if
           harbor.<sov-fqdn> already maps to an auth entry the script
           no-ops. (#1184)
  Phase 1  Live K8s patches: for each HelmRepository in
           .Values.helmRepositories.names, patches spec.url from
           oci://ghcr.io/openova-io/<name> to
           oci://harbor.<sov-fqdn>/openova-io/<name>.
  Phase 2  Push the YAML edit to local Gitea so the bootstrap-kit
           Kustomization reconcile doesn't revert Phase 1 within ~1m.

Image phase: post.
*/ -}}
apiVersion: v1
kind: ConfigMap
metadata:
  name: cutover-step-06-helmrepository-patches
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "bp-self-sovereign-cutover.labels" . | nindent 4 }}
    app.kubernetes.io/component: cutover-step
    bp.openova.io/cutover-order: "6"
    bp.openova.io/cutover-mode: "job"
data:
  stepName: helmrepository-patches
  helmrepository-names.txt: |
    {{- range .Values.helmRepositories.names }}
    {{ . }}
    {{- end }}
  podSpec: |
    serviceAccountName: {{ include "bp-self-sovereign-cutover.serviceAccountName" . }}
    restartPolicy: Never
    activeDeadlineSeconds: {{ .Values.stepTimeouts.helmRepositoryPatchesSeconds }}
    containers:
      - name: helmrepository-patches
        # alpine/k8s ships kubectl + git so we can both patch the live
        # K8s object AND push the YAML edit to local Gitea (#970).
        # Fix #163 (2026-05-11, MIRROR-EVERYTHING): explicit
        # harbor.openova.io/proxy-dockerhub prefix per CLAUDE.md
        # inviolable rule.
        image: harbor.openova.io/proxy-dockerhub/alpine/k8s:1.31.4
        imagePullPolicy: IfNotPresent
        env:
          - name: HELMREPO_NAMESPACE
            value: {{ .Values.helmRepositories.namespace | quote }}
          - name: UPSTREAM_PREFIX
            value: {{ .Values.helmRepositories.upstreamPrefix | quote }}
          - name: HARBOR_PUBLIC_URL
            value: {{ .Values.sovereign.harborPublicURL | 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 }}
          # Phase 0 (#1184) — harbor.<sov-fqdn> auth merge into ghcr-pull.
          # HARBOR_PASSWORD comes from the Reflector-mirrored
          # harbor-admin Secret in this namespace (bp-harbor 1.2.14+
          # emits it in the harbor ns with allowed-namespaces=catalyst).
          - name: HARBOR_USERNAME
            valueFrom:
              secretKeyRef:
                name: {{ .Values.harbor.adminSecretRef.name }}
                key: {{ .Values.harbor.adminSecretRef.usernameKey }}
                optional: true
          - name: HARBOR_PASSWORD
            valueFrom:
              secretKeyRef:
                name: {{ .Values.harbor.adminSecretRef.name }}
                key: {{ .Values.harbor.adminSecretRef.passwordKey }}
          - name: GHCR_PULL_SECRET_NAME
            value: {{ .Values.harbor.ghcrSecretRef.name | quote }}
          - name: GHCR_PULL_SECRET_NAMESPACE
            value: {{ .Values.harbor.ghcrSecretRef.namespace | quote }}
          - name: GHCR_PULL_SECRET_KEY
            value: {{ .Values.harbor.ghcrSecretRef.dockerConfigKey | quote }}
        volumeMounts:
          - name: helmrepository-list
            mountPath: /work
            readOnly: true
          - name: tmp
            mountPath: /tmp
        command: ["/bin/sh", "-c"]
        args:
          - |
            set -eu
            harbor_host=$(printf '%s' "${HARBOR_PUBLIC_URL}" | sed -E 's,^https?://,,; s,/$,,')
            local_prefix="oci://${harbor_host}/openova-io"

            echo "[helmrepository-patches] upstream=${UPSTREAM_PREFIX} -> local=${local_prefix}"

            # ---- Phase 0: merge harbor.<sov-fqdn> auth into ghcr-pull (#1184) ----
            #
            # source-controller pulls Helm OCI charts from
            # oci://harbor.<sov-fqdn>/openova-io using the imagePullSecret
            # named on each HelmRepository (`secretRef.name: ghcr-pull`,
            # see clusters/_template/bootstrap-kit/*.yaml). The ghcr-pull
            # Secret ships from cloud-init carrying ghcr.io +
            # harbor.openova.io auth only — it has NO entry for
            # harbor.<sov-fqdn>. Once Phase-1 below pivots HelmRepository
            # URLs, every reconcile against the local Harbor 401s.
            #
            # Manual fix done on omantel 2026-05-10 (session 5c468708):
            #   HARBOR_AUTH=$(echo -n "admin:${HADMIN_PW}" | base64 -w0)
            #   NEW=$(jq --arg auth "$HARBOR_AUTH" \
            #           '.auths["harbor.omantel.biz"] = {
            #              "username":"admin","auth":$auth }' <existing>)
            #   kubectl patch secret ghcr-pull -n flux-system \
            #     --type=merge -p "{\"data\":{...}}"
            #
            # Phase 0 codifies that patch so the next fresh `tofu apply`
            # comes up GREEN without operator intervention.
            : "${HARBOR_USERNAME:=admin}"
            if [ -z "${HARBOR_PASSWORD:-}" ]; then
              echo "[helmrepository-patches] FATAL: HARBOR_PASSWORD env empty — cannot derive harbor.${harbor_host} auth" >&2
              exit 1
            fi

            # Read the dockerconfigjson via `-o json | jq` (NOT kubectl
            # jsonpath bracket-form). kubectl jsonpath interprets the
            # leading dot in `.dockerconfigjson` as a child accessor
            # INSIDE the bracket, returning empty even though the key
            # exists. Fix #77 (Gap A) — caught live on omantel prov #7
            # 2026-05-10: Job logged
            # `FATAL: ghcr-pull Secret has no .dockerconfigjson key`
            # despite `kubectl get secret ... -o yaml` clearly showing
            # the key present. Confirmed reproducer:
            #   kubectl get secret ghcr-pull -n flux-system \
            #     -o "jsonpath={.data['.dockerconfigjson']}"   → empty
            #   kubectl get secret ghcr-pull -n flux-system \
            #     -o "jsonpath={.data['\.dockerconfigjson']}"  → b64
            # Using `-o json | jq` avoids the escape ambiguity entirely
            # and works regardless of the literal key name.
            #
            # Wait-loop (up to 60s) handles the case where Reflector has
            # not yet propagated the Secret into ${GHCR_PULL_SECRET_NAMESPACE}
            # when this Job stamps. Falls back to the source namespace
            # (flux-system, where cloud-init writes the original) if the
            # local copy is still missing after the timeout.
            read_secret_key() {
              # $1 = namespace
              # Echoes base64 value of GHCR_PULL_SECRET_KEY or empty.
              kubectl get secret "${GHCR_PULL_SECRET_NAME}" -n "$1" -o json 2>/dev/null \
                | jq -r --arg k "${GHCR_PULL_SECRET_KEY}" '.data[$k] // empty'
            }
            existing_b64=""
            target_ns="${GHCR_PULL_SECRET_NAMESPACE}"
            for i in $(seq 1 12); do
              existing_b64=$(read_secret_key "${target_ns}")
              if [ -n "${existing_b64}" ]; then
                break
              fi
              echo "[helmrepository-patches] Phase-0: ${target_ns}/${GHCR_PULL_SECRET_NAME} key ${GHCR_PULL_SECRET_KEY} not yet readable (attempt ${i}/12) — sleeping 5s"
              sleep 5
            done
            if [ -z "${existing_b64}" ] && [ "${target_ns}" != "flux-system" ]; then
              # Fallback: read from the source ns (flux-system) where
              # cloud-init wrote the original. Reflector copies into
              # ${GHCR_PULL_SECRET_NAMESPACE} but the source is always
              # readable by the runner ClusterRole.
              echo "[helmrepository-patches] Phase-0: falling back to flux-system/${GHCR_PULL_SECRET_NAME} (Reflector lag suspected)"
              target_ns="flux-system"
              existing_b64=$(read_secret_key "${target_ns}")
            fi
            if [ -z "${existing_b64}" ]; then
              echo "[helmrepository-patches] FATAL: ${GHCR_PULL_SECRET_NAME} Secret has no ${GHCR_PULL_SECRET_KEY} key in ${GHCR_PULL_SECRET_NAMESPACE} or flux-system after 60s — cloud-init did not run?" >&2
              exit 1
            fi

            existing_json=$(printf '%s' "${existing_b64}" | base64 -d)

            # Idempotency guard: if harbor.<sov-fqdn> already maps to an
            # auth entry, no-op. Re-runs of step-06 (catalyst-api may
            # retry) MUST NOT churn the Secret resourceVersion every
            # time — that triggers a noisy reflector cascade.
            already=$(printf '%s' "${existing_json}" | jq -r --arg h "${harbor_host}" \
              '.auths[$h].auth // empty')
            harbor_auth=$(printf '%s:%s' "${HARBOR_USERNAME}" "${HARBOR_PASSWORD}" | base64 -w0)
            if [ -n "${already}" ] && [ "${already}" = "${harbor_auth}" ]; then
              echo "[helmrepository-patches] Phase-0 skip: ghcr-pull already carries auth for ${harbor_host}"
            else
              new_json=$(printf '%s' "${existing_json}" | jq --arg h "${harbor_host}" \
                --arg user "${HARBOR_USERNAME}" --arg auth "${harbor_auth}" \
                '.auths[$h] = {"username":$user,"auth":$auth}')
              new_b64=$(printf '%s' "${new_json}" | base64 -w0)
              # Merge-patch the data field. Quoting note: ${GHCR_PULL_SECRET_KEY}
              # is `.dockerconfigjson` which is a literal key (the leading
              # dot is part of the field name); JSON merge-patch treats it
              # as a single key, no path traversal.
              patch_payload=$(printf '{"data":{"%s":"%s"}}' "${GHCR_PULL_SECRET_KEY}" "${new_b64}")
              if kubectl patch secret "${GHCR_PULL_SECRET_NAME}" \
                  -n "${GHCR_PULL_SECRET_NAMESPACE}" \
                  --type=merge --patch "${patch_payload}" >/dev/null; then
                echo "[helmrepository-patches] Phase-0 OK: merged ${harbor_host} auth into ${GHCR_PULL_SECRET_NAMESPACE}/${GHCR_PULL_SECRET_NAME}"
              else
                echo "[helmrepository-patches] FATAL: kubectl patch ${GHCR_PULL_SECRET_NAME} failed" >&2
                exit 1
              fi
              # Trigger an immediate refresh of every HelmRepository so
              # source-controller re-loads the Secret on next pull.
              # Phase 1 below only annotates HRs in HELMREPO_NAMESPACE
              # whose URL it pivots — but the auth merge benefits every
              # HR pulling from harbor.<sov-fqdn>, so kick all of them.
              now_ts=$(date +%s)
              kubectl get helmrepositories -A \
                -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' \
                2>/dev/null | while read -r ns name; do
                [ -z "${ns}" ] && continue
                kubectl annotate --overwrite -n "${ns}" "helmrepository/${name}" \
                  "reconcile.fluxcd.io/requestedAt=${now_ts}" >/dev/null 2>&1 || true
              done || true
            fi

            # ---- Phase 1: live K8s patches (immediate) ----
            ok=0
            skip=0
            fail=0
            while IFS= read -r line; do
              name=$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
              [ -z "${name}" ] && continue

              cur_url=$(kubectl get helmrepository "${name}" \
                -n "${HELMREPO_NAMESPACE}" \
                -o jsonpath='{.spec.url}' 2>/dev/null || echo "")

              if [ -z "${cur_url}" ]; then
                echo "[helmrepository-patches] SKIP ${name} (not found)"
                skip=$((skip+1))
                continue
              fi

              new_url=$(printf '%s' "${cur_url}" | sed -e "s,${UPSTREAM_PREFIX},${local_prefix},")
              if [ "${new_url}" = "${cur_url}" ]; then
                echo "[helmrepository-patches] SKIP ${name} (already pivoted or non-default URL)"
                skip=$((skip+1))
                continue
              fi

              patch=$(printf '{"spec":{"url":"%s"}}' "${new_url}")
              if kubectl patch helmrepository "${name}" \
                  -n "${HELMREPO_NAMESPACE}" \
                  --type=merge --patch "${patch}" >/dev/null; then
                kubectl annotate --overwrite helmrepository "${name}" \
                  -n "${HELMREPO_NAMESPACE}" \
                  "reconcile.fluxcd.io/requestedAt=$(date +%s)" >/dev/null
                echo "[helmrepository-patches] OK ${name} -> ${new_url}"
                ok=$((ok+1))
              else
                echo "[helmrepository-patches] FAIL ${name}"
                fail=$((fail+1))
              fi
            done < /work/helmrepository-names.txt

            echo "[helmrepository-patches] live-K8s ok=${ok} skip=${skip} fail=${fail}"
            [ "${fail}" -eq 0 ] || exit 1

            # ---- Phase 2: push YAML edit to local Gitea (#970) ----
            #
            # The kubectl patches above flip the live HelmRepository
            # objects. But the bootstrap-kit Kustomization reconciles
            # YAML from the Sovereign's local Gitea every minute, and
            # those YAML files still declare `url: oci://ghcr.io/openova-io`.
            # Without this phase, Flux reverts every patch within ~1m,
            # and Step-08's verify catches the regression as "OFFENDER".
            #
            # Fix: clone the local Gitea, sed-rewrite every
            # clusters/_template/bootstrap-kit/*.yaml that declares the
            # upstream URL, commit, push. Subsequent reconciles pick up
            # the local Harbor URL as the steady-state.
            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-http is headless (#968); wait for DNS just in case.
            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

            # URL with embedded basic auth — credential goes to git via
            # URL only, never echoed to stdout.
            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")

            # ---- Phase 1.5: convert pull-mirror to standalone (#1028) ----
            #
            # Step-01 created openova/openova as a Gitea pull-mirror so the
            # Sovereign's local Gitea tracks upstream during early
            # bootstrap. After cutover the Sovereign is self-hosted and
            # MUST diverge from upstream — but Gitea blocks pushes to a
            # mirror with HTTP 403 "remote: mirror repository is read-only".
            # PATCH the repo to flip mirror=false BEFORE git push so
            # subsequent push commands write to the now-standalone repo.
            #
            # Without this conversion, Step-06's git push (added in #970)
            # always failed on 403, blocking handover. Caught on otech125
            # 2026-05-05.
            api="${GITEA_INTERNAL_URL%/}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
            demirror_code=$(curl -sS -o /tmp/.demirror.json -w '%{http_code}' \
              -u "${GITEA_USERNAME}:${GITEA_PASSWORD}" \
              -X PATCH \
              -H 'Content-Type: application/json' \
              -d '{"mirror": false, "mirror_interval": "0"}' \
              "${api}")
            if [ "${demirror_code}" != "200" ]; then
              echo "[helmrepository-patches] FATAL: demirror PATCH returned HTTP ${demirror_code}" >&2
              cat /tmp/.demirror.json >&2 || true
              exit 1
            fi
            echo "[helmrepository-patches] demirror PATCH OK — repo is now standalone"

            echo "[helmrepository-patches] cloning ${redacted}"
            cd /tmp
            rm -rf repo
            git clone --depth 1 --branch main "${push_url}" repo >/dev/null 2>&1
            cd repo

            # Find every HelmRepository YAML that declares the upstream
            # URL under clusters/_template/bootstrap-kit/. Rewrite to
            # the local Harbor prefix in-place.
            target_dir="clusters/_template/bootstrap-kit"
            if [ ! -d "${target_dir}" ]; then
              echo "[helmrepository-patches] FATAL: ${target_dir} not present in local mirror" >&2
              exit 1
            fi
            edited=0
            for f in $(grep -lE "^[[:space:]]*url:[[:space:]]*${UPSTREAM_PREFIX}[[:space:]]*$" "${target_dir}"/*.yaml 2>/dev/null || true); do
              sed -i -E "s,^([[:space:]]*url:[[:space:]]*)${UPSTREAM_PREFIX}([[:space:]]*)$,\1${local_prefix}\2," "${f}"
              edited=$((edited+1))
              echo "[helmrepository-patches] edited ${f}"
            done
            echo "[helmrepository-patches] sed edited ${edited} files"

            if [ "${edited}" -eq 0 ]; then
              echo "[helmrepository-patches] no edits — already pivoted in Gitea or upstream prefix not present"
              # Don't fail; phase-1 already succeeded.
              exit 0
            fi

            git add "${target_dir}"
            if git diff --staged --quiet; then
              echo "[helmrepository-patches] git diff empty after sed — nothing to commit"
              exit 0
            fi
            git commit -m "cutover: pivot ${edited} HelmRepository URLs to local Harbor" >/dev/null
            push_err=$(git push origin main 2>&1) || {
              echo "[helmrepository-patches] FATAL: git push failed" >&2
              # Surface the actual git stderr so operators can diagnose
              # auth / branch-protection / hook rejections without re-
              # running the Job. The output is git's own stderr — does
              # NOT contain the embedded credential (those live in the
              # remote URL only and git redacts them in error messages).
              printf '%s\n' "$push_err" >&2
              exit 1
            }
            echo "[helmrepository-patches] pushed to ${redacted} (commit will reconcile via bootstrap-kit Kustomization)"

            # 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 "[helmrepository-patches] 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: helmrepository-list
        configMap:
          name: cutover-step-06-helmrepository-patches
          items:
            - key: helmrepository-names.txt
              path: helmrepository-names.txt
      - name: tmp
        emptyDir: {}
