openova/platform/external-secrets
e3mrah cf35b4a9b6
fix(ci): blueprint.yaml spec.version lockstep in auto-bump (Closes #1856) (#1858)
A17 (#1855) hot-patched 6 drifted blueprints (cilium, cert-manager, flux,
openbao, keycloak, gitea) where blueprint.yaml spec.version had silently
fallen behind chart/Chart.yaml version, breaking
TestBootstrapKit_BlueprintCardsHaveRequiredFields. The structural root
cause: the TBD-A6 auto-bump hook in blueprint-release.yaml updated only
clusters/_template/bootstrap-kit/<N>-<chart>.yaml pins on every chart
publish — never the upstream platform/<bp>/blueprint.yaml.

This PR extends the auto-bump hook to lockstep platform/<bp>/blueprint.yaml
spec.version whenever Chart.yaml version bumps. Both file edits land in
the SAME commit (subject becomes `deploy(<chart>): bump bootstrap-kit pin
X -> Y (auto, Refs TBD-A6)` with a secondary line noting the blueprint
lockstep). Idempotent reset-and-rewrite retry preserved for the existing
parallel-matrix race case.

Workflow changes (.github/workflows/blueprint-release.yaml):
  * New step `bump_blueprint` after `bump_pin` — locates
    ${matrix.path}/blueprint.yaml OR ${matrix.path}/chart/blueprint.yaml
    (handles both platform-leaf and products-umbrella conventions),
    filters to kind:Blueprint (defensive against CRD yaml at the
    products/catalyst/chart/crds path), reads current spec.version at
    2-space indent, sed-rewrites to CHART_VERSION, verifies post-write.
  * Commit step renamed to "Commit + push bootstrap-kit pin bump +
    blueprint.yaml lockstep"; stages both files, single commit, with
    convergent retry on conflict.
  * Summary block surfaces both bumps separately.

Regression test (tests/e2e/bootstrap-kit/main_test.go):
  * New TestBootstrapKit_BlueprintVersionLockstepSweep — walks
    platform/* and products/*, discovers every Blueprint manifest with
    a sibling Chart.yaml, asserts spec.version == Chart.yaml version.
    Covers ALL ~70 blueprints, not just the canonical 10 kit ones the
    existing TestBootstrapKit_BlueprintCardsHaveRequiredFields gates.
  * Failure messages name the file, drift direction, and the exact sed
    command to fix — drift remediation is mechanical.

Drift cleanup (mandatory companion, same shape as A17/#1855):
  26 Application-Blueprint blueprints whose spec.version had been left
  at 1.0.0 / 0.1.0 while Chart.yaml moved forward — synced down to
  Chart.yaml as authoritative. All currently surface in the new sweep
  test; without the cleanup the test would block this PR (and every
  subsequent one). Affected: alloy, cert-manager-{dynadot,powerdns}-webhook,
  cluster-autoscaler-hcloud, cnpg, crossplane-claims, external-secrets[-stores],
  falco, grafana, guacamole, harbor, hcloud-csi, k8s-ws-proxy, mimir,
  netbird, newapi, openclaw, powerdns, seaweedfs, self-sovereign-cutover,
  trivy, valkey, velero, vpa, products/dmz-vcluster.

After this lands, the next chart-version bump in any platform/<bp>/ folder
auto-converges all three artifacts (Chart.yaml, blueprint.yaml,
bootstrap-kit pin) in a single bot commit. No more manual collector PRs;
no more silent drift between chart and Blueprint manifest.

Closes #1856.
Refs #1855 (A17 hot-patch this replaces structurally), #1713 (original TBD-A6 auto-bump hook).

Co-authored-by: hatiyildiz <hatiyildiz@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:04:22 +04:00
..
chart feat(platform): add global.imageRegistry to bp-openbao/external-secrets/cnpg/valkey/nats-jetstream/powerdns/gitea (PR 2/3, #560) (#565) 2026-05-02 12:52:43 +04:00
blueprint.yaml fix(ci): blueprint.yaml spec.version lockstep in auto-bump (Closes #1856) (#1858) 2026-05-19 01:04:22 +04:00
README.md docs(pass-35): completion sweep for surviving DNS placeholders (8 components) 2026-04-27 22:46:16 +02:00

External Secrets Operator

ESO bridges OpenBao (the Catalyst secret backend) and per-Pod K8s Secrets. Per-host-cluster infrastructure (see docs/PLATFORM-TECH-STACK.md §3.3).

Status: Accepted | Updated: 2026-04-27


Overview

External Secrets Operator (ESO) provides the Kubernetes-native interface for secrets management. Kubernetes Secrets are the source of truth, pushed to external backends.

Critical: SOPS is completely eliminated. No secrets in Git, ever.

flowchart LR
    subgraph K8s["Kubernetes"]
        Gen[ESO Generators]
        KS[K8s Secrets]
        PS[PushSecrets]
        ES[ExternalSecrets]
    end

    subgraph OpenBao["Secrets Backend"]
        VL[OpenBao]
    end

    Gen -->|"generate"| KS
    KS -->|"source"| PS
    PS -->|"push"| VL
    VL -->|"pull"| ES
    ES -->|"sync"| KS

See also: platform/openbao/README.md for the secret-backend architecture (independent Raft per region, async perf replication, single-primary writes — see also docs/SECURITY.md §5).


Key Principles

Principle Implementation
No secrets in Git SOPS eliminated, interactive bootstrap
OpenBao is source of truth Secrets live in OpenBao; K8s Secrets are materialized projections
Pull-locally, write-to-primary ExternalSecret reads from local OpenBao replica; PushSecret writes to the primary region
Multi-region reads Async perf replication propagates writes from primary → replicas
Auto-generation ESO Generators create complex secrets directly into OpenBao

ESO Components

Component Purpose
ExternalSecret Pulls secrets from OpenBao into K8s
PushSecret Pushes K8s Secrets to OpenBao instance(s)
ClusterSecretStore Connection to secrets backend
Generators Auto-generate passwords, UUIDs, tokens

Bootstrap Secrets Flow

sequenceDiagram
    participant Wizard as Catalyst Bootstrap (Phase 0)
    participant TF as OpenTofu
    participant OpenBao as OpenBao
    participant ESO as ESO

    Wizard->>TF: Enter cloud credentials (Catalyst bootstrap, Phase 0)
    TF->>TF: Create terraform.tfvars (local only)
    TF->>OpenBao: Provision & initialize
    OpenBao->>Wizard: Return unseal keys
    Note over Wizard: sovereign-admin saves unseal keys offline
    ESO->>OpenBao: Connect via SPIFFE SVID (workload identity)

Configuration

ClusterSecretStore (OpenBao)

apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault-region1
spec:
  provider:
    vault:
      server: "https://openbao.<location-code>.<sovereign-domain>"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

ExternalSecret Template

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: <service>-secrets
  namespace: <org>-prod
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-region1
    kind: ClusterSecretStore
  target:
    name: <service>-secrets
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: <org>/postgres
        property: url
    - secretKey: API_KEY
      remoteRef:
        key: <org>/api-keys
        property: main

PushSecret to the primary OpenBao

Writes go to the primary region only — replicas refuse writes (perf replication is one-way primary→standby).

apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
  name: push-db-credentials
  namespace: databases
spec:
  secretStoreRefs:
    - name: bao-primary             # writes hit the primary region only
      kind: ClusterSecretStore
  selector:
    secret:
      name: db-credentials
  data:
    - match:
        secretKey: password
        remoteRef:
          remoteKey: databases/db-credentials
          property: password

OpenBao's async Performance Replication propagates the new value to all replicas within ~1s. Each region's ExternalSecret then materializes the new K8s Secret locally.


ESO Generators

ESO Generators create complex secrets automatically, eliminating manual password creation.

Password Generator

apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
  name: db-password-generator
  namespace: databases
spec:
  length: 32
  digits: 6
  symbols: 4
  noUpper: false
  allowRepeat: true
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-password
  namespace: databases
spec:
  refreshInterval: "0"  # Generate once, never refresh
  target:
    name: db-credentials
    creationPolicy: Owner
  dataFrom:
    - sourceRef:
        generatorRef:
          apiVersion: generators.external-secrets.io/v1alpha1
          kind: Password
          name: db-password-generator

Available Generator Types

Generator Use Case
Password Database passwords, API keys
UUID Unique identifiers
ECRAuthorizationToken AWS ECR tokens
GCRAccessToken GCP GCR tokens
ACRAccessToken Azure ACR tokens

Gitea Token Management

Gitea access tokens for Flux are managed via ESO, following the same patterns as all other secrets.

Bootstrap Creates Gitea Token

apiVersion: v1
kind: Secret
metadata:
  name: gitea-token
  namespace: flux-system
type: Opaque
data:
  username: Zm... # base64 encoded username
  password: Z2l... # base64 encoded Gitea access token

Flux Uses Token

apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
  name: component
  namespace: flux-system
spec:
  url: https://gitea.<location-code>.<sovereign-domain>/<org>/component.git
  secretRef:
    name: gitea-token  # ESO-managed

Managed Secrets

Secret Purpose Created By
gitea-token Flux access to Gitea Bootstrap
cloudflare-credentials ExternalDNS Bootstrap
hetzner-credentials Cloud provider Bootstrap
openbao-unseal-keys OpenBao auto-unseal Displayed once
db-credentials Database passwords ESO Generator

Secret Types

Secret Layer Storage Rotation
Cloud credentials Bootstrap Interactive (never stored) On compromise
SSH keys Bootstrap Interactive (never stored) On compromise
OpenBao unseal keys Bootstrap Offline backup On compromise
Database passwords K8s ESO + OpenBao 90 days
API keys K8s ESO + OpenBao On compromise
JWT signing keys K8s ESO + OpenBao 30 days
TLS certificates K8s cert-manager Auto
Gitea tokens K8s ESO + OpenBao 90 days

Why No SOPS?

SOPS Approach PushSecrets Approach
Secrets encrypted in Git No secrets in Git
Manual age key management OpenBao handles encryption
Decrypt before apply K8s Secret is source
Risk of leaked decrypted files Secrets never on disk

Decision: Interactive bootstrap is simpler and more secure than SOPS.


Critical Backup

The ONLY manual backup required:

  • OpenBao unseal keys - Displayed once during bootstrap
  • Backup: Password manager + physical copy

Warning: Losing unseal keys makes OpenBao secrets unrecoverable.


Migration from SOPS

If migrating from SOPS-based setup:

  1. Create K8s Secrets from decrypted SOPS files
  2. Create PushSecrets to sync to OpenBao
  3. Verify secrets in OpenBao
  4. Delete SOPS-encrypted files from Git
  5. Delete local decrypted files

Consequences

Positive:

  • No secrets in Git (eliminates leak risk)
  • Auto-generation of complex secrets via ESO Generators
  • Cross-region availability via OpenBao Performance Replication (replicas serve reads with sub-10ms latency in same continent)
  • Backend-agnostic (swap without app changes)
  • Gitea tokens managed consistently with all other secrets

Negative:

  • Requires bootstrap for initial secrets
  • ESO operator dependency
  • OpenBao/backend operational overhead

Part of OpenOva