A Kubernetes operator for Gardener environments that deploys a single Helm chart or
kustomize source across two clusters at once — the seed (in-cluster) and its shoot
(remote) — from one DualDeploymentOperator custom resource.
You point it at a source, tell it which namespace to use on the shoot, and give it credentials to reach the shoot. On every reconcile it renders the source twice (once per cluster), applies your transformations, and server-side-applies each render to its target cluster. It tracks per-resource health, drift-corrects on a periodic reconcile, and cleans both clusters up on deletion.
Splitting an operator's manifests into a seed half and a shoot half traditionally meant
committed generated YAML, bespoke sed/yq build targets, and multiple separate
remote-delivery mechanisms per operator. dual-deployment-operator replaces all of that
with one declarative CR and one delivery path: the operator applies directly to both
clusters — no committed rendered YAML, no make build- targets, one artifact per operator.
┌─────────────────────────────────────────┐
DualDeploymentOperator │ render(seed) → transform → SSA apply ──┼──▶ seed cluster
(one CR) ──────────────┤ │ (in-cluster)
│ render(shoot) → transform → SSA apply ──┼──▶ shoot cluster
└─────────────────────────────────────────┘ (kubeconfig from
Gardener Secret)
- Two renders per reconcile. Helm sources use
seedValues/shootValues; kustomize sources useseedPath/shootPathto select overlay directories. - Per-render transformations (optional):
patch(strategic-merge or JSON Patch),rewriteWebhookURL, andfilterKinds. - Server-side apply to each cluster with drift correction and owned-by-guarded pruning.
- Safe teardown. On CR deletion a finalizer blocks until shoot cleanup succeeds (or you set an explicit force-delete annotation), so deletion is safe even when the shoot is temporarily unreachable.
Reconcile dataflow: the operator renders the source twice (seed + shoot), applies the per-render transforms, and applies each render directly to its target cluster via server-side apply.
Deployment topology: one operator Pod per shoot--cp--* namespace, with two Kubernetes
clients — seed (in-cluster) and shoot (via a Gardener token-requestor kubeconfig).
Full architecture, rationale, and the transformation DSL are in
docs/design.md.
The operator is deployed per shoot — one Helm release per shoot--cp--<name> namespace
on the seed.
helm install dual-deployment-operator ./chart \
--namespace shoot--cp--<name> --create-namespace \
[--set manager.image.tag=<version>]The image defaults to ghcr.io/SAP-cloud-infrastructure/dual-deployment-operator. See
chart/README.md for all values, the required Gardener egress labels,
and the single-install-per-seed contract.
Create a DualDeploymentOperator CR describing your source, the shoot namespace, and how to
reach the shoot.
apiVersion: dual-deployment-operator.cc.sap/v1alpha1
kind: DualDeploymentOperator
metadata:
name: my-operator
namespace: shoot--cp--example # seed render/delivery uses this namespace
spec:
source:
helm:
repo: oci://registry.example.com/charts # MUST be an oci:// reference
name: my-operator
version: 1.2.3
seedValues: # values applied to the seed render
mode: seed
shootValues: # values applied to the shoot render
mode: shoot
shootNamespace: kube-system # target namespace for the shoot render
shootAccess:
secretName: my-operator-shoot-access # Gardener token-requestor Secret (this namespace)
server: https://api.shoot.example.com
applyOrder: ShootFirst # ShootFirst (default) | SeedFirstspec:
source:
kustomize:
url: https://github.com/org/repo//base?ref=<pinned-sha> # ref= is required
seedPath: overlays/seed
shootPath: overlays/shoot
shootNamespace: kube-system
shootAccess:
secretName: my-operator-shoot-access
server: https://api.shoot.example.comApplied to each render independently, in declaration order. All are silent no-ops when they match nothing, so a transform targeting a kind absent from one render does not fail it.
spec:
transformations:
- patch: # strategicMerge OR jsonPatch
target: { kind: Deployment, name: my-operator }
strategicMerge:
spec: { replicas: 2 }
- rewriteWebhookURL:
urlPrefix: https://webhook.shoot.example.com
- filterKinds:
kinds: [ValidatingWebhookConfiguration]For an authenticated OCI registry or git HTTPS repo, add authSecretRef under the source
and create a Secret (in the CR's namespace) with username/password or token keys:
spec:
source:
helm:
repo: oci://registry.example.com/charts
name: my-operator
version: 1.2.3
authSecretRef: { name: my-registry-creds }The CRD exposes printer columns, so kubectl get shows the current state at a glance:
$ kubectl get ddo -n shoot--cp--example
NAME READY REASON AGE
my-operator True ReconcileSuccess 5mThe Ready condition always reflects the situation right now:
| Ready | Reason | Meaning |
|---|---|---|
True |
ReconcileSuccess |
All managed resources applied and healthy |
False |
Progressing |
One or more resources still coming up |
False |
ResourcesDegraded |
One or more resources failed to apply / are unhealthy |
False |
WaitingForShootCredentials |
Shoot token/CA not yet populated by Gardener (benign bootstrap wait) |
False |
ShootApplyFailed / ShootUnreachable |
The shoot render could not be applied / the shoot is unreachable |
False |
Deleting |
The CR is being deleted; teardown is in progress |
Full status (kubectl get ddo my-operator -o yaml) also lists per-resource health under
status.seedResources / status.shootResources, and each condition carries an
observedGeneration so you can tell whether the status reflects your latest spec change.
kubectl delete ddo my-operator triggers a finalizer that tears down both renders. If the
shoot is unreachable, deletion blocks (the CR shows a ShootCleanup=Blocked condition)
and retries rather than orphaning shoot resources. To force removal and accept orphaned
shoot resources:
kubectl annotate ddo my-operator dual-deployment-operator.cc.sap/force-delete=trueBy default CRDs in the render are retained on delete; set spec.retentionPolicy.crds: Delete
to remove them too.
Run the operator against your current kubeconfig. The seed client uses your local
kubeconfig; the shoot client is still built from the shootAccess Secret referenced by
each CR.
make install # install the CRD into the current cluster
make run # run the operator locally (uses current kubeconfig)
# point at a specific kubeconfig:
go run ./cmd/main.go --kubeconfig ~/.kube/other-config
# or:
KUBECONFIG=~/.kube/other-config make runKubeconfig resolution order: --kubeconfig flag → KUBECONFIG env → $HOME/.kube/config →
in-cluster config.
Build and test:
make build # build the binary
make test # run unit + envtest suites (Ginkgo/Gomega)
make lint-fix # auto-fix code styleE2E tests expect a dedicated Kind cluster, not your real dev/prod cluster.
| File | Purpose |
|---|---|
docs/design.md |
Full architecture, CRD reference, transformation DSL, delivery model, migration plan |
docs/implementation.md |
Phase-by-phase implementation guide |
docs/context.md |
Design-decision rationale |
chart/README.md |
Controller Helm chart: values, RBAC, egress labels, install contract |
v1alpha1. The operator is functionally complete — source rendering (OCI Helm + git
kustomize), per-render transformations, dual-cluster server-side apply, finalizer-driven
teardown, and CEL admission validation are implemented and tested. Image publish and
production rollout are in progress.
This project is open to feature requests, suggestions, and bug reports via GitHub issues. For more on how to contribute and the project structure, see our Contribution Guidelines.
If you find a bug that may be a security problem, please follow the instructions in our security policy. Please do not open GitHub issues for security-related concerns.
By participating in this project, you agree to abide by its Code of Conduct at all times.
Copyright 2026 SAP SE or an SAP affiliate company and dual-deployment-operator contributors. Please see our LICENSE for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available via the REUSE tool.