Skip to content

stuttgart-things/sops-secrets-operator

Repository files navigation

sops-secrets-operator

A Kubernetes operator that syncs SOPS-encrypted secrets from Git into Kubernetes Secret resources — without depending on Flux, Argo, or any other GitOps stack.

Contributing? See CONTRIBUTING.md for dev-setup (envtest + Kind e2e), CI mapping, and the conventional-commits rule.

Why another SOPS operator?

isindir/sops-secrets-operator is the reference implementation in this space, but it deliberately does not pull from Git — it relies on Flux or Argo (or kubectl apply) to deliver encrypted CRs into the cluster. That works if you already run one of those stacks.

This operator bundles the Git-sync into the operator itself, so the workflow is:

  1. Encrypt a file with vanilla sops and push it to a git repo (or upload it to an HTTPS / S3 endpoint).
  2. Apply a GitRepository or ObjectSource + a SopsSecret / SopsSecretManifest CR.
  3. The operator decrypts and produces a target Secret.

No delivery tool required.

Architecture

Five CRDs across two API versions in the sops.stuttgart-things.com group. SopsSecret and SopsSecretManifest store as v1alpha2; GitRepository, InlineSopsSecret still store as v1alpha1; ObjectSource is v1alpha2-only.

┌──────────────────┐
│  GitRepository   │◄────┐
│  (source + auth) │     │      ┌──────────────────────┐      ┌──────────────┐
└──────────────────┘     ├──────│  SopsSecret          │─────►│  Secret      │
                         │      │  (flat-map → Secret) │      │              │
┌──────────────────┐     │      └──────────────────────┘      └──────────────┘
│  ObjectSource    │◄────┤
│  (HTTPS / S3)    │     │      ┌──────────────────────┐      ┌──────────────┐
└──────────────────┘     └──────│  SopsSecretManifest  │─────►│  Secret      │
                                │  (passthrough)       │      │              │
                                └──────────────────────┘      └──────────────┘

                                ┌──────────────────────┐      ┌──────────────┐
                                │  InlineSopsSecret    │─────►│  Secret      │
                                │  (inline payload)    │      │              │
                                └──────────────────────┘      └──────────────┘

Consumers reference a source via spec.source.sourceRef:

source:
  sourceRef:
    kind: GitRepository   # or ObjectSource
    name: platform-secrets
  path: prod/app/creds.enc.yaml
  • GitRepository — connection to a Git repo: URL, branch or pinned revision, poll interval, and either HTTP basic or SSH auth.
  • ObjectSource — connection to an HTTPS URL or S3-compatible bucket (MinIO / Ceph / R2 / AWS S3). HTTPS mode caches the object via ETag / If-None-Match; bucket mode validates reachability + auth and fetches per-key on read. Auth: bearer, basic (HTTPS); s3 (access keys, bucket); none for public.
  • SopsSecretmapping mode: source file is a SOPS-encrypted flat key/value YAML. spec.data[] explicitly picks source keys and renames them into target Secret data keys. Unknown keys in the file are dropped; missing declared keys fail-closed.
  • SopsSecretManifestpass-through mode: source file is a SOPS-encrypted kind: Secret manifest. The decrypted manifest is applied directly, but namespace is overridden authoritatively by the CR.
  • InlineSopsSecretno source: the SOPS-encrypted payload lives inside the CR (spec.encryptedYAML). Same Mapping / Manifest semantics via spec.mode. Access control is RBAC on the CR itself — anyone who can create inlinesopssecrets in a namespace can decrypt anything the operator has keys for.

Both source-backed CRDs share their source's cache entry, so one source fed to many secrets is one clone (git) or one cached body (HTTPS) on disk.

Quick start

1. Install

# CRDs + RBAC + deployment
kubectl apply -k https://github.com/stuttgart-things/sops-secrets-operator/config/default?ref=main

The deployment runs in namespace sops-secrets-operator-system with a service account scoped to the CRDs plus Secret read/write.

2. Generate an age keypair

age-keygen -o age.agekey
# public key is printed; also stored as a comment inside age.agekey

3. Encrypt a secrets file

Create prod/app/creds.enc.yaml with plaintext:

database_url: postgres://app@db:5432/app
database_password: s3cret
api_token: xyz

