Advanced Patterns¶
This guide covers production patterns for kardinal-promoter that go beyond the basic quickstart. Each pattern addresses a real-world scenario encountered at scale.
Repository Strategy: Monorepo vs Multi-Repo¶
The repository strategy you choose affects Pipeline configuration and team autonomy. kardinal-promoter supports both.
Monorepo¶
All GitOps manifests for all applications live in one repository. Each application is a directory; each environment is a subdirectory.
gitops-repo/
apps/
my-app/
environments/
dev/ kustomization.yaml
staging/ kustomization.yaml
prod/ kustomization.yaml
payment-service/
environments/
dev/
prod/
Pipeline for a monorepo app:
spec:
git:
url: https://github.com/myorg/gitops-repo
layout: directory
environments:
- name: dev
path: apps/my-app/environments/dev
- name: prod
path: apps/my-app/environments/prod
approval: pr-review
Advantages: - Atomic cross-application changes (update a shared ConfigMap and all consumers in one commit) - Centralized audit trail - Single set of GitHub token credentials
Challenges: - RBAC is coarse: one token has write access to all applications - Noisy PR history (all apps in one repo) - Git clone size grows with repo size (mitigated by shallow clone)
kardinal-promoter behavior in monorepo: The git-clone step uses sparse checkout to fetch only the path directory. Git history is scoped to the target path in commit messages. PRs target the specific path, so CODEOWNERS can still enforce per-app review.
Multi-Repo¶
Each application has its own GitOps repository. The Pipeline's git.url points to the application-specific repo.
Advantages: - Strong isolation: one team cannot accidentally break another team's config - Fine-grained RBAC (per-repo GitHub token) - Independent Git history and branch protection per application
Challenges: - Credential management: one GitHub token Secret per Pipeline namespace - More complex ApplicationSet configuration (one generator per repo or a list of repos)
Recommended: Use multi-repo for teams with 5+ applications or strict security requirements. Use monorepo for small teams or when cross-application atomicity matters.
Multi-Tenant Self-Service via ApplicationSet¶
At scale, a platform team cannot manually create a Pipeline CRD for every new service. The self-service pattern uses Argo CD ApplicationSets to provision Pipelines automatically when a developer creates a new service folder.
Repository structure¶
platform-repo/
teams/
payment-service/
pipeline-values.yaml # team-specific config (image, envs, approval mode)
checkout-service/
pipeline-values.yaml
Root ApplicationSet (platform team applies once)¶
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: team-pipelines
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/myorg/platform-repo
revision: main
directories:
- path: teams/* # one entry per team folder
template:
metadata:
name: "{{path.basename}}-pipeline"
spec:
project: default
source:
repoURL: https://github.com/myorg/platform-repo
targetRevision: main
path: "teams/{{path.basename}}"
helm:
valueFiles:
- pipeline-values.yaml
destination:
server: https://kubernetes.default.svc
namespace: "{{path.basename}}"
syncPolicy:
automated: {}
syncOptions:
- CreateNamespace=true
Pipeline Helm template (platform team owns)¶
The ApplicationSet renders a Pipeline CRD for each team:
# chart/templates/pipeline.yaml
apiVersion: kardinal.io/v1alpha1
kind: Pipeline
metadata:
name: {{ .Values.appName | default .Release.Namespace }}
namespace: {{ .Release.Namespace }}
spec:
git:
url: {{ .Values.gitRepo }}
provider: github
secretRef:
name: github-token
environments:
{{- range .Values.environments }}
- name: {{ .name }}
path: environments/{{ .name }}
approval: {{ .approval | default "auto" }}
{{- end }}
# teams/payment-service/pipeline-values.yaml
appName: payment-service
gitRepo: https://github.com/myorg/payment-service-gitops
environments:
- name: dev
- name: staging
- name: prod
approval: pr-review
How org PolicyGates apply to new teams¶
Because org-level PolicyGates in platform-policies are automatically injected into every Pipeline targeting matching environments, the new team's Pipeline inherits production controls with zero configuration:
Teams cannot remove org gates. They can add their own team-level gates in their namespace.
RBAC isolation¶
Each team's namespace should have RBAC that prevents cross-namespace access:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: team-pipeline-access
namespace: payment-service
rules:
- apiGroups: ["kardinal.io"]
resources: ["bundles", "pipelines"]
verbs: ["get", "list", "watch", "create"]
- apiGroups: ["kardinal.io"]
resources: ["policygates"]
verbs: ["get", "list", "watch", "create"] # can create team gates, not org gates
Teams have no access to platform-policies namespace by default.
Feature Branch / Ephemeral Environments¶
A common requirement is promoting a feature branch to a temporary environment for integration testing before merging to main.
Pattern: Short-lived Bundle with intent.target¶
The simplest approach is to create a Bundle with intent.target: staging from a feature branch CI workflow. The Bundle promotes only up to staging, not to prod.
# feature branch GitHub Actions
- name: Create feature Bundle
uses: kardinal-dev/create-bundle-action@v1
with:
pipeline: my-app
image: ghcr.io/myorg/my-app:feature-auth-${{ github.sha }}
token: ${{ secrets.KARDINAL_TOKEN }}
target: staging # stop at staging, do not promote to prod
The Bundle is marked Verified when staging is healthy. It does not supersede the main-branch Bundle in prod (different intent target).
Pattern: Skip environment for hotfixes¶
For hotfixes that must skip staging and go directly to prod:
# CI creates a Bundle with skip intent
{
"pipeline": "my-app",
"artifacts": { "images": [...] },
"provenance": { "commitSHA": "...", "author": "engineer" },
"intent": { "skip": ["staging"] },
"labels": { "hotfix": "true" }
}
This requires a SkipPermission PolicyGate allowing hotfix Bundles to skip staging:
apiVersion: kardinal.io/v1alpha1
kind: PolicyGate
metadata:
name: allow-staging-skip-for-hotfix
namespace: platform-policies
labels:
kardinal.io/scope: org
kardinal.io/type: skip-permission
kardinal.io/applies-to: staging
spec:
expression: "bundle.labels.hotfix == true"
message: "Hotfix bundles may skip staging"
Without this gate, the skip is denied and the Bundle remains in SkipDenied state.
Pattern: Ephemeral Pipeline for feature environments¶
For teams that need a fully isolated environment per feature branch, create a separate Pipeline per branch using the ApplicationSet pattern:
# ApplicationSet that generates a Pipeline per open PR
generators:
- pullRequest:
github:
owner: myorg
repo: my-app
tokenRef: { secretName: github-token, key: token }
requeueAfterSeconds: 60
template:
metadata:
name: "my-app-pr-{{number}}"
spec:
source:
path: ephemeral/pipeline-template
helm:
values: |
envSuffix: pr-{{number}}
targetBranch: {{head_sha}}
Each PR gets its own Pipeline (and namespace). When the PR closes, the ApplicationSet removes the Pipeline and its Bundles are garbage-collected.
Bundle Supersession¶
When a new Bundle is created while an existing Bundle is still promoting through the same Pipeline, the older Bundle is superseded:
- The old Bundle's in-progress PromotionSteps are cancelled.
- The old Bundle's status is set to
Superseded. - The old Bundle's Graph is deleted (and its PromotionStep CRs are cascade-deleted).
- The new Bundle starts promoting from the beginning.
Open PRs from the superseded Bundle are not automatically closed. The controller adds a comment to the old PR noting it has been superseded. The reviewer should close the old PR manually.
# Check which Bundles were superseded
kardinal get bundles my-app
# BUNDLE PHASE ENV AGE
# v1.29.0-feat Superseded uat 5m (superseded by v1.30.0)
# v1.30.0 Promoting prod 2m
When supersession does not occur: Config Bundles (type: config) and Image Bundles (type: image) have separate supersession tracking. A new image Bundle does not supersede an in-flight config Bundle, and vice versa.
Webhook Responsiveness¶
kardinal-promoter detects PR merges via GitHub webhooks for fast response. Without webhooks, the controller polls for PR status on reconcile.
Setting up webhooks¶
In the GitHub repository settings:
Payload URL: https://kardinal.example.com/webhooks
Content type: application/json
Secret: <same as WEBHOOK_SECRET env var on controller>
Events: Pull requests (pull_request), Pushes (push)
With webhooks configured, the controller advances the promotion within seconds of PR merge. Without webhooks, advancement happens within 30 seconds (next reconcile).
Local development with webhooks¶
For local clusters or clusters behind firewalls, use a webhook forwarding service:
# Using smee.io
npm install --global smee-client
smee --url https://smee.io/your-channel-id \
--target http://localhost:8081/webhooks
Or configure the Pipeline to use polling:
spec:
git:
webhookMode: polling # disable webhook, use periodic polling
pollInterval: 30s # default: 30s (GitHub rate limit: 5000 req/h)
The polling mode is less responsive but works in environments where inbound webhooks are not possible.
Namespace Sprawl Management¶
Each Pipeline creates PromotionStep and PolicyGate CRs in its own namespace. For organizations with many Pipelines, this can create dozens of additional CRDs per namespace.
Recommendations¶
-
Use team namespaces: group related Pipelines in one namespace rather than one namespace per Pipeline.
kardinal-promoterscopes Bundles bykardinal.io/pipelinelabel, not by namespace. -
Set historyLimit: the default
historyLimit: 20retains the last 20 Bundles. For high-frequency teams, reduce to5to limit CRD count. -
Monitor CRD count: the controller exposes
kardinal_bundles_total{phase}andkardinal_steps_total{type}Prometheus metrics. Alert if these exceed expected levels. -
Resource quotas: set
ResourceQuotaon team namespaces to prevent unbounded Bundle creation.
GitOps Tool Agnosticism¶
kardinal-promoter is not tied to Argo CD. If your cluster does not have a GitOps tool installed, the resource health adapter checks Deployment readiness directly.
Without any GitOps tool¶
The controller verifies that the Deployment's Available condition is True after pushing to Git. You are responsible for ensuring Git changes reach the cluster (e.g., via CI, Flux Receiver webhooks, or ArgoCD App-of-Apps).
With Flux¶
Auto-detected if Flux CRDs are installed. Waits for Kustomization Ready=True.
Mixing GitOps tools across environments¶
environments:
- name: dev
health:
type: flux # dev cluster uses Flux
- name: prod
health:
type: argocd # prod cluster uses Argo CD
argocd: { name: my-app-prod }
Different environments can use different health adapters in the same Pipeline.
Anti-Patterns to Avoid¶
Pseudo-GitOps: mutating Argo CD targetRevision directly¶
Some tools shortcut promotion by patching spec.source.targetRevision on an Argo CD Application CRD without writing to Git. This breaks GitOps: Git is no longer the source of truth. Cluster state cannot be reconstructed from Git after a disaster.
kardinal-promoter never mutates GitOps tool CRDs directly. All promotions write to Git first.
Committing templated sources to rendered branches¶
Do not commit Kustomize kustomization.yaml files or Helm values.yaml files to a rendered branch. Rendered branches must contain only plain Kubernetes YAML. Argo CD's Directory source type does not process Kustomize or Helm — if template files are present, Argo CD may fail to apply them or silently ignore them.
Using approval: auto for production¶
approval: auto pushes directly to the target branch without a PR. This is appropriate for dev and staging where speed matters, but not for prod. A human reviewer should always merge the production PR to confirm: - The rendered diff looks correct - Policy gates have all passed - The upstream environments are verified
Not setting historyLimit¶
The default historyLimit: 20 retains 20 Bundles per Pipeline. In active pipelines with frequent deployments, this creates many PromotionStep CRDs. If you deploy multiple times per day, set historyLimit: 5. The Git audit trail is permanent regardless of this setting.