Patterns
Five patterns covering the most common reasons teams reach for external:. Each shows the minimal Katalog snippet that makes it work and explains the key design choice.
Health gate
Gate a Deployment on an upstream service being healthy. When the health check fails the Deployment is not created or updated — no broken app, no partial rollout. The phase state machine surfaces the failure in status so it is visible in the Control Center.
onReconcile:
external:
- name: healthCheck
url: "{{ .spec.healthCheckUrl }}" # full URL — no path appended
expectedStatus: 200
continueOnError: true # reconcile continues — failure visible in status
timeout: 5s
deployments:
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
when:
- field: external.healthCheck.status
equals: "200"
status:
fields:
- path: phase
value: "Degraded"
when:
- field: external.healthCheck.called
equals: "true"
- field: external.healthCheck.status
notEquals: "200"
- path: phase
value: "Ready"
when:
- field: external.healthCheck.status
equals: "200"
- field: "{{ allReplicasReady .children.deployment }}"
equals: "true"
- path: lastHealthCheck
value: "{{ .external.healthCheck.status }}"
Use spec.healthCheckUrl (the full URL) rather than a base serviceUrl with /health appended. Each CR declares exactly which endpoint to call — production operators use internal standards like /healthz, /ready, or /actuator/health. The field name makes the intent explicit.
continueOnError: true means a failed health check surfaces in status.phase rather than as a reconcile error. Use continueOnError: false when the Deployment must never exist without a passing check — the reconcile halts and Ready=False is written to the condition.
Try it:
ork init --pack use-cases
cd external/01-health-gate
ork run --dev-server # GET /health → 200, GET /status/503 → 503
kubectl apply -f cr-dev-healthy.yaml
kubectl apply -f cr-dev-degraded.yaml
Dynamic config injection
Fetch a JSON config blob from a config server on every reconcile. Embed the response body into a ConfigMap. The Deployment mounts the ConfigMap — the app always sees current config without a pod restart or a redeployment.
onReconcile:
external:
- name: appConfig
url: "{{ .spec.serviceUrl }}/config/{{ .metadata.name }}"
continueOnError: true # config unavailable → retain the last written ConfigMap
timeout: 5s
configMaps:
- name: "{{ .metadata.name }}-config"
data:
app.json: "{{ .external.appConfig.body }}"
when:
- field: external.appConfig.called
equals: "true"
- field: external.appConfig.error
operator: notExists
deployments:
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
volumes:
- name: app-config
configMap:
name: "{{ .metadata.name }}-config"
volumeMounts:
- name: app-config
mountPath: /etc/config
status:
fields:
- path: configFresh
value: "true"
when:
- field: external.appConfig.called
equals: "true"
- field: external.appConfig.error
operator: notExists
The when: condition on the ConfigMap means the config is only overwritten when the call succeeds. A transient config service outage leaves the last-written config in place — the Deployment keeps running without interruption.
Try it:
ork init --pack use-cases
cd external/02-config-inject
ork run --dev-server # GET /config/:name → static JSON blob
kubectl apply -f cr.yaml
kubectl get configmap my-app-config -o jsonpath='{.data.app\.json}' | jq .
Image signing — “once per image change” with rejection tracking
Call a signing service when the image changes. Gate the Deployment on the signed status. Track both successful signs (signedImage) and definitive rejections (rejectedImage) in status — different status fields, different gates, different behaviors on the next reconcile.
onReconcile:
external:
- name: signImage
url: "{{ .spec.serviceUrl }}/sign"
method: POST
body: '{"image": "{{ .spec.image }}", "namespace": "{{ .metadata.namespace }}"}'
token: "$IMAGE_SIGNING_TOKEN"
expectedStatus: 200
continueOnError: true # rejection details must be visible in status
timeout: 15s
when:
- field: status.signedImage
notEquals: "{{ .spec.image }}" # skip if already signed
- field: status.rejectedImage
notEquals: "{{ .spec.image }}" # skip if definitively rejected for this image
deployments:
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
when:
- field: status.signedImage
equals: "{{ .spec.image }}" # only deploy confirmed-safe images
status:
fields:
# Phase: Signing → waiting for first sign attempt
- path: phase
value: "Signing"
when:
- field: status.signedImage
notEquals: "{{ .spec.image }}"
# Phase: SigningRejected — reads from persistent status, survives reconciles where call is skipped
- path: phase
value: "SigningRejected"
when:
- field: status.rejectedImage
equals: "{{ .spec.image }}"
# Phase: SigningUnavailable — 5xx, transient, gate stays open, next reconcile retries
- path: phase
value: "SigningUnavailable"
when:
- field: external.signImage.called
equals: "true"
- field: external.signImage.status
prefix: "5"
# Phase: Ready — image signed, all replicas up
- path: phase
value: "Ready"
when:
- field: status.signedImage
equals: "{{ .spec.image }}"
- field: "{{ allReplicasReady .children.deployment }}"
equals: "true"
# Written after successful sign — closes the call gate until spec.image changes
- path: signedImage
value: "{{ .spec.image }}"
when:
- field: external.signImage.called
equals: "true"
- field: external.signImage.status
equals: "200"
# 4xx → definitive rejection → lock out retries for this exact image
# Recovery: change spec.image → rejectedImage != spec.image → gate reopens
- path: rejectedImage
value: "{{ .spec.image }}"
when:
- field: external.signImage.called
equals: "true"
- field: external.signImage.status
prefix: "4"
# Surfaces the rejection reason — clears explicitly when signing succeeds
- path: lastSigningError
value: "{{ .external.signImage.error }}"
when:
- field: external.signImage.called
equals: "true"
- field: external.signImage.status
notEquals: "200"
- path: lastSigningError
value: ""
when:
- field: external.signImage.called
equals: "true"
- field: external.signImage.status
equals: "200"
Why continueOnError: true here: signing failure is a policy decision with meaningful information (status code, rejection reason). That information needs to reach status fields. With continueOnError: false, the reconcile halts before status is written — the rejection is only visible as a raw Ready=False condition message, not as structured status fields.
Why two gates on the call: signedImage != spec.image alone retries the signing service on every reconcile after a rejection — expensive and pointless for a deterministic 403. Adding rejectedImage != spec.image closes the gate after a 4xx. A 5xx does not write rejectedImage, so the gate stays open and the reconcile retries naturally — the reconcile loop is the retry mechanism.
Try it:
ork init --pack use-cases
cd external/03-image-signing
ork run --dev-server # POST /sign → 200 for most images, 403 for nginx:not-secure
kubectl apply -f cr-reject.yaml # nginx:not-secure → SigningRejected, no Deployment
kubectl patch webapp my-app --type=merge -p '{"spec":{"image":"nginx:1.25"}}'
# → sign succeeds → Deployment created
Sequential chained calls
Fetch a short-lived token, then use it in the next call. The resolver is updated after every call so later calls can reference earlier results via template expressions in their url:, token:, or body: fields.
onReconcile:
external:
- name: tokenFetch
url: "{{ .spec.serviceUrl }}/auth/token"
method: POST
body: '{"client_id": "{{ .metadata.name }}", "namespace": "{{ .metadata.namespace }}"}'
token: "$CLIENT_SECRET"
continueOnError: false # no token = nothing to authenticate with, halt here
timeout: 5s
- name: resourceCheck
url: "{{ .spec.serviceUrl }}/resources/{{ .metadata.name }}"
token: "{{ .external.tokenFetch.body }}" # result of the previous call
continueOnError: true
timeout: 5s
deployments:
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
when:
- field: external.tokenFetch.status
equals: "200"
- field: external.resourceCheck.status
equals: "200"
If tokenFetch fails (continueOnError: false), the reconcile halts and resourceCheck never runs. The token is not available — there is nothing to pass forward.
The token: "{{ .external.tokenFetch.body }}" expression on resourceCheck resolves at call time, after tokenFetch has completed and its result has been injected into the resolver. The same mechanism works for url: and body: — any field in a later call can reference any earlier call’s result.
Try it:
ork init --pack use-cases
cd external/04-chained
ork run --dev-server # POST /auth/token → "dev-token-abc123", GET /resources/:name → resource stub
kubectl apply -f cr.yaml
Feature flag rollout — external call drives a resource attribute
Read a live flag from a feature flag service on every reconcile. Use the result to drive replicas directly — not as a gate condition, but as a resource attribute. The cluster converges to the correct replica count within one reconcile cycle of the flag changing.
onCreate:
external:
- name: flags
url: "{{ .spec.serviceUrl }}/flags/{{ .metadata.name }}/v2Enabled"
method: GET
continueOnError: true # flag service down → degrade to baseline, keep running
timeout: 5s
# Full capacity when flag is on
deployments:
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
replicas: "{{ .spec.replicas }}"
reconcile: true
when:
- field: external.flags.body
equals: "true"
# Baseline when flag is off or flag service is unavailable
# notEquals: "true" also catches empty body on service outage — safe degradation
- name: "{{ .metadata.name }}"
image: "{{ .spec.image }}"
replicas: "1"
reconcile: true
when:
- field: external.flags.body
notEquals: "true"
status:
fields:
- path: v2Enabled
value: "{{ .external.flags.body }}"
when:
- field: external.flags.called
equals: "true"
- path: activeReplicas
value: "{{ readyReplicas .children.deployment }}"
Two deployment entries target the same name with different replica counts. Exactly one fires per reconcile — the when: conditions are mutually exclusive. This is declared under onCreate with reconcile: true on each entry: onCreate runs every reconcile, and reconcile: true tells Orkestra to update the existing Deployment (not just create it) when the active entry changes. The second entry catches flag service outages: an empty body from a failed call does not equal "true", so the operator degrades to baseline safely rather than leaving the cluster at stale capacity.
This call fires on every reconcile intentionally — the flag can change at any time. Check orkestra_external_calls_total in metrics to see the call count grow as reconciles run.
Try it:
ork init --pack use-cases
cd external/05-feature-flags
ork run --dev-server # GET /flags/:name/:flag → "true" by default
kubectl apply -f cr.yaml
# Deployment: 5 replicas
curl -X POST http://localhost:9999/flags/my-app/v2Enabled/toggle
# Wait one reconcile → Deployment: 1 replica
curl -X POST http://localhost:9999/flags/my-app/v2Enabled/toggle
# Wait one reconcile → Deployment: 5 replicas