Encrypt in place:

sops --age age1yourPublicKeyHere --encrypt --in-place prod/app/creds.enc.yaml

Commit and push to your git repo.

4. Apply the CRs

kubectl create namespace apps

# Git auth token (PAT for HTTPS, or SSH key — see samples)
kubectl -n apps create secret generic git-auth \
  --from-literal=username=git \
  --from-literal=password=ghp_yourToken

# Age decryption key
kubectl -n apps create secret generic sops-age-key \
  --from-file=age.agekey=age.agekey

# GitRepository
cat <<EOF | kubectl apply -f -
apiVersion: sops.stuttgart-things.com/v1alpha1
kind: GitRepository
metadata:
  name: platform-secrets
  namespace: apps
spec:
  url: https://github.com/your-org/secrets.git
  branch: main          # or pin a commit/tag with `revision: <sha>` (overrides branch)
  interval: 5m
  auth:
    type: basic
    secretRef:
      name: git-auth
EOF

# SopsSecret — maps three keys out of the decrypted file
cat <<EOF | kubectl apply -f -
apiVersion: sops.stuttgart-things.com/v1alpha2
kind: SopsSecret
metadata:
  name: app-creds
  namespace: apps
spec:
  source:
    sourceRef:
      kind: GitRepository
      name: platform-secrets
    path: prod/app/creds.enc.yaml
  decryption:
    keyRef:
      name: sops-age-key
      key: age.agekey
  data:
    - key: DATABASE_URL
      from: database_url
    - key: DATABASE_PASSWORD
      from: database_password
    - key: API_TOKEN
      from: api_token
EOF

To back the same SopsSecret with an HTTPS ObjectSource instead of a git repo, swap kind: GitRepository for kind: ObjectSource and point name at an ObjectSource CR — see config/samples/sops_v1alpha2_objectsource.yaml.

5. Verify

kubectl -n apps get gitrepository,sopssecret
kubectl -n apps get secret app-creds -o yaml

Both CRs should show Applied=True in their status conditions, and the target Secret should carry the three declared keys plus operator-owned labels and annotations (managed-by, owner, content-hash, source-commit).

Using both CRDs

SopsSecret — flat key/value mapping

Use when your SOPS file is a flat key/value YAML and you want explicit control over what ends up in the target Secret (renaming, filtering).

  • Source must be flat (no nested maps or lists).
  • spec.data[] declares every target key; unknown source keys are dropped.
  • Missing declared source keys fail the reconcile (fail-closed).
  • Changing a data[] entry between reconciles removes the old key and adds the new one.

SopsSecretManifest — pass-through k8s Secret

Use when your SOPS file is an encrypted kind: Secret manifest (typically with --encrypted-regex '^(data|stringData)$').

  • Strict whitelist on top-level and metadata fields (see SECURITY.md).
  • Namespace override is authoritative — the CR decides where the Secret lands, not the decrypted file.
  • target.nameOverride optionally replaces metadata.name from the manifest.

InlineSopsSecret — inline payload, no git

Use when you want to apply encrypted secrets without a git repo: ad-hoc/one-off secrets, air-gapped or GitOps-free clusters, or when RBAC on the CR is your delivery control.

  • spec.mode: Mapping — decrypted YAML is flat key/value, same contract as SopsSecret; spec.data[] required.
  • spec.mode: Manifest — decrypted YAML is a kind: Secret, same whitelist and namespace-authoritative rules as SopsSecretManifest.
  • spec.encryptedYAML holds the literal output of sops --encrypt. The SOPS MAC is validated, so preserve the string byte-for-byte.
  • Caveats: encrypted blob lives in etcd (CR size ~1 MB); anyone with create inlinesopssecrets in a namespace can decrypt anything the operator has keys for. See SECURITY.md.

Operator observability

The manager exposes Prometheus metrics at /metrics:

Metric Type Labels
sops_reconcile_total counter kind, result
sops_reconcile_errors_total counter kind, stage (auth / fetch / decrypt / apply)
sops_reconcile_duration_seconds histogram kind, result
sops_git_fetch_duration_seconds histogram result
sops_object_fetch_duration_seconds histogram result
sops_decrypt_duration_seconds histogram result

