* fix(bp-flux-stuck-hr-recovery): detect+correct deployed-but-unknown-Ready HRs (Refs #1989) t37 canonical walk on nbg1-2 / hel1-1 secondary CPs surfaced a second stuck-HR failure mode: helm-controller completes the install — the HR's own `.status.history[0].status` flips to "deployed" — but apiserver flap on the slow secondary CP loses the write that flips `.status.conditions[type=Ready]` from Unknown to True. The existing suspend-toggle recovery (issue #925) does NOT fix this because helm- controller's "release in storage" short-circuit returns yes on every subsequent reconcile, so it never re-evaluates Ready. This PR extends the stuckHelmReleaseRecovery CronJob with a second detection branch: for hr where .status.conditions[type=Ready].status == "Unknown" AND age(Unknown) > stuckThreshold (default 5m) AND .status.history[0].status == "deployed" AND metadata.annotations["stuck-hr-recovery.openova.io/auto-corrected-at"] == "" → kubectl annotate hr stuck-hr-recovery.openova.io/auto-corrected-at=<RFC3339> → kubectl patch hr --subresource=status --type=merge status.conditions=[{type:Ready, status:True, reason:ReconciliationSucceeded, message:"auto-corrected from deployed-but- unknown-Ready by stuck-hr-recovery (TBD-A66)", lastTransitionTime:<RFC3339>}] Safety / idempotency: - Annotation acts as both audit trail AND idempotency guard. Re-runs on an already-corrected HR skip immediately. - If the status patch fails, the annotation is rolled back so the next CronJob run re-attempts. - Guardrail unchanged: >10 acted-on HRs in a single run → exit 1 + operator alert. - The 10-HR guardrail spans BOTH branches combined. RBAC additions: - helmreleases/status with verbs [patch, update] — status subresource is a separate RBAC target in Kubernetes. Without this rule `kubectl patch --subresource=status` returns 403. Validation: - tests/leader-election-and-recovery.sh: 6 → 7 cases (existing 6 issue #925 cases still PASS; new Case 7 covers TBD-A66 — script contains history[0].status check, status-subresource patch verb, audit annotation key, helmreleases/status ClusterRole verb, and operator-greppable "auto-corrected from deployed-but-unknown-Ready" audit string). - Mock JSONPath replay against 4 synthetic HRs: branch B routes deployed-but-unknown to status patch, branch A still handles pending-install via the secret check, idempotency annotation correctly skips re-run, healthy Ready=True HR is no-op. Chart bump: - platform/flux/chart/Chart.yaml: 1.2.2 → 1.2.3 - clusters/_template/bootstrap-kit/03-flux.yaml: bp-flux HR pin 1.2.2 → 1.2.3 (the existing pin for omantel/otech live clusters sits at 1.1.3 — unchanged, those clusters are pre-#925 baseline). Closure note: - Refs #1989 (not Closes — closure happens when the t37 canonical walk reaches handover successfully on a fresh prov). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bp-flux): bump blueprint.yaml spec.version 1.2.2 → 1.2.3 (lockstep with Chart.yaml) Companion to TBD-A66 / #1989 bump. CI gate `TestBootstrapKit_BlueprintVersionLockstepSweep` (TBD-A20, #1856) asserts blueprint.yaml spec.version == chart/Chart.yaml version per platform/*. Missed this in the parent commit because the older bp-flux bumps (1.2.1 → 1.2.2 etc.) did not require this companion bump back when the lockstep gate didn't exist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: claude-bot <claude-bot@openova.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| chart | ||
| blueprint.yaml | ||
| README.md | ||
Flux
GitOps delivery engine. Per-host-cluster infrastructure (see docs/PLATFORM-TECH-STACK.md §3.2). Catalyst runs one Flux instance per vcluster (lightweight: source + kustomize + helm controllers) plus a host-level Flux on each host cluster for the Catalyst control plane itself. Each per-vcluster Flux pulls from the single per-Sovereign Gitea instance (see docs/ARCHITECTURE.md §4).
Status: Accepted | Updated: 2026-04-27
Overview
Flux provides GitOps-based continuous delivery with Gitea as the internal Git provider and External Secrets Operator for secrets management.
Architecture
flowchart TB
subgraph Gitea["Gitea (Internal Git)"]
Components[Component Repos]
Organization[Organization Repos]
end
subgraph K8s["Kubernetes Cluster"]
subgraph Flux["Flux Controllers"]
Source[source-controller]
Kustomize[kustomize-controller]
Helm[helm-controller]
Notify[notification-controller]
end
subgraph ESO["Secrets"]
ESOp[External Secrets Operator]
PS[PushSecrets]
end
Resources[K8s Resources]
end
subgraph External["External"]
OpenBao[OpenBao]
end
Components --> Source
Organization --> Source
Source --> Kustomize
Source --> Helm
Kustomize --> Resources
Helm --> Resources
Notify --> Gitea
ESOp --> OpenBao
ESOp --> Resources
Why Flux
| Factor | Flux | ArgoCD |
|---|---|---|
| Memory overhead | ~200MB | ~500-800MB |
| Architecture | Kubernetes-native CRDs | Separate UI/API |
| Secrets | Via ESO (PushSecrets) | Via ESO (PushSecrets) |
| CLI workflow | Excellent | UI-focused |
Key Decision Factors:
- Lower resource overhead
- CLI-focused fits single-developer workflow
- Kubernetes-native CRDs
- Works well with External Secrets Operator
- Integrates seamlessly with Gitea
Components
| Controller | Memory | Purpose |
|---|---|---|
| source-controller | 64MB | Git/Helm repo sync |
| kustomize-controller | 64MB | Kustomization apply |
| helm-controller | 64MB | HelmRelease management |
| notification-controller | 32MB | Alerts |
Repository Structure
flux/
├── clusters/
│ └── <region>/
│ ├── flux-system/ # Flux controllers
│ ├── network/ # cilium, stunner, powerdns
│ ├── security/ # kyverno, external-secrets, cert-manager
│ ├── database/ # cnpg, ferretdb, valkey
│ ├── middleware/ # strimzi
│ ├── storage/ # seaweedfs, velero
│ ├── observability/ # grafana (LGTM stack)
│ ├── autoscaling/ # keda
│ ├── workplace/ # stalwart
│ └── organizations/ # per-Organization Application deployments
Categories
| Category | Components | Purpose |
|---|---|---|
| network | cilium, stunner, powerdns | CNI + Service Mesh, TURN, authoritative DNS + lua-records GSLB |
| security | kyverno, external-secrets, cert-manager | Policy, secrets, TLS |
| database | cnpg, ferretdb, valkey | Database operators |
| middleware | strimzi | Apache Kafka streaming |
| storage | seaweedfs, velero | Object storage, backup |
| observability | grafana | LGTM stack |
| autoscaling | keda | Event-driven scaling |
| workplace | stalwart | Email server |
| organizations | <org> |
Per-Organization Application deployments |
Configuration
GitRepository for Gitea
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: <component>
namespace: flux-system
spec:
interval: 5m
url: https://gitea.<location-code>.<sovereign-domain>/<org>/<component>.git
ref:
branch: main
secretRef:
name: gitea-token
Kustomization
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: <component>
namespace: flux-system
spec:
interval: 10m
targetNamespace: <namespace>
sourceRef:
kind: GitRepository
name: <component>
path: ./deploy/prod
prune: true
wait: true
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: <component>
namespace: <namespace>
Multi-Region GitOps
Catalyst runs one Gitea per Sovereign on the management cluster (see docs/PLATFORM-TECH-STACK.md §2.3). Each workload region's Flux pulls from that single Gitea over the cross-region network. Per-region HA comes from Gitea's intra-cluster replicas + CNPG-backed metadata, not cross-region bidirectional mirror.
flowchart TB
subgraph Mgt["Management cluster (per Sovereign)"]
Gitea[Gitea — single instance, K8s-replicated for HA]
end
subgraph Region1["Workload region 1 (rtz)"]
Flux1[Per-vcluster Flux]
K8s1[Org vcluster workloads]
end
subgraph Region2["Workload region 2 (rtz)"]
Flux2[Per-vcluster Flux]
K8s2[Org vcluster workloads]
end
Gitea --> Flux1
Gitea --> Flux2
Flux1 --> K8s1
Flux2 --> K8s2
- One Gitea per Sovereign (on the mgt cluster). HA via intra-cluster replicas, not cross-region mirror.
- Each vcluster runs its own lightweight Flux that pulls from this single Gitea.
- Per-region configuration is encoded in the Environment's manifests via Kustomize overlays / Placement metadata.
Secrets Management
Flux uses External Secrets Operator (ESO) with PushSecrets pattern:
- No SOPS: SOPS has been eliminated from the architecture
- PushSecrets: 100% PushSecrets pattern for multi-region
- K8s Secrets as source of truth: Apps read from K8s Secrets only
Release Management
Release Flow
Feature Branch → PR → main → Flux Sync → Staging → Promote → Production
↓
Canary Analysis (Flagger)
↓
Auto-Rollback (on failure)
Environment Strategy
| Environment | Sync Interval |
|---|---|
| Staging | 1m |
| Production | 10m |
Rollback Strategy
| Trigger | Action |
|---|---|
| Flagger metric failure | Auto-rollback |
| Manual intervention | Git revert + sync |
| Emergency | kubectl rollout undo |
Bootstrap
# Initial cluster bootstrap (Gitea)
flux bootstrap git \
--url=https://gitea.<location-code>.<sovereign-domain>/openova/flux \
--branch=main \
--path=clusters/<region> \
--token-auth
Key Commands
# Status overview
flux get all
# Force reconciliation
flux reconcile kustomization organizations
# View logs
flux logs --all-namespaces
# Suspend (manual gate)
flux suspend kustomization <org>-prod
Sync Intervals
| Resource | Interval |
|---|---|
| GitRepository | 1 minute |
| Kustomization | 10 minutes |
| HelmRelease | 10 minutes |
Gitea Actions Integration
Gitea Actions can trigger Flux reconciliation:
# .gitea/workflows/notify-flux.yaml
name: Notify Flux
on:
push:
branches: [main]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Trigger Flux reconciliation
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.FLUX_WEBHOOK_TOKEN }}" \
https://flux-webhook.<location-code>.<sovereign-domain>/hook/...
Part of OpenOva