Each CR has status conditions you can watch with kubectl get -o jsonpath='{.status.conditions}':

  • GitRepository / ObjectSource: AuthResolved, SourceReady
  • SopsSecret / SopsSecretManifest: SourceReady, Decrypted, Applied
  • InlineSopsSecret: Decrypted, Applied

OpenTelemetry tracing (optional)

Set the standard OTLP environment variables on the manager Pod and reconciles will emit one trace per CR with auth / fetch / decrypt / apply child spans:

env:
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-collector.observability:4317
  - name: OTEL_EXPORTER_OTLP_PROTOCOL
    value: grpc
  - name: OTEL_SERVICE_NAME
    value: sops-secrets-operator

Without these vars set, a no-op tracer provider is installed and there is no overhead. Spans carry CR identifiers, sourceRef, the observed commit/ETag, and a content-hash fingerprint — never plaintext data or key material. See SECURITY.md for the full attribute list.

Samples

Runnable examples in config/samples/:

Force-sync

Both source CRs and consumer CRs honor an annotation that bypasses the cached state on the next reconcile:

kubectl annotate gitrepository platform-secrets \
  sops.stuttgart-things.com/reconcile-requested="$(date +%s)" --overwrite

The reconciler compares the annotation value to status.lastProcessedReconcileToken and runs the full pipeline when they differ. On a source CR (GitRepository / ObjectSource), this drops the local cache and re-fetches upstream regardless of commit/ETag — useful when a git push has just landed and you don't want to wait for the next poll. On a consumer CR (SopsSecret / SopsSecretManifest / InlineSopsSecret), it just records the honored token: the consumer pipeline is already idempotent and re-reads the cached source content on every reconcile, so to force a fresh upstream fetch annotate the source.

Adoption, drift, and managed Secrets

Adopting a pre-existing Secret

By default the operator refuses to overwrite a Secret it doesn't already own. A reconcile against an un-owned Secret fails with:

target Secret apps/app-creds exists but is not managed by this operator; set target.adopt=true to take over

To migrate an existing Secret under operator management, set target.adopt: true on the consumer CR (works on SopsSecret, SopsSecretManifest, and InlineSopsSecret):

spec:
  target:
    name: app-creds      # optional; defaults to the CR name
    adopt: true
  # ...

On the next reconcile the operator stamps its labels/annotations onto the existing Secret and replaces its data with the source-derived data. Adoption is opt-in per CR — there is no global override.

Drift detection

Every reconcile compares the source-derived content hash against sops.stuttgart-things.com/content-hash on the live Secret. If they differ — e.g. someone ran kubectl edit secret app-creds — the operator re-applies. Out-of-band edits do not survive the next reconcile; the only way to "freeze" data is to delete the owning CR (the finalizer cleans up the Secret too).

Owned-Secret labels and annotations

Every Secret produced by the operator carries:

Key Kind Meaning
sops.stuttgart-things.com/managed-by label always sops-secrets-operator — used by adoption checks and kubectl get -l
sops.stuttgart-things.com/owner annotation <kind>/<namespace>/<name> of the owning CR
sops.stuttgart-things.com/owner-uid annotation UID of the owning CR — detects ownership transfer across CR kinds
sops.stuttgart-things.com/content-hash annotation SHA-256 of the applied data; powers drift detection
sops.stuttgart-things.com/source-commit annotation git commit (or ETag, for ObjectSource) the data was derived from

List everything the operator currently manages in the cluster:

kubectl get secret -A -l sops.stuttgart-things.com/managed-by=sops-secrets-operator

v1alpha1 backward compatibility

SopsSecret and SopsSecretManifest switched their stored shape between v1alpha1 (source.repositoryRef.name) and v1alpha2 (source.sourceRef.{kind,name}). The CRDs declare spec.conversion.strategy: Webhook so v1alpha1 manifests still apply through the operator's auto-registered /convert handler.

The default install (kubectl apply -k config/default) wires this end-to-end via cert-manager: an Issuer + Certificate in config/certmanager/ issues the webhook serving cert into webhook-server-cert, and kustomize replacements stamp the cert-manager.io/inject-ca-from annotation onto both CRDs so the apiserver picks up the CA bundle. cert-manager must be installed in the cluster before applying the bundle (kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml).

If you cannot run cert-manager, either provision the webhook cert externally and patch the CRDs' spec.conversion.webhook.clientConfig.caBundle yourself, or stick to v1alpha2 manifests (which match the storage version and never go through /convert).

Security model

See SECURITY.md for the full threat model. Highlights:

  • Namespace override on SopsSecretManifest is authoritative (no git-controlled namespace escape).
  • SSH auth requires knownHosts — no insecure-skip option.
  • Adoption of pre-existing un-owned Secrets is opt-in (target.adopt: true).
  • SOPS MAC verification is preserved (integrity check on encrypted files is not disabled).

Development

Requires Go 1.26+, kubebuilder, and kubectl.

make generate manifests   # regenerate CRD YAML + zz_generated
make build                # build the manager binary
make test                 # run unit + envtest (fetches envtest binaries)
make run                  # run the manager against the current kubecontext

Dev deployment to an existing cluster

For testing against a remote cluster (e.g. a shared k3s management cluster) instead of kubectl apply -k …, deploy a pinned release image with kustomize:

# Point at the target cluster
export KUBECONFIG=/path/to/your/kubeconfig

# Apply CRDs, RBAC, controller Deployment, webhook Service, and cert-manager Issuer/Certificate
make deploy IMG=ghcr.io/stuttgart-things/sops-secrets-operator:v0.7.2

# Verify rollout
kubectl -n sops-secrets-operator-system rollout status deploy/sops-secrets-operator-controller-manager
kubectl -n sops-secrets-operator-system get pods

Tear down with make undeploy (controller + webhook) and make uninstall (CRDs).

Cluster prerequisites

  • cert-manager must be installed cluster-wide. The default kustomize overlay (config/default) wires a self-signed Issuer + Certificate to serve the conversion webhook for SopsSecret / SopsSecretManifest (v1alpha1 ↔ v1beta1).
  • The node(s) must be able to pull from ghcr.io. The published image is public.
  • Ensure no other controller is already managing the sops.stuttgart-things.com CRDs in the cluster.

Configuration after deploy

The controller is deployed without any age decryption key — apply yours after the rollout. Generate or reuse an age key, then create a Secret in the same namespace as the CRs that will consume it (the operator looks up decryption.keyRef in the CR's own namespace):

kubectl -n <crs-namespace> create secret generic sops-age \
  --from-file=age.agekey=/path/to/age.agekey

Reference it from each SopsSecret / SopsSecretManifest / InlineSopsSecret via:

spec:
  decryption:
    keyRef:
      name: sops-age
      key: age.agekey

See Quick start for the full end-to-end flow (key, encryption, GitRepository, SopsSecret).

Customizing the deploy

make deploy runs kustomize build config/default | kubectl apply -f -. To override values without forking:

  • Image / tag: make deploy IMG=ghcr.io/<org>/sops-secrets-operator:<tag>
  • Namespace, replicas, resources, env: create an overlay that patches config/manager/manager.yaml
  • Metrics / Prometheus / NetworkPolicy: uncomment the corresponding entries in config/default/kustomization.yaml

To render the manifest without applying (useful for GitOps pipelines):

make build-installer IMG=ghcr.io/stuttgart-things/sops-secrets-operator:v0.7.2
# writes dist/install.yaml

The controllers are scaffolded with kubebuilder v4. Key packages:

  • internal/git/ — go-git wrapper with revision pinning + safe cache directory
  • internal/decrypt/ — age-only SOPS decrypt with in-process serialization
  • internal/source/ — cache registry shared across reconcilers (git + object sources)
  • internal/object/ — HTTPS (If-None-Match/ETag) and S3-compatible (minio-go) fetchers for ObjectSource
  • internal/transform/ — pure helpers (flat-YAML parsing, manifest validation, content hashing)
  • internal/keyresolve/ — age key lookup from Secret refs
  • internal/controller/ — five reconcilers (GitRepository, ObjectSource, SopsSecret, SopsSecretManifest, InlineSopsSecret)
  • internal/metrics/ — Prometheus counters/histograms

License

Apache-2.0 — see LICENSE.

About

Kubernetes operator that syncs SOPS-encrypted secrets from Git repositories into Kubernetes Secrets

Topics

Resources

License

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors