# System Overview
Source: https://docs.ctrlplane.dev/architecture/overview
Birds-eye view of the ctrlplane orchestration flow
This is the developer-facing entry point to the ctrlplane codebase. It shows
how the apps in this monorepo fit together when a deployment version moves
from creation to execution.
```mermaid theme={null}
flowchart TD
CLI["CLI / curl"]:::ext
Users["Users (browser)"]:::ext
Web["apps/web React + tRPC client"]
API["apps/api Express + tRPC + webhooks"]
DB[("Postgres reconcile_work_scope")]
Engine["apps/workspace-engine Go controllers"]
Agents["Job agents GitHub Actions · ArgoCD · Terraform Cloud · custom"]:::ext
Users --> Web
Web -->|tRPC| API
CLI -->|"① register version"| API
API -->|"② enqueue work"| DB
DB <-->|"③ lease / requeue"| Engine
Engine -->|"④ dispatch job"| Agents
Agents -->|"⑤ result"| API
API -->|"⑥ enqueue follow-up"| DB
classDef ext fill:#444,stroke:#888,color:#ddd
```
## The orchestration loop
CLI or `curl` calls register a deployment version against `apps/api` (①). The
api persists the version and writes a work item into the `reconcile_work_scope`
table in Postgres (②) — **this is the only thing the api does to "start"
orchestration; it does not call the engine.**
`apps/workspace-engine` controllers continuously lease items from that queue
(③), and each controller's output enqueues work for the next controller
(planning → policy → dispatch). When dispatch fires, the engine reaches out to
a job agent over HTTPS (④).
Results come back through webhooks to the api (⑤), which writes the job update
plus any follow-up work into the queue (⑥). The engine picks it up again. The
loop ③↔⑥ is the whole orchestration model — every release phase is a trip
through the queue.
# Workspace Engine
Source: https://docs.ctrlplane.dev/architecture/workspace-engine
How apps/workspace-engine orchestrates the release lifecycle
The workspace-engine is the Go service that drives every release forward. It
polls a Postgres work queue (`reconcile_work_scope`), leases items by `kind`,
and runs the matching controller. Each controller's output is enqueueing more
work, so a single release moves through phases by chaining items through the
queue.
## The release-flow chain
When a release-target needs to be evaluated (a new version was created, a
policy changed, a job finished, a resource started matching), a
`desired-release` work item lands in the queue. From there:
```mermaid theme={null}
sequenceDiagram
autonumber
participant Q as reconcile_work_scope
participant DR as desiredrelease
participant JE as jobeligibility
participant JD as jobdispatch
participant JV as jobverificationmetric
participant Ext as Job agent
Note over Q: kind = desired-release scope = release-target
Q->>DR: lease
Note over DR: evaluate policies, pick the deployable version, resolve variables, persist release
DR-->>Q: enqueue kind=job-eligibility
Q->>JE: lease
Note over JE: can this release run now? (concurrency, retry rules)
JE-->>Q: enqueue kind=job-dispatch
Q->>JD: lease
Note over JD: create job, route to the right job agent
JD->>Ext: dispatch
Ext-->>Q: result via api, enqueue kind=job-verification-metric
Q->>JV: lease
Note over JV: poll metrics, on completion re-enqueue desired-release
JV-->>Q: enqueue kind=desired-release (loop)
```
Four controllers, one queue between them. **No controller calls another
directly** — handoff is always via insert-then-lease. That means each phase is
independently retriable, leasable, and observable, and the engine can run as
multiple instances safely.
## How every controller works
Every controller is a `reconcile.Processor` registered for one `kind`. The
pattern is identical across all of them: lease an event, recompute the desired
state from current Postgres state, persist the result, enqueue follow-up.
```mermaid theme={null}
flowchart LR
DB[(reconcile_work_scope)]
C[Controller handles one kind]
DB -->|lease event by kind| C
C -->|persist results + enqueue next kind| DB
```
Two things make this a reconciler rather than a job runner. First,
**controllers are stateless** — every invocation re-reads input from Postgres
rather than carrying state forward in memory. If the world changes between
events (a policy is disabled, an approval lands, a new version appears), the
next event picks up the change automatically. Second, **the loop closes back
to the start** — when a job finishes, `jobverificationmetric` enqueues another
`desired-release` event and `desiredrelease` recomputes from scratch.
Idempotent recomputation is the orchestration model.
## Inside `desiredrelease`
`desiredrelease` is the only controller in the chain that does meaningful
internal work — the other three are mostly routing or checking. Here is what
happens on a single lease:
```mermaid theme={null}
flowchart TD
In([dequeued: desired-release work item])
LP[load scope and policies]
Iter[iterate candidate versions newest-first]
Eval[evaluate policy rules inline via policyeval library]
Decide{any version passes?}
NoRel[persist 'no release']
Resolve[resolve variables]
Persist[persist release record]
Out[enqueue job-eligibility]
In --> LP --> Iter --> Eval --> Decide
Decide -->|no| NoRel
Decide -->|yes| Resolve --> Persist --> Out
```
Two things worth knowing:
1. **Policy evaluation is inline, not a separate controller.** A `policyeval`
directory exists at `svc/controllers/policyeval/` but that's a different
controller that writes per-version rule evaluations for the UI. The gating
logic that decides whether a version can deploy lives in the `policyeval`
*library subpackage* at `svc/controllers/desiredrelease/policyeval/` and is
called as a function from inside `desiredrelease`.
2. **Versions are evaluated newest-first as a stream.** The controller doesn't
load all candidate versions then filter — it iterates them and stops at the
first one that passes all policy rules. That's what makes "skip blocked
versions but deploy the newest passing one" cheap.
## Other release-flow controllers
**`jobeligibility`** — given a release record, decides whether a job can run
*right now*. Runs two evaluators: `releasetargetconcurrency` (under the
configured concurrency cap?) and `retry` (under the retry budget?). If both
pass, enqueue `job-dispatch`. If not, requeue with `notBefore`.
**`jobdispatch`** — given a job, picks the right job-agent adapter (GitHub
Actions, ArgoCD, Terraform Cloud, Argo Workflows, or the test runner) and
sends the job over HTTPS. The agent's `externalId` is recorded so results can
be correlated back later.
**`jobverificationmetric`** — given a finished job, polls verification
providers (Datadog, HTTP probes, Terraform Cloud run status, etc.) until they
return pass/fail. On completion, calls `EnqueueDesiredRelease` to close the
loop.
## Controllers outside the release-flow chain
The `svc/controllers/` directory contains several other controllers that exist
for UI surface or precomputed state, not for moving a release through phases:
* `policyeval` (top-level) — computes per-version rule evaluations so the UI
can show "why isn't this version deploying yet."
* `deploymentplan` / `deploymentplanresult` — power plan previews and dry-run
views.
* `deploymentresourceselectoreval` / `environmentresourceselectoreval` —
precompute which resources currently match a deployment or environment
selector.
* `relationshipeval` — evaluates resource relationship rules into the resource
graph.
* `forcedeploy` — handles user-triggered manual deploys (a separate path from
the policy-gated chain).
If you're trying to understand "what happens when I push a version," you can
safely ignore these and focus on the four chain controllers.
# ctrlc api upsert version
Source: https://docs.ctrlplane.dev/cli/api-upsert-version
Create or update a deployment version
The `ctrlc api upsert version` command creates or updates a deployment version
in Ctrlplane. This is typically called from CI/CD pipelines after a successful
build.
## Usage
```bash theme={null}
ctrlc api upsert version \
--workspace \
--deployment \
--tag \
[--name ""] \
[--metadata key=value ...]
```
## Flags
| Flag | Required | Description |
| -------------- | -------- | ------------------------------- |
| `--workspace` | Yes | Workspace name or ID |
| `--deployment` | Yes | Deployment ID |
| `--tag` | Yes | Unique version identifier |
| `--name` | No | Human-readable version name |
| `--metadata` | No | Key-value metadata (repeatable) |
## Example
```bash theme={null}
ctrlc api upsert version \
--workspace my-workspace \
--deployment dep_abc123 \
--tag v1.2.3 \
--name "Release 1.2.3" \
--metadata git/commit=abc123 \
--metadata git/branch=main
```
The command is idempotent — running it again with the same `--tag` updates the
existing version rather than creating a duplicate.
## Next Steps
Full CI/CD setup guide
All CLI commands
# ctrlc apply
Source: https://docs.ctrlplane.dev/cli/apply
Apply resource definitions from YAML files
The `ctrlc apply` command creates or updates resources in Ctrlplane from YAML
definition files.
## Usage
```bash theme={null}
ctrlc apply -f
```
## Flags
| Flag | Short | Required | Description |
| -------- | ----- | -------- | ----------------------------------------- |
| `--file` | `-f` | Yes | Path to the YAML resource definition file |
## Example
```bash theme={null}
ctrlc apply -f resource.yaml
```
## Next Steps
All CLI commands
Learn about resources
# CLI Reference
Source: https://docs.ctrlplane.dev/cli/overview
Install and use the ctrlc command-line interface
The `ctrlc` CLI is the primary tool for interacting with Ctrlplane from your
terminal, CI/CD pipelines, and automation scripts. It supports syncing
resources, managing deployments, and applying configuration.
## Installation
```bash brew theme={null}
brew tap ctrlplanedev/tap
brew install ctrlplanedev/tap/ctrlc
```
```bash npm theme={null}
npm install -g @ctrlplane/cli
```
```bash curl theme={null}
curl -fsSL https://get.ctrlplane.dev | sh
```
## Authentication
Set your API key and workspace as environment variables or pass them as flags:
```bash theme={null}
# Environment variables (recommended)
export CTRLPLANE_API_KEY="your-api-key"
export CTRLPLANE_WORKSPACE="your-workspace-id"
export CTRLPLANE_URL="https://your-ctrlplane-instance.com"
# Or pass as flags
ctrlc --api-key "your-api-key" --workspace "your-workspace-id"
```
## Commands
### `ctrlc sync`
Sync infrastructure resources into Ctrlplane's inventory. Each subcommand
targets a specific provider or input method.
| Subcommand | Description |
| -------------------------------------------------------------------- | --------------------------------- |
| [`sync pipe`](/cli/sync-pipe) | Read resources from stdin (JSON) |
| [`sync kubernetes`](/integrations/resource-providers/kubernetes) | Sync Kubernetes cluster resources |
| [`sync aws`](/integrations/resource-providers/aws) | Sync AWS resources |
| [`sync google-cloud`](/integrations/resource-providers/google-cloud) | Sync Google Cloud resources |
| [`sync azure`](/integrations/resource-providers/azure) | Sync Azure resources |
| [`sync terraform`](/integrations/resource-providers/terraform) | Sync Terraform state resources |
| [`sync helm`](/integrations/resource-providers/helm) | Sync Helm releases |
| [`sync github`](/integrations/resource-providers/github) | Sync GitHub repositories |
| [`sync vcluster`](/integrations/resource-providers/vcluster) | Sync virtual clusters |
### `ctrlc api`
Interact directly with the Ctrlplane API.
| Subcommand | Description |
| -------------------- | ------------------------------------- |
| `api upsert version` | Create or update a deployment version |
See [CI/CD Integration](/integrations/cicd) for detailed usage.
### `ctrlc apply`
Apply resource definitions from YAML files.
```bash theme={null}
ctrlc apply -f resource.yaml
```
## Global Flags
| Flag | Environment Variable | Description |
| ------------- | --------------------- | -------------------------- |
| `--api-key` | `CTRLPLANE_API_KEY` | API key for authentication |
| `--workspace` | `CTRLPLANE_WORKSPACE` | Workspace name or ID |
| `--url` | `CTRLPLANE_URL` | Ctrlplane API URL |
## Next Steps
Sync resources from stdin
Use the CLI in CI/CD pipelines
Built-in resource providers
Build your own provider
# ctrlc sync
Source: https://docs.ctrlplane.dev/cli/sync
Sync infrastructure resources into Ctrlplane's inventory
The `ctrlc sync` command discovers and syncs infrastructure resources into
Ctrlplane. Each subcommand targets a specific provider or input method.
## Usage
```bash theme={null}
ctrlc sync [flags]
```
## Subcommands
| Subcommand | Description |
| --------------------------------------------------------------- | ----------------------------------- |
| [`pipe`](/cli/sync-pipe) | Read resources from stdin as JSON |
| [`kubernetes`](/integrations/resource-providers/kubernetes) | Sync Kubernetes cluster resources |
| [`aws`](/integrations/resource-providers/aws) | Sync AWS resources (EKS, ECS, etc.) |
| [`google-cloud`](/integrations/resource-providers/google-cloud) | Sync Google Cloud resources |
| [`azure`](/integrations/resource-providers/azure) | Sync Azure resources |
| [`terraform`](/integrations/resource-providers/terraform) | Sync Terraform state resources |
| [`helm`](/integrations/resource-providers/helm) | Sync Helm releases |
| [`github`](/integrations/resource-providers/github) | Sync GitHub repositories |
| [`vcluster`](/integrations/resource-providers/vcluster) | Sync virtual clusters |
## Common Flags
Most sync subcommands support these flags:
| Flag | Description |
| ------------ | -------------------------------------------------- |
| `--provider` | Resource provider name |
| `--interval` | Run continuously on an interval (e.g., `5m`, `1h`) |
## Examples
```bash theme={null}
# One-time sync
ctrlc sync kubernetes --cluster-name prod --cluster-identifier prod-us-east-1
# Continuous sync every 5 minutes
ctrlc sync kubernetes --cluster-name prod --cluster-identifier prod-us-east-1 --interval 5m
# Pipe custom resources from stdin
./discover.sh | ctrlc sync pipe --provider "custom"
```
## Next Steps
Sync from stdin
All resource providers
# ctrlc sync pipe
Source: https://docs.ctrlplane.dev/cli/sync-pipe
Sync resources into Ctrlplane by piping JSON through stdin
The `pipe` subcommand reads JSON resource data from stdin and upserts it into
Ctrlplane via a resource provider. This is the most flexible sync method — use
it to integrate any data source, script output, or API response without writing
a dedicated provider.
## Usage
```bash theme={null}
| ctrlc sync pipe --provider
```
The command reads from stdin, parses the JSON into one or more resources, and
upserts them under the specified resource provider.
## Flags
| Flag | Short | Required | Description |
| ------------ | ----- | -------- | ------------------------------------------------------------------ |
| `--provider` | `-p` | Yes | Resource provider name. Created automatically if it doesn't exist. |
[Global flags](/cli/overview#global-flags) (`--api-key`, `--workspace`, `--url`)
are also supported.
## Input Format
`ctrlc sync pipe` accepts JSON in two forms:
### Array of Resources
```json theme={null}
[
{
"name": "web-1",
"identifier": "web-1-prod",
"version": "custom/v1",
"kind": "Server",
"config": {},
"metadata": {}
},
{
"name": "web-2",
"identifier": "web-2-prod",
"version": "custom/v1",
"kind": "Server",
"config": {},
"metadata": {}
}
]
```
### Single Resource Object
A single object (without the array wrapper) is also accepted and is
automatically normalized to a one-element array:
```json theme={null}
{
"name": "web-1",
"identifier": "web-1-prod",
"version": "custom/v1",
"kind": "Server",
"config": {},
"metadata": {}
}
```
### Required Fields
Every resource must include these fields:
| Field | Type | Description |
| ------------ | ------ | ----------------------------------------------------- |
| `name` | string | Human-readable display name |
| `identifier` | string | Unique identifier for the resource |
| `version` | string | Resource version or schema version |
| `kind` | string | Resource type (e.g., `Server`, `Database/PostgreSQL`) |
### Optional Fields
| Field | Type | Description |
| ---------- | ------ | ------------------------------------------------------------ |
| `metadata` | object | Key-value pairs used for environment selectors and filtering |
| `config` | object | Configuration data passed to job agents during deployment |
For details on the distinction between metadata and config, see
[Resource Schema](/integrations/resource-providers/overview#resource-schema).
## Examples
### Pipe from a Discovery Script
Run a custom script that outputs JSON and pipe it directly:
```bash theme={null}
./discover-databases.sh | ctrlc sync pipe --provider "custom-db"
```
### Inline JSON
Quick one-liner to register resources:
```bash theme={null}
echo '[{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","config":{},"metadata":{}}]' \
| ctrlc sync pipe --provider "my-servers"
```
### Single Resource
No array wrapper needed for a single resource:
```bash theme={null}
echo '{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server"}' \
| ctrlc sync pipe --provider "my-servers"
```
### Transform API Responses with jq
Fetch data from an internal API and reshape it into the expected schema:
```bash theme={null}
curl -s https://cmdb.internal/api/servers \
| jq '[.[] | {
name,
identifier: .id,
version: "cmdb/v1",
kind: "Server",
config: .,
metadata: {}
}]' \
| ctrlc sync pipe --provider "cmdb"
```
### Sync from a CMDB with Metadata
Include metadata so resources can be targeted by environment selectors:
```bash theme={null}
curl -s https://cmdb.internal/api/servers \
| jq '[.[] | {
name: .hostname,
identifier: .asset_id,
version: "cmdb/v1",
kind: "Server/Linux",
config: {
host: .ip_address,
port: .ssh_port
},
metadata: {
environment: .env,
region: .datacenter,
team: .owner_team,
tier: .sla_tier
}
}]' \
| ctrlc sync pipe --provider "cmdb-servers"
```
### Database Inventory from PostgreSQL
Query a database and pipe the results:
```bash theme={null}
psql -h localhost -U admin -d inventory -t -A -c "
SELECT json_agg(json_build_object(
'name', hostname,
'identifier', instance_id,
'version', 'inventory/v1',
'kind', 'Database/' || engine,
'metadata', json_build_object('environment', env, 'region', region),
'config', json_build_object('host', endpoint, 'port', port)
)) FROM databases;
" | ctrlc sync pipe --provider "db-inventory"
```
### Running on a Schedule with cron
Sync resources every 5 minutes:
```bash theme={null}
# crontab -e
*/5 * * * * /usr/local/bin/discover-servers.sh | /usr/local/bin/ctrlc sync pipe --provider "cron-servers" 2>&1 >> /var/log/ctrlc-sync.log
```
### Running in CI/CD
Use `sync pipe` in a GitHub Actions workflow to register build artifacts as
resources:
```yaml theme={null}
name: Sync Resources
on:
schedule:
- cron: "*/10 * * * *"
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ctrlc
run: curl -fsSL https://get.ctrlplane.dev | sh
- name: Discover and sync
env:
CTRLPLANE_API_KEY: ${{ secrets.CTRLPLANE_API_KEY }}
CTRLPLANE_WORKSPACE: ${{ vars.CTRLPLANE_WORKSPACE }}
run: |
./scripts/discover-resources.sh \
| ctrlc sync pipe --provider "ci-discovered"
```
## Behavior
* **Provider auto-creation** — If the named provider doesn't exist, it is
created automatically.
* **Upsert semantics** — Resources are matched by `identifier`. Existing
resources are updated; new ones are created.
* **Stdin required** — The command exits with an error if no piped input is
detected or if stdin is empty.
* **Validation** — Each resource is validated for the required fields (`name`,
`identifier`, `version`, `kind`) before the API call. Missing fields produce a
descriptive error message.
## Error Handling
| Error | Cause | Fix |
| --------------------------- | ------------------------------------------------- | -------------------------------------------------------------------- |
| `no piped input detected` | Command was run interactively without piped input | Pipe JSON data to the command |
| `stdin is empty` | Piped input contained no data | Ensure the upstream command produces output |
| `invalid JSON input` | Input is not valid JSON | Check the JSON syntax; a snippet of the input is shown for debugging |
| `missing required field(s)` | One or more resources are missing required fields | Add the missing fields to each resource object |
## Best Practices
### Use Stable Identifiers
Choose identifiers that won't change across syncs:
```json theme={null}
{ "identifier": "db-prod-primary" }
```
Avoid identifiers derived from volatile attributes like IP addresses.
### Include Rich Metadata
Metadata powers Ctrlplane's environment selectors. Include attributes that are
useful for targeting:
```json theme={null}
{
"metadata": {
"environment": "production",
"region": "us-east-1",
"team": "platform",
"tier": "critical"
}
}
```
### Follow the Kind Naming Convention
Use a `Category/Type` format for consistency:
```json theme={null}
{ "kind": "Server/Linux" }
{ "kind": "Database/PostgreSQL" }
{ "kind": "Cache/Redis" }
```
### Validate Before Syncing
Pipe through `jq` to catch malformed JSON early:
```bash theme={null}
./discover.sh | jq '.' | ctrlc sync pipe --provider "validated"
```
## Next Steps
Overview of all resource providers
Build a custom provider via API or SDK
Target resources with environment selectors
Create dynamic environments
# Ctrlplane vs Alternatives
Source: https://docs.ctrlplane.dev/comparisons
How Ctrlplane compares to other deployment and orchestration tools
Ctrlplane is often compared to CI/CD tools, GitOps engines, and deployment
platforms. This page clarifies where Ctrlplane fits and how it differs from
alternatives.
## The Key Difference
**Ctrlplane is an orchestration layer, not an execution layer.**
It doesn't build your code (that's CI). It doesn't apply manifests to clusters
(that's ArgoCD/Flux/kubectl). It decides *when* and *where* deployments should
happen, enforces policies, and coordinates the flow across environments.
## Ctrlplane vs ArgoCD
| Aspect | ArgoCD | Ctrlplane |
| ------------------- | ---------------------------------------------- | -------------------------------------------------------- |
| **Primary purpose** | GitOps continuous delivery for Kubernetes | Deployment orchestration across environments |
| **What it does** | Syncs Applications to K8s clusters | Coordinates when/where deployments happen |
| **Scope** | Single cluster (or multi with ApplicationSets) | Multi-cluster, multi-environment, multi-region |
| **Policies** | Limited (sync waves, hooks) | Rich policy engine (approval, verification, progression) |
| **Verification** | Health checks on K8s resources | External verification (Datadog, Prometheus, HTTP) |
| **Inventory** | No unified inventory | Centralized resource inventory |
**When to use together**: Ctrlplane orchestrates *when* to deploy to each
cluster; ArgoCD *executes* the deployment. Ctrlplane has a native ArgoCD job
agent that creates/syncs Applications.
**Example flow**:
1. CI creates a Version in Ctrlplane
2. Ctrlplane evaluates policies (approval needed for prod)
3. After approval, Ctrlplane tells ArgoCD to sync the Application
4. ArgoCD applies manifests to the cluster
5. Ctrlplane runs verification (checks Datadog metrics)
6. If verification passes, Ctrlplane promotes to the next environment
## Ctrlplane vs Flux
| Aspect | Flux | Ctrlplane |
| ------------------- | ------------------------------------- | -------------------------------------------- |
| **Primary purpose** | GitOps toolkit for Kubernetes | Deployment orchestration across environments |
| **What it does** | Reconciles Git state to cluster state | Coordinates multi-environment rollouts |
| **Multi-cluster** | Via Flux controllers per cluster | Centralized orchestration of all clusters |
| **Policies** | Kustomize overlays, dependencies | Approval, verification, gradual rollout |
**When to use together**: Flux handles the GitOps reconciliation; Ctrlplane
handles the higher-level orchestration of when each environment should receive
updates.
## Ctrlplane vs Spinnaker
| Aspect | Spinnaker | Ctrlplane |
| ------------------- | -------------------------------------------- | --------------------------------------- |
| **Primary purpose** | Multi-cloud continuous delivery | Deployment orchestration with inventory |
| **Complexity** | Complex, requires significant infrastructure | Lightweight, single binary or container |
| **Pipeline model** | Visual pipeline builder | Policy-based (selectors + rules) |
| **Cloud support** | Deep cloud provider integrations | Provider-agnostic via job agents |
| **Inventory** | Limited | First-class resource inventory |
**When to choose Ctrlplane**: You want simpler operations, policy-based
orchestration rather than complex pipelines, and a unified inventory.
**When to choose Spinnaker**: You need deep cloud provider integrations (AWS
CodeDeploy, GCP, etc.) and prefer visual pipeline building.
## Ctrlplane vs GitHub Actions (alone)
| Aspect | GitHub Actions | Ctrlplane + GitHub Actions |
| ---------------- | --------------------------------- | ---------------------------------------------- |
| **Multi-env** | Separate workflows or matrix jobs | Automatic fan-out to all matching resources |
| **Approvals** | Environment protection rules | Flexible approval policies with selectors |
| **Verification** | Custom scripts in workflow | Built-in verification with Datadog, Prometheus |
| **Visibility** | Per-workflow run logs | Centralized view of all deployments |
| **Rollback** | Manual or custom scripting | Automatic rollback on verification failure |
**When to use together**: GitHub Actions builds your code and creates Versions.
Ctrlplane orchestrates the rollout. GitHub Actions can also be a job agent that
executes deployments triggered by Ctrlplane.
## Ctrlplane vs Harness / Octopus Deploy
| Aspect | Harness / Octopus | Ctrlplane |
| --------------- | ---------------------------------------- | --------------------------------------- |
| **Model** | Enterprise CD platforms with pipelines | Open-source orchestration with policies |
| **Pricing** | Commercial, often per-deployment pricing | Open-source (free) + cloud offering |
| **Flexibility** | Opinionated pipeline structure | Flexible policy engine |
| **Inventory** | Varies | First-class resource inventory |
**When to choose Ctrlplane**: You prefer open-source, policy-based
orchestration, and want to avoid vendor lock-in.
## Ctrlplane vs Terraform Cloud
| Aspect | Terraform Cloud | Ctrlplane |
| ------------------- | ------------------------------------------ | ----------------------------------------------- |
| **Primary purpose** | Infrastructure provisioning | Application deployment orchestration |
| **What it manages** | Cloud resources (VMs, networks, databases) | Application releases to existing infrastructure |
| **Workspace model** | One workspace per environment/component | Dynamic release targets from inventory |
**When to use together**: Terraform Cloud provisions infrastructure; Ctrlplane
orchestrates application deployments to that infrastructure. Ctrlplane has a
native Terraform Cloud job agent.
## Summary: When to Use Ctrlplane
Use Ctrlplane when you need:
| Need | Ctrlplane Helps By |
| ------------------------------------------ | ------------------------------------------------- |
| Coordinated multi-environment rollouts | Policy-based environment progression |
| Approval workflows for production | Flexible approval policies with selectors |
| Verification before promoting releases | Built-in Datadog, Prometheus, HTTP verification |
| Visibility into what's deployed where | Centralized resource inventory |
| Gradual rollouts across many clusters | Gradual rollout policies with configurable timing |
| Consistent deployment process across teams | Centralized policy engine |
## Integration Architecture
Here's how Ctrlplane typically fits with other tools:
```mermaid theme={null}
flowchart TB
subgraph CI["CI/CD"]
GHA["GitHub Actions"]
GitLab["GitLab CI"]
Jenkins["Jenkins"]
end
subgraph Ctrl["Ctrlplane"]
Orch["Orchestration"]
Inv["Inventory"]
Pol["Policies"]
end
subgraph Exec["Execution"]
Argo["ArgoCD"]
TFC["Terraform Cloud"]
K8s["Kubernetes Jobs"]
end
subgraph Monitor["Monitoring"]
DD["Datadog"]
Prom["Prometheus"]
end
CI -->|"create version"| Ctrl
Ctrl -->|"dispatch jobs"| Exec
Monitor -->|"verification"| Ctrl
```
## Next Steps
Set up your first deployment pipeline
Understand the architecture
# Deployments
Source: https://docs.ctrlplane.dev/concepts/deployments
Define what to deploy and how — services, versions, and execution
A **Deployment** defines **what** you want to deploy and **how** it gets
deployed. It represents a service or application along with its job agent
configuration — connecting the *what* (your service and its versions) with the
*how* (the agent and workflow that executes the deployment).
In Ctrlplane's mental model: **Deployments** = what & how, [**Environments**](./environments) = where, [**Policies**](../policies/overview) = when.
## What is a Deployment?
A deployment is a logical unit of software that you want to orchestrate:
* **API Service** - Your backend API
* **Frontend Application** - Web or mobile frontend
* **Background Worker** - Async job processor
* **Database Migration** - Schema changes
* **Configuration Update** - Infrastructure configuration
Each deployment can have multiple **versions** (builds, releases) that get deployed to different environments and resources.
## Deployment Properties
| Property | Type | Required | Description |
| ------------------ | ------ | -------- | ------------------------------------------------------- |
| `id` | string | Auto | Unique identifier |
| `name` | string | Yes | Human-readable display name |
| `slug` | string | Yes | URL-friendly identifier, unique within the workspace |
| `description` | string | No | What this deployment does |
| `resourceSelector` | string | No | CEL expression limiting which resources can be targeted |
| `jobAgents` | array | No | Job agent configurations (see below) |
| `metadata` | object | No | Key-value pairs for classification |
### name
Human-readable display name for the deployment.
**Examples**: "API Service", "Frontend Application", "Payment Processor"
### slug
URL-friendly identifier, unique within the workspace. In the Terraform provider,
the slug is auto-generated from the `name` field.
**Examples**: `api-service`, `frontend-app`, `payment-processor`
### resourceSelector
A CEL expression that limits which resources this deployment can target. If
specified, only resources matching this expression will have release targets
created.
**Examples**:
```
resource.kind == "Kubernetes"
```
```
resource.version == 'ctrlplane.dev/kubernetes/cluster/v1' && resource.metadata['kubernetes/status'] == 'running'
```
```
resource.metadata['region'] == 'us-east-1'
```
If not specified, the deployment can target any resource in its environments.
### jobAgents
An array of job agent configurations that execute deployment jobs. Each entry
specifies which agent to use and how to configure it, with optional routing
via selectors.
| Field | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------------ |
| `ref` | string | Yes | Job agent reference identifier |
| `config` | object | Yes | Agent-specific configuration |
| `selector` | string | No | CEL expression for routing to specific resources |
When multiple job agents are configured, the `selector` field determines which
agent handles which resources. This enables patterns like using ArgoCD for
some clusters and GitHub Actions for others within the same deployment.
### metadata
Optional key-value pairs for classification and policy matching:
```json theme={null}
{
"team": "backend",
"language": "nodejs",
"tier": "critical"
}
```
Metadata can be referenced in policy selectors (e.g.,
`deployment.metadata['tier'] == 'critical'`).
## Creating a Deployment
### Via Terraform
```hcl theme={null}
resource "ctrlplane_deployment" "api" {
name = "API Service"
resource_selector = "resource.version == 'ctrlplane.dev/kubernetes/cluster/v1' && resource.metadata['kubernetes/status'] == 'running'"
metadata = {
team = "backend"
service = "api"
}
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "api-service"
workflow_id = 12345678
}
}
}
resource "ctrlplane_deployment_system_link" "api" {
deployment_id = ctrlplane_deployment.api.id
system_id = ctrlplane_system.main.id
}
```
```hcl theme={null}
resource "ctrlplane_deployment" "operator" {
name = "Weights & Biases Operator"
resource_selector = "resource.version == 'ctrlplane.dev/kubernetes/cluster/v1' && resource.metadata['kubernetes/status'] == 'running'"
metadata = {}
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd {
template = file("${path.module}/templates/application.yaml")
}
}
}
resource "ctrlplane_deployment_system_link" "operator" {
deployment_id = ctrlplane_deployment.operator.id
system_id = ctrlplane_system.main.id
}
```
Deployments must be linked to a system using `ctrlplane_deployment_system_link`.
This is a separate resource that associates a deployment with a system.
### Via REST API
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "API Service",
"slug": "api-service",
"description": "Main backend API service",
"resourceSelector": "resource.kind == '\''Kubernetes'\''",
"jobAgents": [
{
"ref": "github-actions-agent",
"config": {
"workflow": "deploy.yml",
"owner": "my-org",
"repo": "api-service"
}
}
],
"metadata": {
"team": "backend",
"language": "nodejs"
}
}'
```
The API returns `202 Accepted` with the deployment `id`. Reads are available
immediately via `GET /v1/workspaces/{workspaceId}/deployments/{deploymentId}`.
### Via CLI (YAML)
```yaml theme={null}
# deployment.yaml
type: Deployment
name: API Service
slug: api-service
description: Main backend API service
jobAgent:
ref: github-actions-agent
jobAgentConfig:
workflow: deploy.yml
owner: my-org
repo: api-service
resourceSelector: resource.kind == "Kubernetes"
```
```bash theme={null}
ctrlc apply -f deployment.yaml
```
### Multiple Deployments
```hcl theme={null}
resource "ctrlplane_deployment" "api" {
name = "API Service"
resource_selector = "resource.kind == 'Kubernetes'"
metadata = { service = "api" }
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "api-service"
workflow_id = 12345678
}
}
}
resource "ctrlplane_deployment" "frontend" {
name = "Frontend App"
resource_selector = "resource.kind == 'Kubernetes'"
metadata = { service = "frontend" }
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "frontend-app"
workflow_id = 87654321
}
}
}
resource "ctrlplane_deployment_system_link" "api" {
deployment_id = ctrlplane_deployment.api.id
system_id = ctrlplane_system.main.id
}
resource "ctrlplane_deployment_system_link" "frontend" {
deployment_id = ctrlplane_deployment.frontend.id
system_id = ctrlplane_system.main.id
}
```
```yaml theme={null}
# deployments.yaml
---
type: Deployment
name: API Service
slug: api-service
description: Main backend API service
jobAgent:
ref: github-actions-agent
jobAgentConfig:
workflow: deploy.yml
owner: my-org
repo: api-service
---
type: Deployment
name: Frontend App
slug: frontend-app
description: Customer-facing web application
jobAgent:
ref: kubernetes-agent
jobAgentConfig:
namespace: default
```
```bash theme={null}
ctrlc apply -f deployments.yaml
```
## Job Agent Configuration
Each `job_agent` block (Terraform) or entry in the `jobAgents` array (API)
configures how a specific agent executes deployments. The Terraform provider
supports typed provider blocks for each agent type.
### GitHub Actions
```hcl theme={null}
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "api-service"
workflow_id = 12345678
ref = "main"
}
}
```
| Attribute | Type | Description |
| ----------------- | ------ | ---------------------------------------- |
| `owner` | string | GitHub repository owner |
| `repo` | string | GitHub repository name |
| `workflow_id` | int | GitHub Actions workflow ID |
| `ref` | string | Git ref to run on (defaults to `"main"`) |
| `installation_id` | int | GitHub App installation ID |
### ArgoCD
```hcl theme={null}
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd {
template = file("${path.module}/application.yaml")
server_url = "https://argocd.example.com"
}
}
```
| Attribute | Type | Description |
| ------------ | ------ | ---------------------------- |
| `template` | string | ArgoCD Application template |
| `server_url` | string | ArgoCD server address |
| `api_key` | string | ArgoCD API token (sensitive) |
### Terraform Cloud
```hcl theme={null}
job_agent {
id = ctrlplane_job_agent.tfc.id
terraform_cloud {
organization = "my-org"
template = file("${path.module}/workspace.json")
token = var.tfc_token
}
}
```
| Attribute | Type | Description |
| -------------- | ------ | --------------------------------- |
| `organization` | string | Terraform Cloud organization name |
| `template` | string | Workspace template |
| `address` | string | Terraform Cloud address |
| `token` | string | API token (sensitive) |
### Agent Routing with Selectors
When a deployment uses multiple job agents, use `selector` to route to specific
resources:
```hcl theme={null}
resource "ctrlplane_deployment" "app" {
name = "Application"
resource_selector = "resource.kind == 'Kubernetes'"
job_agent {
id = ctrlplane_job_agent.argocd.id
selector = "resource.metadata['cluster-type'] == 'managed'"
argocd {
template = file("${path.module}/managed-app.yaml")
}
}
job_agent {
id = ctrlplane_job_agent.github.id
selector = "resource.metadata['cluster-type'] == 'self-hosted'"
github {
owner = "my-org"
repo = "app-deploy"
workflow_id = 12345678
}
}
}
```
## Deployment Versions
Versions represent specific builds or releases of your deployment. They are
typically created by your CI system after building.
### Version Properties
| Property | Type | Required | Description |
| ---------------- | ------ | -------- | ------------------------------------------------ |
| `id` | string | Auto | Unique identifier |
| `deploymentId` | string | Auto | Parent deployment |
| `tag` | string | Yes | Version tag (e.g., `"v1.2.3"`, git SHA) |
| `name` | string | No | Human-readable name |
| `status` | string | No | `building`, `ready`, or `failed` |
| `config` | object | No | General version configuration, visible in UI |
| `jobAgentConfig` | object | No | Execution config (overrides deployment's config) |
| `metadata` | object | No | Custom key-value pairs |
| `createdAt` | string | Auto | Creation timestamp |
### Creating Versions (from CI)
After your CI builds an artifact, create a version in Ctrlplane:
```yaml theme={null}
# GitHub Actions example
- name: Create Ctrlplane version
env:
CTRLPLANE_API_KEY: ${{ secrets.CTRLPLANE_API_KEY }}
run: |
ctrlc api upsert version \
--workspace ${{ vars.CTRLPLANE_WORKSPACE }} \
--deployment ${{ vars.CTRLPLANE_DEPLOYMENT_ID }} \
--tag ${{ github.sha }} \
--name "Build #${{ github.run_number }}" \
--metadata git/commit=${{ github.sha }} \
--metadata git/branch=${{ github.ref_name }} \
--metadata build/number=${{ github.run_number }}
```
Or via the REST API:
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag": "v1.2.3",
"name": "Release 1.2.3",
"status": "ready",
"config": {
"buildNumber": "456"
},
"jobAgentConfig": {
"imageTag": "my-org/api-service:v1.2.3"
},
"metadata": {
"git_commit": "abc123def",
"git_branch": "main"
}
}'
```
### Version Status
Versions have a status field:
* **`building`** - Version is being built (won't be deployed yet)
* **`ready`** - Version is ready for deployment (default for policies)
* **`failed`** - Build failed (won't be deployed)
**Workflow**:
1. CI starts building → Create version with status `building`
2. Build succeeds → Update status to `ready`
3. Ctrlplane creates releases/jobs for `ready` versions
Or simpler:
1. Build completes → Create version with status `ready` immediately
### Version Config vs Job Agent Config
**config**: General version configuration, visible in UI
```json theme={null}
{
"buildNumber": "456",
"gitCommit": "abc123"
}
```
**jobAgentConfig**: Specific configuration for job execution (merged with the
deployment's job agent config at runtime)
```json theme={null}
{
"imageTag": "my-org/api-service:v1.2.3",
"helmValues": {
"replicas": 3
}
}
```
## Deployment Variables
Variables allow environment-specific or resource-specific configuration that
gets resolved at job creation time and passed to the job agent.
### Via Terraform
The Terraform provider has dedicated resources for deployment variables and
their values:
```hcl theme={null}
resource "ctrlplane_deployment_variable" "replica_count" {
deployment_id = ctrlplane_deployment.api.id
key = "REPLICA_COUNT"
description = "Number of replicas to run"
default_value = "1"
}
resource "ctrlplane_deployment_variable_value" "replica_count_prod" {
deployment_id = ctrlplane_deployment.api.id
variable_id = ctrlplane_deployment_variable.replica_count.id
priority = 0
resource_selector = "resource.metadata['environment'] == 'production'"
literal_value = "5"
}
```
#### Reference Values
Variable values can reference data from the workspace or resource metadata
instead of using literal values:
```hcl theme={null}
resource "ctrlplane_deployment_variable" "size" {
deployment_id = ctrlplane_deployment.redis.id
key = "size"
description = "The size of the Redis deployment"
default_value = "small"
}
resource "ctrlplane_deployment_variable_value" "size_from_workspace" {
deployment_id = ctrlplane_deployment.redis.id
variable_id = ctrlplane_deployment_variable.size.id
priority = 0
resource_selector = "resource.version == 'ctrlplane.dev/kubernetes/cluster/v1'"
reference_value = {
reference = "workspace"
path = ["metadata", "size"]
}
}
```
### Via REST API
**Create a variable:**
```bash theme={null}
curl -X PUT https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments/{deploymentId}/variables/{variableId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "REPLICA_COUNT",
"description": "Number of replicas to run"
}'
```
**Set an environment-specific value:**
```bash theme={null}
curl -X PUT https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments/{deploymentId}/variables/{variableId}/values/{valueId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"priority": 0,
"value": "5"
}'
```
### Using Variables in Jobs
Variables are resolved during job creation and passed to the job agent:
```yaml theme={null}
# In GitHub Actions workflow
- name: Get job inputs
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
job_id: ${{ inputs.job_id }}
- name: Deploy with variables
run: |
echo "Replicas: ${{ steps.job.outputs.variable_REPLICA_COUNT }}"
helm upgrade my-app ./chart \
--set replicaCount=${{ steps.job.outputs.variable_REPLICA_COUNT }}
```
## Release Targets
When you create a deployment, Ctrlplane automatically creates **release targets** by crossing the deployment with environments and resources.
**Formula**: `Deployment × Environment × Resource = Release Targets`
**Example**:
Given:
* Deployment: "API Service"
* Environments: Development, Staging, Production
* Resources in Production: 3 clusters
Release Targets Created:
1. API Service → Development → dev-cluster
2. API Service → Staging → staging-cluster
3. API Service → Production → prod-cluster-1
4. API Service → Production → prod-cluster-2
5. API Service → Production → prod-cluster-3
Each release target can receive deployment versions independently.
### Filtering Release Targets
Use the deployment's `resourceSelector` to limit which targets are created:
```
resource.version == 'ctrlplane.dev/kubernetes/cluster/v1' && resource.metadata['kubernetes/status'] == 'running'
```
Only resources matching this CEL expression will have release targets for this
deployment.
## Deployment Lifecycle
### 1. Create Deployment
Define the deployment in Ctrlplane with job agent configuration.
### 2. CI Builds and Creates Version
Your CI pipeline builds the artifact and creates a deployment version.
### 3. Ctrlplane Evaluates Policies
Ctrlplane checks policies (approvals, environment progression, etc.).
### 4. Jobs Created
For each release target that should receive the version, a job is created.
### 5. Job Agent Executes
The configured job agent picks up the job and executes the deployment.
### 6. Status Updated
The job reports status back to Ctrlplane.
## REST API Reference
### Deployment CRUD
**Create:**
```
POST /v1/workspaces/{workspaceId}/deployments
```
**Get:**
```
GET /v1/workspaces/{workspaceId}/deployments/{deploymentId}
```
**Update:**
```
PUT /v1/workspaces/{workspaceId}/deployments/{deploymentId}
```
**Delete:**
```
DELETE /v1/workspaces/{workspaceId}/deployments/{deploymentId}
```
**List:**
```
GET /v1/workspaces/{workspaceId}/deployments
```
### Versions
**Create version:**
```
POST /v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions
```
**List versions:**
```
GET /v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions
```
**Update version:**
```
PATCH /v1/workspaces/{workspaceId}/deployment-versions/{deploymentVersionId}
```
### Variables
**Upsert variable:**
```
PUT /v1/workspaces/{workspaceId}/deployments/{deploymentId}/variables/{variableId}
```
**Upsert variable value:**
```
PUT /v1/workspaces/{workspaceId}/deployments/{deploymentId}/variables/{variableId}/values/{valueId}
```
## Terraform Provider Reference
| Resource | Description |
| ------------------------------------- | -------------------------------------------- |
| `ctrlplane_deployment` | The deployment itself |
| `ctrlplane_deployment_system_link` | Links a deployment to a system |
| `ctrlplane_deployment_variable` | Defines a deployment variable |
| `ctrlplane_deployment_variable_value` | Sets a value for a variable (with selectors) |
## Viewing Deployment Status
### Via Web UI
1. Navigate to the deployment
2. See tabs:
* **Versions**: All versions created
* **Releases**: Active releases across targets
* **Jobs**: Execution history
* **Variables**: Configured variables
### Via API
**Get deployment details:**
```bash theme={null}
curl https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments/{deploymentId} \
-H "Authorization: Bearer $TOKEN"
```
**List versions:**
```bash theme={null}
curl https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions \
-H "Authorization: Bearer $TOKEN"
```
## Common Patterns
### Microservices
```hcl theme={null}
resource "ctrlplane_deployment" "user_service" {
name = "User Service"
resource_selector = "resource.kind == 'Kubernetes'"
metadata = { service = "user" }
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd { template = file("${path.module}/user-service.yaml") }
}
}
resource "ctrlplane_deployment" "order_service" {
name = "Order Service"
resource_selector = "resource.kind == 'Kubernetes'"
metadata = { service = "order" }
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd { template = file("${path.module}/order-service.yaml") }
}
}
resource "ctrlplane_deployment" "payment_service" {
name = "Payment Service"
resource_selector = "resource.kind == 'Kubernetes'"
metadata = { service = "payment" }
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd { template = file("${path.module}/payment-service.yaml") }
}
}
```
```yaml theme={null}
# microservices.yaml
---
type: Deployment
name: User Service
slug: user-service
jobAgent:
ref: argocd-agent
---
type: Deployment
name: Order Service
slug: order-service
jobAgent:
ref: argocd-agent
---
type: Deployment
name: Payment Service
slug: payment-service
jobAgent:
ref: argocd-agent
```
```bash theme={null}
ctrlc apply -f microservices.yaml
```
### Database + Application
```hcl theme={null}
resource "ctrlplane_deployment" "database_migration" {
name = "Database Migration"
resource_selector = "resource.kind == 'Kubernetes'"
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "migrations"
workflow_id = 11111111
}
}
}
resource "ctrlplane_deployment" "api_service" {
name = "API Service"
resource_selector = "resource.kind == 'Kubernetes'"
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "api-service"
workflow_id = 22222222
}
}
}
```
Use a [Deployment Dependency](../policies/deployment-dependency) policy to
ensure migrations complete before the API deploys.
### Multi-Platform
```hcl theme={null}
resource "ctrlplane_deployment" "web_service" {
name = "Web Service"
resource_selector = "resource.kind == 'Kubernetes'"
job_agent {
id = ctrlplane_job_agent.argocd.id
argocd { template = file("${path.module}/web.yaml") }
}
}
resource "ctrlplane_deployment" "lambda_function" {
name = "Lambda Function"
resource_selector = "resource.kind == 'AWSLambda'"
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "lambda-deploy"
workflow_id = 33333333
}
}
}
```
## Best Practices
### Naming
**Good Names**:
* ✅ "API Service"
* ✅ "Frontend Application"
* ✅ "Payment Processor"
**Good Slugs**:
* ✅ `api-service`
* ✅ `frontend-app`
* ✅ `payment-processor`
**Avoid**:
* ❌ "Service" (too generic)
* ❌ "api\_service" (use hyphens)
* ❌ "API-SERVICE" (use lowercase)
### Job Agent Configuration
**Do**:
* ✅ Use version's `jobAgentConfig` for version-specific values (like image tags)
* ✅ Use deployment's job agent config for stable configuration
* ✅ Keep sensitive data in secrets (not in config)
* ✅ Use agent `selector` when routing to different resource types
### Version Tags
**Good Tags**:
* ✅ `v1.2.3` (semantic version)
* ✅ `2024-01-15-prod` (date-based)
* ✅ `abc123def` (git commit)
**Avoid**:
* ❌ `latest` (not specific)
* ❌ `prod` (not a version)
* ❌ `test` (too vague)
### Resource Selectors
Use CEL resource selectors to:
* Limit Kubernetes deployments to Kubernetes resources
* Filter by resource status (e.g., `resource.metadata['status'] == 'running'`)
* Separate platform-specific deployments
* Control which resources can receive a deployment
## Troubleshooting
### No release targets created
* Check `resourceSelector` CEL expression matches some resources
* Verify environments have matching resources
* Ensure deployment is linked to a system (via `ctrlplane_deployment_system_link`
in Terraform)
### Jobs not being created for new versions
* Check version status is `ready`
* Review policies for denials/pending approvals
* Verify job agent is configured
* Check release target exists
### Job agent config not working
* Verify job agent block matches the agent type (e.g., `github {}` for GitHub
Actions agents)
* Check version's `jobAgentConfig` overrides correctly
* Review job agent logs for configuration errors
## Next Steps
* [Releases and Jobs](./releases-and-jobs) - Understand version lifecycle
* [Release Targets](./release-targets) - How the deployment matrix works
* [CI/CD Integration](../integrations/cicd) - Integrate with your CI system
* [Job Agents](../integrations/job-agents/github) - Configure deployment executors
* [Policies](../policies/overview) - Control when and how deployments proceed
# Environments
Source: https://docs.ctrlplane.dev/concepts/environments
Define where to deploy — logical stages that group resources
An **Environment** defines **where** to deploy. It represents a logical
deployment stage in your pipeline, such as development, staging, or production.
Environments use selectors to dynamically determine which resources belong to
them — answering the question of *where* a version should be released.
In Ctrlplane's mental model: [**Deployments**](./deployments) = what & how, **Environments** = where, [**Policies**](../policies/overview) = when.
## What is an Environment?
Environments organize your deployment pipeline into stages:
* **Development** - Where developers test their changes
* **QA** - Quality assurance and testing
* **Staging** - Pre-production validation
* **Production** - Live customer-facing environment
Environments are **not** static groups of resources. Instead, they use **selectors** to dynamically match resources based on their metadata. This means:
* New resources matching the selector automatically join the environment
* Resources can belong to multiple environments
* No manual resource assignment needed
## Environment Properties
```yaml theme={null}
id: env_abc123
systemId: sys_xyz789
name: Production
directory: environments/prod
description: Production environment for customer-facing services
resourceSelector: resource.metadata["environment"] == "production"
createdAt: "2024-01-15T10:00:00Z"
metadata:
owner: platform-team
alert-channel: "#prod-alerts"
```
### name
Display name for the environment.
**Examples**:
* "Production"
* "Staging"
* "Development"
* "QA US East"
### systemId
The system this environment belongs to. Environments are scoped to systems.
### resourceSelector
The selector that determines which resources belong to this environment. This is the heart of how environments work.
**Example Selectors**:
**Simple metadata match** (CEL expression):
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
**Multiple conditions (AND)**:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
**OR logic**:
```yaml theme={null}
resourceSelector: >-
resource.metadata["region"] == "us-east-1" ||
resource.metadata["region"] == "us-west-2"
```
### directory
Optional path for hierarchical organization of environments.
**Examples**:
* `""` - Root level
* `"regions"` - First level grouping
* `"regions/us-east"` - Nested grouping
**Use Cases**:
* Organizing by region: `regions/us-east`, `regions/eu-west`
* Organizing by type: `permanent/production`, `temporary/feature-1`
* Organizing by team: `teams/platform`, `teams/product`
### description
Optional description of the environment's purpose and usage.
### metadata
Key-value pairs with environment information (not used for resource matching).
**Common Metadata**:
```yaml theme={null}
metadata:
owner: platform-team
alert-channel: "#prod-alerts"
cost-center: engineering
sla: "99.99%"
```
## Creating Environments
### Via Web UI
1. Navigate to your system
2. Click "Environments" tab
3. Click "Create Environment"
4. Fill in:
* Name: "Production"
* Description: "Production environment"
* Resource Selector: Add conditions
5. Click "Create"
### Via CLI
```yaml theme={null}
# environment.yaml
type: Environment
name: Production
description: Production environment for customer-facing services
resourceSelector: resource.metadata["environment"] == "production"
metadata:
owner: platform-team
```
```bash theme={null}
ctrlc apply -f environment.yaml
```
### Multiple Environments
```yaml theme={null}
# environments.yaml
---
type: Environment
name: Development
description: Development environment
resourceSelector: resource.metadata["environment"] == "development"
---
type: Environment
name: Staging
description: Staging environment
resourceSelector: resource.metadata["environment"] == "staging"
---
type: Environment
name: Production
description: Production environment
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f environments.yaml
```
## How Resource Selectors Work
Selectors match resources based on their metadata, kind, identifier, or other properties.
### Selector Types
Ctrlplane uses CEL (Common Expression Language) for resource selectors.
#### Metadata Selector
Match resources by metadata key-value pairs.
**Single condition**:
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
**Operators**:
* `==` - Exact match
* `!=` - Not equals
* `in` - Value in list
* `has()` - Key exists
**Multiple conditions (AND)**:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
**Multiple conditions (OR)**:
```yaml theme={null}
resourceSelector: >-
resource.metadata["region"] == "us-east-1" ||
resource.metadata["region"] == "us-west-2"
```
#### Kind Selector
Match resources by their `kind` field.
```yaml theme={null}
resourceSelector: resource.kind == "KubernetesCluster"
```
#### Identifier Selector
Match resources by their identifier.
```yaml theme={null}
resourceSelector: resource.identifier.contains("prod")
```
#### Complex CEL Expressions
Use CEL for complex matching logic.
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["tier"] == "critical"
```
### Selector Evaluation
When determining which resources belong to an environment:
1. Ctrlplane evaluates the `resourceSelector` against all resources
2. Resources that match are included in the environment
3. As resources are added/updated, they're re-evaluated
4. Resources are automatically added/removed as they match/unmatch
**Example**:
Environment selector:
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
Resources:
* ✅ `{name: "Cluster A", metadata: {environment: "production"}}` - **Matched**
* ❌ `{name: "Cluster B", metadata: {environment: "staging"}}` - Not matched
* ✅ `{name: "Cluster C", metadata: {environment: "production", region: "us-east"}}` - **Matched**
Result: Environment contains Cluster A and Cluster C
## Release Targets
When you create a deployment and an environment, Ctrlplane automatically creates **release targets** for each matching resource.
**Formula**: `Deployment × Environment × Resource = Release Target`
**Example**:
Given:
* Deployment: "API Service"
* Environment: "Production" (matches 3 resources)
* Resource A: k8s-prod-use1
* Resource B: k8s-prod-usw2
* Resource C: k8s-prod-euw1
Result: 3 release targets created:
1. API Service → Production → k8s-prod-use1
2. API Service → Production → k8s-prod-usw2
3. API Service → Production → k8s-prod-euw1
When you create a new deployment version, jobs can be created for each release target (subject to policies).
## Hierarchical Environments
Use the `directory` field to organize environments hierarchically.
### Example Structure
```
Root
├── regions/
│ ├── us-east-1/
│ │ ├── Production
│ │ └── Staging
│ └── eu-west-1/
│ ├── Production
│ └── Staging
└── temporary/
├── feature-branch-1
└── feature-branch-2
```
### Creating Hierarchical Environments
```yaml theme={null}
# hierarchical-environments.yaml
---
type: Environment
name: Global Production
directory: ""
resourceSelector: resource.metadata["environment"] == "production"
---
type: Environment
name: US East Production
directory: regions/us-east
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
---
type: Environment
name: US East 1a Production
directory: regions/us-east/availability-zones
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["zone"] == "us-east-1a"
```
```bash theme={null}
ctrlc apply -f hierarchical-environments.yaml
```
### Benefits
* **Visual organization** in UI
* **Easier navigation** with many environments
* **Logical grouping** by region, team, or purpose
* **Permission scoping** (future feature)
## Common Environment Patterns
### Standard Pipeline
```yaml theme={null}
# environments.yaml
---
type: Environment
name: Development
resourceSelector: resource.metadata["environment"] == "development"
---
type: Environment
name: Staging
resourceSelector: resource.metadata["environment"] == "staging"
---
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f environments.yaml
```
### Multi-Region Environments
```yaml theme={null}
# multi-region-environments.yaml
---
type: Environment
name: Production US East
directory: production/us-east
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
---
type: Environment
name: Production US West
directory: production/us-west
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-west-2"
```
```bash theme={null}
ctrlc apply -f multi-region-environments.yaml
```
### Canary Environments
```yaml theme={null}
# canary-environments.yaml
---
type: Environment
name: Production Canary
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["canary"] == "true"
---
type: Environment
name: Production Stable
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["canary"] != "true"
```
```bash theme={null}
ctrlc apply -f canary-environments.yaml
```
### Team-Specific Environments
```yaml theme={null}
# team-environments.yaml
---
type: Environment
name: Platform Team Development
resourceSelector: >-
resource.metadata["team"] == "platform" &&
resource.metadata["environment"] == "development"
---
type: Environment
name: Product Team Development
resourceSelector: >-
resource.metadata["team"] == "product" &&
resource.metadata["environment"] == "development"
```
```bash theme={null}
ctrlc apply -f team-environments.yaml
```
## Viewing Environment Resources
### Via CLI
```bash theme={null}
ctrlc api get resources --environment {environmentId}
```
Returns all resources matching the environment's selector.
### Via Web UI
1. Navigate to the environment
2. Click "Resources" tab
3. See all matched resources
The UI shows:
* Resource name and kind
* Metadata
* Current deployed versions
* Job status
## Environment Variables
Environments can have variables that differ per deployment.
### Deployment Variables
Define a variable at the deployment level, then set values per environment. Variables are typically configured via the Web UI, but can also be managed via API.
See the [Deployments documentation](./deployments) for details on creating and setting deployment variables.
During job execution, the appropriate value is resolved based on the environment.
## Policies and Environments
Policies often target specific environments using selectors.
### Approval Required for Production
```yaml theme={null}
# policy.yaml
type: Policy
name: Production Approval Required
selectors:
- environments: environment.name == "Production"
rules:
- anyApproval:
minApprovals: 2
```
```bash theme={null}
ctrlc apply -f policy.yaml
```
### Environment Progression
Ensure deployments go to staging before production:
```yaml theme={null}
# progression-policy.yaml
type: Policy
name: Staging Before Production
selectors:
- environments: environment.name == "Production"
rules:
- environmentProgression:
fromEnvironment: Staging
toEnvironment: Production
```
```bash theme={null}
ctrlc apply -f progression-policy.yaml
```
## Best Practices
### Naming
**Good Names**:
* ✅ "Production"
* ✅ "Staging"
* ✅ "Development"
* ✅ "QA US East"
**Avoid**:
* ❌ "Prod" (use full names)
* ❌ "Environment 1" (not descriptive)
* ❌ "John's Test" (not permanent)
### Selector Design
**Do**:
* ✅ Use consistent metadata keys across resources
* ✅ Make selectors explicit and clear
* ✅ Test selectors match expected resources
* ✅ Document complex selectors
**Don't**:
* ❌ Overly complex selectors that are hard to understand
* ❌ Selectors that might accidentally match wrong resources
* ❌ Selectors based on frequently changing metadata
### Environment Count
**Start Simple**:
```
- Development
- Production
```
**Grow as Needed**:
```
- Development
- Staging
- Production
- Production Canary
```
**Don't Over-Engineer**:
* Avoid creating environments you won't use
* Consolidate similar environments
* Use resource metadata to differentiate within an environment
## Troubleshooting
### Environment shows no resources
* Check the resource selector syntax
* Verify resources exist with matching metadata
* Test selector with "Query Resources" feature
* Check resources aren't deleted
### Wrong resources in environment
* Review resource selector logic
* Check for OR vs AND conditions
* Verify resource metadata is correct
* Test selector in isolation
### Release targets not created
* Verify environment has matching resources
* Check deployment has resource selector (if any)
* Ensure deployment and environment are in same system
* Review logs for errors
## Next Steps
* [Deployments](./deployments) - Create deployments
* [Selectors](./selectors) - Deep dive into selector syntax
* [Release Targets](./release-targets) - Understand the deployment matrix
* [Policies](../policies/overview) - Control deployments with policies
# Concepts Overview
Source: https://docs.ctrlplane.dev/concepts/overview
Overview of the core concepts in Ctrlplane
This page provides an overview of the core concepts in Ctrlplane.
The three pillars of Ctrlplane answer the fundamental questions of deployment:
* **Deployments** define **what** to deploy and **how** — the service, its versions, and the job agent that executes the deployment.
* **Environments** define **where** to deploy — logical stages that dynamically group resources via selectors.
* **Policies** define **when** to deploy — rules like approvals, verification, progression gates, and deployment windows that control timing.
```
System
├── Deployments (what & how)
│ └── Versions (specific builds)
├── Environments (where)
│ └── Resources (deployment targets)
└── Policies (when)
Release Target = Deployment × Environment × Resource
Release = Version deployed to a Release Target
└── Job (executed by Job Agent)
```
## System
A **System** is a logical grouping of related deployments, environments, and
resources. Think of it as a workspace for a product or team.
| Property | Description |
| ------------- | ---------------------------- |
| `name` | Display name |
| `slug` | URL-friendly identifier |
| `description` | What this system encompasses |
**Example**: "E-commerce Platform" system containing API, Frontend, and Payment
deployments.
**When to create a System**: One per product, platform, or team boundary.
## Resource
A **Resource** is a deployment target—the actual infrastructure where your code
runs.
| Property | Description |
| ------------ | ---------------------------------------------- |
| `name` | Human-readable name |
| `kind` | Type (e.g., `KubernetesCluster`, `AWS/Lambda`) |
| `identifier` | Unique identifier |
| `metadata` | Key-value pairs for classification |
| `config` | Resource-specific configuration |
| `version` | Current version/state |
**Examples**: Kubernetes cluster, EC2 instance, Lambda function, VM.
**How created**: Via Resource Providers (auto-sync from K8s, AWS, GCP) or API.
```yaml theme={null}
name: prod-us-east-1
kind: KubernetesCluster
identifier: k8s-prod-use1
metadata:
region: us-east-1
environment: production
tier: critical
config:
server: https://k8s.example.com
```
## Environment
An **Environment** defines **where** to deploy. It represents a logical
deployment stage (dev, staging, prod) that groups resources using selectors.
| Property | Description |
| ------------------ | ------------------------------------------- |
| `name` | Environment name |
| `systemId` | Parent system |
| `resourceSelector` | Selector determining which resources belong |
| `directory` | Optional path for hierarchical organization |
**Key concept**: Environments are *dynamic*. When you add a new resource
matching the selector, it automatically joins the environment.
```yaml theme={null}
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
## Deployment
A **Deployment** defines **what** to deploy and **how**. It represents a service
or application you want to deploy, along with the job agent configuration that
determines how the deployment is executed.
| Property | Description |
| ------------------ | ------------------------------------------------ |
| `name` | Deployment name |
| `slug` | URL-friendly identifier |
| `description` | What this deployment does |
| `systemId` | Parent system |
| `resourceSelector` | Optional filter for which resources can run this |
| `jobAgentId` | Which job agent executes deployments |
| `jobAgentConfig` | Configuration passed to the job agent |
**Examples**: "API Service", "Frontend App", "Payment Processor".
## Version
A **Version** is a specific build or release of a deployment, typically created
by your CI pipeline.
| Property | Description |
| ---------------- | ------------------------------------------------- |
| `deploymentId` | Parent deployment |
| `tag` | Version identifier (e.g., `v1.2.3`, `sha-abc123`) |
| `name` | Optional human-readable name |
| `status` | `building`, `ready`, or `failed` |
| `metadata` | Arbitrary metadata (git commit, build number) |
| `config` | Version-specific configuration |
| `jobAgentConfig` | Overrides deployment's job agent config |
**Status meanings**:
* `building` — Still being built, won't be deployed
* `ready` — Ready for deployment (default for policies)
* `failed` — Build failed, won't be deployed
```bash theme={null}
# CI creates a version after building
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v1.2.3",
"status": "ready",
"metadata": {"commit": "abc123"}
}'
```
## Release Target
A **Release Target** is the combination of a Deployment, Environment, and
Resource. It represents a specific place where a deployment can be released.
```
Release Target = Deployment × Environment × Resource
```
**Example**:
* Deployment: "API Service"
* Environment: "Production"
* Resource: "us-east-1 cluster"
* **Release Target**: "API Service on Production/us-east-1"
**Automatic creation**: Release targets are computed from the intersection of:
1. Environment's resource selector → which resources
2. Deployment's resource selector (if any) → further filtering
## Release
A **Release** is an instance of deploying a specific Version to a Release
Target.
| Property | Description |
| --------------- | ---------------------------- |
| `versionId` | Which version to deploy |
| `deploymentId` | Which deployment |
| `environmentId` | Which environment |
| `resourceId` | Which resource |
| `createdAt` | When the release was created |
Releases do not carry their own status. The state of a release is inferred from
the **Release Target State** — which tracks the desired release, current release,
and latest job for each target.
## Job
A **Job** is the actual deployment task executed by a Job Agent.
| Property | Description |
| ------------ | ----------------------------------------------- |
| `releaseId` | The release this job deploys |
| `jobAgentId` | Which agent executes this job |
| `status` | `pending`, `in_progress`, `completed`, `failed` |
| `externalId` | External identifier (e.g., GitHub run ID) |
| `message` | Status message or error details |
**Job lifecycle**:
1. Ctrlplane creates job for approved release
2. Job agent polls and receives the job
3. Agent acknowledges and executes
4. Agent updates status as it progresses
5. Agent marks completed or failed
## Job Agent
A **Job Agent** is the executor that performs deployments. It bridges Ctrlplane
to your infrastructure.
## Policy
A **Policy** defines **when** a version is allowed to deploy. Policies are the
rules governing deployment timing—approvals, gates, windows, and verification
that control when releases progress.
| Component | Description |
| ----------- | -------------------------------------------- |
| `name` | Policy name |
| `selectors` | Which release targets this policy applies to |
| `rules` | The specific rules to enforce |
**Policy types**:
| Type | Description |
| ----------------------- | --------------------------------------------- |
| Approval | Requires manual sign-off |
| Environment Progression | Wait for another environment to succeed first |
| Gradual Rollout | Deploy to targets sequentially with delays |
| Deployment Window | Only deploy during certain hours |
| Version Selector | Filter which versions can deploy |
| Version Cooldown | Minimum time between deployments |
| Deployment Dependency | Wait for another deployment to complete |
| Verification | Check metrics after deployment |
**Policy evaluation**: When a release target needs deployment:
1. Find all policies matching the target
2. Evaluate each rule
3. All pass → create job
4. Any requires action → release is pending
5. Any denies → release is blocked
## Selector
**Selectors** are query expressions used to match resources, environments, or
deployments.
```yaml theme={null}
# Match resources in production
resource.metadata["environment"] == "production"
# Match critical deployments in any region
deployment.metadata["tier"] == "critical" &&
resource.metadata["region"] in ["us-east-1", "eu-west-1"]
```
Used in:
* Environment resource selectors
* Deployment resource selectors
* Policy target selectors
See [Selectors](/concepts/selectors) for full syntax.
## Variables
**Variables** provide dynamic configuration for deployments.
| Type | Description |
| -------------------- | ------------------------------------------- |
| Deployment Variables | Defined per deployment, vary by environment |
| Resource Variables | Defined on resources |
```json theme={null}
{
"deploymentVariable": "replicas",
"values": [
{ "environmentId": "dev", "value": "1" },
{ "environmentId": "prod", "value": "3" }
]
}
```
## Quick Reference Table
| Entity | What It Is |
| -------------- | ----------------------------------------------------------- |
| System | Workspace grouping deployments and environments |
| Resource | Deployment target (cluster, VM, function) |
| Deployment | **What & how** — service/app to deploy and its agent config |
| Environment | **Where** — logical stage grouping resources via selectors |
| Version | Specific build of a deployment |
| Release Target | Deployment × Environment × Resource |
| Release | Version being deployed to a release target |
| Job | Execution task sent to a job agent |
| Job Agent | Executor (ArgoCD, GitHub Actions, etc.) |
| Policy | **When** — rules controlling deployment timing |
| Selector | Query expression matching entities |
## Next Steps
* [How It Works](/how-it-works) — Understand the flow
* [Quickstart](/quickstart) — Hands-on tutorial
* [Selectors](/concepts/selectors) — Deep dive into selector syntax
* [Policies](/policies/overview) — Configure deployment rules
# Release Targets
Source: https://docs.ctrlplane.dev/concepts/release-targets
The combination of a Deployment, Environment, and Resource
A **Release Target** is the combination of a Deployment, Environment, and Resource. It represents a specific place where a deployment version can be released.
## Understanding Release Targets
The core formula:
```
Deployment × Environment × Resource = Release Target
```
### Example
Given:
* **Deployment**: API Service
* **Environment**: Production (matches 2 resources)
* Resource 1: prod-cluster-us-east
* Resource 2: prod-cluster-us-west
**Release Targets Created**:
1. API Service → Production → prod-cluster-us-east
2. API Service → Production → prod-cluster-us-west
Each release target can independently receive deployment versions.
## How Release Targets are Created
Release targets are created automatically based on selectors:
### 1. Environment Selector Matches Resources
Environment defines a resource selector:
```yaml theme={null}
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
This matches all resources with `metadata.environment = "production"`.
### 2. Deployment Selector Further Filters (Optional)
Deployment can have an additional resource selector:
```yaml theme={null}
type: Deployment
name: API Service
resourceSelector: resource.kind == "KubernetesCluster"
```
This limits the deployment to only Kubernetes clusters.
### 3. Intersection Creates Release Targets
The final set of resources is the intersection of:
* Resources matched by environment selector
* Resources matched by deployment selector (if present)
**Example Calculation**:
```
Environment "Production" matches:
- prod-k8s-cluster-1 (kind: kubernetes-cluster, env: production)
- prod-k8s-cluster-2 (kind: kubernetes-cluster, env: production)
- prod-vm-server-1 (kind: vm, env: production)
Deployment "API Service" selector (kind: kubernetes-cluster):
- prod-k8s-cluster-1 ✓
- prod-k8s-cluster-2 ✓
- prod-vm-server-1 ✗ (not a kubernetes-cluster)
Release Targets Created:
1. API Service → Production → prod-k8s-cluster-1
2. API Service → Production → prod-k8s-cluster-2
```
## Release Target Properties
```yaml theme={null}
id: rt_abc123
deploymentId: dep_api
environmentId: env_prod
resourceId: res_cluster1
key: dep_api:env_prod:res_cluster1
createdAt: "2024-01-15T10:00:00Z"
```
### key
Unique identifier combining deployment, environment, and resource IDs.
Format: `{deploymentId}:{environmentId}:{resourceId}`
This key is used to track which version is currently deployed to each target.
## Release Target Lifecycle
### Creation
Release targets are created when:
* A new deployment is created (targets created for all matching environments and resources)
* A new environment is created (targets created for all deployments and matching resources)
* A new resource is created that matches existing environment selectors (targets created for all deployments)
### Deletion
Release targets are deleted when:
* The deployment is deleted
* The environment is deleted
* The resource is deleted or no longer matches the selectors
### Dynamic Updates
As resources are added, updated, or removed:
* Resource metadata changes → Release targets re-evaluated
* Resource matches environment selector → New release targets created
* Resource stops matching → Release targets removed
## Viewing Release Targets
### Via Web UI
1. Navigate to your deployment
2. Click "Release Targets" tab
3. See all targets with current deployed versions
### Via CLI
```bash theme={null}
# List release targets for a deployment
ctrlc api get release-targets --deployment {deploymentId}
```
**Response**:
```yaml theme={null}
releaseTargets:
- id: rt_abc123
deployment:
id: dep_api
name: API Service
environment:
id: env_prod
name: Production
resource:
id: res_cluster1
name: Production US East Cluster
identifier: k8s-prod-use1
currentRelease:
versionTag: v1.2.3
status: completed
```
## Release Target Matrix
Think of release targets as a 3D matrix:
```
Resources
┌─────┬─────┬─────┐
│ R1 │ R2 │ R3 │
┌────────────┼─────┼─────┼─────┤
│ Dev │ RT │ RT │ RT │ Environments
│ Staging │ RT │ RT │ RT │
│ Production │ RT │ RT │ RT │
└────────────┴─────┴─────┴─────┘
↑
Deployment
```
Each cell "RT" is a release target.
For multiple deployments, this becomes a 3D cube:
```
Deployments (layers)
↓
[API] [Frontend] [Worker]
↓ ↓ ↓
Environment × Resource matrices
```
## Release Target States
While release targets don't have an explicit "status" field, their state is determined by their current release:
### No Release
Target exists but no version has been deployed yet.
### Active Release
A version is currently being deployed (job in progress).
### Completed Release
A version is successfully deployed (job completed).
### Failed Release
Latest deployment attempt failed.
### Pending Release
A release exists but is waiting for approval/policies.
## Deployment Targeting Strategies
### Strategy 1: Broad Matching
Environment matches many resources, deployment doesn't filter:
```yaml theme={null}
# Environment matches all production resources
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```yaml theme={null}
# Deployment (no selector)
# → Deploys to ALL production resources
type: Deployment
name: Monitoring Agent
# resourceSelector: not specified
```
**Result**: Deployment goes to every production resource.
**Use Case**: Services that should run everywhere (monitoring agents, logging).
### Strategy 2: Deployment-Level Filtering
Deployment limits which resources it can target:
```yaml theme={null}
# Environment
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```yaml theme={null}
# Deployment filters to Kubernetes only
type: Deployment
name: API Service
resourceSelector: resource.kind == "KubernetesCluster"
```
**Result**: Deployment only goes to production Kubernetes clusters.
**Use Case**: Platform-specific deployments (containers vs. VMs).
### Strategy 3: Fine-Grained Environments
Create specific environments for precise targeting:
```yaml theme={null}
# Environment: Production Kubernetes US East
type: Environment
name: Production Kubernetes US East
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1" &&
resource.kind == "KubernetesCluster"
```
**Result**: Very specific set of release targets.
**Use Case**: Region-specific deployments, gradual rollouts.
## Release Target Locking
Release targets can be locked to prevent new deployments:
```bash theme={null}
ctrlc api lock release-target {releaseTargetId} --reason "Maintenance window"
```
To unlock:
```bash theme={null}
ctrlc api unlock release-target {releaseTargetId}
```
**Use Cases**:
* Maintenance windows
* Incident response (freeze deployments)
* Testing/debugging (keep specific version)
Locked targets won't receive new deployments until unlocked.
## Querying Release Targets
### By Deployment
```bash theme={null}
ctrlc api get release-targets --deployment {deploymentId}
```
Shows all targets for a specific deployment.
### By Environment
```bash theme={null}
ctrlc api get release-targets --environment {environmentId}
```
Shows all targets in a specific environment.
### By Resource
```bash theme={null}
ctrlc api get release-targets --resource {resourceId}
```
Shows all targets for a specific resource (all deployments).
### With Filters
```bash theme={null}
ctrlc api get release-targets \
--deployment dep_api \
--environment env_prod \
--status completed
```
## Release Target Count Estimates
Before creating a deployment, estimate how many release targets will be created:
```bash theme={null}
ctrlc api estimate-targets \
--system sys_abc \
--selector 'resource.kind == "KubernetesCluster"'
```
**Response**:
```yaml theme={null}
environments:
- name: Development
matchingResources: 1
releaseTargets: 1
- name: Production
matchingResources: 3
releaseTargets: 3
totalReleaseTargets: 4
```
## Common Patterns
### Pattern 1: Environment-Per-Region
```yaml theme={null}
# regional-setup.yaml
---
type: Deployment
name: API Service
slug: api-service
jobAgent:
ref: kubernetes-agent
---
type: Environment
name: Production US East
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
---
type: Environment
name: Production US West
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-west-2"
```
```bash theme={null}
ctrlc apply -f regional-setup.yaml
```
**Release Targets**: API Service gets separate targets per region.
**Benefit**: Independent regional deployments, gradual rollout by region.
### Pattern 2: Tiered Resources
```yaml theme={null}
# tiered-setup.yaml
---
type: Deployment
name: Critical Service
slug: critical-service
resourceSelector: resource.metadata["tier"] == "critical"
jobAgent:
ref: kubernetes-agent
---
type: Deployment
name: Standard Service
slug: standard-service
resourceSelector: resource.metadata["tier"] == "standard"
jobAgent:
ref: kubernetes-agent
---
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f tiered-setup.yaml
```
**Release Targets**: Services only deploy to appropriate tier resources.
**Benefit**: Resource isolation, cost optimization.
### Pattern 3: Canary Deployments
```yaml theme={null}
# canary-setup.yaml
---
type: Deployment
name: API Service
slug: api-service
jobAgent:
ref: kubernetes-agent
---
type: Environment
name: Production Canary
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["canary"] == "true"
---
type: Environment
name: Production Stable
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["canary"] != "true"
```
```bash theme={null}
ctrlc apply -f canary-setup.yaml
```
**Release Targets**: Separate targets for canary vs. stable.
**Benefit**: Test new versions on canary before rolling out to stable.
## Best Practices
### Resource Metadata Design
Design resource metadata with targeting in mind:
```yaml theme={null}
# Good resource metadata
metadata:
environment: production
region: us-east-1
zone: us-east-1a
tier: critical
team: platform
canary: "false"
```
This allows flexible targeting across multiple dimensions.
### Selector Simplicity
**Prefer Simple Selectors**:
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
**Over Complex Ones**:
```yaml theme={null}
resourceSelector: >-
(resource.metadata["environment"] == "production" ||
resource.metadata["environment"] == "prod") &&
resource.metadata["tier"] != "deprecated" && ...
```
Complex selectors are harder to understand and maintain.
### Validate Target Count
Before deploying:
1. Check estimated release target count
2. Verify targets match expectations
3. Test in lower environment first
### Monitor Release Targets
* Track release target creation/deletion events
* Alert on unexpected target count changes
* Review targets when resources are added/updated
## Troubleshooting
### Too many release targets created
* Review environment resource selectors
* Check if deployment needs a resource selector to filter
* Verify resource metadata is correct
### Expected targets not created
* Check environment selector matches resources
* Verify deployment selector (if present) isn't too restrictive
* Ensure deployment and environment are in same system
* Check resources exist and aren't deleted
### Targets created for wrong resources
* Review selector logic (AND vs. OR)
* Check resource metadata matches expectations
* Test selectors with query API before applying
### Release target disappeared
* Check if resource was deleted
* Verify resource still matches environment selector
* Check if resource metadata changed
## Next Steps
* [Releases and Jobs](./releases-and-jobs) - How versions deploy to targets
* [Selectors](./selectors) - Master the selector syntax
* [Environments](./environments) - Create and configure environments
* [Policies](../policies/overview) - Control which targets receive deployments
# Releases and Jobs
Source: https://docs.ctrlplane.dev/concepts/releases-and-jobs
How Ctrlplane turns deployment versions into actual deployments
This guide explains how Ctrlplane turns deployment versions into actual
deployments through **releases** and **jobs**.
## Overview
The flow from version to execution:
```
Deployment Version (what to deploy)
↓
Release (intent to deploy to a specific target)
↓
Job (actual deployment task)
↓
Job Agent (executes the deployment)
```
## Release
A **Release** represents the intent to deploy a
specific version to a specific release target
(Deployment × Environment × Resource).
Ctrlplane creates one release per target. A target is a single resource that
matches the deployment's resource selector and is also included in an
environment (via the environment's selector). When a release is approved,
Ctrlplane creates a job for that same target.
### Release Properties
```yaml theme={null}
id: rel_abc123
deploymentId: dep_xyz
environmentId: env_prod
resourceId: res_cluster1
versionId: ver_v123
createdAt: "2024-01-15T12:00:00Z"
```
Releases do not carry their own status field. Instead, the state of a release
is determined by the **Release Target State**, which tracks:
* **Desired release** — the release that policies have selected for deployment
* **Current release** — the release whose job completed successfully (with passing verifications)
* **Latest job** — the most recent job created for this target, along with its status and verifications
### How Releases are Created
Releases are created automatically by Ctrlplane when:
1. A new deployment version is created with status `ready`
2. Ctrlplane evaluates which release targets should receive this version
3. For each target, a release is created
4. Policies are evaluated to determine if the release can proceed
**Example Flow**:
```
1. CI creates version: API Service v1.2.3 (status: ready)
↓
2. Ctrlplane finds release targets for "API Service":
- API Service → Production → cluster-1
- API Service → Production → cluster-2
- API Service → Staging → staging-cluster
↓
3. Ctrlplane creates 3 releases, one for each target
↓
4. Ctrlplane evaluates policies:
- Production requires approval → Releases pending
- Staging auto-deploys → Release approved
↓
5. Jobs created for approved releases
```
### Release Lifecycle
```
[Version Created]
↓
[Policy Evaluation]
↓ ↓
[Blocked] [Passes] ←→ [User Action]
↓
[Release Created]
↓
[Job Created]
↓
[Job Executes]
↓ ↓
[Completed] [Failed]
```
### Viewing Releases
**Via Web UI**:
1. Navigate to deployment
2. Click "Releases" tab
3. See all releases with their current and desired state
**Via CLI**:
```bash theme={null}
# List releases for a deployment
ctrlc api get releases --deployment {deploymentId}
# Get release details
ctrlc api get release {releaseId}
```
### Release Variables
Releases have resolved variables from:
* Deployment variables (with environment-specific values)
* Resource variables
* Version config
These are passed to the job for execution.
### Cancelling Releases
Cancel a pending or active release:
```bash theme={null}
ctrlc api cancel release {releaseId}
```
Active releases will have their jobs cancelled.
## Job
A **Job** is the actual deployment task that gets executed by a job agent. Jobs are created from approved releases.
### Job Properties
```yaml theme={null}
id: job_abc123
releaseId: rel_xyz789
jobAgentId: agent_123
status: in_progress
externalId: "12345"
message: Deploying version v1.2.3
createdAt: "2024-01-15T12:00:00Z"
startedAt: "2024-01-15T12:01:00Z"
completedAt: null
```
### Job Status
* **`pending`** - Job created, waiting for agent to pick up
* **`triggered`** - Job dispatched to external system (e.g., GitHub Actions)
* **`in_progress`** - Job is currently executing
* **`completed`** - Job finished successfully
* **`failed`** - Job failed
* **`cancelled`** - Job was cancelled
* **`skipped`** - Job was skipped
* **`invalid_job_agent`** - Job agent configuration invalid
### Job Lifecycle
```
[Release Approved]
↓
[Job Created]
↓
[Pending] ──────→ Agent polls /queue/next
↓
[Agent Gets Job]
↓
[Agent Acknowledges] ──→ Job status: in_progress
↓
[Agent Executes Deployment]
↓
[Agent Reports Status]
↓
[Completed / Failed]
```
### Job Configuration
Jobs receive configuration from multiple sources (in priority order):
1. **Version's jobAgentConfig** (highest priority)
2. **Deployment's jobAgentConfig**
3. **Resolved variables**
4. **Release context** (environment, resource info)
**Example merged config**:
```yaml theme={null}
# From deployment
workflow: deploy.yml
owner: my-org
repo: api-service
# From version (overrides)
imageTag: my-org/api-service:v1.2.3
# From variables
replicaCount: "5"
# From release context
environmentName: Production
resourceIdentifier: k8s-prod-use1
```
### Job Agents
Job agents are responsible for:
1. Polling for new jobs (`GET /job-agents/{agentId}/queue/next`)
2. Acknowledging jobs
3. Executing the deployment
4. Reporting status updates
5. Marking job as completed or failed
**Built-in Agent Types**:
* **GitHub Actions** - Triggers GitHub workflow
* **Kubernetes** - Creates Kubernetes Job
* **ArgoCD** - Syncs ArgoCD Application
### Job Execution Flow
#### 1. Job Agent Polls
```bash theme={null}
GET /api/v1/job-agents/{agentId}/queue/next
```
**Response**:
```yaml theme={null}
jobs:
- id: job_abc123
release:
id: rel_xyz
version:
tag: v1.2.3
config: {}
environment:
name: Production
resource:
identifier: k8s-prod-use1
config: {}
variables:
REPLICA_COUNT: "5"
jobAgentConfig:
imageTag: my-org/api-service:v1.2.3
```
#### 2. Agent Acknowledges Job
```bash theme={null}
PATCH /api/v1/jobs/{jobId}
```
```yaml theme={null}
status: in_progress
message: Starting deployment
```
#### 3. Agent Executes Deployment
The agent uses the job configuration to perform the deployment:
**Kubernetes Agent Example**:
```typescript theme={null}
const manifest = renderManifest(job.jobAgentConfig.manifest, {
version: job.release.version.tag,
replicas: job.release.variables.REPLICA_COUNT,
image: job.jobAgentConfig.imageTag
});
await kubectl.apply(manifest, job.release.resource.config.namespace);
```
**GitHub Actions Example**:
```typescript theme={null}
await github.actions.createWorkflowDispatch({
owner: job.jobAgentConfig.owner,
repo: job.jobAgentConfig.repo,
workflow_id: job.jobAgentConfig.workflow,
ref: job.jobAgentConfig.ref || 'main',
inputs: {
job_id: job.id,
deployment_version: job.release.version.tag,
environment: job.release.environment.name,
resource_identifier: job.release.resource.identifier
}
});
```
#### 4. Agent Updates Status
As the deployment progresses:
```bash theme={null}
# Update with progress message
PATCH /api/v1/jobs/{jobId}
# Body: { "message": "Applying Kubernetes manifests" }
# Mark as completed
PATCH /api/v1/jobs/{jobId}
# Body: { "status": "completed", "message": "Deployment successful" }
# Or mark as failed
PATCH /api/v1/jobs/{jobId}
# Body: { "status": "failed", "message": "Deployment failed: connection timeout" }
```
### External Job IDs
When job agents trigger external systems (like GitHub Actions), they can store the external job ID:
```bash theme={null}
PATCH /api/v1/jobs/{jobId}
# Body: { "externalId": "github-workflow-run-12345" }
```
This allows linking to the external system's UI for detailed logs.
### Job Retry
If a job fails, you can create a new job for the same release:
```bash theme={null}
ctrlc api redeploy release {releaseId}
```
This creates a new job for the release.
### Viewing Jobs
**Via Web UI**:
1. Navigate to deployment
2. Click "Jobs" tab
3. See execution history with status
**Via CLI**:
```bash theme={null}
# List jobs for a deployment
ctrlc api get jobs --deployment {deploymentId}
# Get job details
ctrlc api get job {jobId}
```
## Version → Release → Job Example
Let's walk through a complete example:
### 1. CI Creates Version
```bash theme={null}
ctrlc api upsert version \
--workspace my-workspace \
--deployment dep_api \
--tag v1.2.3 \
--metadata image/tag=my-org/api:v1.2.3
```
### 2. Ctrlplane Creates Releases
Ctrlplane finds release targets:
* API Service → Production → cluster-1
* API Service → Production → cluster-2
Creates 2 releases. Production releases are blocked by the approval policy.
### 3. User Approves
```bash theme={null}
ctrlc api approve release rel_123
```
Both releases are now eligible for job creation.
### 4. Ctrlplane Creates Jobs
Two jobs created:
* `job_1` for cluster-1
* `job_2` for cluster-2
Both jobs start with status: `pending`
### 5. Job Agent Polls
```bash theme={null}
GET /api/v1/job-agents/agent_k8s/queue/next
```
Receives both jobs.
### 6. Agent Processes Jobs
For each job:
```typescript theme={null}
// Acknowledge
await job.acknowledge();
// Execute
await kubectl.apply(manifest);
// Complete
await job.update({ status: 'completed' });
```
### 7. Jobs Complete
Both jobs complete successfully. The release target state updates: the release
becomes the **current release** for each target.
## Release Sequencing
By default, when a new version is created for a deployment:
* **Existing jobs** for older versions may be cancelled
* **New jobs** are created for the new version
This behavior is controlled by policies and can be configured.
### Sequential Releases
Ensure releases happen one at a time:
```yaml theme={null}
releaseSequencing:
mode: sequential
```
### Parallel Releases
Allow multiple versions to deploy concurrently:
```yaml theme={null}
releaseSequencing:
mode: parallel
```
## Deployment Tracing
For detailed observability, use the trace API to report execution steps:
```bash theme={null}
# In your deployment script
ctrlplane trace start "Deploy to Kubernetes"
ctrlplane trace step "Pull image" "completed"
ctrlplane trace step "Apply manifests" "completed"
ctrlplane trace end "completed" "Deployment successful"
```
Traces are linked to jobs and provide fine-grained visibility into deployment execution.
## Best Practices
### Job Status Updates
**Do**:
* ✅ Acknowledge jobs immediately when picked up
* ✅ Update status regularly during execution
* ✅ Provide descriptive messages
* ✅ Mark as completed or failed explicitly
**Don't**:
* ❌ Leave jobs in pending state indefinitely
* ❌ Skip acknowledging jobs
* ❌ Provide generic error messages
### Error Handling
When jobs fail:
1. Set status to `failed`
2. Include detailed error message
3. Store external job ID for log access
4. Consider automatic retry logic in agent
### Release Management
* Monitor pending releases (awaiting approval)
* Set up notifications for failed jobs
* Regularly review release history
* Clean up old completed releases (retention policy)
## Troubleshooting
### Releases not progressing
* Check if policies require approval
* Review policy evaluation results
* Verify environment progression requirements met
* Check for blocking policy rules
### Jobs not being picked up by agent
* Verify agent is running and polling
* Check agent ID matches deployment configuration
* Review job agent logs for errors
* Confirm agent has network access to API
### Jobs fail immediately
* Review job agent configuration
* Check external system (GitHub, Kubernetes) availability
* Verify credentials/permissions
* Review job logs and error messages
### Multiple jobs created for same release
* Check release sequencing configuration
* Review job creation logic
* May be intentional for retry scenarios
## Next Steps
* [Job Agents](../integrations/job-agents/github) - Set up and configure job agents
* [Policies](../policies/overview) - Control release approval and progression
* [CI/CD Integration](../integrations/cicd) - Integrate version creation into your CI
* [Verification](../policies/verification/overview) - Validate deployment health
# Resources
Source: https://docs.ctrlplane.dev/concepts/resources
Deployment targets in your infrastructure
A **Resource** represents a deployment target - the actual infrastructure where your code runs. Resources can be Kubernetes clusters, VMs, cloud functions, or any other compute environment.
## What is a Resource?
Resources are the "where" in your deployment pipeline. They represent:
* **Kubernetes clusters** (e.g., prod-us-east-1-cluster)
* **Virtual machines** (e.g., web-server-01)
* **Cloud functions** (e.g., lambda-handler-prod)
* **Containers** (e.g., ECS service)
* **Custom infrastructure** (anything you can deploy to)
## Resource Properties
### Core Fields
```yaml theme={null}
id: res_abc123
name: Production US East Cluster
kind: KubernetesCluster
identifier: k8s-prod-use1
version: "1.28.0"
workspaceId: ws_xyz789
providerId: provider_123
config:
endpoint: https://k8s.prod.example.com
region: us-east-1
metadata:
environment: production
region: us-east-1
team: platform
cost-center: engineering
createdAt: "2024-01-15T10:00:00Z"
updatedAt: "2024-01-15T10:00:00Z"
```
### name
Human-readable display name for the resource.
**Examples**:
* "Production US East Cluster"
* "Web Server 01"
* "Lambda Production Handler"
### kind
Classification of the resource type. Used for filtering and grouping.
**Common Values**:
* `kubernetes-cluster`
* `vm`
* `lambda-function`
* `ecs-service`
* `cloud-run-service`
* `server` (generic)
### identifier
Unique identifier for this resource. This is how external systems reference the resource.
**Examples**:
* `k8s-prod-use1`
* `i-0abc123def456789` (EC2 instance ID)
* `arn:aws:lambda:us-east-1:123456789:function:my-function`
**Requirements**:
* Must be unique within the workspace
* Should be stable (don't change frequently)
* Often matches the infrastructure's native identifier
### metadata
Key-value pairs used for classification and selector matching. This is crucial for resource targeting.
**Common Metadata Keys**:
```yaml theme={null}
metadata:
environment: production
region: us-east-1
zone: us-east-1a
team: platform
cost-center: engineering
tier: high-availability
version: "1.28.0"
managed-by: terraform
```
**Best Practices**:
* Use consistent key names across resources
* Use lowercase with hyphens: `cost-center` not `CostCenter`
* Include classification useful for targeting
* Don't put sensitive data in metadata
### config
Resource-specific configuration. Unlike metadata (which is for selection), config contains operational details.
**Examples**:
**Kubernetes Cluster**:
```yaml theme={null}
config:
endpoint: https://k8s.prod.example.com
certificateAuthority: "..."
namespace: default
```
**VM**:
```yaml theme={null}
config:
ipAddress: 10.0.1.50
sshUser: deploy
port: 22
```
**Lambda**:
```yaml theme={null}
config:
functionName: my-function
region: us-east-1
runtime: nodejs20.x
```
### version
The current version or state of the resource itself (not the deployed application).
**Examples**:
* `1.28.0` (Kubernetes version)
* `20.04` (Ubuntu version)
* `nodejs20.x` (Lambda runtime)
### providerId
Reference to the Resource Provider that created/manages this resource. Optional for manually created resources.
## Creating Resources
### Via CLI
```yaml theme={null}
# resource.yaml
type: Resource
name: Production US East Cluster
kind: KubernetesCluster
identifier: k8s-prod-use1
version: "1.28.0"
metadata:
environment: production
region: us-east-1
team: platform
config:
endpoint: https://k8s.prod.example.com
```
```bash theme={null}
ctrlc apply -f resource.yaml
```
### Via Web UI
1. Navigate to your system
2. Go to "Resources" tab
3. Click "Create Resource"
4. Fill in the form:
* Name, Kind, Identifier
* Add metadata key-value pairs
* Add config (JSON)
5. Click "Create"
### Automated Creation (Resource Providers)
**Recommended approach for production**: Use Resource Providers to automatically sync resources from your infrastructure.
Resource Providers continuously sync resources from external sources:
* **Kubernetes Provider**: Discovers clusters from kubeconfig
* **AWS Provider**: Syncs EC2 instances, ECS services, Lambda functions
* **GCP Provider**: Syncs GCE instances, Cloud Run services
* **Azure Provider**: Syncs VMs, container instances
* **Custom Provider**: Your own integration
**Example using Node SDK**:
```typescript theme={null}
import { createClient } from "@ctrlplane/node-sdk";
const client = createClient({
baseUrl: "https://your-ctrlplane-instance.com",
apiKey: process.env.CTRLPLANE_API_KEY,
});
const provider = client.resourceProvider({
name: "AWS EC2 Provider",
workspaceId: "ws_xyz789",
});
// Sync resources from AWS
const instances = await getEC2Instances(); // Your AWS SDK call
await provider.set(
instances.map(instance => ({
name: instance.Tags.Name,
kind: "ec2-instance",
identifier: instance.InstanceId,
version: instance.ImageId,
metadata: {
environment: instance.Tags.Environment,
region: instance.Placement.AvailabilityZone,
instanceType: instance.InstanceType,
},
config: {
privateIp: instance.PrivateIpAddress,
publicIp: instance.PublicIpAddress,
},
}))
);
```
The provider automatically:
* Creates new resources
* Updates existing resources
* Removes resources no longer in the source
## Resource Metadata for Targeting
Metadata is how resources get matched to environments and deployments. Design your metadata schema carefully.
### Example Metadata Schema
```yaml theme={null}
# Metadata schema for consistent targeting
metadata:
environment: production | staging | development
region: us-east-1 | us-west-2 | eu-west-1
zone: us-east-1a | us-east-1b | ...
team: platform | product | data
cluster-tier: high-availability | standard
cost-center: engineering | sales | ...
managed-by: terraform | manual
```
### Selector Matching Example
**Environment Configuration**:
```yaml theme={null}
type: Environment
name: Production US East
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
**Matched Resources**:
* ✅ Resource A: `{environment: "production", region: "us-east-1"}`
* ✅ Resource B: `{environment: "production", region: "us-east-1", team: "platform"}`
* ❌ Resource C: `{environment: "production", region: "us-west-2"}`
* ❌ Resource D: `{environment: "staging", region: "us-east-1"}`
## Resource Variables
Resources can have variables attached to them, which are available during job execution.
### Creating Resource Variables
Variables are typically configured via the Web UI. You can also use the API:
```bash theme={null}
ctrlc api create resource-variable \
--resource {resourceId} \
--key KUBERNETES_NAMESPACE \
--value production
```
### Using in Job Execution
When a job executes on a resource, it receives all resource variables:
```yaml theme={null}
# In GitHub Actions workflow
- name: Deploy
run: |
kubectl apply -f manifest.yaml \
--namespace ${{ steps.job.outputs.resource_variables_KUBERNETES_NAMESPACE }}
```
### Common Resource Variables
**Kubernetes Resources**:
* `KUBERNETES_NAMESPACE` - Target namespace
* `KUBERNETES_CONTEXT` - Kubectl context
* `HELM_RELEASE_NAME` - Helm release name
**VM Resources**:
* `SSH_HOST` - Host to SSH into
* `SSH_USER` - SSH username
* `DEPLOY_PATH` - Where to deploy files
**Lambda Resources**:
* `FUNCTION_NAME` - Lambda function name
* `AWS_REGION` - AWS region
* `AWS_ACCOUNT_ID` - AWS account
## Resource Lifecycle
### States
Resources don't have explicit states in Ctrlplane, but they can be:
1. **Active** - `deletedAt` is null, available for deployments
2. **Deleted** - `deletedAt` is set, excluded from deployments
### Updating Resources
Update resource metadata with a YAML file:
```yaml theme={null}
# resource-update.yaml
type: Resource
identifier: k8s-prod-use1
metadata:
environment: production
region: us-east-1
updated: "2024-01-15"
```
```bash theme={null}
ctrlc apply -f resource-update.yaml
```
### Deleting Resources
Soft delete (recommended):
```bash theme={null}
ctrlc api delete resource {resourceId}
```
This sets `deletedAt` timestamp. The resource is excluded from new deployments but historical data is preserved.
## Querying Resources
### List All Resources
```bash theme={null}
ctrlc api get resources --workspace {workspaceId}
```
### Filter by Selector
```bash theme={null}
ctrlc api get resources \
--workspace {workspaceId} \
--selector 'resource.metadata["environment"] == "production"'
```
### Get Resource Details
```bash theme={null}
ctrlc api get resource {resourceId}
```
## Resource Providers
### Creating a Resource Provider
```typescript theme={null}
import { createClient } from "@ctrlplane/node-sdk";
const client = createClient({
baseUrl: "https://your-ctrlplane-instance.com",
apiKey: process.env.CTRLPLANE_API_KEY,
});
const provider = client.resourceProvider({
name: "My Infrastructure Provider",
workspaceId: "ws_xyz789",
});
await provider.get(); // Registers provider
```
### Syncing Resources
```typescript theme={null}
// Fetch resources from your infrastructure
const resources = await fetchInfrastructure();
// Sync to Ctrlplane
await provider.set(
resources.map(r => ({
name: r.name,
kind: r.type,
identifier: r.id,
metadata: r.tags,
config: r.config,
}))
);
```
The provider:
* Creates resources that don't exist
* Updates resources that changed
* **Removes** resources not in the provided list (careful!)
### Provider Sync Strategy
**Full Sync** (default):
```typescript theme={null}
// All resources currently in infrastructure
await provider.set(allResources);
```
This removes resources not in the list. Good for keeping Ctrlplane in sync with source of truth.
**Incremental Updates**:
If you want to preserve manually created resources, filter by provider:
```typescript theme={null}
// Only sync resources managed by this provider
const managedResources = await fetchManagedResources();
await provider.set(managedResources);
```
## Best Practices
### Metadata Design
**Do**:
* ✅ Use consistent key names across all resources
* ✅ Include classification useful for targeting
* ✅ Use hierarchical values when appropriate (`region/zone`)
* ✅ Include ownership information (`team`, `cost-center`)
**Don't**:
* ❌ Put sensitive data in metadata (use config or variables)
* ❌ Use inconsistent naming (`env` vs `environment`)
* ❌ Include data that changes frequently
* ❌ Duplicate information already in other fields
### Resource Identifiers
**Good Identifiers**:
* ✅ `k8s-prod-us-east-1` - Descriptive and stable
* ✅ `i-0abc123def456789` - Native AWS instance ID
* ✅ `arn:aws:...` - Full ARN for AWS resources
**Poor Identifiers**:
* ❌ `cluster-1` - Not descriptive
* ❌ `10.0.1.50` - IP addresses can change
* ❌ `temp-cluster` - Suggests instability
### Resource Variables
* Use for environment-specific configuration
* Mark sensitive variables as `sensitive: true`
* Prefer resource variables over hardcoding in deployment config
* Use consistent variable naming across similar resources
### Provider Usage
* Use providers for production (keeps resources in sync)
* Run provider sync on a schedule (cron job, CI pipeline)
* Test provider sync in staging first
* Monitor provider sync failures
## Common Patterns
### Multi-Region Kubernetes
```yaml theme={null}
# multi-region-resources.yaml
---
type: Resource
name: Production US East
kind: KubernetesCluster
identifier: k8s-prod-use1
metadata:
environment: production
region: us-east-1
---
type: Resource
name: Production EU West
kind: KubernetesCluster
identifier: k8s-prod-euw1
metadata:
environment: production
region: eu-west-1
```
```bash theme={null}
ctrlc apply -f multi-region-resources.yaml
```
### Tiered Resources
```yaml theme={null}
# tiered-resources.yaml
---
type: Resource
name: Critical Production Cluster
kind: KubernetesCluster
identifier: k8s-prod-critical
metadata:
environment: production
tier: critical
---
type: Resource
name: Standard Production Cluster
kind: KubernetesCluster
identifier: k8s-prod-standard
metadata:
environment: production
tier: standard
```
```bash theme={null}
ctrlc apply -f tiered-resources.yaml
```
### Team-Based Resources
```yaml theme={null}
# team-resources.yaml
---
type: Resource
name: Platform Team Cluster
kind: KubernetesCluster
identifier: k8s-platform-team
metadata:
team: platform
environment: shared
---
type: Resource
name: Product Team Cluster
kind: KubernetesCluster
identifier: k8s-product-team
metadata:
team: product
environment: shared
```
```bash theme={null}
ctrlc apply -f team-resources.yaml
```
## Troubleshooting
### Resource not appearing in environment
* Check the environment's resource selector
* Verify resource metadata matches the selector
* Confirm resource is not deleted (`deletedAt` is null)
### Provider sync not working
* Verify API key has correct permissions
* Check provider name matches
* Review provider sync logs for errors
### Duplicate resources created
* Ensure identifier is truly unique
* Check if provider is creating duplicates
* Review provider logic for identifier generation
## Next Steps
* [Environments](./environments) - Group resources into environments
* [Selectors](./selectors) - Learn selector syntax for targeting resources
* [Resource Providers](../integrations/resource-providers/overview) - Set up automated resource sync
# Selectors
Source: https://docs.ctrlplane.dev/concepts/selectors
Powerful filtering system for targeting resources, environments, and deployments
**Selectors** are Ctrlplane's powerful filtering system for targeting resources, environments, and deployments. They allow dynamic, rules-based matching instead of static assignments.
## Why Selectors?
Traditional deployment tools require manually assigning resources to environments. Ctrlplane uses selectors for dynamic matching:
### Without Selectors (Traditional)
```
Production Environment:
├─ Manually add: cluster-1
├─ Manually add: cluster-2
└─ Manually add: cluster-3
New cluster added? Manually add it to production.
```
### With Selectors (Ctrlplane)
```
Production Environment:
└─ Selector: metadata.environment == "production"
New cluster with metadata.environment = "production"?
Automatically included in production!
```
## Selector Types
Ctrlplane supports several types of selectors:
1. **Metadata Selector** - Match on resource metadata
2. **Kind Selector** - Match on resource kind
3. **Identifier Selector** - Match on resource identifier
4. **Name Selector** - Match on resource/environment name
5. **CEL Selector** - Complex expressions using CEL (Common Expression Language)
6. **Composite Selectors** - Combine selectors with AND/OR logic
## Metadata Selector
Match resources based on metadata key-value pairs using CEL expressions.
### Simple Equality
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
Matches resources where `metadata.environment === "production"`.
### Supported Operators
#### equals
Exact match:
```yaml theme={null}
resourceSelector: resource.metadata["region"] == "us-east-1"
```
#### not\_equals
Does not match:
```yaml theme={null}
resourceSelector: resource.metadata["deprecated"] != "true"
```
#### contains
Value contains substring:
```yaml theme={null}
resourceSelector: resource.identifier.contains("prod")
```
Matches: `prod-cluster-1`, `k8s-prod`, `production-server`.
#### matches (Regex)
Regular expression match:
```yaml theme={null}
resourceSelector: resource.identifier.matches("^prod-.*-[0-9]+$")
```
Matches: `prod-cluster-1`, `prod-server-42`.
#### exists
Key exists with any value:
```yaml theme={null}
resourceSelector: has(resource.metadata["team"])
```
Matches any resource with a `team` metadata key.
#### not\_exists
Key does not exist:
```yaml theme={null}
resourceSelector: "!has(resource.metadata[\"deprecated\"])"
```
#### in
Value is in a list:
```yaml theme={null}
resourceSelector: resource.metadata["region"] in ["us-east-1", "us-east-2", "us-west-1"]
```
#### not\_in
Value is not in a list:
```yaml theme={null}
resourceSelector: "!(resource.metadata[\"status\"] in [\"deprecated\", \"decommissioned\"])"
```
## Kind Selector
Match resources by their `kind` field.
```yaml theme={null}
resourceSelector: resource.kind == "KubernetesCluster"
```
**Common Use Cases**:
* Kubernetes-only deployments: `resource.kind == "KubernetesCluster"`
* VM-only deployments: `resource.kind == "VirtualMachine"`
* Exclude certain kinds: `resource.kind != "DeprecatedResource"`
## Identifier Selector
Match resources by their identifier.
```yaml theme={null}
resourceSelector: resource.identifier.contains("prod")
```
**Use Cases**:
* Match specific naming patterns
* Filter by identifier prefix/suffix
* Regex matching on identifiers
## Name Selector
Match by the resource or environment name field.
```yaml theme={null}
# In policy selectors
environments: environment.name == "Production"
```
**Use Cases**:
* Policy selectors targeting specific environments
* Matching resources by name patterns
## Composite Selectors (AND/OR)
Combine multiple conditions with boolean logic using CEL operators.
### AND Logic
All conditions must match:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
Matches: Resources with BOTH `environment=production` AND `region=us-east-1`.
### OR Logic
Any condition can match:
```yaml theme={null}
resourceSelector: >-
resource.metadata["region"] == "us-east-1" ||
resource.metadata["region"] == "us-west-2"
```
Matches: Resources in EITHER `us-east-1` OR `us-west-2`.
### Nested Logic
Combine AND and OR:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
(resource.metadata["region"] == "us-east-1" ||
resource.metadata["region"] == "us-west-2")
```
Matches: `environment=production` AND (`region=us-east-1` OR `region=us-west-2`).
## CEL Expressions
Ctrlplane uses CEL (Common Expression Language) for all selectors:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["tier"] == "critical"
```
### CEL Basics
**Field Access**:
* `resource.metadata["environment"]` - Access metadata field
* `resource.kind` - Access kind field
* `resource.name` - Access name field
* `resource.identifier` - Access identifier field
**Operators**:
* `==`, `!=` - Equality
* `&&`, `||` - Logical AND/OR
* `!` - Logical NOT
* `<`, `>`, `<=`, `>=` - Comparison
* `in` - List membership
* `.matches()` - Regex match
**Examples**:
```yaml theme={null}
# Production OR Staging
resourceSelector: resource.metadata["environment"] in ["production", "staging"]
# Critical tier in production
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["tier"] == "critical"
# Not deprecated
resourceSelector: >-
!has(resource.metadata["deprecated"]) ||
resource.metadata["deprecated"] != "true"
# Regex match on identifier
resourceSelector: resource.identifier.matches("^prod-cluster-[0-9]+$")
# Complex nested logic
resourceSelector: >-
(resource.metadata["environment"] == "production" &&
resource.metadata["region"] in ["us-east-1", "us-west-2"]) ||
(resource.metadata["environment"] == "staging" &&
resource.metadata["region"] == "us-east-1")
```
### CEL Best Practices
**Do**:
* ✅ Use CEL for complex nested logic
* ✅ Keep expressions readable with line breaks
* ✅ Test selectors before applying
* ✅ Reference the [CEL Reference](../reference/cel) for syntax
**Avoid**:
* ❌ Overly complex expressions that are hard to understand
* ❌ Expressions that might accidentally match wrong resources
## Selector Use Cases
### Environment Resource Selectors
Define which resources belong to an environment:
```yaml theme={null}
# Production environment
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```yaml theme={null}
# Multi-region production
type: Environment
name: Production US East
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
### Deployment Resource Selectors
Limit which resources a deployment can target:
```yaml theme={null}
# Kubernetes-only deployment
type: Deployment
name: Container Service
resourceSelector: resource.kind == "KubernetesCluster"
```
```yaml theme={null}
# Region-specific deployment
type: Deployment
name: US-Only Service
resourceSelector: resource.metadata["region"].matches("^us-.*")
```
### Policy Selectors
Target policies to specific deployments/environments/resources:
```yaml theme={null}
type: Policy
name: Production Approval Required
selectors:
- environments: environment.name == "Production"
deployments: deployment.metadata["critical"] == "true"
rules:
- anyApproval:
minApprovals: 2
```
This policy applies to critical deployments going to production.
## Testing Selectors
### Via CLI
Test a selector against your resources:
```bash theme={null}
ctrlc api get resources \
--workspace {workspaceId} \
--selector 'resource.metadata["environment"] == "production"'
```
Returns all resources matching the selector.
### Via Web UI
Most selector configuration screens have a "Test Selector" or "Preview Resources" button that shows which resources match.
## Common Selector Patterns
### Pattern 1: Environment-Based
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
**Use Case**: Standard environment separation (dev/staging/prod).
### Pattern 2: Region-Based
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
**Use Case**: Multi-region deployments, gradual regional rollout.
### Pattern 3: Team-Based
```yaml theme={null}
resourceSelector: resource.metadata["team"] == "platform"
```
**Use Case**: Team-specific resources and deployments.
### Pattern 4: Tier-Based
```yaml theme={null}
resourceSelector: resource.metadata["tier"] in ["critical", "high-priority"]
```
**Use Case**: SLA-based resource grouping.
### Pattern 5: Canary
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["canary"] == "true"
```
**Use Case**: Canary deployment pattern.
### Pattern 6: Percentage-Based (using identifier)
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.identifier.matches(".*[02468]$")
```
**Use Case**: Target resources with even-numbered identifiers (50% rollout).
## Best Practices
### Metadata Schema Design
Design your resource metadata with selectors in mind:
**Good Metadata Schema**:
```yaml theme={null}
# Recommended metadata keys
metadata:
environment: production | staging | development
region: us-east-1 | us-west-2 | eu-west-1
zone: us-east-1a | us-east-1b | ...
tier: critical | high | standard | low
team: platform | product | data
canary: "true" | "false"
```
**Why Good**:
* Consistent key names
* Enumerated values (not free-form)
* Multiple dimensions for targeting
* Clear, hierarchical organization
### Selector Simplicity
**Prefer Simple Selectors**:
```yaml theme={null}
resourceSelector: resource.metadata["environment"] == "production"
```
**Over Complex Ones**:
```yaml theme={null}
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"].startsWith("us-") &&
resource.metadata["tier"] == "critical" &&
!has(resource.metadata["deprecated"])
```
Simple selectors are:
* Easier to understand
* Less error-prone
* More performant
### Document Complex Selectors
If you must use complex selectors, document them:
```yaml theme={null}
type: Environment
name: Production Critical
description: >-
Targets production resources in US regions that are marked
critical tier and not deprecated
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"].startsWith("us-") &&
resource.metadata["tier"] == "critical" &&
!has(resource.metadata["deprecated"])
```
### Test Before Applying
Always test selectors before using them:
1. Use the query API to see matched resources
2. Verify count matches expectations
3. Check for unexpected matches
4. Test in lower environment first
### Avoid Over-Matching
Be specific to avoid accidentally matching wrong resources:
**Too Broad**:
```yaml theme={null}
resourceSelector: resource.identifier.contains("prod")
```
Might match: `prod-cluster`, `product-server`, `reproduction-env`.
**Better**:
```yaml theme={null}
resourceSelector: resource.identifier.matches("^prod-.*")
```
Only matches identifiers starting with `prod-`.
## Troubleshooting
### Selector matches too many resources
* Make selector more specific (add conditions)
* Use AND instead of OR
* Add negation conditions
* Check resource metadata for unexpected values
### Selector matches too few resources
* Check metadata key names match exactly (case-sensitive)
* Verify resources have the expected metadata
* Use OR logic if resources vary
* Test with simpler selector first
### Selector matches nothing
* Verify selector syntax is correct
* Check resources exist with expected metadata
* Test each condition independently
* Review resource metadata in UI
### CEL expression errors
* Check syntax (use CEL validator)
* Verify field names are correct
* Test expression step-by-step
* Consult [CEL Reference](../reference/cel) or [CEL specification](https://github.com/google/cel-spec)
## Advanced Topics
### Selector Evaluation Order
When multiple selectors apply (e.g., environment + deployment):
1. Environment selector filters resources
2. Deployment selector (if present) filters further
3. Final set is intersection of both
### Selector Performance
Selectors are evaluated:
* When resources are created/updated
* When environments/deployments are created/updated
* When querying resources
**Performance Tips**:
* Simple selectors are faster than complex ones
* Metadata equality checks are very fast
* Regex matching is slower
* CEL expressions have some overhead
### Selector Caching
Ctrlplane caches selector evaluation results:
* Cache invalidated on resource/metadata changes
* Improves performance for repeated queries
* No manual cache management needed
## Next Steps
* [CEL Reference](../reference/cel) - Full CEL expression language reference
* [Environments](./environments) - Use selectors in environments
* [Deployments](./deployments) - Filter deployment targets
* [Resources](./resources) - Design resource metadata
* [Policies](../policies/overview) - Target policies with selectors
# Systems
Source: https://docs.ctrlplane.dev/concepts/systems
Logical groupings of related deployments and environments
A **System** is the top-level organizational unit in Ctrlplane. It represents a logical grouping of related deployments, environments, and resources.
## What is a System?
Think of a system as a workspace for a product, platform, or major component of your infrastructure. It contains everything related to deploying that product:
* **Deployments** - The services/applications you deploy
* **Environments** - Where you deploy (dev, staging, prod)
* **Resources** - The infrastructure targets
* **Policies** - Rules governing deployments
* **Variables** - Configuration values
## When to Create a System
Create a system when you have:
✅ **A product or platform** - E.g., "E-commerce Platform", "Data Pipeline"\
✅ **Multiple related services** - Microservices that work together\
✅ **Shared deployment policies** - Common approval/progression rules\
✅ **Logical separation** - Different teams, security boundaries
## System Structure
```txt theme={null}
System: E-commerce Platform
├── Deployments
│ ├── API Service
│ ├── Frontend App
│ ├── Payment Service
│ └── Notification Service
├── Environments
│ ├── Development
│ ├── Staging
│ └── Production
├── Resources
│ ├── dev-cluster-1
│ ├── staging-cluster-1
│ ├── prod-cluster-us-east
│ └── prod-cluster-us-west
└── Policies
├── Production Approval Required
└── Environment Progression (Staging → Production)
```
## Creating a System
### Via Web UI
1. Navigate to your workspace
2. Click "Create System"
3. Fill in the form:
* **Name**: Display name (e.g., "E-commerce Platform")
* **Slug**: URL-friendly identifier (e.g., "ecommerce-platform")
* **Description**: What this system encompasses
### Via CLI
```yaml theme={null}
# system.yaml
type: System
name: E-commerce Platform
slug: ecommerce-platform
description: Complete e-commerce system including API, frontend, and backend services
```
```bash theme={null}
ctrlc apply -f system.yaml
```
### Full System Configuration
Create a system with deployments and environments in one file:
```yaml theme={null}
# ecommerce-system.yaml
---
type: System
name: E-commerce Platform
slug: ecommerce-platform
description: Complete e-commerce system
---
type: Deployment
name: API Service
slug: api-service
description: Backend API
jobAgent:
ref: github-actions
---
type: Deployment
name: Frontend App
slug: frontend-app
description: Customer-facing web application
jobAgent:
ref: github-actions
---
type: Environment
name: Development
description: Development environment
resourceSelector: resource.metadata["environment"] == "development"
---
type: Environment
name: Production
description: Production environment
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f ecommerce-system.yaml
```
## System Properties
### Name
* Display name shown in the UI
* Can contain spaces and special characters
* Can be changed without affecting existing deployments
### Slug
* URL-friendly identifier
* Must be unique within workspace
* Used in API paths and CLI commands
* Cannot be changed after creation
* Format: lowercase letters, numbers, hyphens
### Description
* Optional text describing the system's purpose
* Supports markdown formatting
* Displayed in the UI
### Workspace ID
* The parent workspace this system belongs to
* Automatically set when creating a system
* Cannot be changed
## Organizing with Multiple Systems
### When to Use Multiple Systems
Use separate systems when:
1. **Team Boundaries**
```txt theme={null}
System: Platform Team Services
System: Product Team Services
```
2. **Security Boundaries**
```txt theme={null}
System: Public Services
System: Internal Services
System: PCI Compliant Services
```
3. **Deployment Independence**
```txt theme={null}
System: Core Platform
System: Analytics Platform
System: ML Platform
```
4. **Different Policies**
```txt theme={null}
System: Experimental Features (auto-deploy)
System: Production Services (requires approval)
```
### When to Use One System
Keep things in one system when:
1. **Tightly Coupled Services**
* Microservices that must deploy together
* Services with shared dependencies
2. **Shared Deployment Pipeline**
* Same approval requirements
* Same environment progression
3. **Single Team/Product**
* One team owns all services
* Deployed as a unit
## System-Level Operations
### Viewing System Status
Get an overview of all deployments in a system:
```bash theme={null}
ctrlc api get system {systemId} --status
```
**Response**:
```yaml theme={null}
systemId: sys_abc123
name: E-commerce Platform
deployments: 4
environments: 3
resources: 8
activeReleases: 12
pendingApprovals: 2
failedJobs: 1
```
### Listing All Entities
**List Deployments**:
```bash theme={null}
ctrlc api get deployments --system {systemId}
```
**List Environments**:
```bash theme={null}
ctrlc api get environments --system {systemId}
```
**List Policies**:
```bash theme={null}
ctrlc api get policies --system {systemId}
```
### Deleting a System
⚠️ **Warning**: Deleting a system removes all associated deployments, releases, and jobs. This cannot be undone.
```bash theme={null}
ctrlc api delete system {systemId}
```
## Best Practices
### Naming Conventions
**Good Names**:
* ✅ "E-commerce Platform"
* ✅ "Analytics Pipeline"
* ✅ "Customer Portal"
**Good Slugs**:
* ✅ `ecommerce-platform`
* ✅ `analytics-pipeline`
* ✅ `customer-portal`
**Avoid**:
* ❌ "System 1", "Test", "Prod" (too generic)
* ❌ `My_System`, `PROD-SYSTEM` (inconsistent formatting)
### Start Simple
Begin with one system for your product:
```
System: My Application
├── API Deployment
├── Frontend Deployment
├── Dev Environment
└── Prod Environment
```
Split into multiple systems as you grow:
```
System: Customer-Facing Services
├── API Deployment
└── Frontend Deployment
System: Internal Services
├── Admin API Deployment
└── Analytics Deployment
```
### Documentation
Use the description field to document:
* Purpose of the system
* Ownership/team responsible
* Key dependencies
* Links to external documentation
Example:
```
E-commerce platform for online sales.
Owner: Platform Team
Slack: #platform-team
Docs: https://wiki.company.com/ecommerce
```
### Consistent Structure
Maintain consistent structure across systems:
* Standard environment names (dev, staging, prod)
* Similar deployment naming patterns
* Common policy approaches
## Common Patterns
### Microservices System
```yaml theme={null}
# microservices-system.yaml
---
type: System
name: Microservices Platform
slug: microservices-platform
---
type: Deployment
name: User Service
slug: user-service
jobAgent:
ref: kubernetes-agent
---
type: Deployment
name: Order Service
slug: order-service
jobAgent:
ref: kubernetes-agent
---
type: Deployment
name: Payment Service
slug: payment-service
jobAgent:
ref: kubernetes-agent
---
type: Environment
name: Development
resourceSelector: resource.metadata["environment"] == "development"
---
type: Environment
name: Staging
resourceSelector: resource.metadata["environment"] == "staging"
---
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f microservices-system.yaml
```
### Multi-Region System
```yaml theme={null}
# global-system.yaml
---
type: System
name: Global Application
slug: global-application
---
type: Deployment
name: API Service
slug: api-service
jobAgent:
ref: kubernetes-agent
---
type: Environment
name: Production US East
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
---
type: Environment
name: Production EU West
resourceSelector: >-
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "eu-west-1"
```
```bash theme={null}
ctrlc apply -f global-system.yaml
```
### Monorepo with Multiple Apps
```yaml theme={null}
# monorepo-system.yaml
---
type: System
name: Monorepo Applications
slug: monorepo-applications
---
type: Deployment
name: Web App
slug: web-app
jobAgent:
ref: github-actions
---
type: Deployment
name: Mobile API
slug: mobile-api
jobAgent:
ref: github-actions
---
type: Deployment
name: Admin Dashboard
slug: admin-dashboard
jobAgent:
ref: github-actions
---
type: Environment
name: Staging
resourceSelector: resource.metadata["environment"] == "staging"
---
type: Environment
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
```bash theme={null}
ctrlc apply -f monorepo-system.yaml
```
## Troubleshooting
### System not appearing in UI
* Verify system was created successfully
* Check you're in the correct workspace
* Refresh the page
### Cannot create deployment in system
* Verify you have permissions for the system
* Check system ID is correct
* Ensure deployment slug is unique within system
### System deletion fails
* Active releases may prevent deletion
* Cancel or complete all active releases first
* Or force delete via API with `force=true` parameter
## Next Steps
* [Resources](./resources) - Define deployment targets
* [Environments](./environments) - Create deployment stages
* [Deployments](./deployments) - Set up your first deployment
# Deployment Overview
Source: https://docs.ctrlplane.dev/deployment/overview
Progressive delivery with policy-driven orchestration
Ctrlplane's deployment system orchestrates releases across environments with
configurable policies, verification, and approval workflows.
## What is Deployment Orchestration?
Deployment orchestration manages how releases flow through your environments —
from build to production — with:
* **Gradual rollouts** — Deploy to targets sequentially with verification
between each
* **Policy-driven gates** — Approvals, verification, and dependencies
* **Environment promotion** — Automated staging → production progression
* **Rollback & recovery** — Automatic rollback on verification failure
```mermaid theme={null}
flowchart LR
Build["Build (CI)"] --> Version["Version"]
subgraph Staging["Staging"]
direction TB
S1["Deploy"] --> S2["Verify"]
end
subgraph Production["Production"]
direction TB
P0["Approval"] --> P1["Deploy"] --> P2["Verify"]
end
Version --> Staging
Staging -->|"policy gates"| Production
```
## Core Concepts
Logical groupings of related deployments
Services or applications to deploy
Deployment execution units
Deployment × Environment × Resource
## How It Works
### 1. CI Creates Versions
Your CI pipeline creates a version after successful builds:
```yaml theme={null}
- name: Create Version
env:
CTRLPLANE_API_KEY: ${{ secrets.CTRLPLANE_API_KEY }}
run: |
ctrlc api upsert version \
--workspace ${{ vars.CTRLPLANE_WORKSPACE }} \
--deployment ${{ vars.CTRLPLANE_DEPLOYMENT_ID }} \
--tag ${{ github.sha }} \
--name "Build #${{ github.run_number }}" \
--metadata git/commit=${{ github.sha }} \
--metadata git/branch=${{ github.ref_name }}
```
### 2. Ctrlplane Creates Releases
For each release target (deployment × environment × resource), Ctrlplane:
1. Evaluates policies (approvals, gates, dependencies)
2. Creates a release with the new version
3. Dispatches a job to the job agent
### 3. Job Agents Execute
Job agents perform the actual deployment:
* **GitHub Actions** — Trigger workflows
* **ArgoCD** — Create/sync Applications
* **Terraform Cloud** — Create workspaces and runs
### 4. Verification Validates
After deployment, verification checks health metrics (error rates, latency,
etc.) and automatically determines whether the release should proceed or roll
back. See [Verification](../policies/verification/overview) for details.
## Defining a Deployment
A deployment connects *what* (your service) with *how* (the job agent that
executes it). You can define deployments via Terraform, CLI, or API.
```hcl theme={null}
resource "ctrlplane_deployment" "api" {
name = "API Service"
resource_selector = "resource.kind == 'Kubernetes' && resource.metadata['status'] == 'running'"
metadata = {
team = "backend"
service = "api"
}
job_agent {
id = ctrlplane_job_agent.github.id
github {
owner = "my-org"
repo = "api-service"
workflow_id = 12345678
}
}
}
resource "ctrlplane_deployment_system_link" "api" {
deployment_id = ctrlplane_deployment.api.id
system_id = ctrlplane_system.example.id
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/deployments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "API Service",
"slug": "api-service",
"resourceSelector": "resource.kind == '\''Kubernetes'\''",
"jobAgents": [
{
"ref": "github-actions-agent",
"config": {
"owner": "my-org",
"repo": "api-service",
"workflow": "deploy.yml"
}
}
],
"metadata": { "team": "backend" }
}'
```
See [Deployments](../concepts/deployments) for the full reference on
properties, versions, variables, and more.
## Policies
Policies control how releases flow through environments:
Require sign-off before deployment
Gate production on staging success
Stagger deployments across targets
Validate deployment health
Enforce ordering between deployments
Time-based deployment scheduling
Batch frequent releases
Control which versions can deploy
Automatic retry on failure
## Job Agents
Execute deployments on your infrastructure:
Trigger workflow dispatch
GitOps deployments
Infrastructure as code
## Key Benefits
| Benefit | Description |
| ---------------------- | ------------------------------------- |
| **Consistent process** | Same workflow for all deployments |
| **Policy enforcement** | Gates prevent unauthorized releases |
| **Visibility** | Track what's deployed where |
| **Automatic rollback** | Failed verification triggers rollback |
## Next Steps
* [Systems](../concepts/systems) — Organize your deployments
* [CI/CD Integration](../integrations/cicd) — Connect your build pipeline
* [Policies](../policies/overview) — Configure deployment rules
* [Job Agents](../integrations/job-agents/github) — Set up execution
# Version Dependencies
Source: https://docs.ctrlplane.dev/deployment/version-dependencies
Declare per-version dependencies between deployments so a version only deploys to a resource when an upstream deployment is running an acceptable version on that same resource.
A **version dependency** is a hard, per-version edge from a deployment version
to another deployment. The version will only roll out to a resource when the
upstream deployment is *currently deployed* on that resource and its current
version satisfies a CEL `versionSelector`.
Use version dependencies to pin compatibility against a specific upstream cut
(e.g. `frontend v3.4.0` requires `api ≥ v2.1.0`) — without baking that rule
into a workspace-wide policy.
## How It Works
```mermaid theme={null}
flowchart LR
V[frontend v3.4.0] -- requires --> D[api deployment]
D -. current version on resource .-> S{satisfies versionSelector?}
S -- yes --> Allow[Allow deploy]
S -- no / not deployed --> Block[Block]
```
1. **Resolution is per-resource.** For each resource the version targets,
Ctrlplane looks up the upstream deployment's current release on that same
resource.
2. **Current = last successful release.** If the upstream has no successful
release on the resource, the dependency is **unsatisfied** and the version
is blocked on that target.
3. **CEL evaluation.** The `versionSelector` is evaluated against the
upstream's current `version.*`. All declared edges must pass.
4. **Reconciliation.** When an upstream deployment successfully deploys a new
version on a resource, every downstream version with an edge to it is
re-evaluated for that resource — so a previously-blocked version can
automatically unblock.
## Version Dependencies vs. Deployment-Dependency Policy
Both gate a deployment on another, but they're different tools:
| | Version Dependency | [Deployment Dependency Policy](../policies/deployment-dependency) |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Lives on | A specific **version** | A **policy** matched by CEL `selector` |
| Pin granularity | Per-version (different versions can require different upstreams) | Per-deployment (uniform across all versions matching the policy) |
| CEL scope | `version.*` of upstream's current release | `deployment.*` and `version.*` of any successful upstream release on the resource |
| Typical author | CI / the build that produced the version | Platform / SRE author authoring a workspace rule |
| Edit after creation | Yes, via the dependency endpoints | Edit the policy |
Rule of thumb: if the requirement comes from the **build** itself
("this build of frontend needs api ≥ v2"), use a version dependency. If it
comes from an **operational rule** ("api always waits for db migrations"),
use a deployment-dependency policy.
## Creating Version Dependencies
### Inline at version creation
The `POST .../deployments/{deploymentId}/versions` endpoint accepts a
`dependencies` map keyed by **upstream deployment ID**. The dependency edges
are inserted in the same transaction as the version itself, so reconciliation
never sees a version with a missing edge.
```bash theme={null}
curl -X POST "https://api.ctrlplane.com/v1/workspaces/$WORKSPACE_ID/deployments/$DEPLOYMENT_ID/versions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag": "v3.4.0",
"name": "Release 3.4.0",
"status": "ready",
"dependencies": {
"dep_api_uuid": {
"versionSelector": "version.tag.startsWith(\"v2.\")"
},
"dep_auth_uuid": {
"versionSelector": "version.metadata.channel == \"stable\""
}
}
}'
```
If any selector is malformed or any dependency deployment doesn't exist in the
workspace, the whole request 4xxs and no version is created.
### Upsert a single dependency
To add or change one edge after the version exists:
```bash theme={null}
curl -X PUT "https://api.ctrlplane.com/v1/workspaces/$WORKSPACE_ID/deployment-versions/$VERSION_ID/dependencies/$DEPENDENCY_DEPLOYMENT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"versionSelector": "version.tag.startsWith(\"v2.\")"
}'
```
Returns `202 Accepted`; downstream release targets are re-queued for
re-evaluation.
### List dependencies for a version
```bash theme={null}
curl "https://api.ctrlplane.com/v1/workspaces/$WORKSPACE_ID/deployment-versions/$VERSION_ID/dependencies" \
-H "Authorization: Bearer $TOKEN"
```
### Delete a dependency
```bash theme={null}
curl -X DELETE "https://api.ctrlplane.com/v1/workspaces/$WORKSPACE_ID/deployment-versions/$VERSION_ID/dependencies/$DEPENDENCY_DEPLOYMENT_ID" \
-H "Authorization: Bearer $TOKEN"
```
## `versionSelector` Reference
The selector is a CEL expression evaluated against the upstream deployment's
**current** version on the resource:
| Variable | Type | Description |
| ------------------- | --------- | ------------------------------------------------ |
| `version.id` | string | Upstream version ID |
| `version.tag` | string | Upstream version tag (e.g. `v2.1.0`) |
| `version.name` | string | Upstream version name |
| `version.status` | string | Upstream version status |
| `version.metadata` | map | Upstream version metadata (`map`) |
| `version.createdAt` | timestamp | When the upstream version was created |
`deployment.*` and `environment.*` are **not** in scope — this selector only
filters on the upstream version. Use a deployment-dependency policy if you
need to gate on `deployment.*` or `environment.*`.
### Common Selectors
```cel theme={null}
// Require any v2.x release of the upstream
version.tag.startsWith("v2.")
// Require a specific minimum tag (lexicographic)
version.tag >= "v2.1.0"
// Require a stable channel
version.metadata.channel == "stable"
// Always require *some* successful upstream release (any version)
true
// Pin to a specific upstream version
version.tag == "v2.4.7"
```
## Constraints
* A version cannot depend on **its own deployment** (`400`).
* Both the version and every dependency deployment must live in the same
workspace (`404` otherwise).
* The `(deploymentVersionId, dependencyDeploymentId)` pair is unique — `PUT`
upserts the `versionSelector` for that pair.
* Edges cascade-delete with the version or the dependency deployment.
## Reconciliation Behavior
Version dependencies hook into the release-policy evaluator and the
job-dispatch downstream trigger:
* **At evaluation time**, every declared edge must pass; the first failing
edge denies the release.
* **On upstream success**, each downstream deployment that has *any* version
declaring an edge to the upstream is re-queued so previously-blocked
versions can flip to allowed without manual intervention.
This means you can ship a frontend version that requires `api v2.x`
*before* the API has actually rolled out — it will sit blocked, and unblock
itself the moment the API reaches `v2.x` on each resource.
## Next Steps
* [Deployment Dependency Policy](../policies/deployment-dependency) — the
policy-based, deployment-wide variant
* [Deployment Overview](./overview) — how versions become releases
* [CEL Reference](../reference/cel) — the expression language
# Introduction
Source: https://docs.ctrlplane.dev/index
Deployment orchestration and infrastructure inventory for platform teams
## What is Ctrlplane?
**Ctrlplane is the orchestration layer between your CI/CD pipelines and your
infrastructure.**
Your CI builds code. Your infrastructure runs it. Ctrlplane orchestrates the
rest through three core concepts:
| Concept | Question It Answers | Example |
| --------------- | ------------------------------ | ---------------------------------------------- |
| **Deployment** | **What** to deploy and **how** | "API Service" deployed via ArgoCD |
| **Environment** | **Where** to deploy | "Production" = all clusters with `env: prod` |
| **Policy** | **When** to deploy | "Only after staging succeeds and SRE approves" |
```
Your CI/CD ──► Ctrlplane ──► Your Infrastructure
(builds) (what, where, when) (deploys)
```
## Start Here
See the problems Ctrlplane solves—you'll recognize them
Understand the mental model quickly
Set up your first deployment pipeline in 15 minutes
How Ctrlplane fits with ArgoCD, Spinnaker, GitHub Actions
## Two Core Systems
Control **when** and **where** releases happen:
* Auto-promote after verification passes
* Approval gates for production
* Gradual rollouts across regions
* Automatic rollback on failure
Track **what exists** and **what's running**:
* Real-time resource inventory
* Dynamic environment membership
* Version tracking across all targets
* Works with K8s, AWS, GCP, custom
## Common Use Cases
| Scenario | What Ctrlplane Does |
| ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| [Multi-region deployments](./use-cases/multi-region) | Deploy to 10 clusters sequentially with verification between each |
| [Environment promotion](./use-cases/environment-promotion) | Auto-promote staging → prod when verification passes |
| [Deployment verification](./use-cases/deployment-verification) | Check Datadog metrics before marking a deploy successful |
| [Infrastructure inventory](./use-cases/infrastructure-inventory) | Answer "what version is running where?" instantly |
| [Dynamic environments](./use-cases/dynamic-environments) | New clusters auto-join environments via selectors |
## Who Uses Ctrlplane?
| Team | Use Case |
| ------------------------ | --------------------------------------------------------------------------- |
| **Platform Engineering** | Building an IDP with deployment orchestration and infrastructure visibility |
| **DevOps / SRE** | Enforcing deployment policies and tracking what's running where |
| **Scaling Startups** | Moving from "we manually deploy" to automated, policy-driven releases |
| **Multi-region Teams** | Coordinating deployments across clusters/regions with a unified inventory |
## How It Fits Your Stack
Ctrlplane doesn't replace your CI or deployment tooling—it coordinates them:
| Layer | Tools | What They Do |
| --------------- | ------------------------------------------- | --------------------------------------------- |
| **Build** | GitHub Actions, GitLab CI, Jenkins | Build artifacts, create versions in Ctrlplane |
| **Orchestrate** | **Ctrlplane** | Decide when/where to deploy, enforce policies |
| **Execute** | ArgoCD, K8s Jobs, GitHub Actions, Terraform | Perform the actual deployment |
| **Monitor** | Datadog, Prometheus | Provide metrics for verification |
## Next Steps
Problems you'll recognize
Build your first pipeline
# Installation
Source: https://docs.ctrlplane.dev/installation
Deploy Ctrlplane to your infrastructure.
Ctrlplane is self-hosted and can be deployed on your infrastructure. This guide
covers production-ready deployment options for platform teams.
## Deployment Options
| Option | Best For | Maintenance |
| -------------- | ---------------------------------- | ------------ |
| Kubernetes | Production self-hosted deployments | Self-managed |
| Docker Compose | Development and evaluation | Self-managed |
## Self-Hosted Options
Choose the deployment method that best fits your infrastructure:
### Docker Compose (Development & Testing)
Best for: Local development, testing, small teams
**Prerequisites**:
* Docker Engine 20.10+
* Docker Compose v2.0+
* 2GB RAM minimum
**Quick Start**:
1. Clone the repository:
```bash theme={null}
git clone https://github.com/ctrlplanedev/ctrlplane.git
cd ctrlplane
```
2. Copy environment files:
```bash theme={null}
cp .env.example .env
# Edit .env with your configuration
```
3. Start services:
```bash theme={null}
docker compose up -d
```
4. Run database migrations:
```bash theme={null}
docker compose exec api pnpm --filter @ctrlplane/db migrate
```
5. Access Ctrlplane:
* Web UI: [http://localhost:3000](http://localhost:3000)
* API: [http://localhost:4000](http://localhost:4000)
**Services Started**:
* Web UI (port 3000)
* API Server (port 4000)
* Workspace Engine (port 50051)
* PostgreSQL (port 5432)
* Redis (optional, for job queue)
**Configuration**:
Key environment variables in `.env`:
```bash theme={null}
# Database
DATABASE_URL=postgresql://user:password@postgres:5432/ctrlplane
# Authentication
AUTH_SECRET=your-secret-key-change-this
NEXTAUTH_URL=http://localhost:3000
# API Keys
API_KEY_SALT=your-api-key-salt
# GitHub OAuth (optional)
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
**Updating**:
```bash theme={null}
git pull
docker compose pull
docker compose up -d
```
### Kubernetes (Production)
Best for: Production deployments, high availability
**Prerequisites**:
* Kubernetes cluster 1.24+
* kubectl configured
* Helm 3.8+
* PostgreSQL database (managed or in-cluster)
**Installation with Helm**:
1. Add the Ctrlplane Helm repository:
```bash theme={null}
helm repo add ctrlplane https://charts.ctrlplane.dev
helm repo update
```
2. Create a namespace:
```bash theme={null}
kubectl create namespace ctrlplane
```
3. Create a values file `values.yaml`:
```yaml theme={null}
# Basic configuration
ingress:
enabled: true
hostname: ctrlplane.example.com
tls:
enabled: true
secretName: ctrlplane-tls
# Database configuration (use managed DB in production)
postgresql:
enabled: false # Set to true for embedded PostgreSQL
external:
host: your-postgres-host
port: 5432
database: ctrlplane
username: ctrlplane
existingSecret: ctrlplane-db-secret # Contains password
# API Server
api:
replicaCount: 2
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
# Workspace Engine
workspaceEngine:
replicaCount: 2
resources:
requests:
memory: "1Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "2000m"
# Web UI
web:
replicaCount: 2
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
# Authentication
auth:
secret: your-auth-secret
providers:
github:
enabled: true
clientId: your-github-client-id
existingSecret: github-oauth-secret
# Redis (for job queue)
redis:
enabled: true
auth:
enabled: true
existingSecret: redis-secret
```
4. Create secrets:
```bash theme={null}
# Database password
kubectl create secret generic ctrlplane-db-secret \
--from-literal=password=your-db-password \
-n ctrlplane
# GitHub OAuth (if enabled)
kubectl create secret generic github-oauth-secret \
--from-literal=client-secret=your-github-client-secret \
-n ctrlplane
# Redis password
kubectl create secret generic redis-secret \
--from-literal=password=your-redis-password \
-n ctrlplane
```
5. Install the chart:
```bash theme={null}
helm install ctrlplane ctrlplane/ctrlplane \
--namespace ctrlplane \
--values values.yaml
```
6. Run database migrations:
```bash theme={null}
kubectl run migrate --rm -it --restart=Never \
--image=ctrlplane/api:latest \
--namespace ctrlplane \
--env="DATABASE_URL=$DATABASE_URL" \
-- pnpm --filter @ctrlplane/db migrate
```
7. Verify installation:
```bash theme={null}
kubectl get pods -n ctrlplane
kubectl get svc -n ctrlplane
```
**Accessing Ctrlplane**:
If you configured ingress:
```bash theme={null}
# Should be accessible at your configured hostname
https://ctrlplane.example.com
```
If using port-forward for testing:
```bash theme={null}
kubectl port-forward -n ctrlplane svc/ctrlplane-web 3000:80
# Access at http://localhost:3000
```
**Updating**:
```bash theme={null}
helm repo update
helm upgrade ctrlplane ctrlplane/ctrlplane \
--namespace ctrlplane \
--values values.yaml
```
### Manual Installation
For custom deployments, you can run each component separately.
**Components**:
1. **PostgreSQL Database** (required)
* Version: 14+
* Extensions: uuid-ossp, pgcrypto
2. **API Server**
```bash theme={null}
docker run -d \
--name ctrlplane-api \
-p 4000:4000 \
-e DATABASE_URL=postgresql://... \
-e AUTH_SECRET=your-secret \
ctrlplane/api:latest
```
3. **Workspace Engine**
```bash theme={null}
docker run -d \
--name ctrlplane-workspace-engine \
-p 50051:50051 \
-e DATABASE_URL=postgresql://... \
ctrlplane/workspace-engine:latest
```
4. **Web UI**
```bash theme={null}
docker run -d \
--name ctrlplane-web \
-p 3000:3000 \
-e API_URL=http://localhost:4000 \
ctrlplane/web:latest
```
5. **Run Migrations**
```bash theme={null}
docker run --rm \
-e DATABASE_URL=postgresql://... \
ctrlplane/api:latest \
pnpm --filter @ctrlplane/db migrate
```
## Configuration
### Environment Variables
#### API Server
```bash theme={null}
# Database
DATABASE_URL=postgresql://user:pass@host:5432/ctrlplane
# Authentication
AUTH_SECRET=random-secret-key
API_KEY_SALT=random-salt-for-api-keys
# GitHub OAuth (optional)
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# Logging
LOG_LEVEL=info
# OpenTelemetry (optional) - To fully disable OTEL, set OTEL_SDK_DISABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
```
#### Workspace Engine
```bash theme={null}
# Database
DATABASE_URL=postgresql://user:pass@host:5432/ctrlplane
# gRPC Server
WORKSPACE_ENGINE_HOST=0.0.0.0
WORKSPACE_ENGINE_PORT=50051
# Logging
LOG_LEVEL=info
```
#### Web UI
```bash theme={null}
# API endpoint
NEXT_PUBLIC_API_URL=https://api.ctrlplane.example.com
# Authentication
NEXTAUTH_URL=https://ctrlplane.example.com
NEXTAUTH_SECRET=same-as-auth-secret
```
### Database Setup
**Create Database**:
```sql theme={null}
CREATE DATABASE ctrlplane;
CREATE USER ctrlplane WITH PASSWORD 'your-password';
GRANT ALL PRIVILEGES ON DATABASE ctrlplane TO ctrlplane;
```
**Enable Extensions**:
```sql theme={null}
\c ctrlplane
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
```
**Run Migrations**:
```bash theme={null}
DATABASE_URL=postgresql://ctrlplane:password@localhost:5432/ctrlplane \
pnpm --filter @ctrlplane/db migrate
```
### Authentication Setup
#### GitHub OAuth
1. Create a GitHub OAuth App:
* Go to GitHub Settings → Developer settings → OAuth Apps
* New OAuth App
* Homepage URL: `https://ctrlplane.example.com`
* Callback URL: `https://ctrlplane.example.com/api/auth/callback/github`
2. Configure environment variables:
```bash theme={null}
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
```
#### API Keys
API keys are used for programmatic access (CI/CD integration).
Generate salt for API keys:
```bash theme={null}
openssl rand -base64 32
```
Set in environment:
```bash theme={null}
API_KEY_SALT=generated-salt
```
Users can generate API keys in the UI under Settings → API Keys.
## Resource Requirements
### Minimum (Development/Testing)
* CPU: 2 cores
* RAM: 4GB
* Disk: 20GB
* Database: Shared PostgreSQL
### Recommended (Small Production)
* CPU: 4-8 cores
* RAM: 8-16GB
* Disk: 50GB SSD
* Database: Managed PostgreSQL (2 vCPU, 8GB RAM)
### Scaling (Large Production)
* API Server: 2-4 replicas (2 vCPU, 2GB RAM each)
* Workspace Engine: 2-4 replicas (2 vCPU, 4GB RAM each)
* Web UI: 2+ replicas (1 vCPU, 1GB RAM each)
* Database: Managed PostgreSQL (4+ vCPU, 16+ GB RAM)
* Redis: Managed Redis (optional, for job queue)
## High Availability
For production deployments:
1. **Multiple Replicas**: Run 2+ replicas of each service
2. **Managed Database**: Use managed PostgreSQL with automated backups
3. **Load Balancing**: Use Kubernetes service or external load balancer
4. **Health Checks**: Configure liveness and readiness probes
5. **Monitoring**: Set up observability with metrics and logs
6. **Backups**: Regular database backups
## Upgrades
### Before Upgrading
1. **Backup Database**: Always backup before upgrading
```bash theme={null}
pg_dump -h localhost -U ctrlplane ctrlplane > backup.sql
```
2. **Check Release Notes**: Review breaking changes
3. **Test in Staging**: Test upgrade in non-production environment
### Upgrade Process
**Docker Compose**:
```bash theme={null}
docker compose down
git pull
docker compose pull
docker compose up -d
```
**Kubernetes**:
```bash theme={null}
helm repo update
helm upgrade ctrlplane ctrlplane/ctrlplane \
--namespace ctrlplane \
--values values.yaml
```
### Rollback
**Docker Compose**:
```bash theme={null}
git checkout previous-tag
docker compose up -d
psql < backup.sql # Restore database if needed
```
**Kubernetes**:
```bash theme={null}
helm rollback ctrlplane -n ctrlplane
```
## Troubleshooting
### Cannot connect to database
* Verify DATABASE\_URL is correct
* Check database is running and accessible
* Verify credentials and database exists
### Migrations fail
* Ensure database user has proper permissions
* Check for schema conflicts
* Review migration logs
### Services won't start
* Check logs: `docker compose logs` or `kubectl logs`
* Verify all environment variables are set
* Ensure ports are not already in use
### Authentication not working
* Verify AUTH\_SECRET is set and consistent across services
* Check OAuth configuration if using GitHub
* Review callback URLs match your domain
## Next Steps
* [Quickstart Guide](./quickstart.md) - Set up your first deployment
* [Core Concepts](./core-concepts.md) - Understand Ctrlplane fundamentals
* [Operations](../operations/monitoring.md) - Set up monitoring and
observability
# CI/CD Integration
Source: https://docs.ctrlplane.dev/integrations/cicd
Create deployment versions from your CI/CD pipeline
Connect your CI/CD pipeline to Ctrlplane to automatically create deployment
versions after successful builds. This triggers the deployment orchestration
flow.
## How It Works
```mermaid theme={null}
sequenceDiagram
participant CI as CI/CD Pipeline
participant C as Ctrlplane
participant A as Job Agents
CI->>CI: Build & Test
CI->>C: Create Version
C->>C: Evaluate Policies
C->>C: Create Releases
C->>A: Dispatch Jobs
```
1. Your CI/CD pipeline builds and tests your code
2. After success, CI creates a version in Ctrlplane
3. Ctrlplane evaluates policies for each release target
4. Jobs are dispatched to job agents
5. Deployments execute across your environments
## GitHub Actions
### Basic Integration
```yaml theme={null}
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
# Your build steps
docker build -t myapp:${{ github.sha }} .
docker push myapp:${{ github.sha }}
- name: Create Ctrlplane Version
env:
CTRLPLANE_API_KEY: ${{ secrets.CTRLPLANE_API_KEY }}
run: |
ctrlc api upsert version \
--workspace ${{ vars.CTRLPLANE_WORKSPACE }} \
--deployment ${{ vars.CTRLPLANE_DEPLOYMENT_ID }} \
--tag ${{ github.sha }} \
--name "Build #${{ github.run_number }}" \
--metadata git/commit=${{ github.sha }} \
--metadata git/branch=${{ github.ref_name }} \
--metadata github/actor=${{ github.actor }} \
--metadata github/run-url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
```
### Using the Ctrlplane Action
```yaml theme={null}
- name: Create Ctrlplane Version
uses: ctrlplanedev/create-version@v1
with:
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
deployment_id: ${{ vars.DEPLOYMENT_ID }}
tag: ${{ github.sha }}
name: "Build #${{ github.run_number }}"
metadata: |
commit: ${{ github.sha }}
branch: ${{ github.ref_name }}
```
## GitLab CI
```yaml theme={null}
stages:
- build
- deploy
build:
stage: build
script:
- docker build -t myapp:$CI_COMMIT_SHA .
- docker push myapp:$CI_COMMIT_SHA
create-version:
stage: deploy
script:
- |
ctrlc api upsert version \
--workspace ${CTRLPLANE_WORKSPACE} \
--deployment ${CTRLPLANE_DEPLOYMENT_ID} \
--tag ${CI_COMMIT_SHA} \
--name "Pipeline #${CI_PIPELINE_ID}" \
--metadata git/commit=${CI_COMMIT_SHA} \
--metadata git/branch=${CI_COMMIT_REF_NAME} \
--metadata gitlab/pipeline-url=${CI_PIPELINE_URL}
only:
- main
```
## Jenkins
```groovy theme={null}
pipeline {
agent any
environment {
CTRLPLANE_API_KEY = credentials('ctrlplane-api-key')
CTRLPLANE_WORKSPACE = 'your-workspace'
CTRLPLANE_DEPLOYMENT_ID = 'your-deployment-id'
}
stages {
stage('Build') {
steps {
sh 'docker build -t myapp:${GIT_COMMIT} .'
sh 'docker push myapp:${GIT_COMMIT}'
}
}
stage('Create Version') {
steps {
sh '''
ctrlc api upsert version \
--workspace ${CTRLPLANE_WORKSPACE} \
--deployment ${CTRLPLANE_DEPLOYMENT_ID} \
--tag ${GIT_COMMIT} \
--name "Build #${BUILD_NUMBER}" \
--metadata git/commit=${GIT_COMMIT} \
--metadata git/branch=${GIT_BRANCH} \
--metadata jenkins/build-url=${BUILD_URL}
'''
}
}
}
}
```
## CircleCI
```yaml theme={null}
version: 2.1
jobs:
build:
docker:
- image: cimg/base:stable
steps:
- checkout
- setup_remote_docker
- run:
name: Build and Push
command: |
docker build -t myapp:${CIRCLE_SHA1} .
docker push myapp:${CIRCLE_SHA1}
- run:
name: Create Ctrlplane Version
command: |
ctrlc api upsert version \
--workspace ${CTRLPLANE_WORKSPACE} \
--deployment ${CTRLPLANE_DEPLOYMENT_ID} \
--tag ${CIRCLE_SHA1} \
--name "Build #${CIRCLE_BUILD_NUM}" \
--metadata git/commit=${CIRCLE_SHA1} \
--metadata git/branch=${CIRCLE_BRANCH} \
--metadata circleci/build-url=${CIRCLE_BUILD_URL}
workflows:
build-deploy:
jobs:
- build:
filters:
branches:
only: main
```
## Installing ctrlc
The `ctrlc` CLI is the recommended way to interact with Ctrlplane from CI/CD pipelines.
```bash theme={null}
# Download and install
curl -fsSL https://get.ctrlplane.dev | sh
# Or using npm
npm install -g @ctrlplane/cli
```
Set the `CTRLPLANE_API_KEY` environment variable in your CI secrets.
## CLI Reference
### Create Version
```bash theme={null}
ctrlc api upsert version \
--workspace \
--deployment \
--tag \
--name "" \
--metadata key=value \
--metadata another/key=value
```
| Flag | Required | Description |
| -------------- | -------- | ------------------------------- |
| `--workspace` | Yes | Workspace name or ID |
| `--deployment` | Yes | Deployment ID |
| `--tag` | Yes | Unique version identifier |
| `--name` | No | Human-readable version name |
| `--metadata` | No | Key-value metadata (repeatable) |
## REST API
For environments where the CLI isn't available, use the REST API:
```bash theme={null}
POST /api/v1/deployments/{deploymentId}/versions
curl -X POST \
"https://your-ctrlplane-instance.com/api/v1/deployments/{deploymentId}/versions" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "v1.2.3",
"name": "Release 1.2.3",
"status": "ready",
"metadata": {
"commit": "abc123",
"branch": "main"
}
}'
```
## Best Practices
### Semantic Versioning
Use semantic versions for release branches:
```yaml theme={null}
- name: Create Version
run: |
VERSION=$(cat version.txt)
ctrlc api upsert version \
--workspace $CTRLPLANE_WORKSPACE \
--deployment $CTRLPLANE_DEPLOYMENT_ID \
--tag $VERSION
```
### Include Build Metadata
Add useful context to help with debugging:
```yaml theme={null}
- name: Create Version
run: |
ctrlc api upsert version \
--workspace $CTRLPLANE_WORKSPACE \
--deployment $CTRLPLANE_DEPLOYMENT_ID \
--tag ${{ github.sha }} \
--metadata git/commit=${{ github.sha }} \
--metadata git/branch=${{ github.ref_name }} \
--metadata build/number=${{ github.run_number }} \
--metadata build/url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} \
--metadata build/actor=${{ github.actor }} \
--metadata build/trigger=${{ github.event_name }}
```
### Idempotency
The `ctrlc api upsert version` command is idempotent — it creates or updates the
version if it already exists. This makes it safe to retry failed CI runs without
worrying about duplicate version errors.
# Ansible
Source: https://docs.ctrlplane.dev/integrations/job-agents/ansible
Run Ansible playbooks via GitHub Actions
Use the GitHub Actions job agent to dispatch workflows that execute Ansible
playbooks. This lets you keep your existing Ansible automation while Ctrlplane
orchestrates environments, approvals, and verification.
## How It Works
```mermaid theme={null}
sequenceDiagram
participant C as Ctrlplane
participant G as GitHub API
participant W as Workflow
participant A as Ansible
C->>G: Dispatch workflow (job_id)
G->>W: Trigger workflow_dispatch
W->>W: Get job context from Ctrlplane
W->>A: Run ansible-playbook
W->>C: Update job status
```
1. Ctrlplane creates a job and dispatches it to GitHub
2. GitHub triggers your workflow with `workflow_dispatch`
3. The workflow fetches job context (version, environment, resource)
4. Ansible runs the playbook against the target inventory
5. Job status is reported back to Ctrlplane
## Prerequisites
* GitHub App installed in your organization
* Workflow file with `workflow_dispatch` trigger
* Repository permissions for the GitHub App
* Ansible available on the runner (or install via pip)
## Configuration
### Job Agent Setup
Create a job agent with type `github-app`:
```yaml theme={null}
type: JobAgent
name: ansible
agentType: github-app
```
### Deployment Configuration
Configure the deployment to dispatch the workflow:
```yaml theme={null}
type: Deployment
name: ansible-playbook
jobAgent: ansible
jobAgentConfig:
installationId: 12345678
owner: your-org
repo: your-repo
workflowId: 12345678
ref: main # optional, defaults to main
```
| Field | Required | Description |
| ---------------- | -------- | ------------------------------------------ |
| `installationId` | Yes | GitHub App installation ID |
| `owner` | Yes | Repository owner (org or user) |
| `repo` | Yes | Repository name |
| `workflowId` | Yes | Workflow ID (numeric) |
| `ref` | No | Git ref to run workflow on (default: main) |
## Workflow Setup
Create a workflow file in your repository:
```yaml theme={null}
# .github/workflows/ansible-deploy.yml
name: Ansible Deploy
on:
workflow_dispatch:
inputs:
job_id:
description: "Ctrlplane Job ID"
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Ansible
run: pip install ansible
- name: Get job context
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
base_url: ${{ secrets.CTRLPLANE_BASE_URL }}
job_id: ${{ inputs.job_id }}
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Build inventory
run: |
echo '${{ steps.job.outputs.resource_config }}' | jq -r '.hosts[]' > inventory.txt
- name: Run playbook
run: |
ansible-playbook \
-i inventory.txt \
playbooks/deploy.yml \
--extra-vars "version=${{ steps.job.outputs.version_tag }} env=${{ steps.job.outputs.environment_name }}"
```
## Resource Config Example
Store inventory targets on the resource so each release target can map to a
different Ansible inventory:
```yaml theme={null}
type: Resource
identifier: prod-web-1
kind: Server
name: prod-web-1
version: infra/servers/v1
config:
hosts:
- web-01.example.com
- web-02.example.com
```
## Templating
You can use Go templates in your job agent config to select repositories,
workflows, or refs dynamically:
```yaml theme={null}
jobAgentConfig:
installationId: "{{.variables.github_installation_id}}"
owner: "{{.variables.github_org}}"
repo: "{{.deployment.slug}}"
workflowId: "{{.variables.workflow_id}}"
ref: "{{.version.tag}}"
```
## Status Reporting
The workflow should update job status. You can use the Ctrlplane API:
```yaml theme={null}
- name: Mark job successful
if: success()
run: |
curl -X PATCH "https://your-ctrlplane-instance.com/api/v1/jobs/${{ inputs.job_id }}" \
-H "Authorization: Bearer ${{ secrets.CTRLPLANE_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"status": "successful"}'
- name: Mark job failed
if: failure()
run: |
curl -X PATCH "https://your-ctrlplane-instance.com/api/v1/jobs/${{ inputs.job_id }}" \
-H "Authorization: Bearer ${{ secrets.CTRLPLANE_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"status": "failure"}'
```
# ArgoCD
Source: https://docs.ctrlplane.dev/integrations/job-agents/argocd
Deploy applications using ArgoCD GitOps
The ArgoCD job agent creates and syncs ArgoCD Applications, enabling
GitOps-style deployments with automatic health verification.
## How It Works
```mermaid theme={null}
sequenceDiagram
participant C as Ctrlplane
participant A as ArgoCD
participant K as Kubernetes
C->>A: Create/Update Application
A->>K: Sync resources
C->>A: Poll health status
A-->>C: Healthy + Synced
C->>C: Mark job successful
```
1. Ctrlplane renders an ArgoCD Application from your template
2. The Application is created or updated via ArgoCD API
3. ArgoCD syncs the application to Kubernetes
4. Ctrlplane verifies the application reaches `Healthy` + `Synced` status
5. Job is marked successful when verification passes
## Prerequisites
* ArgoCD server with API access
* API token with application create/update permissions
* Network connectivity from Ctrlplane to ArgoCD
## Configuration
### Job Agent Setup
Create a job agent with type `argo-cd`:
```yaml theme={null}
type: JobAgent
name: argocd
agentType: argo-cd
```
### Deployment Configuration
```yaml theme={null}
type: Deployment
name: api-service
jobAgent: argocd
jobAgentConfig:
serverUrl: argocd.example.com:443
apiKey: "{{.variables.argocd_token}}"
template: |
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {{.deployment.slug}}-{{.environment.name}}
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/your-repo
targetRevision: {{.version.tag}}
path: k8s/{{.environment.name}}
destination:
server: {{.resource.config.server}}
namespace: {{.resource.config.namespace}}
syncPolicy:
automated:
prune: true
selfHeal: true
```
| Field | Required | Description |
| ----------- | -------- | ---------------------------------- |
| `serverUrl` | Yes | ArgoCD server URL (host:port) |
| `apiKey` | Yes | ArgoCD API token |
| `template` | Yes | Go template for ArgoCD Application |
## Template Context
The template has access to all job context. Variables are accessed using Go template
syntax: `{{.variable.property}}`. The job context is derived from the resources
returned by the deployment selector (and narrowed by the environment selector),
so the data available to the template reflects that specific target.
### Top-Level Variables
| Variable | Description |
| -------------- | ----------------------------------------------------------- |
| `.job` | Current job details |
| `.version` | Version being deployed (shortcut) |
| `.deployment` | User-defined deployment configuration and properties |
| `.environment` | User-defined environment you deploy into and its properties |
| `.resource` | User-defined resource you deploy against and its properties |
| `.variables` | Merged deployment variables (key-value strings) |
### Resource Properties
Each job invocation is tied to a specific resource instance returned by
your selector, so the `.resource` values can differ for every ArgoCD
template invocation.
| Property | Type | Description |
| ----------------------- | ------------------ | --------------------------------------- |
| `.resource.id` | string | Unique resource ID |
| `.resource.name` | string | Display name |
| `.resource.identifier` | string | Unique identifier within workspace |
| `.resource.kind` | string | Resource kind (e.g., KubernetesCluster) |
| `.resource.version` | string | Resource schema version |
| `.resource.config` | object | Arbitrary configuration data |
| `.resource.metadata` | map\[string]string | Key-value metadata labels |
| `.resource.workspaceId` | string | Parent workspace ID |
| `.resource.providerId` | string | Resource provider ID (if any) |
| `.resource.createdAt` | timestamp | Creation timestamp |
| `.resource.updatedAt` | timestamp | Last update timestamp |
| `.resource.lockedAt` | timestamp | Lock timestamp (if locked) |
### Deployment Properties
Each job invocation is tied to a specific deployment, so `.deployment` values can differ
for every ArgoCD template invocation.
| Property | Type | Description |
| ------------------------------ | ------ | ------------------------------- |
| `.deployment.id` | string | Unique deployment ID |
| `.deployment.name` | string | Display name |
| `.deployment.slug` | string | URL-friendly identifier |
| `.deployment.description` | string | Optional description |
| `.deployment.systemId` | string | Parent system ID |
| `.deployment.jobAgentId` | string | Associated job agent ID |
| `.deployment.jobAgentConfig` | object | Job agent configuration |
| `.deployment.resourceSelector` | object | Resource selector for targeting |
### Environment Properties
Each job invocation is tied to a specific environment, so `.environment` values can differ
for every ArgoCD template invocation.
| Property | Type | Description |
| ------------------------------- | --------- | --------------------- |
| `.environment.id` | string | Unique environment ID |
| `.environment.name` | string | Display name |
| `.environment.description` | string | Optional description |
| `.environment.systemId` | string | Parent system ID |
| `.environment.resourceSelector` | object | Resource selector |
| `.environment.createdAt` | timestamp | Creation timestamp |
### Version Properties
Access via `.version`:
| Property | Type | Description |
| ------------------------- | ------------------ | ------------------------------- |
| `.version.id` | string | Unique version ID |
| `.version.tag` | string | Version tag (e.g., v1.2.3) |
| `.version.name` | string | Display name |
| `.version.message` | string | Optional commit/release message |
| `.version.status` | string | Version status |
| `.version.config` | object | Version-specific configuration |
| `.version.metadata` | map\[string]string | Version metadata |
| `.version.jobAgentConfig` | object | Version-level job agent config |
| `.version.deploymentId` | string | Parent deployment ID |
| `.version.createdAt` | timestamp | Creation timestamp |
### Job Properties
| Property | Type | Description |
| ------------------ | ------------------ | ------------------------- |
| `.job.id` | string | Unique job ID |
| `.job.status` | string | Current status |
| `.job.message` | string | Status message |
| `.job.externalId` | string | External system reference |
| `.job.metadata` | map\[string]string | Job metadata |
| `.job.jobAgentId` | string | Executing job agent ID |
| `.job.releaseId` | string | Associated release ID |
| `.job.createdAt` | timestamp | Creation timestamp |
| `.job.startedAt` | timestamp | Execution start timestamp |
| `.job.completedAt` | timestamp | Completion timestamp |
| `.job.updatedAt` | timestamp | Last update timestamp |
### Variables
The `.variables` map contains all resolved deployment variables as strings:
```yaml theme={null}
template: |
metadata:
annotations:
replicas: "{{.variables.replica_count}}"
db-host: "{{.variables.database_host}}"
```
### Accessing Nested Config
Resource and version configs are arbitrary objects. Access nested properties:
```yaml theme={null}
template: |
spec:
destination:
server: {{.resource.config.cluster_url}}
namespace: {{.resource.config.namespaces.app}}
source:
helm:
parameters:
- name: image.tag
value: {{.version.config.imageTag}}
```
## Template Functions
The template supports [Sprig](http://masterminds.github.io/sprig/) functions:
```yaml theme={null}
template: |
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {{ .deployment.slug | lower | replace "_" "-" }}
labels:
version: {{ .version.tag | trunc 63 }}
deployed-at: {{ now | date "2006-01-02" }}
```
## Automatic Verification
When an ArgoCD Application is created, Ctrlplane automatically starts a
verification that checks:
* Application sync status is `Synced`
* Application health status is `Healthy` or `Progressing`
The verification polls the ArgoCD API every 10 seconds for 5 iterations.
## Example: Multi-Environment Setup
```yaml theme={null}
type: Deployment
name: frontend
jobAgent: argocd
jobAgentConfig:
serverUrl: "{{.variables.argocd_server}}"
apiKey: "{{.variables.argocd_token}}"
template: |
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: frontend-{{.environment.name | lower}}
namespace: argocd
labels:
app: frontend
env: {{.environment.name | lower}}
version: {{.version.tag}}
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: {{.environment.name | lower}}
source:
repoURL: https://github.com/your-org/frontend
targetRevision: {{.version.tag}}
path: deploy/{{.environment.name | lower}}
helm:
valueFiles:
- values.yaml
- values-{{.environment.name | lower}}.yaml
parameters:
- name: image.tag
value: {{.version.tag}}
- name: replicas
value: "{{.resource.config.replicas | default 2}}"
destination:
server: {{.resource.config.cluster_url}}
namespace: frontend
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
## Example: Kustomize Overlay
```yaml theme={null}
template: |
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {{.deployment.slug}}-{{.resource.identifier}}
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/{{.deployment.slug}}
targetRevision: {{.version.tag}}
path: overlays/{{.environment.name}}
kustomize:
images:
- {{.deployment.slug}}={{.variables.registry}}/{{.deployment.slug}}:{{.version.tag}}
destination:
server: {{.resource.config.server}}
namespace: {{.deployment.slug}}
```
## Troubleshooting
### Application not syncing
* Check ArgoCD server logs
* Verify the repository is accessible from ArgoCD
* Check the target revision exists
### Authentication errors
* Verify the API token is valid
* Check token has correct permissions
* Ensure `serverUrl` includes the correct port
### Verification failing
* Check ArgoCD Application status in the UI
* Review sync errors in ArgoCD
* Verify destination cluster is accessible
# GitHub Actions
Source: https://docs.ctrlplane.dev/integrations/job-agents/github
Execute deployments using GitHub Actions workflows
The GitHub Actions job agent triggers workflow dispatch events to execute your
deployments. This is ideal for teams already using GitHub Actions for CI/CD.
## How It Works
```mermaid theme={null}
sequenceDiagram
participant C as Ctrlplane
participant G as GitHub API
participant W as Workflow
C->>G: Dispatch workflow (job_id)
G->>W: Trigger workflow_dispatch
W->>W: Get job context from Ctrlplane
W->>W: Execute deployment
G->>C: Webhook: workflow_run event
C->>C: Update job status automatically
```
1. Ctrlplane creates a job and dispatches it to GitHub
2. GitHub triggers your workflow with `workflow_dispatch`
3. Your workflow fetches job context (version, environment, resource)
4. Your workflow executes the deployment
5. GitHub sends a `workflow_run` webhook event to Ctrlplane as the run progresses
6. Ctrlplane automatically updates the job status (in progress, successful, failure, etc.)
## Prerequisites
* A **GitHub App** registered in your organization (see [GitHub App Setup](#github-app-setup))
* The GitHub App **installed** on the repositories you want to dispatch workflows to
* A workflow file with `workflow_dispatch` trigger in each target repository
* Ctrlplane's workspace engine configured with the GitHub App credentials (see [Server Configuration](#server-configuration))
## GitHub App Setup
Ctrlplane authenticates with GitHub using a GitHub App. The workspace engine
generates a JWT from the App's private key and exchanges it for a short-lived
installation token to dispatch workflows.
### Creating a GitHub App
1. Go to **GitHub → Settings → Developer settings → GitHub Apps → New GitHub App**
2. Fill in the required fields:
* **GitHub App name**: e.g. `ctrlplane-bot`
* **Homepage URL**: your Ctrlplane instance URL
3. Under **Webhook**:
* **Webhook URL**: `https:///api/github/webhook`
* **Webhook secret**: generate a strong secret (e.g. `openssl rand -hex 32`)
* Subscribe to the **Workflow runs** event
4. Under **Permissions**, grant the following **Repository permissions**:
* **Actions**: Read and write (required to dispatch workflow events)
* **Contents**: Read-only (required to access workflow files)
* **Metadata**: Read-only (granted by default)
5. Click **Create GitHub App**
### Gathering Credentials
After creating the App, collect the following values:
| Credential | Where to find it |
| ------------------ | ----------------------------------------------------------------------------------- |
| **App ID** | GitHub App settings page → **App ID** (numeric) |
| **Private Key** | GitHub App settings page → **Private keys** → **Generate a private key** (PEM file) |
| **Client ID** | GitHub App settings page → **Client ID** |
| **Client Secret** | GitHub App settings page → **Generate a new client secret** |
| **Webhook Secret** | The secret you entered when creating the App (step 3 above) |
### Installing the App
1. From the GitHub App settings page, click **Install App** in the left sidebar
2. Choose the organization or account to install on
3. Select the repositories the App should have access to
4. Note the **Installation ID** — you can find it in the URL after installation:
`https://github.com/settings/installations/INSTALLATION_ID`
You can also retrieve it via the GitHub API:
```bash theme={null}
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/orgs/YOUR_ORG/installations
```
## Server Configuration
The workspace engine (and the API server) need the GitHub App credentials to
authenticate with the GitHub API. These are provided via environment variables.
### Environment Variables
| Variable | Service | Required | Description |
| ------------------------ | ---------------- | -------- | ------------------------------------------------------------------ |
| `GITHUB_BOT_APP_ID` | Workspace Engine | Yes | The numeric App ID from your GitHub App settings |
| `GITHUB_BOT_PRIVATE_KEY` | Workspace Engine | Yes | The PEM-encoded private key generated for the GitHub App |
| `GITHUB_WEBHOOK_SECRET` | API Server | Yes | The webhook secret used to verify incoming GitHub webhook payloads |
The **workspace engine** uses `GITHUB_BOT_APP_ID` and `GITHUB_BOT_PRIVATE_KEY`
to generate JWTs for authenticating with the GitHub API and dispatching
workflows. If either is missing, dispatching will fail with a
`GitHub bot not configured` error.
The **API server** uses `GITHUB_WEBHOOK_SECRET` to verify incoming
`workflow_run` webhook events from GitHub. When configured, Ctrlplane
automatically updates job status as workflows progress — no manual status
reporting is needed in your workflow.
### Helm Chart Configuration
When deploying with the Ctrlplane Helm chart, configure the GitHub App
credentials in your `values.yaml` under `global.integrations.github.bot`:
```yaml theme={null}
global:
integrations:
github:
url: "https://github.com"
bot:
appId: "123456"
privateKey: |
-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
webhookSecret: "your-webhook-secret"
```
All bot values support either inline strings or Kubernetes `valueFrom`
references for secrets:
```yaml theme={null}
global:
integrations:
github:
url: "https://github.com"
bot:
appId:
valueFrom:
secretKeyRef:
name: github-bot-secret
key: app-id
privateKey:
valueFrom:
secretKeyRef:
name: github-bot-secret
key: private-key
webhookSecret:
valueFrom:
secretKeyRef:
name: github-bot-secret
key: webhook-secret
```
The full set of Helm values for the GitHub bot:
| Helm Value | Env Variable | Required for Actions | Description |
| ---------------------------------------------- | -------------------------- | -------------------- | --------------------------------------------------- |
| `global.integrations.github.bot.appId` | `GITHUB_BOT_APP_ID` | Yes | GitHub App numeric ID |
| `global.integrations.github.bot.privateKey` | `GITHUB_BOT_PRIVATE_KEY` | Yes | PEM private key |
| `global.integrations.github.bot.name` | `GITHUB_BOT_NAME` | No | Display name for the bot |
| `global.integrations.github.bot.clientId` | `GITHUB_BOT_CLIENT_ID` | No | GitHub App client ID |
| `global.integrations.github.bot.clientSecret` | `GITHUB_BOT_CLIENT_SECRET` | No | GitHub App client secret |
| `global.integrations.github.bot.webhookSecret` | `GITHUB_WEBHOOK_SECRET` | Yes | Webhook secret for verifying incoming GitHub events |
### Docker / Manual Deployment
When running the workspace engine directly, pass the environment variables:
```bash theme={null}
docker run -d \
--name ctrlplane-workspace-engine \
-e GITHUB_BOT_APP_ID=123456 \
-e GITHUB_BOT_PRIVATE_KEY="$(cat private-key.pem)" \
# ... other env vars ...
ctrlplane/workspace-engine:latest
```
## Configuration
### Job Agent Setup
Create a job agent with type `github-app`:
```yaml theme={null}
type: JobAgent
name: github-actions
agentType: github-app
```
### Deployment Configuration
Configure the deployment to use GitHub Actions:
```yaml theme={null}
type: Deployment
name: api-service
jobAgent: github-actions
jobAgentConfig:
installationId: 12345678
owner: your-org
repo: your-repo
workflowId: 12345678
ref: main # optional, defaults to main
```
| Field | Required | Description |
| ---------------- | -------- | ------------------------------------------ |
| `installationId` | Yes | GitHub App installation ID |
| `owner` | Yes | Repository owner (org or user) |
| `repo` | Yes | Repository name |
| `workflowId` | Yes | Workflow ID (numeric) |
| `ref` | No | Git ref to run workflow on (default: main) |
### Finding Your Installation ID
The installation ID is the numeric ID assigned when the GitHub App is installed
on an organization or user account. You can find it:
1. **From the URL**: after installing the App, the URL will be
`https://github.com/settings/installations/INSTALLATION_ID`
2. **From the API**:
```bash theme={null}
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/orgs/YOUR_ORG/installations
```
Look for the `id` field in the response.
### Finding Your Workflow ID
Use the GitHub API to find your workflow ID:
```bash theme={null}
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/OWNER/REPO/actions/workflows
```
The `id` field in each workflow object is the numeric workflow ID you need.
## Workflow Setup
Create a workflow file in your repository:
```yaml theme={null}
# .github/workflows/deploy.yml
name: Deploy
on:
workflow_dispatch:
inputs:
job_id:
description: "Ctrlplane Job ID"
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get job context
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
base_url: ${{ secrets.CTRLPLANE_BASE_URL }}
job_id: ${{ inputs.job_id }}
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Deploy
run: |
echo "Deploying ${{ steps.job.outputs.version_tag }}"
echo "Environment: ${{ steps.job.outputs.environment_name }}"
echo "Resource: ${{ steps.job.outputs.resource_identifier }}"
# Your deployment commands here
```
## Available Job Context
The `get-job-inputs` action provides these outputs:
| Output | Description |
| ---------------------- | --------------------------- |
| `job_id` | The Ctrlplane job ID |
| `version_tag` | Version tag being deployed |
| `version_name` | Version name |
| `environment_name` | Target environment name |
| `environment_id` | Target environment ID |
| `resource_identifier` | Target resource identifier |
| `resource_name` | Target resource name |
| `resource_kind` | Target resource kind |
| `resource_config` | Resource config (JSON) |
| `deployment_name` | Deployment name |
| `deployment_variables` | Deployment variables (JSON) |
## Templating
You can use Go templates in your job agent config to dynamically configure
workflows:
```yaml theme={null}
jobAgentConfig:
installationId: "{{.variables.github_installation_id}}"
owner: "{{.variables.github_org}}"
repo: "{{.deployment.slug}}"
workflowId: "{{.variables.workflow_id}}"
ref: "{{.version.tag}}"
```
## Status Reporting
### Automatic via Webhooks (Recommended)
When the GitHub App webhook is configured (see [GitHub App Setup](#creating-a-github-app)),
Ctrlplane automatically receives `workflow_run` events from GitHub and updates
job status without any extra steps in your workflow.
The API server listens at `/api/github/webhook` and maps GitHub workflow run
states to Ctrlplane job statuses:
| GitHub Conclusion | Ctrlplane Status |
| ----------------- | ----------------- |
| *(in progress)* | `in_progress` |
| `success` | `successful` |
| `failure` | `failure` |
| `cancelled` | `cancelled` |
| `action_required` | `action_required` |
| `skipped` | `skipped` |
| `neutral` | `skipped` |
Ctrlplane matches the workflow run to a job by extracting the job ID from the
workflow run name. The `get-job-inputs` action includes the job ID in the run
name automatically.
For automatic status reporting to work, ensure:
* The GitHub App webhook URL is set to `https:///api/github/webhook`
* The `GITHUB_WEBHOOK_SECRET` env var matches the secret configured in the GitHub App
* The App is subscribed to **Workflow runs** events
### Manual via API (Fallback)
If you are not using webhooks, you can manually report status from the workflow
using the Ctrlplane API:
```yaml theme={null}
- name: Mark job successful
if: success()
run: |
curl -X PATCH "https://your-ctrlplane-instance.com/api/v1/jobs/${{ inputs.job_id }}" \
-H "Authorization: Bearer ${{ secrets.CTRLPLANE_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"status": "successful"}'
- name: Mark job failed
if: failure()
run: |
curl -X PATCH "https://your-ctrlplane-instance.com/api/v1/jobs/${{ inputs.job_id }}" \
-H "Authorization: Bearer ${{ secrets.CTRLPLANE_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"status": "failure"}'
```
## Example: Kubernetes Deployment
```yaml theme={null}
name: Deploy to Kubernetes
on:
workflow_dispatch:
inputs:
job_id:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get job context
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
base_url: ${{ secrets.CTRLPLANE_BASE_URL }}
job_id: ${{ inputs.job_id }}
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Configure kubectl
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Deploy
run: |
kubectl set image deployment/${{ steps.job.outputs.deployment_name }} \
app=${{ steps.job.outputs.version_tag }} \
-n ${{ fromJson(steps.job.outputs.resource_config).namespace }}
```
## Troubleshooting
### `GitHub bot not configured` error
The workspace engine cannot find the App credentials. Verify:
* `GITHUB_BOT_APP_ID` is set and is a valid numeric ID
* `GITHUB_BOT_PRIVATE_KEY` is set and contains the full PEM-encoded private key
* If using Helm, check that `global.integrations.github.bot.appId` and
`global.integrations.github.bot.privateKey` are set in your values
### `failed to get installation token` error
The App credentials are set but the token exchange failed. Check:
* The **App ID** matches the GitHub App you created
* The **private key** has not been revoked or regenerated
* The **installation ID** in your deployment config is correct
* The GitHub App is still installed on the target organization/account
### Workflow not triggered
* Verify the workflow file has `workflow_dispatch` as a trigger
* Ensure the `ref` in your config points to a branch where the workflow file exists
* Check that the GitHub App has **Actions: Read and write** permission on the repository
* Confirm the **workflow ID** is correct (use the API to verify)
### Job status not updating automatically
* Verify the GitHub App webhook URL is set to `https:///api/github/webhook`
* Ensure `GITHUB_WEBHOOK_SECRET` is set on the API server and matches the secret in GitHub App settings
* Check that the App is subscribed to **Workflow runs** events
* Confirm the API server is reachable from GitHub (not behind a firewall)
### Authentication errors in the workflow
* Ensure `CTRLPLANE_API_KEY` is set as a repository or organization secret in GitHub
* Verify the API key has permissions to read jobs in your Ctrlplane workspace
# Terraform Cloud
Source: https://docs.ctrlplane.dev/integrations/job-agents/terraform-cloud
Manage infrastructure deployments with Terraform Cloud/Enterprise
The Terraform Cloud job agent creates workspaces and triggers runs, enabling
infrastructure-as-code deployments with webhook-based status tracking.
## How It Works
```mermaid theme={null}
sequenceDiagram
participant C as Ctrlplane
participant T as Terraform Cloud
participant I as Infrastructure
C->>T: Create/Update Workspace
C->>T: Sync Variables
C->>T: Ensure Notification Config
C->>T: Create Run
T->>I: Plan & Apply
T->>C: Webhook: run status updates
C->>C: Update job status
```
1. Ctrlplane renders a workspace configuration from your template
2. The workspace is created or updated via Terraform Cloud API
3. Variables are synced to match your template
4. A webhook notification configuration (`ctrlplane-webhook`) is created on the workspace (idempotent)
5. A run is triggered with auto-apply
6. Terraform Cloud sends webhook notifications as the run progresses
7. The ctrlplane API receives webhooks and updates job status in the database
This is a **fire-and-forget** dispatch model — the workspace-engine does not poll
or maintain long-running goroutines. Status tracking is handled entirely by
TFC webhooks, making it resilient to engine restarts.
## Prerequisites
* Terraform Cloud or Terraform Enterprise account
* API token with workspace, run, and notification configuration permissions
* VCS connection (optional, for Git-based workflows)
* A reachable webhook endpoint (the ctrlplane API must be accessible from TFC)
## Configuration
### Job Agent Setup
Create a job agent with type `tfe`:
```yaml theme={null}
type: JobAgent
name: terraform-cloud
agentType: tfe
```
### Deployment Configuration
```yaml theme={null}
type: Deployment
name: infrastructure
jobAgent: terraform-cloud
jobAgentConfig:
organization: your-org
address: https://app.terraform.io
token: "{{.variables.tfe_token}}"
webhookUrl: https://ctrlplane.example.com/api/tfe/webhook
triggerRunOnChange: true
template: |
name: {{.deployment.slug}}-{{.resource.identifier}}
description: "Managed by Ctrlplane"
execution_mode: remote
auto_apply: true
terraform_version: "1.6.0"
working_directory: environments/{{.environment.name}}
vcs_repo:
identifier: your-org/infrastructure
branch: main
oauth_token_id: ot-xxxxxxxxxx
variables:
- key: environment
value: {{.environment.name}}
category: terraform
- key: version
value: {{.version.tag}}
category: terraform
```
| Field | Required | Default | Description |
| -------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------- |
| `organization` | Yes | | Terraform Cloud organization |
| `address` | Yes | | Terraform Cloud/Enterprise URL |
| `token` | Yes | | API token |
| `template` | Yes | | Go template for workspace configuration |
| `webhookUrl` | Yes | | Ctrlplane API endpoint for TFC notifications (e.g. `https://ctrlplane.example.com/api/tfe/webhook`) |
| `triggerRunOnChange` | No | `true` | Whether to create a TFC run on dispatch. When `false`, only the workspace and variables are synced. |
### Environment Variables
| Variable | Where | Description |
| -------------------- | ---------------- | ----------------------------------------------------------- |
| `TFE_WEBHOOK_SECRET` | API | HMAC secret for verifying incoming TFC webhooks |
| `TFE_WEBHOOK_SECRET` | Workspace Engine | Same secret, used when creating notification configs on TFC |
Both the API and workspace-engine must share the same `TFE_WEBHOOK_SECRET`.
## Webhook Status Mapping
When TFC sends a notification, the webhook handler maps the trigger to a
ctrlplane job status:
| TFC Trigger | Example Run Status | Ctrlplane Status |
| --------------------- | --------------------------------------- | ---------------- |
| `run:created` | pending | `pending` |
| `run:planning` | planning | `inProgress` |
| `run:needs_attention` | planned (confirmable), policy\_override | `actionRequired` |
| `run:applying` | applying | `inProgress` |
| `run:completed` | applied, planned\_and\_finished | `successful` |
| `run:errored` | errored | `failure` |
## Workspace Template
The template defines the Terraform Cloud workspace:
| Field | Type | Description |
| ------------------- | ------ | -------------------------------- |
| `name` | string | Workspace name (required) |
| `description` | string | Workspace description |
| `execution_mode` | string | `remote`, `local`, or `agent` |
| `auto_apply` | bool | Auto-apply after plan |
| `terraform_version` | string | Terraform version to use |
| `working_directory` | string | Subdirectory for Terraform files |
| `vcs_repo` | object | VCS repository settings |
| `variables` | array | Workspace variables |
### VCS Repository Settings
```yaml theme={null}
vcs_repo:
identifier: org/repo
branch: main
oauth_token_id: ot-xxxxxxxxxx
ingress_submodules: false
tags_regex: ""
```
### Variable Configuration
```yaml theme={null}
variables:
- key: aws_region
value: us-east-1
category: terraform # or "env"
hcl: false
sensitive: false
description: "AWS region"
```
## Template Context
The template has access to the full dispatch context:
| Variable | Description |
| -------------- | ---------------------------------- |
| `.deployment` | Deployment details |
| `.environment` | Environment details |
| `.resource` | Target resource (config, metadata) |
| `.release` | Release details |
| `.version` | Deployment version (tag, name) |
| `.variables` | Merged deployment variables |
## `triggerRunOnChange: false`
When `triggerRunOnChange` is set to `false`, the dispatcher will:
1. Upsert the workspace
2. Sync variables
3. Ensure the notification config exists
4. **Skip** creating a run
This is useful when you want VCS pushes to trigger runs instead of ctrlplane
creating them directly. The webhook notification config is still created so
that run status updates flow back to ctrlplane.
> **Note:** Correlating VCS-triggered runs back to ctrlplane jobs (by workspace
> name/ID instead of run ID) is a planned follow-up.
## Example: Multi-Environment Infrastructure
```yaml theme={null}
type: Deployment
name: vpc-infrastructure
jobAgent: terraform-cloud
jobAgentConfig:
organization: "{{.variables.tfe_org}}"
address: "{{.variables.tfe_address}}"
token: "{{.variables.tfe_token}}"
webhookUrl: "{{.variables.ctrlplane_webhook_url}}"
template: |
name: vpc-{{.environment.name}}-{{.resource.metadata.region}}
description: "VPC for {{.environment.name}} in {{.resource.metadata.region}}"
execution_mode: remote
auto_apply: {{if eq .environment.name "production"}}false{{else}}true{{end}}
terraform_version: "1.6.0"
working_directory: modules/vpc
vcs_repo:
identifier: {{.variables.github_org}}/infrastructure
branch: {{.version.tag}}
oauth_token_id: {{.variables.vcs_oauth_token}}
variables:
- key: environment
value: {{.environment.name}}
category: terraform
- key: region
value: {{.resource.metadata.region}}
category: terraform
- key: vpc_cidr
value: {{.resource.config.vpc_cidr}}
category: terraform
- key: AWS_ACCESS_KEY_ID
value: {{.variables.aws_access_key}}
category: env
sensitive: true
- key: AWS_SECRET_ACCESS_KEY
value: {{.variables.aws_secret_key}}
category: env
sensitive: true
```
## Example: Agent-Based Execution
For private infrastructure, use agent execution mode:
```yaml theme={null}
template: |
name: private-{{.resource.identifier}}
execution_mode: agent
agent_pool_id: {{.variables.agent_pool_id}}
auto_apply: true
variables:
- key: target_host
value: {{.resource.config.host}}
category: terraform
```
## Terraform Provider Configuration
When using the ctrlplane Terraform provider:
```hcl theme={null}
resource "ctrlplane_job_agent" "tfc" {
name = "terraform-cloud"
terraform_cloud {
address = "https://app.terraform.io"
organization = "your-org"
token = var.tfc_token
webhook_url = "https://ctrlplane.example.com/api/tfe/webhook"
trigger_run_on_change = true
template = file("workspace-template.yaml")
}
}
```
## Troubleshooting
### Workspace creation fails
* Verify organization name is correct
* Check API token has workspace:write permission
* Ensure workspace name is valid (alphanumeric, hyphens, underscores)
### VCS connection errors
* Verify OAuth token ID is correct
* Check repository exists and is accessible
* Ensure branch or tag exists
### Run fails to start
* Check workspace has valid configuration
* Verify VCS connection is working
* Review workspace settings in Terraform Cloud UI
### Variables not updating
* Verify variable keys match expected format
* Check for duplicate variable definitions
* Sensitive variables won't show values in UI
### Webhook returns 401
* Check `TFE_WEBHOOK_SECRET` is set on the API
* Verify the same secret was used when creating the notification config on TFC
* Ensure the `x-tfe-notification-signature` header is present
### No webhook notifications received
* Verify the `webhookUrl` is reachable from Terraform Cloud
* Check the notification config exists on the TFC workspace (Settings > Notifications)
* Review TFC's notification delivery log for errors
* For local development, use smee.io or localtunnel to expose your API
### Job stays in `inProgress`
* Verify webhooks are reaching the API (check API logs for `POST /api/tfe/webhook`)
* Check the TFC run status directly in the TFC UI
# AWS Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/aws
Sync AWS resources into Ctrlplane
The AWS provider syncs resources from Amazon Web Services into Ctrlplane's
inventory—EKS clusters, EC2 instances, RDS databases, and VPC networks.
## Prerequisites
* `ctrlc` CLI installed
* AWS credentials configured (environment variables, `~/.aws/credentials`, or IAM role)
* Ctrlplane API key
## Supported Resources
| Command | Resource Type | Ctrlplane Kind |
| -------------- | -------------- | ----------------------- |
| `aws eks` | EKS Clusters | `AWS/EKS` |
| `aws ec2` | EC2 Instances | `AWS/EC2` |
| `aws rds` | RDS Instances | `AWS/RDS` |
| `aws networks` | VPCs & Subnets | `AWS/VPC`, `AWS/Subnet` |
## Authentication
Configure AWS credentials using any standard method:
```bash theme={null}
# Environment variables
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_REGION="us-east-1"
# Or use AWS CLI profile
export AWS_PROFILE="production"
# Or use IAM role (when running in AWS)
# Credentials are automatically retrieved
```
## EKS Clusters
Sync Amazon Elastic Kubernetes Service clusters:
```bash theme={null}
# Sync from a specific region
ctrlc sync aws eks --region us-east-1
# Sync from multiple regions
ctrlc sync aws eks --region us-east-1 --region us-west-2
# Sync from all regions
ctrlc sync aws eks
# Continuous sync
ctrlc sync aws eks --region us-east-1 --interval 5m
```
### Options
| Flag | Description | Required |
| ------------ | -------------------------------- | --------------------------------- |
| `--region` | AWS region(s) to sync from | No (all regions if not specified) |
| `--provider` | Resource provider name | No |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
### Resource Metadata
EKS clusters include metadata from AWS tags:
```yaml theme={null}
identifier: arn:aws:eks:us-east-1:123456789:cluster/prod-cluster
name: prod-cluster
kind: AWS/EKS
metadata:
region: us-east-1
account: "123456789"
environment: production # from AWS tag
team: platform # from AWS tag
config:
endpoint: https://XXXXX.eks.us-east-1.amazonaws.com
version: "1.28"
```
## EC2 Instances
Sync EC2 instances:
```bash theme={null}
# Sync from a specific region
ctrlc sync aws ec2 --region us-east-1
# Continuous sync
ctrlc sync aws ec2 --region us-east-1 --interval 5m
```
### Resource Metadata
```yaml theme={null}
identifier: i-0123456789abcdef0
name: web-server-1 # from Name tag
kind: AWS/EC2
metadata:
region: us-east-1
availability_zone: us-east-1a
instance_type: t3.medium
environment: production # from AWS tag
config:
private_ip: 10.0.1.100
public_ip: 54.123.45.67
vpc_id: vpc-12345
```
## RDS Instances
Sync RDS database instances:
```bash theme={null}
# Sync from a specific region
ctrlc sync aws rds --region us-east-1
# Continuous sync
ctrlc sync aws rds --region us-east-1 --interval 10m
```
### Resource Metadata
```yaml theme={null}
identifier: arn:aws:rds:us-east-1:123456789:db:prod-db
name: prod-db
kind: AWS/RDS
metadata:
region: us-east-1
engine: postgres
engine_version: "15.4"
instance_class: db.r5.large
environment: production # from AWS tag
config:
endpoint: prod-db.xxxxx.us-east-1.rds.amazonaws.com
port: 5432
```
## VPC Networks
Sync VPCs and subnets:
```bash theme={null}
# Sync from a specific region
ctrlc sync aws networks --region us-east-1
```
## Running in AWS
### ECS Task
```json theme={null}
{
"family": "ctrlplane-sync",
"containerDefinitions": [
{
"name": "sync",
"image": "ghcr.io/ctrlplanedev/cli:latest",
"command": [
"ctrlc", "sync", "aws", "eks",
"--region", "us-east-1",
"--interval", "5m"
],
"environment": [
{
"name": "CTRLPLANE_API_KEY",
"value": "your-api-key"
},
{
"name": "CTRLPLANE_WORKSPACE",
"value": "your-workspace-id"
}
]
}
],
"taskRoleArn": "arn:aws:iam::123456789:role/ctrlplane-sync-role"
}
```
### IAM Policy
The sync task needs read permissions:
```json theme={null}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"eks:ListClusters",
"eks:DescribeCluster",
"ec2:DescribeInstances",
"ec2:DescribeVpcs",
"ec2:DescribeSubnets",
"rds:DescribeDBInstances",
"tag:GetResources"
],
"Resource": "*"
}
]
}
```
### Lambda Function
Run sync periodically with Lambda:
```python theme={null}
import subprocess
def handler(event, context):
subprocess.run([
"ctrlc", "sync", "aws", "eks",
"--region", "us-east-1"
], check=True)
```
## Environment Targeting
Target AWS resources in environments:
```yaml theme={null}
# All production EKS clusters
type: Environment
name: Production EKS
resourceSelector: |
resource.kind == "AWS/EKS" &&
resource.metadata["environment"] == "production"
```
```yaml theme={null}
# US East resources only
type: Environment
name: US East
resourceSelector: |
resource.metadata["region"] == "us-east-1"
```
```yaml theme={null}
# Production databases
type: Environment
name: Production Databases
resourceSelector: |
resource.kind == "AWS/RDS" &&
resource.metadata["environment"] == "production"
```
## Best Practices
### Tag Your Resources
Ensure AWS resources have meaningful tags:
```bash theme={null}
aws ec2 create-tags --resources i-12345 --tags \
Key=environment,Value=production \
Key=team,Value=platform \
Key=tier,Value=critical
```
### Use Multiple Regions
Sync from all regions your infrastructure spans:
```bash theme={null}
ctrlc sync aws eks \
--region us-east-1 \
--region us-west-2 \
--region eu-west-1 \
--interval 5m
```
### Separate by Resource Type
Run separate sync processes for different resource types:
```bash theme={null}
# EKS sync
ctrlc sync aws eks --interval 5m &
# EC2 sync
ctrlc sync aws ec2 --interval 5m &
# RDS sync (less frequent)
ctrlc sync aws rds --interval 15m &
```
## Next Steps
Sync GCP resources
Sync Azure resources
Learn selector syntax
Create dynamic environments
# Azure Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/azure
Sync Azure resources into Ctrlplane
The Azure provider syncs resources from Microsoft Azure into Ctrlplane's
inventory—AKS clusters and virtual networks.
## Prerequisites
* `ctrlc` CLI installed
* Azure credentials configured (Azure CLI, environment variables, or managed identity)
* Ctrlplane API key
## Supported Resources
| Command | Resource Type | Ctrlplane Kind |
| ---------------- | ---------------- | -------------- |
| `azure aks` | AKS Clusters | `Azure/AKS` |
| `azure networks` | Virtual Networks | `Azure/VNet` |
## Authentication
Configure Azure credentials:
```bash theme={null}
# Azure CLI (recommended for local development)
az login
# Service Principal (for CI/CD)
export AZURE_CLIENT_ID="your-client-id"
export AZURE_CLIENT_SECRET="your-client-secret"
export AZURE_TENANT_ID="your-tenant-id"
# Managed Identity (when running in Azure)
# Credentials are automatically retrieved
```
## AKS Clusters
Sync Azure Kubernetes Service clusters:
```bash theme={null}
# Sync from default subscription
ctrlc sync azure aks
# Sync from a specific subscription
ctrlc sync azure aks --subscription-id 00000000-0000-0000-0000-000000000000
# Continuous sync
ctrlc sync azure aks --interval 5m
```
### Options
| Flag | Description | Required |
| ------------------- | -------------------------------- | ----------------- |
| `--subscription-id` | Azure subscription ID | No (uses default) |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
### Resource Metadata
```yaml theme={null}
identifier: /subscriptions/xxx/resourceGroups/prod-rg/providers/Microsoft.ContainerService/managedClusters/prod-cluster
name: prod-cluster
kind: Azure/AKS
metadata:
subscription: 00000000-0000-0000-0000-000000000000
resource_group: prod-rg
location: eastus
environment: production # from Azure tag
team: platform # from Azure tag
config:
fqdn: prod-cluster-xxxxx.hcp.eastus.azmk8s.io
kubernetes_version: "1.28.3"
```
## Virtual Networks
Sync Azure Virtual Networks:
```bash theme={null}
# Sync from default subscription
ctrlc sync azure networks
# Sync from a specific subscription
ctrlc sync azure networks --subscription-id 00000000-0000-0000-0000-000000000000
```
### Resource Metadata
```yaml theme={null}
identifier: /subscriptions/xxx/resourceGroups/prod-rg/providers/Microsoft.Network/virtualNetworks/prod-vnet
name: prod-vnet
kind: Azure/VNet
metadata:
subscription: 00000000-0000-0000-0000-000000000000
resource_group: prod-rg
location: eastus
config:
address_space: ["10.0.0.0/16"]
```
## Running in Azure
### Azure Container Instances
```bash theme={null}
az container create \
--resource-group ctrlplane-rg \
--name ctrlplane-sync \
--image ghcr.io/ctrlplanedev/cli:latest \
--command-line "ctrlc sync azure aks --interval 5m" \
--environment-variables \
CTRLPLANE_API_KEY=your-api-key \
CTRLPLANE_WORKSPACE=your-workspace-id \
--assign-identity
```
### AKS Deployment with Workload Identity
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-azure-sync
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-azure-sync
template:
metadata:
labels:
app: ctrlplane-azure-sync
azure.workload.identity/use: "true"
spec:
serviceAccountName: ctrlplane-sync
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- azure
- aks
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ctrlplane-sync
annotations:
azure.workload.identity/client-id: your-client-id
```
### Required Azure Permissions
The sync identity needs Reader permissions:
```bash theme={null}
# Assign Reader role at subscription level
az role assignment create \
--assignee \
--role "Reader" \
--scope /subscriptions/
# Or at resource group level
az role assignment create \
--assignee \
--role "Reader" \
--scope /subscriptions//resourceGroups/
```
## Environment Targeting
Target Azure resources in environments:
```yaml theme={null}
# All production AKS clusters
type: Environment
name: Production AKS
resourceSelector: |
resource.kind == "Azure/AKS" &&
resource.metadata["environment"] == "production"
```
```yaml theme={null}
# East US resources
type: Environment
name: East US
resourceSelector: |
resource.metadata["location"] == "eastus"
```
```yaml theme={null}
# Specific resource group
type: Environment
name: Production Resource Group
resourceSelector: |
resource.metadata["resource_group"] == "prod-rg"
```
## Best Practices
### Tag Your Resources
Ensure Azure resources have meaningful tags:
```bash theme={null}
az aks update \
--resource-group prod-rg \
--name prod-cluster \
--tags environment=production team=platform tier=critical
```
### Sync Multiple Subscriptions
Run sync for each subscription:
```bash theme={null}
# Production subscription
ctrlc sync azure aks \
--subscription-id prod-subscription-id \
--interval 5m &
# Staging subscription
ctrlc sync azure aks \
--subscription-id staging-subscription-id \
--interval 5m &
```
## Next Steps
Sync AWS resources
Sync GCP resources
Learn selector syntax
Create dynamic environments
# Custom Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/custom
Build your own resource provider via API
Create custom resource providers to sync any infrastructure into Ctrlplane's
inventory using the API or SDK.
## When to Use Custom Providers
Use custom providers when you need to sync:
* Internal infrastructure management systems
* Custom cloud platforms
* Database clusters
* Edge devices
* Any resource not covered by built-in providers
## Using the API
### Create or Update a Resource
```bash theme={null}
curl -X PUT "https://your-ctrlplane-instance.com/api/v1/workspaces/{workspaceId}/resources" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"identifier": "my-server-1",
"name": "Production Server 1",
"kind": "Server",
"version": "1.0.0",
"metadata": {
"environment": "production",
"region": "us-east-1",
"team": "platform"
},
"config": {
"host": "10.0.1.100",
"port": 22
}
}'
```
### Delete a Resource
```bash theme={null}
curl -X DELETE \
"https://your-ctrlplane-instance.com/api/v1/resources/{resourceId}" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY"
```
### List Resources
```bash theme={null}
curl "https://your-ctrlplane-instance.com/api/v1/workspaces/{workspaceId}/resources" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY"
```
## Using the Node.js SDK
```typescript theme={null}
import { Ctrlplane } from "@ctrlplane/node-sdk";
const client = new Ctrlplane({ apiKey: process.env.CTRLPLANE_API_KEY });
// Sync resources from your infrastructure
async function syncResources() {
const servers = await discoverServers(); // Your discovery logic
for (const server of servers) {
await client.resources.upsert({
workspaceId: "your-workspace-id",
identifier: server.id,
name: server.hostname,
kind: "Server",
version: "1.0.0",
metadata: {
environment: server.environment,
region: server.region,
team: server.team,
},
config: {
host: server.ip,
port: 22,
},
});
}
}
// Run on interval
setInterval(syncResources, 5 * 60 * 1000); // Every 5 minutes
```
## Using a Shell Script
```bash theme={null}
#!/bin/bash
# sync-resources.sh
WORKSPACE_ID="your-workspace-id"
API_URL="https://your-ctrlplane-instance.com/api/v1"
# Discover resources (example: from cloud provider)
INSTANCES=$(aws ec2 describe-instances --query 'Reservations[].Instances[]')
# Sync each instance to Ctrlplane
echo "$INSTANCES" | jq -c '.[]' | while read instance; do
INSTANCE_ID=$(echo "$instance" | jq -r '.InstanceId')
NAME=$(echo "$instance" | jq -r '.Tags[] | select(.Key=="Name") | .Value')
ENV=$(echo "$instance" | jq -r '.Tags[] | select(.Key=="Environment") | .Value')
REGION=$(echo "$instance" | jq -r '.Placement.AvailabilityZone' | sed 's/.$//')
curl -X PUT "${API_URL}/workspaces/${WORKSPACE_ID}/resources" \
-H "Authorization: Bearer ${CTRLPLANE_API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"identifier\": \"${INSTANCE_ID}\",
\"name\": \"${NAME}\",
\"kind\": \"AWS/EC2\",
\"version\": \"1.0.0\",
\"metadata\": {
\"environment\": \"${ENV}\",
\"region\": \"${REGION}\"
}
}"
done
```
## Using Python
```python theme={null}
import os
import requests
import time
API_KEY = os.environ["CTRLPLANE_API_KEY"]
WORKSPACE_ID = os.environ["CTRLPLANE_WORKSPACE"]
API_URL = "https://your-ctrlplane-instance.com/api/v1"
def sync_resource(resource):
"""Upsert a resource to Ctrlplane."""
response = requests.put(
f"{API_URL}/workspaces/{WORKSPACE_ID}/resources",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
},
json=resource
)
response.raise_for_status()
return response.json()
def discover_resources():
"""Your custom discovery logic."""
# Example: query your internal CMDB
return [
{
"identifier": "server-001",
"name": "Web Server 1",
"kind": "Server",
"version": "1.0.0",
"metadata": {
"environment": "production",
"region": "us-east-1",
"team": "platform"
},
"config": {
"host": "10.0.1.100",
"port": 22
}
}
]
def sync_all():
"""Sync all resources."""
resources = discover_resources()
for resource in resources:
sync_resource(resource)
print(f"Synced: {resource['name']}")
if __name__ == "__main__":
while True:
sync_all()
time.sleep(300) # Every 5 minutes
```
## Resource Schema
| Field | Required | Description |
| ------------ | -------- | ------------------------------------------ |
| `identifier` | Yes | Unique identifier for the resource |
| `name` | Yes | Human-readable name |
| `kind` | Yes | Resource type (use `Category/Type` format) |
| `version` | Yes | Resource version/schema version |
| `metadata` | No | Key-value pairs for filtering |
| `config` | No | Configuration data for job agents |
### Kind Naming Convention
Use a consistent naming convention:
```yaml theme={null}
# Good: Category/Type format
kind: Server/Linux
kind: Database/PostgreSQL
kind: Cache/Redis
kind: Queue/RabbitMQ
# Bad: inconsistent formats
kind: linux-server
kind: postgres
kind: redis
```
### Metadata vs Config
* **Metadata**: Used for environment selectors and filtering
* **Config**: Passed to job agents for deployment execution
```yaml theme={null}
# Metadata: for targeting
metadata:
environment: production
region: us-east-1
team: platform
tier: critical
# Config: for deployment
config:
host: 10.0.1.100
port: 22
ssh_key_path: /path/to/key
```
## Running Continuously
### Kubernetes CronJob
```yaml theme={null}
apiVersion: batch/v1
kind: CronJob
metadata:
name: sync-custom-resources
spec:
schedule: "*/5 * * * *" # Every 5 minutes
jobTemplate:
spec:
template:
spec:
containers:
- name: sync
image: your-sync-image
command: ["python", "sync.py"]
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
restartPolicy: OnFailure
```
### Kubernetes Deployment
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: custom-resource-sync
spec:
replicas: 1
selector:
matchLabels:
app: custom-resource-sync
template:
metadata:
labels:
app: custom-resource-sync
spec:
containers:
- name: sync
image: your-sync-image
command: ["python", "sync.py"]
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
```
## Best Practices
### Use Stable Identifiers
Use identifiers that won't change:
```yaml theme={null}
# Good: stable identifiers
identifier: server-001
identifier: db-prod-primary
identifier: cache-us-east-1
# Bad: may change
identifier: 10.0.1.100
identifier: ip-10-0-1-100
```
### Include Essential Metadata
Include metadata for effective targeting:
```yaml theme={null}
metadata:
environment: production
region: us-east-1
team: platform
tier: critical
```
### Handle Deletions
Remove resources that no longer exist:
```python theme={null}
def sync_all():
# Get current resources from source
current_resources = discover_resources()
current_ids = {r["identifier"] for r in current_resources}
# Get existing resources in Ctrlplane
existing = get_ctrlplane_resources()
existing_ids = {r["identifier"] for r in existing}
# Sync current resources
for resource in current_resources:
sync_resource(resource)
# Delete removed resources
for resource in existing:
if resource["identifier"] not in current_ids:
delete_resource(resource["id"])
```
## Next Steps
Learn selector syntax
Create dynamic environments
Full API documentation
Node.js SDK reference
# GitHub Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/github
Sync GitHub resources into Ctrlplane
The GitHub provider syncs resources from GitHub into Ctrlplane's
inventory—currently supporting pull requests as deployment targets.
## Prerequisites
* `ctrlc` CLI installed
* GitHub personal access token or GitHub App
* Ctrlplane API key
## Supported Resources
| Command | Resource Type | Ctrlplane Kind |
| ---------------------- | ------------- | -------------------- |
| `github pull-requests` | Pull Requests | `GitHub/PullRequest` |
## Authentication
Set your GitHub token:
```bash theme={null}
export GITHUB_TOKEN="your-github-token"
```
## Pull Requests
Sync GitHub pull requests as resources for preview environments:
```bash theme={null}
# Sync pull requests from a repository
ctrlc sync github pull-requests \
--owner my-org \
--repo my-app
# Continuous sync
ctrlc sync github pull-requests \
--owner my-org \
--repo my-app \
--interval 5m
```
## Resource Metadata
Each pull request is synced with metadata:
```yaml theme={null}
identifier: github.com/my-org/my-app/pull/123
name: "PR #123: Add new feature"
kind: GitHub/PullRequest
metadata:
owner: my-org
repo: my-app
number: "123"
state: open
author: developer
branch: feature/new-feature
base_branch: main
config:
url: https://github.com/my-org/my-app/pull/123
head_sha: abc123def456
```
## Use Case: Preview Environments
Sync PRs to create dynamic preview environments:
```yaml theme={null}
# Environment for all open PRs
type: Environment
name: Preview Environments
resourceSelector: |
resource.kind == "GitHub/PullRequest" &&
resource.metadata["state"] == "open"
```
When a PR is opened:
1. GitHub provider syncs it as a resource
2. Environment selector matches the PR
3. Deployments can target the PR environment
4. When PR is closed/merged, resource is removed
## Running Continuously
### GitHub Actions
```yaml theme={null}
name: Sync PRs to Ctrlplane
on:
schedule:
- cron: '*/5 * * * *' # Every 5 minutes
pull_request:
types: [opened, closed, reopened]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync PRs
run: |
ctrlc sync github pull-requests \
--owner ${{ github.repository_owner }} \
--repo ${{ github.event.repository.name }}
env:
CTRLPLANE_API_KEY: ${{ secrets.CTRLPLANE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### Kubernetes Deployment
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-github-sync
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-github-sync
template:
metadata:
labels:
app: ctrlplane-github-sync
spec:
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- github
- pull-requests
- --owner
- my-org
- --repo
- my-app
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: github-credentials
key: token
```
## Best Practices
### Sync on PR Events
Trigger sync immediately when PRs change:
```yaml theme={null}
# GitHub Actions workflow
on:
pull_request:
types: [opened, closed, synchronize, reopened]
```
### Label for Targeting
Use PR labels for more specific targeting:
```yaml theme={null}
# Only PRs with 'preview' label
type: Environment
name: Preview Environments
resourceSelector: |
resource.kind == "GitHub/PullRequest" &&
resource.metadata["labels"].contains("preview")
```
## Next Steps
Deploy to PRs with GitHub Actions
Learn about dynamic environments
# Google Cloud Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/google-cloud
Sync Google Cloud resources into Ctrlplane
The Google Cloud provider syncs resources from GCP into Ctrlplane's
inventory—GKE clusters, VMs, Cloud SQL, Cloud Run, and more.
## Prerequisites
* `ctrlc` CLI installed
* Google Cloud credentials (application default credentials or service account)
* Ctrlplane API key
## Supported Resources
| Command | Resource Type | Ctrlplane Kind |
| ----------------------- | ------------------- | -------------- |
| `google-cloud gke` | GKE Clusters | `GCP/GKE` |
| `google-cloud vms` | Compute Engine VMs | `GCP/VM` |
| `google-cloud cloudsql` | Cloud SQL Instances | `GCP/CloudSQL` |
| `google-cloud cloudrun` | Cloud Run Services | `GCP/CloudRun` |
| `google-cloud buckets` | Storage Buckets | `GCP/Bucket` |
| `google-cloud bigtable` | Bigtable Instances | `GCP/Bigtable` |
| `google-cloud redis` | Memorystore Redis | `GCP/Redis` |
| `google-cloud secrets` | Secret Manager | `GCP/Secret` |
| `google-cloud networks` | VPC Networks | `GCP/VPC` |
| `google-cloud projects` | GCP Projects | `GCP/Project` |
## Authentication
Configure GCP credentials:
```bash theme={null}
# Application Default Credentials (recommended for local development)
gcloud auth application-default login
# Service Account key file
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
# Workload Identity (when running in GKE)
# Credentials are automatically retrieved
```
## GKE Clusters
Sync Google Kubernetes Engine clusters:
```bash theme={null}
# Sync from a specific project
ctrlc sync google-cloud gke --project my-project
# Continuous sync
ctrlc sync google-cloud gke --project my-project --interval 5m
```
### Resource Metadata
```yaml theme={null}
identifier: projects/my-project/locations/us-central1/clusters/prod-cluster
name: prod-cluster
kind: GCP/GKE
metadata:
project: my-project
region: us-central1
environment: production # from GCP label
team: platform # from GCP label
config:
endpoint: https://XXX.XXX.XXX.XXX
version: "1.28.3-gke.1286000"
```
## Compute Engine VMs
Sync virtual machine instances:
```bash theme={null}
# Sync from a project
ctrlc sync google-cloud vms --project my-project
# Continuous sync
ctrlc sync google-cloud vms --project my-project --interval 5m
```
### Resource Metadata
```yaml theme={null}
identifier: projects/my-project/zones/us-central1-a/instances/web-server-1
name: web-server-1
kind: GCP/VM
metadata:
project: my-project
zone: us-central1-a
machine_type: e2-medium
environment: production # from GCP label
config:
internal_ip: 10.128.0.2
external_ip: 34.123.45.67
```
## Cloud SQL Instances
Sync Cloud SQL database instances:
```bash theme={null}
# Sync from a project
ctrlc sync google-cloud cloudsql --project my-project
# Continuous sync
ctrlc sync google-cloud cloudsql --project my-project --interval 10m
```
### Resource Metadata
```yaml theme={null}
identifier: projects/my-project/instances/prod-db
name: prod-db
kind: GCP/CloudSQL
metadata:
project: my-project
region: us-central1
database_version: POSTGRES_15
tier: db-custom-4-16384
environment: production # from GCP label
config:
connection_name: my-project:us-central1:prod-db
ip_address: 10.0.0.5
```
## Cloud Run Services
Sync Cloud Run services:
```bash theme={null}
# Sync from a project
ctrlc sync google-cloud cloudrun --project my-project
# Continuous sync
ctrlc sync google-cloud cloudrun --project my-project --interval 5m
```
### Resource Metadata
```yaml theme={null}
identifier: projects/my-project/locations/us-central1/services/api-service
name: api-service
kind: GCP/CloudRun
metadata:
project: my-project
region: us-central1
environment: production # from GCP label
config:
url: https://api-service-xxxxx-uc.a.run.app
```
## Running in GCP
### Cloud Run Job
```yaml theme={null}
apiVersion: run.googleapis.com/v1
kind: Job
metadata:
name: ctrlplane-sync
spec:
template:
spec:
containers:
- image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- google-cloud
- gke
- --project
- my-project
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
```
### GKE Deployment with Workload Identity
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-gcp-sync
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-gcp-sync
template:
metadata:
labels:
app: ctrlplane-gcp-sync
spec:
serviceAccountName: ctrlplane-sync
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- google-cloud
- gke
- --project
- my-project
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ctrlplane-sync
annotations:
iam.gke.io/gcp-service-account: ctrlplane-sync@my-project.iam.gserviceaccount.com
```
### IAM Permissions
The sync service account needs read permissions:
```bash theme={null}
# Create service account
gcloud iam service-accounts create ctrlplane-sync
# Grant permissions
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:ctrlplane-sync@my-project.iam.gserviceaccount.com" \
--role="roles/container.viewer"
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:ctrlplane-sync@my-project.iam.gserviceaccount.com" \
--role="roles/compute.viewer"
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:ctrlplane-sync@my-project.iam.gserviceaccount.com" \
--role="roles/cloudsql.viewer"
```
## Environment Targeting
Target GCP resources in environments:
```yaml theme={null}
# All production GKE clusters
type: Environment
name: Production GKE
resourceSelector: |
resource.kind == "GCP/GKE" &&
resource.metadata["environment"] == "production"
```
```yaml theme={null}
# US Central resources
type: Environment
name: US Central
resourceSelector: |
resource.metadata["region"].startsWith("us-central")
```
```yaml theme={null}
# All Cloud Run services
type: Environment
name: Cloud Run Production
resourceSelector: |
resource.kind == "GCP/CloudRun" &&
resource.metadata["environment"] == "production"
```
## Best Practices
### Label Your Resources
Ensure GCP resources have meaningful labels:
```bash theme={null}
gcloud compute instances add-labels web-server-1 \
--labels=environment=production,team=platform,tier=critical
```
### Sync Multiple Projects
Run sync for each project:
```bash theme={null}
# Production project
ctrlc sync google-cloud gke --project prod-project --interval 5m &
# Staging project
ctrlc sync google-cloud gke --project staging-project --interval 5m &
```
### Sync Multiple Resource Types
Run separate sync processes:
```bash theme={null}
# GKE clusters
ctrlc sync google-cloud gke --project my-project --interval 5m &
# Cloud SQL (less frequent)
ctrlc sync google-cloud cloudsql --project my-project --interval 15m &
# Cloud Run
ctrlc sync google-cloud cloudrun --project my-project --interval 5m &
```
## Next Steps
Sync AWS resources
Sync Azure resources
Learn selector syntax
Create dynamic environments
# Helm Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/helm
Sync Helm releases into Ctrlplane
The Helm provider syncs Helm releases from your Kubernetes clusters into
Ctrlplane's inventory.
## Prerequisites
* `ctrlc` CLI installed
* Kubernetes cluster access (kubeconfig or in-cluster)
* Ctrlplane API key
## Basic Usage
```bash theme={null}
# Sync all Helm releases from the cluster
ctrlc sync helm \
--cluster-name my-cluster \
--cluster-identifier k8s-prod-us-east-1
# Sync from a specific namespace
ctrlc sync helm \
--cluster-name my-cluster \
--cluster-identifier k8s-prod-us-east-1 \
--namespace production
# Continuous sync
ctrlc sync helm \
--cluster-name my-cluster \
--cluster-identifier k8s-prod-us-east-1 \
--interval 5m
```
## Options
| Flag | Description | Required |
| ---------------------- | --------------------------------------------------- | -------- |
| `--cluster-name` | Display name for the cluster | Yes |
| `--cluster-identifier` | Unique identifier (or `CLUSTER_IDENTIFIER` env var) | Yes |
| `--namespace` | Kubernetes namespace (all namespaces if not set) | No |
| `--provider` | Resource provider name | No |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
## Resource Metadata
Each Helm release is synced with metadata:
```yaml theme={null}
identifier: k8s-prod-us-east-1/helm/production/api-gateway
name: api-gateway
kind: Helm/Release
metadata:
cluster: k8s-prod-us-east-1
namespace: production
chart: api-gateway
chart_version: "1.2.3"
app_version: "2.0.0"
status: deployed
config:
cluster: k8s-prod-us-east-1
namespace: production
release_name: api-gateway
```
## Running in Kubernetes
Deploy as a Deployment:
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-helm-sync
namespace: ctrlplane
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-helm-sync
template:
metadata:
labels:
app: ctrlplane-helm-sync
spec:
serviceAccountName: ctrlplane-sync
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- helm
- --cluster-name
- "$(CLUSTER_NAME)"
- --cluster-identifier
- "$(CLUSTER_IDENTIFIER)"
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
- name: CLUSTER_NAME
value: "Production US-East"
- name: CLUSTER_IDENTIFIER
value: "prod-us-east-1"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ctrlplane-sync
namespace: ctrlplane
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: ctrlplane-helm-reader
rules:
- apiGroups: [""]
resources: ["secrets", "configmaps"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ctrlplane-helm-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: ctrlplane-helm-reader
subjects:
- kind: ServiceAccount
name: ctrlplane-sync
namespace: ctrlplane
```
## Environment Targeting
Target Helm releases in environments:
```yaml theme={null}
# All production releases
type: Environment
name: Production Helm Releases
resourceSelector: |
resource.kind == "Helm/Release" &&
resource.metadata["namespace"] == "production"
```
```yaml theme={null}
# Specific chart releases
type: Environment
name: API Gateway Releases
resourceSelector: |
resource.kind == "Helm/Release" &&
resource.metadata["chart"] == "api-gateway"
```
## Best Practices
### Sync All Namespaces
Unless you have specific needs, sync from all namespaces:
```bash theme={null}
# Omit --namespace to sync all
ctrlc sync helm \
--cluster-name my-cluster \
--cluster-identifier prod-cluster
```
### Combine with Kubernetes Sync
Run both Kubernetes and Helm sync:
```bash theme={null}
# Kubernetes resources
ctrlc sync kubernetes \
--cluster-name my-cluster \
--cluster-identifier prod-cluster \
--interval 5m &
# Helm releases
ctrlc sync helm \
--cluster-name my-cluster \
--cluster-identifier prod-cluster \
--interval 5m &
```
## Next Steps
Sync Kubernetes resources
Sync virtual clusters
# Kubernetes Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/kubernetes
Sync Kubernetes resources into Ctrlplane
The Kubernetes provider syncs resources from your Kubernetes clusters into
Ctrlplane's inventory—namespaces, deployments, services, and more.
## Prerequisites
* `ctrlc` CLI installed
* Kubernetes cluster access (kubeconfig or in-cluster)
* Ctrlplane API key
## Basic Usage
```bash theme={null}
# Sync all resources from the current cluster
ctrlc sync kubernetes \
--cluster-name my-cluster \
--cluster-identifier k8s-prod-us-east-1
```
## Options
| Flag | Description | Required |
| ---------------------- | ------------------------------------------------------- | -------- |
| `--cluster-name` | Display name for the cluster | Yes |
| `--cluster-identifier` | Unique identifier (or set `CLUSTER_IDENTIFIER` env var) | Yes |
| `--provider` | Resource provider name | No |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
## Examples
### One-Time Sync
```bash theme={null}
# Sync from current kubeconfig context
ctrlc sync kubernetes \
--cluster-name "Production US-East" \
--cluster-identifier prod-us-east-1
```
### Continuous Sync
```bash theme={null}
# Sync every 5 minutes
ctrlc sync kubernetes \
--cluster-name "Production US-East" \
--cluster-identifier prod-us-east-1 \
--interval 5m
```
### Using Environment Variables
```bash theme={null}
export CTRLPLANE_API_KEY="your-api-key"
export CTRLPLANE_WORKSPACE="your-workspace-id"
export CLUSTER_IDENTIFIER="prod-us-east-1"
ctrlc sync kubernetes --cluster-name "Production US-East"
```
## Synced Resources
The Kubernetes provider creates resources for:
| Kubernetes Resource | Ctrlplane Kind | Description |
| ------------------- | ----------------------- | ----------------------------- |
| Namespaces | `KubernetesNamespace` | All namespaces in the cluster |
| Deployments | `KubernetesDeployment` | Deployment workloads |
| StatefulSets | `KubernetesStatefulSet` | Stateful workloads |
| Services | `KubernetesService` | Service endpoints |
### Resource Metadata
Each synced resource includes metadata from Kubernetes labels:
```yaml theme={null}
# Kubernetes Namespace with labels
apiVersion: v1
kind: Namespace
metadata:
name: api-production
labels:
environment: production
team: platform
tier: critical
```
Becomes:
```yaml theme={null}
# Ctrlplane Resource
identifier: prod-us-east-1/namespace/api-production
name: api-production
kind: Kubernetes/Namespace
metadata:
environment: production
team: platform
tier: critical
cluster: prod-us-east-1
config:
namespace: api-production
cluster: prod-us-east-1
```
## Running in Kubernetes
Deploy as a Deployment with in-cluster authentication:
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-k8s-sync
namespace: ctrlplane
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-k8s-sync
template:
metadata:
labels:
app: ctrlplane-k8s-sync
spec:
serviceAccountName: ctrlplane-sync
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- kubernetes
- --interval
- "5m"
- --cluster-name
- "$(CLUSTER_NAME)"
- --cluster-identifier
- "$(CLUSTER_IDENTIFIER)"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: "your-workspace-id"
- name: CLUSTER_NAME
value: "Production US-East"
- name: CLUSTER_IDENTIFIER
value: "prod-us-east-1"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ctrlplane-sync
namespace: ctrlplane
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ctrlplane-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: ctrlplane-sync
namespace: ctrlplane
```
## Best Practices
### Label Your Resources
Ensure Kubernetes resources have meaningful labels:
```yaml theme={null}
metadata:
labels:
environment: production
team: platform
app: api-gateway
tier: critical
```
### Use Consistent Identifiers
Use stable cluster identifiers that won't change:
```bash theme={null}
# Good: descriptive and stable
--cluster-identifier prod-us-east-1
# Bad: may change
--cluster-identifier cluster-12345
```
### Sync Frequently
Keep resources up-to-date with short sync intervals:
```bash theme={null}
# Every 1 minutes is a good default
--interval 1m
```
## Next Steps
Sync Helm releases
Sync virtual clusters
Learn selector syntax
Create dynamic environments
# Resource Providers Overview
Source: https://docs.ctrlplane.dev/integrations/resource-providers/overview
Sync infrastructure resources into Ctrlplane's inventory
Resource providers automatically discover and sync your infrastructure into
Ctrlplane's resource inventory. This enables dynamic environment targeting based
on resource metadata.
## Quick Start
All providers use the same pattern:
```bash theme={null}
# Set up authentication
export CTRLPLANE_API_KEY="your-api-key"
# Sync resources (one-time)
ctrlc sync [options]
# Sync on interval (continuous)
ctrlc sync --interval 5m [options]
```
## Common Patterns
### Continuous Sync
Run sync on an interval to keep resources up-to-date:
```bash theme={null}
# Sync every 5 minutes
ctrlc sync kubernetes --interval 5m --cluster-name prod-cluster
# Sync every hour
ctrlc sync aws eks --region us-east-1 --interval 1h
```
### Running in Kubernetes
Deploy a sync job as a Kubernetes CronJob or Deployment:
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-sync
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-sync
template:
metadata:
labels:
app: ctrlplane-sync
spec:
containers:
- name: sync
image: ctrlplane/cli:latest
command:
- ctrlc
- sync
- kubernetes
- --interval
- "5m"
- --cluster-name
- "$(CLUSTER_NAME)"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CLUSTER_NAME
value: "my-cluster"
```
## Resource Schema
All resources follow the same schema:
| Field | Required | Description |
| ------------ | -------- | ----------------------------------------------- |
| `identifier` | Yes | Unique identifier (auto-generated by providers) |
| `name` | Yes | Human-readable name |
| `kind` | Yes | Resource type (e.g., `Kubernetes/Namespace`) |
| `version` | Yes | Resource version |
| `metadata` | No | Key-value pairs for filtering and selectors |
| `config` | No | Configuration passed to job agents |
### Metadata vs Config
* **Metadata** — Used for environment selectors and filtering
* **Config** — Passed to job agents for deployment execution
```yaml theme={null}
# Metadata: used for targeting
metadata:
environment: production
region: us-east-1
team: platform
# Config: used by job agents
config:
namespace: my-app
cluster_url: https://k8s.example.com
```
## Environment Selectors
Resources are matched to environments using selectors:
```yaml theme={null}
type: Environment
name: Production US
resourceSelector: |
resource.metadata["environment"] == "production" &&
resource.metadata["region"].startsWith("us-")
```
When resources are synced, environments automatically re-evaluate selectors.
## Next Steps
Sync Kubernetes resources
Sync AWS resources
Sync GCP resources
Build your own
# Terraform Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/terraform
Sync Terraform Cloud/Enterprise workspaces into Ctrlplane
The Terraform provider syncs workspaces from Terraform Cloud or Terraform
Enterprise into Ctrlplane's inventory.
## Prerequisites
* `ctrlc` CLI installed
* Terraform Cloud/Enterprise API token
* Ctrlplane API key
## Authentication
Set your Terraform Cloud token:
```bash theme={null}
# Environment variable
export TFE_TOKEN="your-terraform-cloud-token"
# Optional: Custom Terraform Enterprise URL
export TFE_ADDRESS="https://tfe.example.com"
```
## Basic Usage
```bash theme={null}
# Sync all workspaces in an organization
ctrlc sync terraform \
--organization my-org \
--workspace
# Continuous sync
ctrlc sync terraform \
--organization my-org \
--workspace \
--interval 5m
```
## Options
| Flag | Description | Required |
| ---------------- | -------------------------------- | -------- |
| `--organization` | Terraform organization name | Yes |
| `--workspace` | Ctrlplane workspace ID | Yes |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
## Resource Metadata
Each Terraform workspace is synced with metadata:
```yaml theme={null}
identifier: ws-abc123def456
name: production-infrastructure
kind: Terraform/Workspace
metadata:
organization: my-org
environment: production # from workspace tags
team: platform # from workspace tags
config:
workspace_id: ws-abc123def456
vcs_repo: github.com/my-org/infrastructure
working_directory: environments/production
```
## Running Continuously
### Kubernetes Deployment
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-terraform-sync
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-terraform-sync
template:
metadata:
labels:
app: ctrlplane-terraform-sync
spec:
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- terraform
- --organization
- my-org
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
- name: TFE_TOKEN
valueFrom:
secretKeyRef:
name: terraform-credentials
key: token
```
## Environment Targeting
Target Terraform workspaces in environments:
```yaml theme={null}
# Production workspaces
type: Environment
name: Production Infrastructure
resourceSelector: |
resource.kind == "Terraform/Workspace" &&
resource.metadata["environment"] == "production"
```
```yaml theme={null}
# Platform team workspaces
type: Environment
name: Platform Infrastructure
resourceSelector: |
resource.kind == "Terraform/Workspace" &&
resource.metadata["team"] == "platform"
```
## Best Practices
### Tag Your Workspaces
Add tags to Terraform workspaces for better targeting:
```hcl theme={null}
# In Terraform Cloud UI or via API
# Add tags like: environment:production, team:platform
```
### Sync Frequently
Keep workspace state current:
```bash theme={null}
ctrlc sync terraform \
--organization my-org \
--interval 5m
```
## Next Steps
Trigger Terraform runs from Ctrlplane
Learn selector syntax
# vCluster Provider
Source: https://docs.ctrlplane.dev/integrations/resource-providers/vcluster
Sync vCluster virtual clusters into Ctrlplane
The vCluster provider syncs virtual Kubernetes clusters created with
[vCluster](https://www.vcluster.com/) into Ctrlplane's inventory.
## Prerequisites
* `ctrlc` CLI installed
* Access to the host Kubernetes cluster running vClusters
* Ctrlplane API key
## Basic Usage
```bash theme={null}
# Sync all vClusters from the host cluster
ctrlc sync vcluster \
--cluster-identifier host-cluster-id
# Continuous sync
ctrlc sync vcluster \
--cluster-identifier host-cluster-id \
--interval 5m
```
## Options
| Flag | Description | Required |
| ---------------------- | ---------------------------------------------------------------- | -------- |
| `--cluster-identifier` | Identifier of the host cluster (or `CLUSTER_IDENTIFIER` env var) | Yes |
| `--provider` | Resource provider name | No |
| `--interval` | Sync interval (e.g., `5m`, `1h`) | No |
## Resource Metadata
Each vCluster is synced with metadata:
```yaml theme={null}
identifier: host-cluster/vcluster/dev-team-cluster
name: dev-team-cluster
kind: vCluster/Cluster
metadata:
host_cluster: host-cluster-id
namespace: vcluster-dev-team
release_name: dev-team-cluster
config:
host_cluster: host-cluster-id
namespace: vcluster-dev-team
```
## Running in Kubernetes
Deploy on the host cluster:
```yaml theme={null}
apiVersion: apps/v1
kind: Deployment
metadata:
name: ctrlplane-vcluster-sync
namespace: ctrlplane
spec:
replicas: 1
selector:
matchLabels:
app: ctrlplane-vcluster-sync
template:
metadata:
labels:
app: ctrlplane-vcluster-sync
spec:
serviceAccountName: ctrlplane-sync
containers:
- name: sync
image: ghcr.io/ctrlplanedev/cli:latest
command:
- ctrlc
- sync
- vcluster
- --cluster-identifier
- "$(CLUSTER_IDENTIFIER)"
- --interval
- "5m"
env:
- name: CTRLPLANE_API_KEY
valueFrom:
secretKeyRef:
name: ctrlplane-credentials
key: api-key
- name: CTRLPLANE_WORKSPACE
value: your-workspace-id
- name: CLUSTER_IDENTIFIER
value: host-cluster-id
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ctrlplane-sync
namespace: ctrlplane
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ctrlplane-vcluster-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: ctrlplane-sync
namespace: ctrlplane
```
## Use Case: Development Environments
vClusters are ideal for dynamic development environments:
```yaml theme={null}
# All vClusters as dev environments
type: Environment
name: Development vClusters
resourceSelector: |
resource.kind == "vCluster/Cluster"
```
```yaml theme={null}
# Team-specific vClusters
type: Environment
name: Backend Team Dev
resourceSelector: |
resource.kind == "vCluster/Cluster" &&
resource.metadata["namespace"].startsWith("vcluster-backend-")
```
## Best Practices
### Use Namespace Conventions
Name vCluster namespaces consistently for easy targeting:
```bash theme={null}
# Pattern: vcluster-{team}-{purpose}
vcluster-backend-dev
vcluster-frontend-preview
vcluster-data-staging
```
### Combine with Host Cluster Sync
Sync both the host cluster and vClusters:
```bash theme={null}
# Host cluster resources
ctrlc sync kubernetes \
--cluster-identifier host-cluster \
--interval 5m &
# vClusters
ctrlc sync vcluster \
--cluster-identifier host-cluster \
--interval 5m &
```
## Next Steps
Sync Kubernetes resources
Create environments for vClusters
# Datadog Provider
Source: https://docs.ctrlplane.dev/integrations/verification-providers/datadog
Query metrics from Datadog's Metrics API for verification
The **Datadog provider** allows you to query metrics from Datadog's Metrics API
for verification checks. It uses the Datadog v2 Scalar Query API to fetch
aggregated metric values.
## Configuration
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
site: us5.datadoghq.com
aggregator: last
intervalSeconds: 300
queries:
a: "avg:kubernetes.cpu.usage{cluster:{{.resource.name}}}"
formula: "a"
```
## Properties
Must be `"datadog"`.
Datadog API key. Supports Go templates (e.g., `{{.variables.dd_api_key}}`).
Datadog Application key. Supports Go templates. Use the actual key value, NOT
the Key ID.
Named queries map. Keys become accessible as `result.queries.` in success conditions.
Values support Go templates for dynamic filtering.
Formula to combine query results. Reference queries by their key names (e.g.,
`"a / b * 100"`).
Aggregation method. Options: `last`, `avg`, `min`, `max`, `sum`, `mean`,
`percentile`, `l2norm`, `area`.
Time window in seconds for the query. Determines how far back to look for
metric data.
Datadog site URL. Options: `datadoghq.com`, `datadoghq.eu`,
`us3.datadoghq.com`, `us5.datadoghq.com`, `ap1.datadoghq.com`.
## Aggregator Options
The `aggregator` property specifies how to aggregate metric values:
* `last` (default) - Most recent value
* `avg` - Average value
* `min` - Minimum value
* `max` - Maximum value
* `sum` - Sum of values
* `mean` - Mean value
* `percentile` - Percentile value
* `l2norm` - L2 norm
* `area` - Area under the curve
## Supported Sites
* `datadoghq.com` (US1 - default)
* `datadoghq.eu` (EU)
* `us3.datadoghq.com` (US3)
* `us5.datadoghq.com` (US5)
* `ap1.datadoghq.com` (AP1)
## Response Data Available in CEL
The Datadog provider makes the following data available in your CEL success
conditions:
| Field | Type | Description |
| ----------------------- | ----------------- | ----------------------------------------- |
| `result.ok` | boolean | `true` if API call succeeded (2xx status) |
| `result.statusCode` | integer | HTTP status code from Datadog API |
| `result.queries.` | float64 (or null) | Value for each named query |
| `result.json` | object | Full Datadog API response |
| `result.body` | string | Raw response body |
| `result.duration` | integer | Request duration in milliseconds |
### Accessing Query Values
Since queries are named, you access them by name in your success condition:
```yaml theme={null}
queries:
cpu: "avg:kubernetes.cpu.usage{cluster:prod}"
memory: "avg:kubernetes.memory.usage{cluster:prod}"
successCondition: result.queries.cpu < 80 && result.queries.memory < 90
```
## Example Configurations
### Single Query
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
error_rate: "sum:requests.error.rate{service:api-service}"
successCondition: result.queries.error_rate < 0.01
```
### Multiple Queries with Formula
Use multiple queries with a formula to calculate ratios or complex metrics:
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
errors: "sum:requests.errors{service:api}"
total: "sum:requests.total{service:api}"
formula: "errors / total * 100"
successCondition: result.queries.errors < 1
```
### With Environment and Resource Tags
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
site: us5.datadoghq.com
intervalSeconds: 600
queries:
cpu: "avg:kubernetes.cpu.usage{cluster:{{.resource.name}},env:{{.environment.name}}}"
successCondition:
result.ok && result.queries.cpu != null && result.queries.cpu < 80
```
### Latency Percentile
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
aggregator: percentile
queries:
p99: "trace.http.request.duration.by.service.99p{service:api-service}"
successCondition: result.queries.p99 < 200
```
## Example Success Conditions
```yaml theme={null}
# Simple threshold check
successCondition: result.queries.value < 0.01
# Check if value exists and is under threshold
successCondition: result.ok && result.queries.cpu != null && result.queries.cpu < 80
# Multiple query conditions
successCondition: result.queries.errors < 10 && result.queries.latency < 500
# Check API success first
successCondition: result.ok && result.statusCode == 200 && result.queries.rate < 0.1
```
## Template Variables
The Datadog provider supports Go templates in the `apiKey`, `appKey`, `site`,
`formula`, and `queries` fields:
```yaml theme={null}
# Resource information
{{.resource.name}}
{{.resource.identifier}}
{{.resource.kind}}
# Environment information
{{.environment.name}}
{{.environment.id}}
# Deployment information
{{.deployment.name}}
{{.deployment.slug}}
# Version information
{{.version.tag}}
{{.version.id}}
# Custom variables (from deployment variables)
{{.variables.dd_api_key}}
{{.variables.dd_app_key}}
```
## Storing Secrets in Variables
For sensitive values like API keys, use deployment variables:
1. **Create deployment variables** for your Datadog credentials
2. **Reference them in the provider configuration** using template syntax
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
errors: "sum:errors{service:{{.resource.name}}}"
```
## Finding Your Datadog Keys
### API Key
1. Go to Datadog console → **Organization Settings** → **API Keys**
2. Create or copy an existing API key (40-character hex string)
### Application Key
1. Go to Datadog console → **Organization Settings** → **Application Keys**
2. Create or copy an existing Application key
3. **Important**: Use the actual key value, NOT the "Key ID" (which is a UUID)
## Best Practices
* **Use deployment variables** for API keys and application keys - never
hardcode credentials
* **Name queries descriptively** to make success conditions readable
* **Use appropriate intervals** - shorter intervals provide faster feedback but
may have less data
* **Tag your metrics** with service, environment, and version information for
better filtering
* **Test queries manually** in Datadog before using them in verification
* **Handle missing data** by checking `result.ok` and
`result.queries. != null`
* **Use formulas** for calculated metrics like error rates or ratios
# HTTP Provider
Source: https://docs.ctrlplane.dev/integrations/verification-providers/http
Query any HTTP endpoint for verification metrics
The **HTTP provider** allows you to query any HTTP endpoint that returns JSON
data for verification metrics.
## Configuration
```yaml theme={null}
provider:
type: http
url: "http://{{.resource.name}}/health"
method: GET
headers:
Authorization: "Bearer {{.variables.health_token}}"
timeout: 30s
```
## Properties
Must be `"http"`.
HTTP endpoint URL. Supports Go templates for dynamic URLs
(e.g., `http://{{.resource.name}}/health`).
HTTP method. Options: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`,
`OPTIONS`.
HTTP headers to include in the request. Values support Go templates
(e.g., `Authorization: "Bearer {{.variables.token}}"`).
Request body for POST/PUT/PATCH requests. Supports Go templates for dynamic
content.
Request timeout duration. Use format like `30s`, `1m`, `500ms`.
### Supported HTTP Methods
* `GET` (default)
* `POST`
* `PUT`
* `PATCH`
* `DELETE`
* `HEAD`
* `OPTIONS`
## Response Data Available in CEL
The HTTP provider makes the following data available in your CEL success
conditions:
* `result.ok` - `true` if status code is 2xx
* `result.statusCode` - HTTP status code (e.g., `200`, `404`, `500`)
* `result.body` - Response body as string
* `result.json` - Parsed JSON response (if response is valid JSON)
* `result.headers` - Response headers as an object
* `result.duration` - Request duration in milliseconds
## Example Success Conditions
```yaml theme={null}
# Status code check
successCondition: result.ok
# JSON field check
successCondition: result.json.healthy == true
# Numeric threshold
successCondition: result.json.error_rate < 0.01
# Combined conditions
successCondition: result.ok && result.json.ready == true
# Check specific status code
successCondition: result.statusCode == 200
# Check response time
successCondition: result.duration < 500
```
## Examples
### Basic Health Check
```yaml theme={null}
provider:
type: http
url: "http://{{.resource.name}}/health"
method: GET
successCondition: result.ok && result.json.status == "healthy"
```
### POST Request with Body
```yaml theme={null}
provider:
type: http
url: "http://smoke-test-runner/run"
method: POST
headers:
Content-Type: "application/json"
body: |
{
"service": "{{.resource.name}}",
"version": "{{.version.tag}}",
"environment": "{{.environment.name}}"
}
successCondition: result.json.status == "passed"
```
### Authenticated Request
```yaml theme={null}
provider:
type: http
url: "http://api.example.com/health"
method: GET
headers:
Authorization: "Bearer {{.variables.health_token}}"
X-API-Key: "{{.variables.api_key}}"
successCondition: result.ok
```
### Custom Timeout
```yaml theme={null}
provider:
type: http
url: "http://slow-service/health"
method: GET
timeout: 60s
successCondition: result.ok
```
## Template Variables
The HTTP provider supports Go templates in the `url`, `headers`, and `body`
fields:
```yaml theme={null}
# Resource information
{{.resource.name}}
{{.resource.identifier}}
{{.resource.kind}}
# Environment information
{{.environment.name}}
{{.environment.id}}
# Deployment information
{{.deployment.name}}
{{.deployment.slug}}
# Version information
{{.version.tag}}
{{.version.id}}
# Custom variables (from deployment variables)
{{.variables.my_variable}}
{{.variables.health_token}}
```
## Best Practices
* **Use HTTPS** for production endpoints to ensure secure communication
* **Set appropriate timeouts** based on your service's expected response time
* **Handle authentication** using deployment variables for sensitive tokens
* **Validate JSON responses** by checking `result.json` exists before accessing
fields
* **Use meaningful success conditions** that check both status codes and
response content
# Sleep Provider
Source: https://docs.ctrlplane.dev/integrations/verification-providers/sleep
Wait for a specified duration before considering verification passed
The **Sleep provider** allows you to add a simple time-based delay to your
verification checks. This is useful for waiting for services to stabilize or for
scheduled tasks to complete.
## Configuration
```yaml theme={null}
provider:
type: sleep
durationSeconds: 30
```
## Properties
| Property | Type | Required | Description |
| ----------------- | ------- | -------- | ------------------------------------ |
| `type` | string | Yes | Must be `"sleep"` |
| `durationSeconds` | integer | Yes | Duration to wait in seconds (1-3600) |
## Response Data Available in CEL
The Sleep provider makes the following data available in your CEL success
conditions:
* `result.ok` - Always `true` after the sleep duration completes
* `result.value` - The duration that was waited (in seconds)
* `result.duration` - The actual duration waited in milliseconds
## Example Success Conditions
```yaml theme={null}
# Always passes after sleep completes
successCondition: result.ok
# Check that the expected duration was waited
successCondition: result.value == 30
```
## Examples
### Basic Sleep
```yaml theme={null}
provider:
type: sleep
durationSeconds: 30
successCondition: result.ok
```
### Wait for Service Stabilization
```yaml theme={null}
# Wait 2 minutes for service to stabilize after deployment
provider:
type: sleep
durationSeconds: 120
successCondition: result.ok
```
### Combined with Other Metrics
```yaml theme={null}
metrics:
# First, wait for service to start
- name: stabilization-wait
intervalSeconds: 1
count: 1
provider:
type: sleep
durationSeconds: 60
successCondition: result.ok
# Then check health endpoint
- name: health-check
intervalSeconds: 10
count: 5
provider:
type: http
url: "http://{{.resource.name}}/health"
successCondition: result.ok
```
## Use Cases
* **Service Stabilization**: Wait for services to fully start up before running
health checks
* **Scheduled Tasks**: Wait for scheduled tasks or cron jobs to complete
* **Warm-up Period**: Allow caches or connections to warm up before verification
* **Sequential Verification**: Create a delay between different verification
steps
## Best Practices
* **Use appropriate durations**: Keep sleep durations reasonable (typically
30-300 seconds)
* **Combine with other metrics**: Use sleep as a first step, then follow with
actual health checks
* **Consider deployment time**: Account for how long your service takes to start
* **Don't overuse**: Prefer actual health checks over sleep when possible
# Terraform Cloud Run Provider
Source: https://docs.ctrlplane.dev/integrations/verification-providers/terraform-cloud-run
Verify Terraform Cloud run status for infrastructure deployments
The **Terraform Cloud Run provider** allows you to verify that a Terraform Cloud
run has completed successfully. This is useful for infrastructure deployments
managed through Terraform Cloud.
## Configuration
```yaml theme={null}
provider:
type: terraformCloudRun
organization: "my-org"
address: "https://app.terraform.io"
token: "{{.variables.terraform_cloud_token}}"
runId: "run-1234567890"
```
## Properties
| Property | Type | Required | Description |
| -------------- | ------ | -------- | ---------------------------------------------------------- |
| `type` | string | Yes | Must be `"terraformCloudRun"` |
| `organization` | string | Yes | Terraform Cloud organization name |
| `address` | string | Yes | Terraform Cloud address (e.g., `https://app.terraform.io`) |
| `token` | string | Yes | Terraform Cloud token (supports Go templates) |
| `runId` | string | Yes | Terraform Cloud run ID |
## Response Data Available in CEL
The Terraform Cloud Run provider makes the following data available in your CEL
success conditions:
* `result.ok` - `true` if the run completed successfully
* `result.statusCode` - HTTP status code from Terraform Cloud API
* `result.value` - Run status (e.g., `"applied"`, `"errored"`, `"planned"`)
* `result.json` - Full Terraform Cloud API response
* `result.duration` - Request duration in milliseconds
## Example Success Conditions
```yaml theme={null}
# Check that run was applied successfully
successCondition: result.ok && result.value == "applied"
# Check that run completed (applied or planned)
successCondition: result.ok && (result.value == "applied" || result.value == "planned")
# Ensure run didn't error
successCondition: result.ok && result.value != "errored"
```
## Examples
### Basic Run Verification
```yaml theme={null}
provider:
type: terraformCloudRun
organization: "my-org"
address: "https://app.terraform.io"
token: "{{.variables.terraform_cloud_token}}"
runId: "{{.variables.terraform_run_id}}"
successCondition: result.ok && result.value == "applied"
```
### Using Template Variables
```yaml theme={null}
provider:
type: terraformCloudRun
organization: "{{.variables.tf_org}}"
address: "https://app.terraform.io"
token: "{{.variables.terraform_cloud_token}}"
runId: "{{.variables.terraform_run_id}}"
successCondition: result.ok && result.value == "applied"
```
## Run Status Values
The provider returns the following status values:
* `"applied"` - Run was successfully applied
* `"planned"` - Run was planned but not yet applied
* `"errored"` - Run encountered an error
* `"canceled"` - Run was canceled
* `"pending"` - Run is pending
* `"planning"` - Run is currently planning
* `"applying"` - Run is currently applying
## Template Variables
The Terraform Cloud Run provider supports Go templates in the `organization`,
`address`, `token`, and `runId` fields:
```yaml theme={null}
# Resource information
{{.resource.name}}
{{.resource.identifier}}
# Environment information
{{.environment.name}}
{{.environment.id}}
# Deployment information
{{.deployment.name}}
{{.deployment.slug}}
# Version information
{{.version.tag}}
{{.version.id}}
# Custom variables (from deployment variables)
{{.variables.terraform_cloud_token}}
{{.variables.terraform_run_id}}
{{.variables.tf_org}}
```
## Storing Secrets in Variables
For sensitive values like tokens, use deployment variables:
```yaml theme={null}
provider:
type: terraformCloudRun
organization: "my-org"
address: "https://app.terraform.io"
token: "{{.variables.terraform_cloud_token}}"
runId: "{{.variables.terraform_run_id}}"
```
## Best Practices
* **Use deployment variables** for tokens and run IDs - never hardcode sensitive
values
* **Verify run completion** by checking that the status is `"applied"` or
`"planned"`
* **Handle run failures** by checking for `"errored"` status
* **Use appropriate intervals** to poll for run status (typically 30-60 seconds)
* **Set failure limits** to avoid waiting indefinitely for stuck runs
# Inventory Overview
Source: https://docs.ctrlplane.dev/inventory/overview
Unified visibility into your infrastructure resources
Ctrlplane's inventory system provides a centralized view of all your
infrastructure resources with custom relationships and dynamic grouping.
## What is the Inventory?
The inventory is a real-time database of your deployment targets — Kubernetes
clusters, cloud functions, VMs, databases, or any custom infrastructure. It
enables:
* **Unified visibility** across providers (AWS, GCP, Kubernetes, custom)
* **Dynamic environments** that automatically include matching resources
* **Custom relationships** to model dependencies and ownership
* **Rich metadata** for filtering, grouping, and automation
```mermaid theme={null}
flowchart TB
subgraph Providers["Resource Providers"]
K8s["Kubernetes"]
AWS["AWS"]
Custom["Custom Scripts"]
end
subgraph Inventory["Ctrlplane Inventory"]
Resources["Resources"]
Metadata["Metadata & Config"]
Relations["Relationships"]
end
subgraph Targeting["Environment Targeting"]
Staging["Staging"]
Prod["Production"]
Regional["Regional"]
end
Providers -->|"sync"| Inventory
Inventory -->|"selectors"| Targeting
```
## Core Concepts
Deployment targets with metadata and configuration
Dynamic groups of resources using selectors
Model dependencies between resources
Query language for matching resources
Sync infrastructure into the inventory
## How It Works
### 1. Resources are Synced
Resources are synced into Ctrlplane via resource providers or API:
```yaml theme={null}
type: Resource
name: production-cluster-us-east
kind: KubernetesCluster
identifier: k8s-prod-useast1
metadata:
environment: production
region: us-east-1
team: platform
config:
server: https://k8s.example.com
namespace: default
```
### 2. Environments Match Resources
Environments use selectors to dynamically include resources:
```yaml theme={null}
type: Environment
name: Production US-East
resourceSelector: |
resource.metadata["environment"] == "production" &&
resource.metadata["region"] == "us-east-1"
```
When new resources are added that match the selector, they're automatically
included in the environment.
### 3. Deployments Target Environments
Deployments create release targets for each resource in each environment:
```
Deployment × Environment × Resource = Release Target
```
## Key Benefits
| Benefit | Description |
| -------------------------- | -------------------------------------------------- |
| **Single source of truth** | All resources visible in one place |
| **Dynamic targeting** | Environments auto-update as infrastructure changes |
| **Rich metadata** | Filter and group by any attribute |
| **Cross-provider** | Kubernetes, cloud, and custom in one view |
## Next Steps
* [Resources](../concepts/resources) — Understand resource structure and
lifecycle
* [Relationships](./relationships) — Model dependencies between resources
* [Environments](../concepts/environments) — Create dynamic resource groups
* [Selectors](../concepts/selectors) — Write powerful resource queries
* [Resource Providers](../integrations/resource-providers/overview) — Sync your
infrastructure
# Relationships
Source: https://docs.ctrlplane.dev/inventory/relationships
Model dependencies and connections between resources
Relationships in Ctrlplane allow you to model how your infrastructure components
connect to each other—VPCs containing clusters, clusters running services,
databases backing applications.
## Why Relationships?
Understanding infrastructure dependencies helps you:
* **Visualize architecture** — See how resources connect across your stack
* **Impact analysis** — Understand what's affected when a resource changes
* **Deployment ordering** — Deploy dependencies before dependents
* **Troubleshooting** — Trace issues through connected resources
## Relationship Rules
Relationship rules automatically create connections between entities based on
matching criteria. When resources are synced, Ctrlplane evaluates rules and
creates relationships dynamically.
### Structure
```yaml theme={null}
type: RelationshipRule
id: vpc-to-cluster
name: VPC to Kubernetes Cluster
description: Links VPCs to K8s clusters in the same region
fromType: resource
toType: resource
relationshipType: contains
fromSelector:
type: kind
operator: equals
value: vpc
toSelector:
type: kind
operator: equals
value: kubernetes-cluster
propertyMatchers:
- fromProperty: ["metadata", "region"]
toProperty: ["metadata", "region"]
operator: equals
```
### Components
| Field | Description |
| ------------------ | ------------------------------------------------------------ |
| `fromType` | Source entity type (`resource`, `deployment`, `environment`) |
| `toType` | Target entity type (`resource`, `deployment`, `environment`) |
| `relationshipType` | Type of connection (see below) |
| `fromSelector` | Selector matching source entities |
| `toSelector` | Selector matching target entities |
| `propertyMatchers` | Property conditions for matching |
### Relationship Types
| Type | Description | Example |
| ------------- | ------------------------------ | ----------------------------- |
| `contains` | Parent contains child | VPC contains clusters |
| `runs-on` | Service runs on infrastructure | App runs on cluster |
| `depends-on` | Requires another resource | API depends on database |
| `deployed-by` | Managed by a deployment | Resource deployed by pipeline |
| `deploys-to` | Deployment targets environment | Service deploys to production |
## Examples
### VPC to Kubernetes Clusters
Connect VPCs to the Kubernetes clusters running within them:
```yaml theme={null}
type: RelationshipRule
id: vpc-to-cluster
name: VPC Contains Clusters
fromType: resource
toType: resource
relationshipType: contains
fromSelector:
type: kind
operator: equals
value: vpc
toSelector:
type: kind
operator: equals
value: kubernetes-cluster
propertyMatchers:
- fromProperty: ["metadata", "region"]
toProperty: ["metadata", "region"]
operator: equals
- fromProperty: ["metadata", "account"]
toProperty: ["metadata", "account"]
operator: equals
```
This rule:
1. Finds all resources with `kind: vpc`
2. Finds all resources with `kind: kubernetes-cluster`
3. Creates a `contains` relationship when both have the same region AND account
### Database Dependencies
Model which services depend on which databases:
```yaml theme={null}
type: RelationshipRule
id: service-to-database
name: Service Database Dependencies
fromType: resource
toType: resource
relationshipType: depends-on
fromSelector:
type: kind
operator: equals
value: kubernetes-deployment
toSelector:
type: kind
operator: equals
value: rds-instance
propertyMatchers:
- fromProperty: ["metadata", "database-cluster"]
toProperty: ["metadata", "cluster-id"]
operator: equals
```
### Deployment to Environment
Link deployments to the environments they target:
```yaml theme={null}
type: RelationshipRule
id: deployment-to-env
name: Deployment Targets Environment
fromType: deployment
toType: environment
relationshipType: deploys-to
fromSelector:
type: metadata
key: team
operator: equals
value: platform
toSelector:
type: name
operator: contains
value: production
```
### Cross-Account Resources
Connect resources across AWS accounts:
```yaml theme={null}
type: RelationshipRule
id: transit-gateway-connections
name: Transit Gateway VPC Attachments
fromType: resource
toType: resource
relationshipType: contains
fromSelector:
type: kind
operator: equals
value: transit-gateway
toSelector:
type: kind
operator: equals
value: vpc
propertyMatchers:
- fromProperty: ["config", "attachments"]
toProperty: ["metadata", "vpc-id"]
operator: contains
```
## Property Matchers
Property matchers define conditions for when relationships should be created
between matching entities.
### Operators
| Operator | Description | Example |
| ------------ | -------------------- | ------------------------ |
| `equals` | Exact match | Region equals region |
| `contains` | Array contains value | Tags contain team |
| `startsWith` | String prefix match | Name starts with "prod-" |
| `regex` | Regular expression | Name matches pattern |
### Nested Properties
Access nested properties using array paths:
```yaml theme={null}
propertyMatchers:
# Match metadata.region
- fromProperty: ["metadata", "region"]
toProperty: ["metadata", "region"]
operator: equals
# Match config.cluster.name
- fromProperty: ["config", "cluster", "name"]
toProperty: ["metadata", "cluster-name"]
operator: equals
```
### Multiple Matchers
All property matchers must match for a relationship to be created:
```yaml theme={null}
propertyMatchers:
# Must match region AND account AND environment
- fromProperty: ["metadata", "region"]
toProperty: ["metadata", "region"]
operator: equals
- fromProperty: ["metadata", "account"]
toProperty: ["metadata", "account"]
operator: equals
- fromProperty: ["metadata", "environment"]
toProperty: ["metadata", "environment"]
operator: equals
```
## Selectors
Selectors filter which entities are considered for relationships.
### By Kind
```yaml theme={null}
fromSelector:
type: kind
operator: equals
value: kubernetes-cluster
```
### By Metadata
```yaml theme={null}
fromSelector:
type: metadata
key: environment
operator: equals
value: production
```
### By Name
```yaml theme={null}
fromSelector:
type: name
operator: contains
value: prod
```
## Managing Relationship Rules
### Create a Rule
```bash theme={null}
curl -X POST "https://your-ctrlplane-instance.com/api/v1/workspaces/{workspaceId}/relationship-rules" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "vpc-to-cluster",
"name": "VPC to Kubernetes Cluster",
"fromType": "resource",
"toType": "resource",
"relationshipType": "contains",
"fromSelector": {
"type": "kind",
"operator": "equals",
"value": "vpc"
},
"toSelector": {
"type": "kind",
"operator": "equals",
"value": "kubernetes-cluster"
},
"propertyMatchers": [{
"fromProperty": ["metadata", "region"],
"toProperty": ["metadata", "region"],
"operator": "equals"
}]
}'
```
### Update a Rule
```bash theme={null}
curl -X PUT "https://your-ctrlplane-instance.com/api/v1/relationship-rules/{ruleId}" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Updated Rule Name",
"description": "New description"
}'
```
### Delete a Rule
```bash theme={null}
curl -X DELETE "https://your-ctrlplane-instance.com/api/v1/relationship-rules/{ruleId}" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY"
```
Deleting a relationship rule removes the rule definition but does not delete the
underlying resources.
## Use Cases
### Infrastructure Topology
Model your complete infrastructure hierarchy:
```
Account
└── Region
└── VPC
└── Subnet
└── Kubernetes Cluster
└── Namespace
└── Deployment
```
### Service Dependencies
Track service-to-service and service-to-infrastructure dependencies:
```
API Gateway
├── depends-on → Auth Service
├── depends-on → User Database (RDS)
└── runs-on → EKS Cluster
```
### Multi-Cloud Relationships
Connect resources across cloud providers:
```
On-Prem Database
└── replicated-to → AWS RDS Read Replica
└── accessed-by → EKS Application
```
## Best Practices
### Use Meaningful Relationship Types
Choose relationship types that reflect the actual connection:
```yaml theme={null}
# Good: specific relationship type
relationshipType: depends-on # Service needs database
relationshipType: runs-on # App runs on cluster
relationshipType: contains # VPC contains subnets
# Avoid: generic types
relationshipType: related-to # Too vague
```
### Keep Property Matchers Simple
Start with minimal matchers and add more only if needed:
```yaml theme={null}
# Good: simple and clear
propertyMatchers:
- fromProperty: ["metadata", "region"]
toProperty: ["metadata", "region"]
operator: equals
# Avoid: overly complex
propertyMatchers:
- fromProperty: ["metadata", "tags", "aws:region"]
toProperty: ["config", "spec", "provider", "region"]
operator: regex
value: "^us-(east|west)-[0-9]$"
```
### Document Your Rules
Add descriptions to explain the purpose:
```yaml theme={null}
type: RelationshipRule
name: VPC to Cluster
description: |
Links VPCs to Kubernetes clusters based on matching region and account.
Used for infrastructure topology visualization and impact analysis.
```
## Next Steps
Full expression language reference
Learn selector syntax
Create environments from relationships
Sync infrastructure automatically
# 5-Minute Overview
Source: https://docs.ctrlplane.dev/overview
Understand Ctrlplane's mental model in 5 minutes
## The Core Flow
Every deployment in Ctrlplane follows this flow:
```mermaid theme={null}
flowchart LR
CI --"1\. create version"--> Ctrlplane
Ctrlplane --"2\. evaluate policies"--> Policies
Policies --"3\. dispatch job"--> JobAgent
JobAgent --"4\. deploy"--> Resource
Resource --"5\. verify"--> Ctrlplane
Ctrlplane --"6\. promote or rollback"--> Next
```
1. **CI creates a version** — After building, your CI tells Ctrlplane about the new version
2. **Policies are evaluated** — Ctrlplane checks if approvals, dependencies, and gates are satisfied
3. **Job is dispatched** — Ctrlplane tells your job agent (ArgoCD, GitHub Actions, etc.) to deploy
4. **Deployment executes** — The job agent performs the actual deployment
5. **Verification runs** — Ctrlplane checks metrics (Datadog, Prometheus, HTTP) to confirm health and can also validate resource presence/status (e.g., newly discovered clusters are visible and ready)
6. **Promote or rollback** — If verification passes, continue; if it fails, roll back
## The Key Entities
You only need to understand 5 things:
| Entity | Question It Answers | Example |
| --------------- | ------------------------------ | -------------------------------------------- |
| **Resource** | What infrastructure exists? | `prod-us-east-1` K8s cluster |
| **Deployment** | **What** to deploy and **how** | "API Gateway" deployed via ArgoCD |
| **Environment** | **Where** to deploy | "Production" = all clusters with `env: prod` |
| **Version** | Which build to deploy? | `v1.2.3` or `sha-abc123` |
| **Policy** | **When** to deploy | "After staging succeeds and SRE approves" |
In short: **Deployments** define *what and how*, **Environments** define *where*, and **Policies** define *when*.
### How They Connect
```
Deployment × Environment × Resource = Release Target
```
When you deploy "API Gateway" to "Production" (which has 3 clusters), Ctrlplane creates 3 **release targets**:
* API Gateway → Production → us-east-1
* API Gateway → Production → us-west-2
* API Gateway → Production → eu-west-1
Each release target can have its own policies, verification, and rollout timing.
**Advanced example:** Version `v1.2.3` of the "API Gateway" deployment targets the "Production" environment,
which expands to multiple clusters including `us-west-2`. Ctrlplane creates one release target per cluster,
so each target can be governed independently. A deployment window policy can be scoped to the `us-west-2`
release target so it only deploys on weekdays before 9 a.m., while other production targets follow their
own timing. When a new version arrives, Ctrlplane waits for that window before dispatching the job for
`us-west-2`, even if other targets can deploy sooner.
## Dynamic Environments
Environments use **selectors** to automatically include resources:
```yaml theme={null}
type: Environment
name: Production
resourceSelector: resource.metadata["env"] == "production"
```
When you add a new cluster with `env: production` metadata, it automatically becomes part of the Production environment. No config changes needed.
```mermaid theme={null}
flowchart TB
subgraph Resources["All Resources"]
R1["us-east-1 cluster env: production"]
R2["us-west-2 cluster env: production"]
R3["dev cluster env: development"]
end
subgraph Envs["Environments"]
Prod["Production"]
Dev["Development"]
end
R1 -.->|"matches"| Prod
R2 -.->|"matches"| Prod
R3 -.->|"matches"| Dev
```
## Policies Define When to Deploy
Policies are the rules that govern *when* a version is allowed to deploy:
| Policy | What It Does |
| --------------------------- | --------------------------------- |
| **Approval** | Require sign-off before deploying |
| **Environment Progression** | Wait for staging before prod |
| **Gradual Rollout** | Deploy to targets one at a time |
| **Verification** | Check Datadog/Prometheus metrics |
| **Deployment Window** | Only deploy during certain hours |
Policies use selectors to target specific releases:
```yaml theme={null}
type: Policy
name: production-approval-policy
description: Production Approval Policy
selectors:
- environments: environment.metadata['requires-approval'] == 'true'
rules:
- approval:
required: 1
```
## Job Agents Execute Deployments
Ctrlplane doesn't deploy directly—it tells **job agents** what to do:
| Agent | What It Does |
| ------------------- | --------------------------------- |
| **GitHub Actions** | Triggers workflow dispatch |
| **ArgoCD** | Creates/syncs ArgoCD Applications |
| **Terraform Cloud** | Triggers Terraform runs |
| **Kubernetes** | Applies manifests directly |
This means Ctrlplane works with your existing deployment tooling.
## A Complete Example
```mermaid theme={null}
flowchart TB
subgraph Build["1\. Build"]
CI["GitHub Actions"]
end
subgraph Orchestrate["2\. Orchestrate (Ctrlplane)"]
Version["Version v1.2.3"]
Staging["Staging Release"]
Verify1["Verify"]
Prod["Production Release"]
Approve["Approval Gate"]
Verify2["Verify"]
end
subgraph Execute["3\. Execute"]
ArgoStaging["ArgoCD Staging"]
ArgoProd["ArgoCD Prod"]
end
CI -->|"create version"| Version
Version --> Staging
Staging -->|"dispatch"| ArgoStaging
ArgoStaging -->|"complete"| Verify1
Verify1 -->|"pass"| Prod
Prod --> Approve
Approve -->|"approved"| ArgoProd
ArgoProd -->|"complete"| Verify2
```
1. GitHub Actions builds `v1.2.3` and creates a version in Ctrlplane
2. Ctrlplane creates a release for staging
3. ArgoCD deploys to staging
4. Verification checks Datadog metrics
5. Staging passes → production release is unblocked
6. Production requires approval → team lead approves
7. ArgoCD deploys to production
8. Verification confirms production is healthy
## What's Next?
Build this pipeline yourself in 15 minutes
See specific deployment scenarios
# Approval
Source: https://docs.ctrlplane.dev/policies/approval
Learn how to use approval rules to require manual sign-off before deployments proceed.
**Approval rules** require manual approval from authorized users before a
deployment can proceed. This adds a human checkpoint to your deployment pipeline
for high-risk changes.
## Overview
```mermaid theme={null}
flowchart TD
A[Release Created] --> B{Approval Required?}
B -->|No| C[Deployment Proceeds]
B -->|Yes| D[Awaiting Approval]
D --> E{Approvals Met?}
E -->|Yes| C
E -->|No| D
```
## Why Use Approval Rules?
Approval rules help you:
* **Add human oversight** - Require sign-off for production deployments
* **Enforce compliance** - Meet regulatory requirements for change management
* **Coordinate releases** - Ensure stakeholders are aware before deployment
* **Reduce risk** - Catch issues that automated checks might miss
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "production_approval" {
name = "Production Approval Policy"
description = "Require approval for production deployments"
selector = "environment.metadata['requires-approval'] == 'true'"
any_approval {
min_approvals = 1
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Approval Policy",
"description": "Require approval for production deployments",
"selector": "environment.metadata['\''requires-approval'\''] == '\''true'\''",
"rules": [
{
"anyApproval": {
"minApprovals": 1
}
}
]
}'
```
## Properties
Minimum number of approvals required before the deployment can proceed.
## Grandfathering
Versions that were created **before** an approval rule was added to a policy are
automatically allowed through (grandfathered in). This prevents newly added
approval rules from blocking already-in-flight releases.
## Common Patterns
### Single Approval for Production
Basic approval gate for production deployments:
```hcl theme={null}
resource "ctrlplane_policy" "production_gate" {
name = "Production Gate Policy"
selector = "environment.metadata['requires-approval'] == 'true'"
any_approval {
min_approvals = 1
}
}
```
```json theme={null}
{
"name": "Production Gate Policy",
"selector": "environment.metadata['requires-approval'] == 'true'",
"rules": [
{
"anyApproval": { "minApprovals": 1 }
}
]
}
```
### Multiple Approvals for Critical Services
Require multiple sign-offs for high-risk deployments:
```hcl theme={null}
resource "ctrlplane_policy" "critical_service_approval" {
name = "Critical Service Approval Policy"
selector = "environment.metadata['requires-approval'] == 'true'"
any_approval {
min_approvals = 2
}
}
```
```json theme={null}
{
"name": "Critical Service Approval Policy",
"selector": "environment.metadata['requires-approval'] == 'true'",
"rules": [
{
"anyApproval": { "minApprovals": 2 }
}
]
}
```
### Approval with Gradual Rollout
Approve once, then roll out gradually:
```hcl theme={null}
resource "ctrlplane_policy" "controlled_production_release" {
name = "Controlled Production Release"
selector = "environment.metadata['requires-approval'] == 'true'"
any_approval {
min_approvals = 1
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```json theme={null}
{
"name": "Controlled Production Release",
"selector": "environment.metadata['requires-approval'] == 'true'",
"rules": [
{
"anyApproval": { "minApprovals": 1 }
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}
```
## Approval Workflow
### 1. Release Created
When a new release is created that matches an approval policy, it enters an
"awaiting approval" state.
### 2. Approval Requested
Users with appropriate permissions can view pending approvals in the Ctrlplane
UI or via API.
### 3. Approval Granted
Authorized users approve (or reject) the release. Each approval is recorded with
the user and timestamp.
### 4. Deployment Proceeds
Once the required number of approvals is met, the deployment continues through
any remaining policy rules.
## Best Practices
### Environment-Based Approvals
| Environment | Approvals | Notes |
| ----------- | --------- | ---------------------------- |
| Development | 0 | No approval needed |
| QA | 0 | Automated testing sufficient |
| Staging | 0-1 | Optional for visibility |
| Production | 1-2 | Always require approval |
### Recommendations
* ✅ Require approvals for production environments
* ✅ Use multiple approvals for critical services
* ✅ Combine with verification for defense in depth
* ✅ Document approval requirements in runbooks
* ✅ Set up notifications for pending approvals
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Environment Progression](./environment-progression) - Enforce deployment order
# Deployment Dependency
Source: https://docs.ctrlplane.dev/policies/deployment-dependency
Learn how to use deployment dependency rules to create dependencies between different deployments.
**Deployment dependency rules** ensure that one deployment succeeds before
another can proceed. Use them to coordinate related services, enforce deployment
order across microservices, or manage infrastructure dependencies.
## Overview
```mermaid theme={null}
flowchart LR
A[Database Migration] -->|Must succeed| B[API Service]
B -->|Must succeed| C[Frontend]
```
## Why Use Deployment Dependencies?
Deployment dependency rules help you:
* **Coordinate services** - Deploy database before API, API before frontend
* **Manage infrastructure** - Infrastructure changes before application updates
* **Enforce order** - Shared libraries before dependent services
* **Reduce failures** - Prevent cascading failures from out-of-order deploys
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "api_requires_database" {
name = "API Requires Database"
selector = "deployment.name == 'api-service'"
deployment_dependency {
depends_on_selector = "deployment.name == 'database-migration'"
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "API Requires Database",
"selector": "deployment.name == '\''api-service'\''",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == '\''database-migration'\''"
}
}
]
}'
```
## Properties
CEL expression to match upstream release targets that must exist before this
deployment can proceed. The expression can reference both **deployment** and
**version** properties of the currently deployed upstream release.
### Available CEL Variables
The `dependsOn` expression is evaluated against each release target on the same
resource that has a successful release. Both `deployment.*` and `version.*`
fields are available:
| Variable | Type | Description |
| --------------------- | --------- | ----------------------------------- |
| `deployment.id` | string | Deployment ID |
| `deployment.name` | string | Deployment name |
| `deployment.slug` | string | Deployment slug |
| `deployment.metadata` | map | Deployment metadata key-value pairs |
| `version.id` | string | Deployed version ID |
| `version.tag` | string | Version tag (e.g. `v2.1.0`) |
| `version.name` | string | Version name |
| `version.status` | string | Version status |
| `version.metadata` | map | Version metadata key-value pairs |
| `version.createdAt` | timestamp | When the version was created |
## How It Works
1. **Release created** - A new version is released for a deployment with
dependency rules.
2. **Same-resource resolution** - Ctrlplane finds all release targets on the
**same resource** as the current target.
3. **Version resolution** - For each release target, Ctrlplane resolves the
deployment and its currently deployed version (from the latest successful
job).
4. **CEL evaluation** - The `dependsOn` expression is evaluated against each
`{deployment, version}` pair.
5. **Deployment allowed** - If at least one upstream release target matches the
selector, the deployment can proceed.
## Common Patterns
### Database Before API
Ensure database migrations complete before API deploys:
```hcl theme={null}
resource "ctrlplane_policy" "api_requires_db" {
name = "API Requires DB Migration"
selector = "deployment.name == 'api-service'"
deployment_dependency {
depends_on_selector = "deployment.name == 'database-migration'"
}
}
```
```json theme={null}
{
"name": "API Requires DB Migration",
"selector": "deployment.name == 'api-service'",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'database-migration'"
}
}
]
}
```
### Service Dependency Chain
Create a chain of dependencies:
```hcl theme={null}
resource "ctrlplane_policy" "api_depends_on_db" {
name = "API Depends on DB"
selector = "deployment.name == 'api-service'"
deployment_dependency {
depends_on_selector = "deployment.name == 'database-migration'"
}
}
resource "ctrlplane_policy" "frontend_depends_on_api" {
name = "Frontend Depends on API"
selector = "deployment.name == 'frontend'"
deployment_dependency {
depends_on_selector = "deployment.name == 'api-service'"
}
}
```
```json theme={null}
[
{
"name": "API Depends on DB",
"selector": "deployment.name == 'api-service'",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'database-migration'"
}
}
]
},
{
"name": "Frontend Depends on API",
"selector": "deployment.name == 'frontend'",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'api-service'"
}
}
]
}
]
```
### Shared Library Dependencies
Ensure shared libraries are deployed before dependent services:
```hcl theme={null}
resource "ctrlplane_policy" "services_require_shared_lib" {
name = "Services Require Shared Lib"
selector = "deployment.metadata['usesSharedLib'] == 'true'"
deployment_dependency {
depends_on_selector = "deployment.metadata['type'] == 'shared-library'"
}
}
```
### Version-Scoped Dependencies
Require a specific version range of an upstream deployment:
```hcl theme={null}
resource "ctrlplane_policy" "api_requires_db_v2" {
name = "API Requires DB Migration v2"
selector = "deployment.name == 'api-service'"
deployment_dependency {
depends_on_selector = "deployment.name == 'database-migration' && version.tag.startsWith('v2.')"
}
}
```
```json theme={null}
{
"name": "API Requires DB Migration v2",
"selector": "deployment.name == 'api-service'",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'database-migration' && version.tag.startsWith('v2.')"
}
}
]
}
```
### Version Metadata Filtering
Depend on an upstream deployment running a version with specific metadata:
```hcl theme={null}
resource "ctrlplane_policy" "frontend_requires_stable_api" {
name = "Frontend Requires Stable API"
selector = "deployment.name == 'frontend'"
deployment_dependency {
depends_on_selector = "deployment.name == 'api-service' && version.metadata.channel == 'stable'"
}
}
```
### Infrastructure First
Deploy infrastructure changes before application updates:
```hcl theme={null}
resource "ctrlplane_policy" "app_requires_infrastructure" {
name = "App Requires Infrastructure"
selector = "deployment.metadata['type'] == 'application'"
deployment_dependency {
depends_on_selector = "deployment.metadata['type'] == 'infrastructure'"
}
}
```
### Multi-Service Dependency
Depend on multiple services using CEL `in` operator:
```hcl theme={null}
resource "ctrlplane_policy" "gateway_requires_services" {
name = "Gateway Requires Backend Services"
selector = "deployment.name == 'api-gateway'"
deployment_dependency {
depends_on_selector = "deployment.name in ['auth-service', 'user-service', 'billing-service']"
}
}
```
## Combining with Other Rules
### With Environment Progression
```hcl theme={null}
resource "ctrlplane_policy" "api_full_gates" {
name = "API Full Gates"
selector = "deployment.name == 'api-service' && environment.name == 'production'"
deployment_dependency {
depends_on_selector = "deployment.name == 'database-migration'"
}
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
}
any_approval {
min_approvals = 1
}
}
```
```json theme={null}
{
"name": "API Full Gates",
"selector": "deployment.name == 'api-service' && environment.name == 'production'",
"rules": [
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'database-migration'"
}
},
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'"
}
},
{
"anyApproval": { "minApprovals": 1 }
}
]
}
```
### With Gradual Rollout
```hcl theme={null}
resource "ctrlplane_policy" "frontend_controlled_release" {
name = "Frontend Controlled Release"
selector = "deployment.name == 'frontend'"
deployment_dependency {
depends_on_selector = "deployment.name == 'api-service'"
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
## Best Practices
### Dependency Design
| Pattern | Use Case |
| --------------------- | --------------------------------- |
| Database → API | Schema changes before code |
| API → Frontend | API contracts before consumers |
| Infrastructure → App | Platform changes before workloads |
| Shared lib → Services | Common code before dependents |
| Config → Application | Configuration before apps |
### Recommendations
* ✅ Keep dependency chains short (2-3 levels max)
* ✅ Use metadata to group related deployments
* ✅ Document why dependencies exist
* ✅ Test dependency resolution in staging
* ✅ Monitor for circular dependency issues
### Anti-Patterns
* ❌ Deep dependency chains (> 3 levels)
* ❌ Circular dependencies (A → B → A)
* ❌ Over-coupling unrelated services
* ❌ Using dependencies when environment progression would suffice
## Troubleshooting
### Deployment Blocked
If a deployment is blocked waiting for dependencies:
1. Check the dependency deployment's status
2. Verify the CEL expression matches the expected deployment
3. Remember that dependencies are resolved **per-resource** -- the upstream
deployment must have a successful release on the same resource
4. Review the dependency deployment's success status
### Circular Dependencies
If you encounter circular dependency errors:
1. Review the dependency graph
2. Break the cycle by removing one dependency
3. Consider using environment progression instead
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Environment Progression](./environment-progression) - Cross-environment gates
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
# Deployment Window
Source: https://docs.ctrlplane.dev/policies/deployment-window
Learn how to use deployment window rules to control when deployments can occur using time-based schedules.
**Deployment window rules** define specific time periods when deployments are
allowed or blocked. Using RFC 5545 recurrence rules (rrules), you can create
flexible schedules like "weekdays 9am-5pm" or "block deployments during
maintenance windows."
## Overview
```mermaid theme={null}
flowchart TD
A[Release Created] --> B{Check Window}
B --> C{Inside Window?}
C -->|Allow Window + Inside| D[Deployment Proceeds]
C -->|Allow Window + Outside| E[Wait for Window]
C -->|Deny Window + Inside| E
C -->|Deny Window + Outside| D
E --> F[Re-evaluate when window changes]
F --> B
```
## Why Use Deployment Windows?
Deployment windows help you:
* **Reduce risk** - Only deploy during business hours when teams are available
* **Coordinate operations** - Block deployments during maintenance windows
* **Meet compliance** - Enforce change control windows required by regulations
* **Protect stability** - Prevent deployments during high-traffic periods
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "business_hours_only" {
name = "Business Hours Only"
selector = "environment.name == 'production'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
duration_minutes = 480
timezone = "America/New_York"
allow_window = true
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Business Hours Only",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"deploymentWindow": {
"rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0",
"durationMinutes": 480,
"timezone": "America/New_York",
"allowWindow": true
}
}
]
}'
```
## Properties
RFC 5545 recurrence rule defining when windows start.
Duration of each window in minutes.
IANA timezone for the rrule (e.g., `"America/New_York"`).
If `true`, deployments are only allowed during the window. If `false`,
deployments are blocked during the window (deny window).
## Understanding rrules
RFC 5545 recurrence rules define repeating patterns. Common components:
| Component | Description | Example |
| ---------- | ----------------------- | ---------------------------- |
| `FREQ` | Frequency of recurrence | `DAILY`, `WEEKLY`, `MONTHLY` |
| `BYDAY` | Days of the week | `MO,TU,WE,TH,FR` |
| `BYHOUR` | Hours of the day (0-23) | `9` (9am) |
| `BYMINUTE` | Minutes of the hour | `0`, `30` |
### Example rrules
| Pattern | rrule |
| ------------------------ | ------------------------------------------------------ |
| Weekdays at 9am | `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0` |
| Daily at 2am | `FREQ=DAILY;BYHOUR=2;BYMINUTE=0` |
| Every Sunday at midnight | `FREQ=WEEKLY;BYDAY=SU;BYHOUR=0;BYMINUTE=0` |
| First Monday of month | `FREQ=MONTHLY;BYDAY=1MO;BYHOUR=9;BYMINUTE=0` |
## Window Types
### Allow Windows
When `allowWindow: true` (default), deployments are **only allowed during** the
defined window:
```hcl theme={null}
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
duration_minutes = 480
timezone = "America/New_York"
allow_window = true
}
```
```json theme={null}
{
"deploymentWindow": {
"rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0",
"durationMinutes": 480,
"timezone": "America/New_York",
"allowWindow": true
}
}
```
### Deny Windows (Blackouts)
When `allowWindow: false`, deployments are **blocked during** the defined
window:
```hcl theme={null}
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=SU;BYHOUR=0;BYMINUTE=0"
duration_minutes = 360
timezone = "America/New_York"
allow_window = false
}
```
```json theme={null}
{
"deploymentWindow": {
"rrule": "FREQ=WEEKLY;BYDAY=SU;BYHOUR=0;BYMINUTE=0",
"durationMinutes": 360,
"timezone": "America/New_York",
"allowWindow": false
}
}
```
## Common Patterns
### Business Hours Only
Allow deployments only during business hours:
```hcl theme={null}
resource "ctrlplane_policy" "business_hours_deployments" {
name = "Business Hours Deployments"
selector = "environment.name == 'production'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
duration_minutes = 480
timezone = "America/New_York"
}
}
```
### Maintenance Window Blackout
Block deployments during scheduled maintenance:
```hcl theme={null}
resource "ctrlplane_policy" "maintenance_blackout" {
name = "Maintenance Blackout"
selector = "environment.name == 'production'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=SU;BYHOUR=2;BYMINUTE=0"
duration_minutes = 240
timezone = "UTC"
allow_window = false
}
}
```
### Weekend Freeze
Prevent deployments over weekends:
```hcl theme={null}
resource "ctrlplane_policy" "weekend_freeze" {
name = "Weekend Freeze"
selector = "environment.name == 'production'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=SA;BYHOUR=0;BYMINUTE=0"
duration_minutes = 2880
timezone = "America/New_York"
allow_window = false
}
}
```
### Late Night Deployments Only
For services that require off-peak deployments:
```hcl theme={null}
resource "ctrlplane_policy" "off_peak_deployments" {
name = "Off-Peak Deployments"
selector = "deployment.metadata['requires_off_peak'] == 'true'"
deployment_window {
rrule = "FREQ=DAILY;BYHOUR=2;BYMINUTE=0"
duration_minutes = 180
timezone = "America/New_York"
}
}
```
### Combined with Other Rules
Use deployment windows alongside other policy rules:
```hcl theme={null}
resource "ctrlplane_policy" "production_controlled_release" {
name = "Production Controlled Release"
selector = "environment.name == 'production'"
any_approval {
min_approvals = 1
}
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
duration_minutes = 480
timezone = "America/New_York"
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```json theme={null}
{
"name": "Production Controlled Release",
"selector": "environment.name == 'production'",
"rules": [
{ "anyApproval": { "minApprovals": 1 } },
{
"deploymentWindow": {
"rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0",
"durationMinutes": 480,
"timezone": "America/New_York",
"allowWindow": true
}
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}
```
## Behavior Details
### First Deployment Exemption
If a release target has never had a deployment before, deployment window rules
are bypassed. This ensures that initial deployments are not blocked by window
restrictions.
### Window Evaluation
* Deployments are evaluated against the current time
* If outside an allow window (or inside a deny window), the deployment waits
* Ctrlplane automatically re-evaluates when the window state changes, using the
computed `nextEvaluationTime`
### Gradual Rollout Integration
When combined with gradual rollout rules:
* **Allow windows**: Rollout start time is adjusted to the next window opening
* **Deny windows**: Individual deployments within a rollout respect the deny
period
### Timezone Handling
* Always specify a timezone for predictable behavior
* If omitted, UTC is used
* Use IANA timezone names (e.g., "America/New\_York", "Europe/London")
## Best Practices
### Environment-Based Windows
| Environment | Window Type | Notes |
| ----------- | ----------------------- | ---------------------------- |
| Development | None | Deploy anytime |
| QA | None | Deploy anytime |
| Staging | Optional business hours | Mirror production if desired |
| Production | Strict business hours | When support is available |
### Recommendations
* ✅ Use timezones matching your operational team's location
* ✅ Account for holidays with deny windows
* ✅ Provide adequate window duration for rollouts to complete
* ✅ Combine with approval rules for additional oversight
* ✅ Test rrule patterns in staging before production
* ❌ Don't create windows too narrow for deployments to complete
* ❌ Don't forget to account for gradual rollout duration
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Approval](./approval) - Add human approval gates
# Environment Progression
Source: https://docs.ctrlplane.dev/policies/environment-progression
Learn how to use environment progression rules to enforce deployment order across environments.
**Environment progression rules** ensure that releases are deployed to
prerequisite environments before they can proceed to downstream environments.
This enforces a deployment pipeline where changes must pass through QA before
staging, and staging before production.
## Overview
```mermaid theme={null}
flowchart LR
A[QA] -->|Must succeed| B[Staging]
B -->|Must succeed| C[Production]
```
## Why Use Environment Progression?
Environment progression rules help you:
* **Enforce deployment order** - Prevent skipping environments in your pipeline
* **Catch issues early** - Problems surface in lower environments first
* **Build confidence** - Each environment validates before the next
* **Meet compliance** - Satisfy audit requirements for change promotion
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "staging_requires_qa" {
name = "Staging Requires QA"
selector = "environment.name == 'staging'"
environment_progression {
depends_on_environment_selector = "environment.name == 'qa'"
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Staging Requires QA",
"selector": "environment.name == '\''staging'\''",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == '\''qa'\''"
}
}
]
}'
```
## Properties
CEL expression or selector matching the prerequisite environment(s). The
release must be successfully deployed to environments matching this selector
before proceeding.
Percentage of targets in the prerequisite environment that must succeed
(0-100). Use a lower value if some targets are expected to be unavailable.
Job statuses considered successful. Defaults to `["successful"]`.
Minutes to wait after the dependency succeeds before allowing progression. Use
this to ensure the release has time to stabilize.
Maximum age (in hours) of the dependency deployment. If the prerequisite
deployment is older than this, progression is blocked until redeployed.
When enabled, jobs must also have passed verification to count toward the
success percentage. Jobs with no verification metrics configured are still
counted. Jobs with verification in a running, failed, or cancelled state are
excluded from the success count.
## How It Works
1. **Find dependency environments** - Ctrlplane finds all environments matching
the selector that share at least one system with the current environment.
This prevents cross-system dependency issues.
2. **Evaluate pass rate** - For each dependency environment, Ctrlplane checks
if the success percentage of release targets meets the threshold.
3. **Check verification** - If `requireVerificationPassed` is enabled,
only jobs that have passed verification count toward the success percentage.
Jobs without verification metrics are still counted.
4. **Check soak time** - If `minimumSoakTimeMinutes` is configured, Ctrlplane
verifies that enough time has elapsed since the most recent successful job.
5. **Check freshness** - If `maximumAgeHours` is configured, Ctrlplane ensures
the dependency deployment is not too old.
6. **OR logic across environments** - If the selector matches multiple
environments, the rule passes if **at least one** of them satisfies all
criteria.
## Common Patterns
### Simple Linear Progression
Enforce QA → Staging → Production order:
```hcl theme={null}
resource "ctrlplane_policy" "staging_requires_qa" {
name = "Staging Requires QA"
selector = "environment.name == 'staging'"
environment_progression {
depends_on_environment_selector = "environment.name == 'qa'"
}
}
resource "ctrlplane_policy" "production_requires_staging" {
name = "Production Requires Staging"
selector = "environment.name == 'production'"
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
}
}
```
```json theme={null}
[
{
"name": "Staging Requires QA",
"selector": "environment.name == 'staging'",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'qa'"
}
}
]
},
{
"name": "Production Requires Staging",
"selector": "environment.name == 'production'",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'"
}
}
]
}
]
```
### Soak Time Requirements
Require the release to "soak" in staging before production:
```hcl theme={null}
resource "ctrlplane_policy" "production_soak_requirement" {
name = "Production Soak Requirement"
selector = "environment.name == 'production'"
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
minimum_soak_time_minutes = 60
}
}
```
```json theme={null}
{
"name": "Production Soak Requirement",
"selector": "environment.name == 'production'",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'",
"minimumSoakTimeMinutes": 60
}
}
]
}
```
### Freshness Requirements
Block promotion if the staging deployment is too old:
```hcl theme={null}
resource "ctrlplane_policy" "production_freshness" {
name = "Production Freshness"
selector = "environment.name == 'production'"
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
maximum_age_hours = 24
}
}
```
```json theme={null}
{
"name": "Production Freshness",
"selector": "environment.name == 'production'",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'",
"maximumAgeHours": 24
}
}
]
}
```
### Partial Success Threshold
Allow promotion when most (not all) targets succeed:
```hcl theme={null}
resource "ctrlplane_policy" "staging_partial_success" {
name = "Staging Partial Success"
selector = "environment.name == 'staging'"
environment_progression {
depends_on_environment_selector = "environment.name == 'qa'"
minimum_success_percentage = 80
}
}
```
### Complete Pipeline with All Options
Full-featured production gate:
```hcl theme={null}
resource "ctrlplane_policy" "production_full_gate" {
name = "Production Full Gate"
selector = "environment.name == 'production'"
any_approval {
min_approvals = 1
}
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
minimum_success_percentage = 100
minimum_soak_time_minutes = 30
maximum_age_hours = 48
require_verification_passed = true
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```json theme={null}
{
"name": "Production Full Gate",
"selector": "environment.name == 'production'",
"rules": [
{ "anyApproval": { "minApprovals": 1 } },
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'",
"minimumSuccessPercentage": 100,
"minimumSoakTimeMinutes": 30,
"maximumAgeHours": 48,
"requireVerificationPassed": true
}
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}
```
## Progression Lifecycle
### 1. Version Created
A new deployment version is created and ready for release.
### 2. Lower Environment Deployment
The version is deployed to the prerequisite environment (e.g., QA).
### 3. Success Evaluation
Ctrlplane evaluates if the deployment meets success criteria:
* Job status matches `successStatuses`
* Success percentage meets `minimumSuccessPercentage`
* If `requireVerificationPassed` is enabled, verification must have passed
### 4. Soak Time (if configured)
If `minimumSoakTimeMinutes` is set, the clock starts after success.
### 5. Progression Allowed
Once all criteria are met, the version can proceed to the next environment.
## Best Practices
### Timing Guidelines
| Transition | Soak Time | Max Age | Notes |
| -------------------- | --------- | ------- | -------------------- |
| Dev → QA | 0 | - | Fast iteration |
| QA → Staging | 0-15 min | 24h | Quick validation |
| Staging → Production | 30-60 min | 48h | Thorough soak |
| Critical services | 2-4 hours | 24h | Extended observation |
### Recommendations
* ✅ Start with simple progression, add soak time later
* ✅ Use `maximumAgeHours` to prevent stale promotions
* ✅ Combine with verification for automated quality gates
* ✅ Use lower `minimumSuccessPercentage` for environments with flaky tests
* ✅ Document your progression requirements for the team
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Deployment Dependency](./deployment-dependency) - Cross-deployment gates
# Gradual Rollouts
Source: https://docs.ctrlplane.dev/policies/gradual-rollouts
Learn how to use gradual rollouts to control the pace of deployments across multiple release targets.
**Gradual rollouts** allow you to control the pace of deployments across
multiple release targets. Instead of deploying to all targets simultaneously,
you can stagger deployments over time to reduce risk and catch issues early.
## Overview
When deploying to multiple targets (e.g., multiple Kubernetes clusters in
production), gradual rollouts let you:
* **Reduce blast radius** - If something goes wrong, only a subset of targets
are affected
* **Catch issues early** - Problems surface in early batches before full rollout
* **Control timing** - Space out deployments to manage load and monitoring
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "production_rollout" {
name = "Production Rollout"
selector = "environment.name == 'production'"
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Rollout",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}'
```
## Properties
The rollout strategy to use:
* `linear` — Deploy to each target at a fixed interval
* `linear-normalized` — Space deployments evenly within the time window
For `linear`: seconds between each target deployment. For
`linear-normalized`: total seconds to complete the rollout.
## Rollout Types
### Linear Rollout
Deploy to each target at a fixed interval:
```hcl theme={null}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
```
```json theme={null}
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
```
**Example with 5 targets**:
```
t+0m: Target 1 deployed
t+5m: Target 2 deployed
t+10m: Target 3 deployed
t+15m: Target 4 deployed
t+20m: Target 5 deployed
```
### Linear Normalized Rollout
Deployments are spaced evenly so that the last target is scheduled at or before
the `timeScaleInterval`:
```hcl theme={null}
gradual_rollout {
rollout_type = "linear-normalized"
time_scale_interval = 600
}
```
```json theme={null}
{
"gradualRollout": {
"rolloutType": "linear-normalized",
"timeScaleInterval": 600
}
}
```
**Example with 5 targets and 10 minute window**:
```
t+0m: Target 1 deployed
t+2m: Target 2 deployed
t+4m: Target 3 deployed
t+6m: Target 4 deployed
t+8m: Target 5 deployed
```
## How Rollout Start Time Is Determined
The gradual rollout evaluator determines its start time by looking at other
policy rules that must be satisfied first:
1. **Approval rules** - Uses the `satisfiedAt` time when approvals were met
2. **Environment progression rules** - Uses the `satisfiedAt` time when
progression criteria were met
3. **Policy skip overrides** - Uses the skip creation time
4. **Deployment windows** - Allow windows push the start to the window opening;
deny windows push the start past the window closing
If no approval or progression rules exist, the rollout starts from the version
creation time. The rollout start is the **latest** time at which all
prerequisites were met.
Target ordering within a rollout is determined by a consistent hash of the
release target key and version ID, ensuring deterministic but varied ordering
across different versions.
## Common Patterns
### Conservative Production Rollout
Long intervals with approval:
```hcl theme={null}
resource "ctrlplane_policy" "conservative_rollout" {
name = "Conservative Rollout"
selector = "environment.name == 'production'"
any_approval {
min_approvals = 1
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 900
}
}
```
### Fast Staging Rollout
Quick rollout for non-production environments:
```hcl theme={null}
resource "ctrlplane_policy" "staging_rollout" {
name = "Staging Rollout"
selector = "environment.name == 'staging'"
gradual_rollout {
rollout_type = "linear-normalized"
time_scale_interval = 120
}
}
```
### Critical Service Rollout
Extra cautious rollout for critical services:
```hcl theme={null}
resource "ctrlplane_policy" "critical_service_rollout" {
name = "Critical Service Rollout"
selector = "deployment.metadata['tier'] == 'critical' && environment.name == 'production'"
any_approval {
min_approvals = 2
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 1800
}
}
```
```json theme={null}
{
"name": "Critical Service Rollout",
"selector": "deployment.metadata['tier'] == 'critical' && environment.name == 'production'",
"rules": [
{ "anyApproval": { "minApprovals": 2 } },
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 1800
}
}
]
}
```
## Best Practices
### Timing Guidelines
| Environment | Rollout Type | Interval | Notes |
| ----------- | ----------------- | --------- | ----------------------------- |
| QA | linear-normalized | 60-120s | Fast feedback |
| Staging | linear-normalized | 120-300s | Reasonable pace |
| Production | linear | 300-900s | Conservative, time to monitor |
| Critical | linear | 900-1800s | Extra time for verification |
### Recommendations
* ✅ Use longer intervals for production environments
* ✅ Combine with verification to catch issues between batches
* ✅ Use `linear-normalized` when you have a time constraint
* ✅ Use `linear` when you want consistent spacing regardless of target count
* ✅ Monitor each batch before the next one deploys
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Deployment Window](./deployment-window) - Time-based deployment control
* [Version Cooldown](./version-cooldown) - Batch frequent releases
# Overview
Source: https://docs.ctrlplane.dev/policies/overview
# Policies
**Policies** define **when** a version is allowed to deploy. They are the rules
that govern how releases progress through your environments — controlling
deployment timing through approvals, verification gates, progression
requirements, deployment windows, and more.
In Ctrlplane's mental model: [**Deployments**](../concepts/deployments) = what & how, [**Environments**](../concepts/environments) = where, **Policies** = when.
## Building Confidence Through Policies
While [deployments](../concepts/deployments) define *what and how* and
[environments](../concepts/environments) define *where*, policies answer the
critical question of *when*: is this version ready to deploy? Has it passed
through the right gates? Policies help you deploy with confidence by ensuring
that each stage meets your quality standards before progressing to the next.
```
Development → QA → Staging → Production
↓ ↓ ↓ ↓
(none) smoke integration full
tests tests gates
```
With policies, you can:
* **Start simple, grow complex** - Begin with basic health checks in QA, add
integration tests in staging, require approvals and verification in production
* **Catch issues early** - Run smoke tests in QA to catch problems before they
reach production
* **Automate quality gates** - Let verification results automatically determine
if a release can proceed
* **Reduce deployment anxiety** - Know that every production deployment has
passed through proven checks
* **Customize per environment** - Apply stricter rules where they matter most
## Policy Structure
A policy consists of:
1. **Name & Description** - Identify and document the policy's purpose
2. **Selector** - A CEL expression defining which release targets the policy
applies to
3. **Rules** - One or more rules specifying behavior or requirements
4. **Priority** - Higher priority policies are evaluated first
5. **Metadata** - Arbitrary key-value pairs for organization
```hcl theme={null}
resource "ctrlplane_policy" "production_gates" {
name = "Production Gates"
description = "Require approval and gradual rollout for production"
priority = 10
enabled = true
selector = "environment.name == 'production'"
any_approval {
min_approvals = 1
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```json theme={null}
{
"name": "Production Gates",
"description": "Require approval and gradual rollout for production",
"priority": 10,
"enabled": true,
"selector": "environment.name == 'production'",
"rules": [
{
"anyApproval": { "minApprovals": 1 }
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}
```
## Policy Selectors
The `selector` field is a CEL expression that determines which release targets
a policy applies to. Policies only affect releases that match the selector.
### Environment Selector
Target releases going to specific environments:
```
environment.name == "production"
environment.name in ["staging", "production"]
environment.name.startsWith("prod-")
environment.metadata['tier'] == "critical"
```
### Resource Selector
Target releases for specific resources:
```
resource.kind == "Kubernetes"
resource.metadata['region'] == "us-east-1"
resource.name.contains("critical")
```
### Deployment Selector
Target releases for specific deployments:
```
deployment.name == "api-service"
deployment.metadata['team'] == "platform"
```
### Combined Selectors
Combine conditions with `&&` (AND) and `||` (OR):
```
environment.name == "production" && deployment.metadata['tier'] == "critical"
```
## Policy Rules
Rules define what the policy enforces. A single policy can contain multiple
rules across different types. Each rule in the `rules` array contains exactly
one rule type.
### Approval Rule
Require manual approval before deployment:
| API Field | Terraform Attribute | Description |
| -------------------------- | ------------------- | -------------------------- |
| `anyApproval.minApprovals` | `min_approvals` | Number of approvals needed |
See [Approval](./approval) for details.
### Environment Progression Rule
Require successful deployment to a prerequisite environment:
| API Field | Terraform Attribute | Description |
| ----------------------------------------------------- | --------------------------------- | -------------------------------- |
| `environmentProgression.dependsOnEnvironmentSelector` | `depends_on_environment_selector` | CEL matching prerequisite env |
| `environmentProgression.minimumSuccessPercentage` | `minimum_success_percentage` | Required success % (0-100) |
| `environmentProgression.minimumSoakTimeMinutes` | `minimum_soak_time_minutes` | Soak time after success |
| `environmentProgression.maximumAgeHours` | `maximum_age_hours` | Max age of dependency deployment |
See [Environment Progression](./environment-progression) for details.
### Gradual Rollout Rule
Control the pace of deployments across multiple targets:
| API Field | Terraform Attribute | Description |
| ---------------------------------- | --------------------- | ------------------------------------ |
| `gradualRollout.rolloutType` | `rollout_type` | `"linear"` or `"linear-normalized"` |
| `gradualRollout.timeScaleInterval` | `time_scale_interval` | Seconds between targets / total time |
See [Gradual Rollouts](./gradual-rollouts) for details.
### Deployment Dependency Rule
Require upstream deployments to succeed first:
| API Field | Terraform Attribute | Description |
| -------------------------------- | --------------------- | -------------------------------- |
| `deploymentDependency.dependsOn` | `depends_on_selector` | CEL matching upstream deployment |
See [Deployment Dependency](./deployment-dependency) for details.
### Deployment Window Rule
Control when deployments are allowed using time-based schedules:
| API Field | Terraform Attribute | Description |
| ---------------------------------- | ------------------- | ----------------------------------- |
| `deploymentWindow.rrule` | `rrule` | RFC 5545 recurrence rule |
| `deploymentWindow.durationMinutes` | `duration_minutes` | Window duration in minutes |
| `deploymentWindow.timezone` | `timezone` | IANA timezone |
| `deploymentWindow.allowWindow` | `allow_window` | Allow during window (deny if false) |
See [Deployment Window](./deployment-window) for details.
### Version Cooldown Rule
Batch frequent releases by enforcing a minimum time between deployments:
| API Field | Terraform Attribute | Description |
| --------------------------------- | ------------------- | ---------------------------- |
| `versionCooldown.intervalSeconds` | `duration` | Minimum time between deploys |
See [Version Cooldown](./version-cooldown) for details.
### Retry Rule
Configure automatic retry behavior for failed jobs:
| API Field | Terraform Attribute | Description |
| ------------------------- | ------------------- | ----------------------------- |
| `retry.maxRetries` | *Not available* | Maximum retry attempts |
| `retry.backoffSeconds` | *Not available* | Seconds between retries |
| `retry.backoffStrategy` | *Not available* | `"linear"` or `"exponential"` |
| `retry.maxBackoffSeconds` | *Not available* | Backoff cap |
| `retry.retryOnStatuses` | *Not available* | Which statuses trigger retry |
See [Retry](./retry) for details.
### Version Selector Rule
Filter which versions can deploy to specific targets:
| API Field | Terraform Attribute | Description |
| ----------------------------- | ------------------- | ------------------------------- |
| `versionSelector.selector` | *Not available* | CEL expression or JSON selector |
| `versionSelector.description` | *Not available* | Human-readable description |
See [Version Selector](./version-selector) for details.
### Verification Rule
Run automated checks after deployment:
See [Verification](./verification) for detailed configuration options.
### Plan Validation Rule
Run OPA/Rego policies against a deployment plan to preview proposed changes.
This rule is **non-blocking** — it reports violations but does not gate
deployments.
| API Field | Terraform Attribute | Description |
| ------------------------------- | --------------------------------- | ----------------------------- |
| `planValidationOpa.name` | `plan_validation_opa.name` | Rule name shown in violations |
| `planValidationOpa.description` | `plan_validation_opa.description` | Human-readable description |
| `planValidationOpa.rego` | `plan_validation_opa.rego` | Rego v1 source code |
See [Plan Validation](./plan-validation) for details.
## Terraform Provider Reference
The Terraform provider supports the following policy blocks:
| Block | Description |
| ------------------------- | ----------------------------------------------- |
| `any_approval` | Require manual approvals |
| `environment_progression` | Require success in prerequisite environments |
| `gradual_rollout` | Stagger deployments over time |
| `deployment_dependency` | Require upstream deployments to succeed first |
| `deployment_window` | Time-based deployment scheduling |
| `version_cooldown` | Batch frequent releases |
| `verification` | Automated health checks |
| `plan_validation_opa` | OPA/Rego plan validation (non-blocking preview) |
`retry` and `version_selector` are not yet available in the Terraform
provider. Use the REST API for these rule types.
## REST API Reference
**Create a policy:**
```
POST /v1/workspaces/{workspaceId}/policies
```
**Get/Update/Delete a policy:**
```
GET /v1/workspaces/{workspaceId}/policies/{policyId}
PUT /v1/workspaces/{workspaceId}/policies/{policyId}
DELETE /v1/workspaces/{workspaceId}/policies/{policyId}
```
**List policies:**
```
GET /v1/workspaces/{workspaceId}/policies
```
Each rule in the `rules` array is an object with exactly one rule-type key
(e.g., `anyApproval`, `gradualRollout`, `deploymentWindow`).
## Policy Evaluation
When a release is created, Ctrlplane:
1. **Finds matching policies** - Evaluates the `selector` CEL expression
against each release target
2. **Merges rules** - Combines rules from all matching policies
3. **Applies rules** - Enforces each rule type
### Rule Interactions
* **Approval + Gradual Rollout**: The rollout start time is determined by when
the approval was satisfied
* **Environment Progression + Gradual Rollout**: The rollout waits until
progression criteria are met
* **Deployment Window + Gradual Rollout**: Allow windows push the rollout start
to the window opening; deny windows pause the rollout
* **Version Cooldown + Deployment Window**: Both must be satisfied -- cooldown
determines which version, window determines when
## Common Patterns
### Environment Progression
Different requirements per environment:
```hcl theme={null}
resource "ctrlplane_policy" "staging_policy" {
name = "Staging Policy"
selector = "environment.name == 'staging'"
environment_progression {
depends_on_environment_selector = "environment.name == 'qa'"
}
}
resource "ctrlplane_policy" "production_policy" {
name = "Production Policy"
selector = "environment.name == 'production'"
any_approval {
min_approvals = 1
}
environment_progression {
depends_on_environment_selector = "environment.name == 'staging'"
minimum_soak_time_minutes = 30
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
```json theme={null}
[
{
"name": "Staging Policy",
"selector": "environment.name == 'staging'",
"rules": [
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'qa'"
}
}
]
},
{
"name": "Production Policy",
"selector": "environment.name == 'production'",
"rules": [
{ "anyApproval": { "minApprovals": 1 } },
{
"environmentProgression": {
"dependsOnEnvironmentSelector": "environment.name == 'staging'",
"minimumSoakTimeMinutes": 30
}
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
}
]
}
]
```
### Critical Service Protection
Extra protection for critical services:
```hcl theme={null}
resource "ctrlplane_policy" "critical_service_gates" {
name = "Critical Service Gates"
selector = "deployment.metadata['tier'] == 'critical' && environment.name == 'production'"
priority = 20
any_approval {
min_approvals = 2
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 600
}
}
```
## Best Practices
### Policy Organization
* ✅ Use descriptive policy names
* ✅ Document policy purpose in description
* ✅ Start with permissive policies and tighten over time
* ✅ Test policies in lower environments first
* ✅ Use `priority` to control evaluation order for overlapping policies
### Selector Design
* ✅ Be specific with selectors to avoid unexpected matches
* ✅ Use environment selectors for environment-specific rules
* ✅ Use metadata for cross-cutting concerns (team, tier, etc.)
* ✅ Test selector expressions before applying
### Rule Configuration
* ✅ Set reasonable timeouts and failure limits
* ✅ Use verification to catch issues before they impact users
* ✅ Require approvals for high-risk deployments
* ✅ Use gradual rollouts for large-scale deployments
* ✅ Combine complementary rules (e.g., approval + gradual rollout)
## Next Steps
* [Approval](./approval) - Require manual sign-off before deployment
* [Deployment Dependency](./deployment-dependency) - Create service dependencies
* [Deployment Window](./deployment-window) - Control when deployments can occur
* [Environment Progression](./environment-progression) - Enforce deployment
order
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Retry](./retry) - Configure automatic retry behavior
* [Version Cooldown](./version-cooldown) - Batch frequent releases
* [Version Selector](./version-selector) - Filter deployable versions
# Plan Validation (OPA)
Source: https://docs.ctrlplane.dev/policies/plan-validation
Learn how to use OPA/Rego plan validation rules to inspect a deployment plan before you roll it out and surface policy violations as a preview.
**Plan validation rules** run [OPA](https://www.openpolicyagent.org/) (Open
Policy Agent) policies against a deployment **plan** — the diff between what is
currently deployed and what a new version proposes — and report any violations
back to you.
Plan validation is **non-blocking**. It does **not** gate or stop a
deployment. It exists to give you a preview: when a new version is planned,
you can see whether the proposed changes pass your policies before you decide
to roll them out. Violations are surfaced on the deployment's GitHub check and
in the ctrlplane UI, but the deployment is never automatically held back.
## Overview
```mermaid theme={null}
flowchart TD
A[Version Planned] --> B[Agent computes plan current vs. proposed]
B --> C{Policy selector matches target?}
C -->|No| D[No validation run]
C -->|Yes| E[Evaluate Rego deny rules]
E --> F{Any denials?}
F -->|No| G[Reported as passed]
F -->|Yes| H[Reported as violations]
```
A plan validation rule is a normal **policy rule**, so it shares the policy's
CEL `selector` that decides which release targets it applies to. The difference
is that instead of approvals or windows, the rule carries a snippet of **Rego**
that inspects the computed plan.
## When does it run?
Validation runs after an agent finishes computing a plan. Only agents that
produce a plan are evaluated:
* **ArgoCD** — the rendered manifest diff
* **Terraform Cloud** — the speculative plan output
If a deployment uses an agent that does not support plan operations, no
validation is run for that target.
## Why use plan validation?
* **Preview before you ship** — confirm a proposed change looks right before
promoting it.
* **Catch risky diffs** — flag deletions, replacements, or scaling changes in a
Terraform plan.
* **Guard against secrets or forbidden values** in rendered manifests.
* **Codify review checklists** that your team would otherwise eyeball by hand.
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "no_destroy_in_prod" {
name = "No destructive changes in production"
selector = "environment.name == 'production'"
plan_validation_opa {
name = "no-resource-deletions"
description = "Flag plans that delete resources"
rego = <<-EOT
package ctrlplane
import rego.v1
deny contains msg if {
input.agentType == "terraform-cloud"
plan := json.unmarshal(input.proposed)
change := plan.resource_changes[_]
change.change.actions[_] == "delete"
msg := sprintf("resource %q would be deleted", [change.address])
}
EOT
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "No destructive changes in production",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"planValidationOpa": {
"name": "no-resource-deletions",
"description": "Flag plans that delete resources",
"rego": "package ctrlplane\n\nimport rego.v1\n\ndeny contains msg if {\n input.agentType == \"terraform-cloud\"\n plan := json.unmarshal(input.proposed)\n change := plan.resource_changes[_]\n change.change.actions[_] == \"delete\"\n msg := sprintf(\"resource %q would be deleted\", [change.address])\n}"
}
}
]
}'
```
## Properties
Human-readable rule name. Shown in the check output to identify which rule
produced a violation.
Optional human-readable explanation of what the rule does.
Rego v1 source code. Must define a `deny` rule set following the
[Conftest](https://www.conftest.dev/) convention
(`deny contains msg if { ... }`). Each member of the `deny` set becomes one
violation message. An empty `deny` set means the plan passed.
## Writing the Rego
Policies must be **Rego v1** (include `import rego.v1`) and define a `deny` rule
set. The package name can be anything — ctrlplane detects it automatically.
```rego theme={null}
package ctrlplane
import rego.v1
deny contains msg if {
input.environment.name == "production"
msg := "changes to production require manual review"
}
```
* The set is **empty by default** → the plan passes.
* Every string you add to `deny` becomes a separate violation line.
* Use the OPA standard library (`json.unmarshal`, `yaml.unmarshal`, `contains`,
`sprintf`, regex, etc.) to parse and inspect the plan.
If a policy contains invalid Rego (for example, a syntax error), it cannot be
evaluated and no validation result is recorded for that plan. Test your
policies with the [OPA playground](https://play.openpolicyagent.org/) or
`opa eval` before saving them.
## The `input` document
Your Rego policy is evaluated against an `input` document with the following
fields:
| Field | Type | Description |
| ----------------------- | ------- | -------------------------------------------------------------------- |
| `input.current` | string | The currently deployed state (raw, agent-specific format) |
| `input.proposed` | string | The proposed state from the new version (raw, agent-specific format) |
| `input.hasChanges` | boolean | Whether the plan contains any changes |
| `input.agentType` | string | The agent that produced the plan (e.g. `argo-cd`, `terraform-cloud`) |
| `input.environment` | object | The target environment |
| `input.resource` | object | The target resource |
| `input.deployment` | object | The deployment |
| `input.proposedVersion` | object | The deployment version being planned (the new version) |
| `input.currentVersion` | object | The version currently deployed to this target (may be null) |
`input.current` and `input.proposed` are **strings**, not parsed objects,
because the format is agent-specific (Terraform JSON plan, rendered Kubernetes
YAML, etc.). Parse them inside your policy with `json.unmarshal` or
`yaml.unmarshal`.
## Examples
### Flag resource deletions in a Terraform plan
```rego theme={null}
package ctrlplane
import rego.v1
deny contains msg if {
input.agentType == "terraform-cloud"
plan := json.unmarshal(input.proposed)
change := plan.resource_changes[_]
change.change.actions[_] == "delete"
msg := sprintf("resource %q would be deleted", [change.address])
}
```
### Forbid hard-coded secrets in a rendered manifest
```rego theme={null}
package ctrlplane
import rego.v1
deny contains msg if {
contains(input.proposed, "SECRET=")
msg := "proposed manifest appears to contain a hard-coded secret"
}
```
### Require that a production change actually has a diff
```rego theme={null}
package ctrlplane
import rego.v1
deny contains "no changes detected for a production deploy" if {
input.environment.name == "production"
not input.hasChanges
}
```
### Flag a major version jump
```rego theme={null}
package ctrlplane
import rego.v1
deny contains msg if {
current := input.currentVersion.tag
proposed := input.proposedVersion.tag
startswith(current, "v1.")
startswith(proposed, "v2.")
msg := sprintf("major version jump %s -> %s", [current, proposed])
}
```
## Viewing results
When a plan completes, ctrlplane records each rule's result against the plan.
Results surface in two places:
* **GitHub check run** — if the version carries GitHub metadata, the deployment
check shows the plan diff and a **Policy violations** section listing each
failing rule and its messages. A violation marks the check as failed so it is
visible on the pull request, but it does not block the ctrlplane deployment.
* **ctrlplane UI** — the plan preview for a release target shows the diff
alongside any validation results.
## Best practices
* ✅ Use a clear `name` — it is how violations are labeled in the output.
* ✅ Scope rules with the policy `selector` so they only run where they matter
(e.g. production only).
* ✅ Branch on `input.agentType` when a policy is specific to Terraform or
ArgoCD output.
* ✅ Test Rego in the OPA playground before saving.
* ❌ Don't rely on plan validation to *stop* a deployment — it is a preview, not
a gate. Use [Approval](./approval) or
[Environment Progression](./environment-progression) for gating.
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Approval](./approval) - Require sign-off before deploying
* [Verification](./verification/overview) - Check metrics after deploying
# Retry
Source: https://docs.ctrlplane.dev/policies/retry
Learn how to configure automatic retry behavior for failed deployments.
**Retry rules** configure how Ctrlplane handles failed jobs. You can control the
number of retry attempts, which failure types trigger retries, and the backoff
strategy between attempts.
## Overview
```mermaid theme={null}
flowchart TD
A[Job Runs] --> B{Success?}
B -->|Yes| C[Complete]
B -->|No| D{Retries Left?}
D -->|Yes| E[Wait for Backoff]
E --> A
D -->|No| F[Failed]
```
## Why Use Retry Rules?
Retry rules help you:
* **Handle transient failures** - Automatically recover from temporary issues
* **Reduce manual intervention** - Let the system retry before alerting
* **Configure per-environment** - More retries in dev, fewer in production
* **Control retry behavior** - Set backoff strategies to avoid thundering herd
## Configuration
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Retry on Failure",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"retry": {
"maxRetries": 3
}
}
]
}'
```
Retry rules are not yet supported in the Terraform provider. Use the REST API
to configure retry behavior.
## Properties
Maximum retry attempts. `0` means no retries (1 attempt total), `3` means up
to 4 attempts (1 initial + 3 retries).
Job statuses that trigger a retry. Defaults to `["failure",
"invalidIntegration", "invalidJobAgent"]` when `maxRetries > 0`. When
`maxRetries = 0`, also includes `"successful"` to enforce deploy-once
semantics.
Seconds to wait between retry attempts. If not set, retries are allowed
immediately after job completion.
Backoff strategy: `linear` (constant delay) or `exponential` (doubling delay
with each retry using `backoffSeconds * 2^(attempt-1)`).
Maximum backoff cap in seconds (for exponential backoff). If not set, no
maximum is enforced.
## Job Statuses
The following job statuses can be used in `retryOnStatuses`:
| Status | Description |
| -------------------- | ------------------------------- |
| `failure` | Job failed during execution |
| `successful` | Job completed successfully |
| `cancelled` | Job was manually cancelled |
| `skipped` | Job was skipped |
| `invalidIntegration` | Integration configuration error |
| `invalidJobAgent` | Job agent configuration error |
Cancelled and skipped jobs never count toward the retry limit by default,
allowing redeployment after manual cancellation.
## Common Patterns
### Basic Retry
Retry failed jobs up to 3 times:
```json theme={null}
{
"retry": {
"maxRetries": 3
}
}
```
### Retry with Backoff
Wait between retry attempts:
```json theme={null}
{
"retry": {
"maxRetries": 3,
"backoffSeconds": 30
}
}
```
### Exponential Backoff
Increase wait time with each retry:
```json theme={null}
{
"retry": {
"maxRetries": 5,
"backoffSeconds": 10,
"backoffStrategy": "exponential",
"maxBackoffSeconds": 300
}
}
```
With exponential backoff, wait times are: 10s → 20s → 40s → 80s → 160s (capped
at 300s)
### No Retries (Deploy-Once)
Disable retries for critical deployments. When `maxRetries` is `0`, the default
`retryOnStatuses` also includes `"successful"`, enforcing deploy-once semantics:
```json theme={null}
{
"name": "No Retry Production",
"selector": "environment.name == 'production'",
"rules": [
{
"retry": { "maxRetries": 0 }
}
]
}
```
### Retry Specific Statuses
Only retry on specific failure types:
```json theme={null}
{
"retry": {
"maxRetries": 3,
"retryOnStatuses": ["failure", "invalidIntegration"],
"backoffSeconds": 60
}
}
```
### Environment-Specific Retry
Different retry behavior per environment:
```json theme={null}
[
{
"name": "Dev Retry",
"selector": "environment.name == 'development'",
"rules": [
{ "retry": { "maxRetries": 5, "backoffSeconds": 5 } }
]
},
{
"name": "Staging Retry",
"selector": "environment.name == 'staging'",
"rules": [
{ "retry": { "maxRetries": 3, "backoffSeconds": 30 } }
]
},
{
"name": "Production Retry",
"selector": "environment.name == 'production'",
"rules": [
{
"retry": {
"maxRetries": 2,
"backoffSeconds": 60,
"backoffStrategy": "exponential"
}
}
]
}
]
```
## Backoff Strategies
### Linear Backoff
Constant wait time between retries:
```
Attempt 1: immediate
Attempt 2: wait 30s
Attempt 3: wait 30s
Attempt 4: wait 30s
```
### Exponential Backoff
Doubling wait time with each retry:
```
Attempt 1: immediate
Attempt 2: wait 10s (10 * 2^0)
Attempt 3: wait 20s (10 * 2^1)
Attempt 4: wait 40s (10 * 2^2)
Attempt 5: wait 80s (10 * 2^3)
```
Use `maxBackoffSeconds` to cap the maximum wait time.
## Retry Lifecycle
### 1. Job Fails
A job completes with a status in `retryOnStatuses`.
### 2. Retry Check
Ctrlplane checks if retries remain (`attempt < maxRetries + 1`).
### 3. Backoff Wait
If `backoffSeconds` is configured, Ctrlplane waits before the next attempt. The
`nextEvaluationTime` is set to indicate when the retry will be allowed.
### 4. Retry Attempt
A new job is created for the retry attempt.
### 5. Success or Exhausted
The process continues until success or all retries are exhausted.
## Best Practices
### Retry Guidelines
| Scenario | Max Retries | Backoff | Strategy |
| -------------------- | ----------- | ------- | ----------- |
| Transient network | 3-5 | 10-30s | exponential |
| Rate limiting | 3 | 60s | exponential |
| Resource contention | 2-3 | 30s | linear |
| Critical production | 1-2 | 60s | linear |
| Flaky tests (dev/qa) | 5 | 5s | linear |
### Recommendations
* ✅ Use exponential backoff for external service failures
* ✅ Set `maxBackoffSeconds` to avoid excessive wait times
* ✅ Use fewer retries in production than in development
* ✅ Monitor retry rates to identify systemic issues
* ✅ Combine with alerting on final failure
### Anti-Patterns
* ❌ Infinite retries (always set `maxRetries`)
* ❌ No backoff for rate-limited APIs
* ❌ Same retry config across all environments
* ❌ Retrying on non-transient failures
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Environment Progression](./environment-progression) - Control promotion flow
* [Version Cooldown](./version-cooldown) - Batch frequent releases
# Overview
Source: https://docs.ctrlplane.dev/policies/verification/overview
Learn how to use verification to validate that a deployment is healthy after it completes.
**Verification** allows you to validate that a deployment is healthy after it
completes. Ctrlplane can automatically run verification checks by querying
metrics from external providers and evaluating success conditions.
## Overview
```mermaid theme={null}
flowchart TD
A[Job Completed] --> B{Policy has verification?}
B -->|No| C[Release Continues]
B -->|Yes| D[Run Verification]
D --> E[Query Metrics from Provider]
E --> F[Evaluate CEL Success Condition]
F --> G{Result}
G -->|Pass| H[✓ Promote Release]
G -->|Fail| I[✗ Trigger Rollback / Stop Promotion]
```
## Why Use Verification?
Verification helps you:
* **Catch Issues Early** - Detect problems before they impact users
* **Automate Rollbacks** - Trigger rollback policies when verification fails
* **Build Confidence** - Ensure deployments meet quality standards
* **Gate Promotions** - Block progression to production until QA verifies
* **Environment-Specific Checks** - Run different verifications per environment
## Basic Configuration
Add a verification rule to your policy:
```yaml theme={null}
policies:
- name: qa-smoke-tests
description: Run E2E smoke tests in QA before promotion
selectors:
- environment: environment.name == "qa"
rules:
- verification:
metrics:
- name: e2e-smoke-tests
interval: 30s
count: 5
provider:
type: http
url: "http://e2e-runner.qa/run?service={{.resource.name}}"
successCondition: result.ok && result.json.passed == true
failureLimit: 1
```
## Environment-Specific Verifications
Different environments can have completely different verification requirements:
```yaml theme={null}
policies:
# QA: Run E2E smoke tests
- name: qa-verification
selectors:
- environment: environment.name == "qa"
rules:
- verification:
metrics:
- name: e2e-smoke-tests
interval: 1m
count: 3
provider:
type: http
url: "http://e2e-runner/smoke?env=qa&service={{.resource.name}}"
successCondition: result.json.all_passed == true
# Staging: Check error rates and latency
- name: staging-verification
selectors:
- environment: environment.name == "staging"
rules:
- verification:
metrics:
- name: error-rate
interval: 30s
count: 10
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
errors: "sum:errors{service:{{.resource.name}},env:staging}.as_rate()"
successCondition: result.queries.errors < 0.01
failureLimit: 2
# Production: Comprehensive health checks
- name: production-verification
selectors:
- environment: environment.name == "production"
rules:
- verification:
metrics:
- name: error-rate
interval: 1m
count: 10
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
errors: "sum:errors{service:{{.resource.name}},env:prod}.as_rate()"
successCondition: result.queries.errors < 0.005
failureLimit: 2
- name: p99-latency
interval: 1m
count: 10
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
latency: "avg:latency.p99{service:{{.resource.name}},env:prod}"
successCondition: result.queries.latency < 200
failureLimit: 2
```
## Reusable Verification with Selectors
Use policy selectors to apply the same verification across multiple deployments
or environments:
```yaml theme={null}
policies:
# Apply to all backend services
- name: backend-health-verification
selectors:
- deployment: deployment.metadata.serviceType == "backend"
rules:
- verification:
metrics:
- name: health-check
interval: 30s
count: 5
provider:
type: http
url: "http://{{.resource.name}}/health"
successCondition: result.ok
# Apply to all services with canary deployments
- name: canary-verification
selectors:
- deployment: deployment.metadata.deploymentStrategy == "canary"
rules:
- verification:
metrics:
- name: canary-error-comparison
interval: 2m
count: 5
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
canary: "sum:errors{service:{{.resource.name}},version:canary}.as_rate()"
stable: "sum:errors{service:{{.resource.name}},version:stable}.as_rate()"
formula: "canary / stable"
successCondition:
result.queries.canary < result.queries.stable * 1.1
```
## Progressive Delivery Gates
Use verification to gate promotion through environments:
```yaml theme={null}
policies:
# QA must pass smoke tests before staging
- name: qa-gate
selectors:
- environment: environment.name == "qa"
rules:
- verification:
metrics:
- name: smoke-tests
interval: 30s
count: 3
provider:
type: http
url: "http://smoke-test-runner/run"
method: POST
body: |
{
"service": "{{.resource.name}}",
"version": "{{.version.tag}}",
"environment": "qa"
}
successCondition: result.json.status == "passed"
failureLimit: 0
# Staging must pass before production
- name: staging-gate
selectors:
- environment: environment.name == "staging"
rules:
- verification:
metrics:
- name: integration-tests
interval: 1m
count: 5
provider:
type: http
url: "http://integration-runner/run?service={{.resource.name}}"
successCondition:
result.json.passed_count == result.json.total_count
```
## Metric Configuration
### Metric Properties
Unique name for this verification metric. Used for identification in logs and
UI.
Seconds between each measurement. For example, `30` means check every 30
seconds.
Total number of measurements to take. Combined with `intervalSeconds`, this
determines the verification duration.
Configuration for the metric provider (HTTP, Datadog, etc.). See
provider-specific documentation for available options.
CEL expression evaluated against the provider response. Returns `true` for
success. Example: `result.ok && result.statusCode == 200`
Optional CEL expression for explicit failure. If matched, verification fails
immediately without waiting for more measurements.
Number of consecutive failures allowed before the metric is considered failed.
Set to `0` for no tolerance (fail on first failure).
Number of consecutive successes required before the metric is considered
passed.
## Metric Providers
Ctrlplane supports multiple metric providers for collecting verification data.
Each provider has its own configuration and capabilities:
* **[HTTP Provider](../../integrations/verification-providers/http)** - Query any HTTP endpoint
that returns JSON
* **[Datadog Provider](../../integrations/verification-providers/datadog)** - Query metrics from
Datadog's Metrics API
* **[Sleep Provider](../../integrations/verification-providers/sleep)** - Wait for a specified
duration before considering verification passed
* **[Terraform Cloud Run Provider](../../integrations/verification-providers/terraform-cloud-run)** -
Verify Terraform Cloud run status for infrastructure deployments
See the individual provider documentation for detailed configuration options,
examples, and best practices.
## Template Variables
Provider configurations, success conditions, and failure conditions all support
Go templates with access to deployment context:
```yaml theme={null}
# Resource information
{{.resource.name}}
{{.resource.identifier}}
{{.resource.kind}}
# Environment information
{{.environment.name}}
{{.environment.id}}
# Deployment information
{{.deployment.name}}
{{.deployment.slug}}
# Version information
{{.version.tag}}
{{.version.id}}
# Custom variables (from deployment variables)
{{.variables.my_variable}}
{{.variables.dd_api_key}}
```
### Templated Conditions
Success and failure conditions can also use Go templates, which are rendered
before CEL evaluation:
```yaml theme={null}
metrics:
- name: resource-check
interval: 30s
count: 5
provider:
type: http
url: "http://api.internal/health/{{.resource.name}}"
successCondition: result.json.name == "{{.resource.name}}"
failureCondition: result.json.env != "{{.environment.name}}"
```
### Storing Secrets in Variables
For sensitive values like API keys, use deployment variables:
1. **Create a deployment variable**:
```bash theme={null}
curl -X POST https://your-ctrlplane-instance.com/api/v1/deployments/{deploymentId}/variables \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-d '{"key": "dd_api_key", "description": "Datadog API key"}'
```
2. **Set the value**:
```bash theme={null}
curl -X POST https://your-ctrlplane-instance.com/api/v1/deployments/{deploymentId}/variables/{variableId}/values \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-d '{"value": "your-actual-api-key"}'
```
3. **Reference in verification config**:
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.variables.dd_api_key}}"
appKey: "{{.variables.dd_app_key}}"
queries:
errors: "sum:errors{service:api}"
```
## Success Conditions (CEL)
Success conditions are written in
[CEL (Common Expression Language)](https://github.com/google/cel-spec). The
measurement data is available as the `result` variable.
```yaml theme={null}
# Boolean check (HTTP provider)
successCondition: result.ok
# Numeric comparison (HTTP provider)
successCondition: result.json.value < 0.01
# Datadog provider - access queries by name
successCondition: result.queries.errors < 0.01
# String comparison
successCondition: result.json.status == "healthy"
# Logical operators
successCondition: result.ok && result.json.ready
successCondition: result.json.status == "healthy" || result.json.status == "degraded"
# Arithmetic
successCondition: result.json.success_count / result.json.total_count > 0.99
# Datadog - compare multiple queries
successCondition: result.queries.canary < result.queries.stable * 1.1
```
## Verification Lifecycle
### 1. Policy Evaluation
When a job completes, Ctrlplane evaluates policies to determine which
verifications apply based on the policy selectors.
### 2. Verification Starts
If a matching policy has verification rules, Ctrlplane creates a verification
record and starts the measurement process.
### 3. Measurements Taken
For each configured metric, measurements are taken at the specified interval:
```
Metric: error-rate (interval: 30s, count: 10)
t+0s: Measurement 1 → Passed (value: 0.005)
t+30s: Measurement 2 → Passed (value: 0.007)
t+60s: Measurement 3 → Failed (value: 0.015)
t+90s: Measurement 4 → Passed (value: 0.008)
...
t+270s: Measurement 10 → Passed (value: 0.006)
```
### 4. Verification Result
* **Passed**: All measurements passed, or failures stayed below `failureLimit`
* **Failed**: Failures exceeded `failureLimit`
### 5. Policy Action
Based on the verification result, the policy can:
* **Allow promotion** to the next environment
* **Trigger rollback** to a previous version
* **Block release** until manual intervention
### Verification Status
| Status | Description |
| ----------- | --------------------------------------------- |
| `running` | Verification in progress, taking measurements |
| `passed` | All checks passed within acceptable limits |
| `failed` | Too many measurements failed |
| `cancelled` | Verification was manually cancelled |
## Best Practices
### Timing Recommendations
| Scenario | Recommended Interval | Recommended Count |
| --------------------- | -------------------- | ----------------- |
| Quick smoke test | 10-30s | 3-5 |
| Standard verification | 30s-1m | 5-10 |
| Extended soak test | 5m | 12-24 |
### Failure Limits
| Risk Tolerance | Failure Limit | Notes |
| -------------- | ------------- | ---------------------- |
| Strict | 1 | Fail on first failure |
| Normal | 2-3 | Allow transient issues |
| Lenient | 5+ | For noisy metrics |
### Environment-Specific Recommendations
| Environment | Verification Focus | Timing |
| ----------- | ----------------------------------- | -------------- |
| QA | Smoke tests, E2E tests | Quick (1-3min) |
| Staging | Integration tests, error rates | Medium (5min) |
| Production | Error rates, latency, business KPIs | Extended (10m) |
## Troubleshooting
### Verification always fails
* Check if the provider can reach the target (network, DNS)
* Verify API credentials are correct
* Test the query manually
* Review measurement data for unexpected values
* Check if success condition is too strict
### Verification not running
* Verify the policy selector matches the release target
* Check that the policy is enabled
* Review policy evaluation logs
* Ensure verification is configured in the policy rules
### Wrong verification applied
* Review policy selectors
* Check policy priority/ordering
* Verify environment and metadata values
* Review which policies matched the release
## Provider Documentation
For detailed information about each metric provider, see:
* [HTTP Provider](../../integrations/verification-providers/http)
* [Datadog Provider](../../integrations/verification-providers/datadog)
* [Sleep Provider](../../integrations/verification-providers/sleep)
* [Terraform Cloud Run Provider](../../integrations/verification-providers/terraform-cloud-run)
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Selectors](../concepts/selectors) - Deep dive into selector syntax
# Version Cooldown
Source: https://docs.ctrlplane.dev/policies/version-cooldown
Learn how to use version cooldown rules to batch frequent releases and prevent rapid sequential deployments.
**Version cooldown rules** prevent rapid sequential deployments by requiring a
minimum time period to pass since the currently deployed (or in-progress)
version was created before allowing another deployment. This helps batch
frequent upstream releases into periodic deployments, reducing deployment churn
and infrastructure load.
## Why Use Version Cooldown?
Version cooldown helps you:
* **Reduce deployment frequency** - Batch multiple rapid releases into fewer
deployments
* **Decrease infrastructure load** - Avoid constant rolling updates from CI/CD
pipelines
* **Improve stability** - Give each deployment time to prove itself before the
next
* **Save resources** - Reduce compute spent on deployment overhead
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "batch_deployments" {
name = "Batch Deployments"
selector = "environment.name == 'production'"
version_cooldown {
duration = "1h"
}
}
```
The Terraform provider accepts a Go duration string for `duration` (e.g.,
`"1h"`, `"30m"`, `"90s"`). This is converted to seconds internally.
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Batch Deployments",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"versionCooldown": {
"intervalSeconds": 3600
}
}
]
}'
```
The API accepts `intervalSeconds` as an integer representing the minimum seconds
that must pass.
## Properties
Minimum seconds that must pass since the currently deployed (or in-progress)
version was created before allowing another deployment. Set to `0` to disable.
## How It Works
Version cooldown checks if enough **time has elapsed** since the currently
deployed (or in-progress) version was created, not the time gap between version
creation times:
1. **Find reference version**: The currently deployed or in-progress version
(in-progress takes precedence)
2. **Calculate elapsed time**: Time since the reference version was created
(using current time)
3. **Apply cooldown**: If elapsed time >= interval, allow any version;
otherwise, deny
### Example Timeline
```
Version Created: v1.0 ────── v1.1 ── v1.2 ── v1.3 ────── v1.4
12:00 12:15 12:20 12:25 13:00
With intervalSeconds: 3600 (1 hour)
Currently deployed: v1.0 (created 12:00)
At 12:30 (30min elapsed since v1.0):
Candidate v1.3 (created 12:25): DENIED - only 30min elapsed (need 60min)
Candidate v1.2 (created 12:20): DENIED - only 30min elapsed
Candidate v1.1 (created 12:15): DENIED - only 30min elapsed
At 13:05 (65min elapsed since v1.0):
Candidate v1.3 (created 12:25): ALLOWED - 65min elapsed (>= 60min)
Candidate v1.2 (created 12:20): ALLOWED - 65min elapsed
Candidate v1.1 (created 12:15): ALLOWED - 65min elapsed
Candidate v1.4 (created 13:00): ALLOWED - 65min elapsed
```
Once the cooldown period has elapsed, **any** version can be deployed,
regardless of when it was created. This enables batching rapid releases.
## Common Patterns
### Hourly Batching
Deploy at most once per hour:
```hcl theme={null}
resource "ctrlplane_policy" "hourly_deployments" {
name = "Hourly Deployments"
selector = "environment.name == 'production'"
version_cooldown {
duration = "1h"
}
}
```
```json theme={null}
{
"name": "Hourly Deployments",
"selector": "environment.name == 'production'",
"rules": [
{ "versionCooldown": { "intervalSeconds": 3600 } }
]
}
```
### Per-Environment Cooldown
Different intervals for different environments:
```hcl theme={null}
resource "ctrlplane_policy" "staging_cooldown" {
name = "Staging Cooldown"
selector = "environment.name == 'staging'"
version_cooldown {
duration = "15m"
}
}
resource "ctrlplane_policy" "production_cooldown" {
name = "Production Cooldown"
selector = "environment.name == 'production'"
version_cooldown {
duration = "1h"
}
}
```
```json theme={null}
[
{
"name": "Staging Cooldown",
"selector": "environment.name == 'staging'",
"rules": [
{ "versionCooldown": { "intervalSeconds": 900 } }
]
},
{
"name": "Production Cooldown",
"selector": "environment.name == 'production'",
"rules": [
{ "versionCooldown": { "intervalSeconds": 3600 } }
]
}
]
```
### Combined with Other Rules
Use cooldown alongside other policy rules:
```hcl theme={null}
resource "ctrlplane_policy" "production_controlled_release" {
name = "Production Controlled Release"
selector = "environment.name == 'production'"
version_cooldown {
duration = "30m"
}
any_approval {
min_approvals = 1
}
gradual_rollout {
rollout_type = "linear"
time_scale_interval = 300
}
}
```
### With Deployment Windows
Combine cooldown with deployment windows for comprehensive control:
```hcl theme={null}
resource "ctrlplane_policy" "controlled_production" {
name = "Controlled Production"
selector = "environment.name == 'production'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
duration_minutes = 480
timezone = "America/New_York"
}
version_cooldown {
duration = "2h"
}
}
```
### Weekly Scheduled Deployments
Deploy updates on a specific day (e.g., every Monday):
```hcl theme={null}
resource "ctrlplane_policy" "monday_deployments" {
name = "Monday Deployments"
selector = "deployment.name == 'datadog-agent'"
deployment_window {
rrule = "FREQ=WEEKLY;BYDAY=MO;BYHOUR=0;BYMINUTE=0"
duration_minutes = 1440
timezone = "America/New_York"
allow_window = true
}
version_cooldown {
duration = "120h"
}
gradual_rollout {
rollout_type = "linear-normalized"
time_scale_interval = 82800
}
}
```
## Behavior Details
### Version Selection
When a candidate version fails cooldown:
1. Ctrlplane tries the next older version
2. This continues until a qualifying version is found
3. If no versions qualify, the release target waits
### Same Version Redeploys
Redeploying the currently deployed version is always allowed, regardless of
cooldown settings. This enables:
* Manual re-runs of failed deployments
* Rollback-and-redeploy workflows
* **Configuration-only changes** - Deploy the same version with updated
configuration immediately, bypassing cooldown
### Urgent Deployments
For urgent deployments that need to bypass cooldown (e.g., security patches,
critical fixes):
* **Same-version redeploy**: If the urgent change uses the same version ID, it
automatically bypasses cooldown
* **Policy Skip**: Create a PolicySkip to bypass cooldown for a specific
version. This allows urgent deployments while maintaining cooldown for regular
releases
### In-Progress Deployments
If a deployment is in progress, the cooldown uses that version as the reference:
* Prevents deploying a newer version while one is still rolling out
* Ensures sequential deployments respect the interval
## Best Practices
### Interval Guidelines
| Use Case | Suggested Interval | Notes |
| -------------------- | ------------------ | -------------------------- |
| High-frequency CI/CD | 15-30 minutes | Balance freshness vs churn |
| Standard services | 1-2 hours | Reasonable batching |
| Stable/low-priority | 4-24 hours | Significant batching |
| Development/staging | 5-15 minutes | Faster feedback loops |
### Recommendations
* ✅ Start with shorter intervals and increase as needed
* ✅ Use longer intervals for production vs staging
* ✅ Combine with gradual rollouts for safer deployments
* ✅ Monitor deployment frequency to tune intervals
* ✅ Use same-version redeploys for urgent configuration changes
* ✅ Use PolicySkip for urgent deployments that need to bypass cooldown
* ❌ Don't set intervals so long that critical fixes are delayed
* ❌ Don't use cooldown on environments that need immediate updates
* ⚠️ Ensure deployment windows are long enough for gradual rollouts to complete
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Gradual Rollouts](./gradual-rollouts) - Control deployment pace
* [Deployment Window](./deployment-window) - Time-based deployment control
# Version Selector
Source: https://docs.ctrlplane.dev/policies/version-selector
Learn how to use version selector rules to control which versions can be deployed to specific environments.
**Version selector rules** filter which deployment versions are allowed to
deploy to matching environments. Use them to restrict production to stable
releases, enforce naming conventions, or block specific versions.
## Overview
```mermaid theme={null}
flowchart TD
A[Version Created] --> B{Matches Selector?}
B -->|Yes| C[Allowed to Deploy]
B -->|No| D[Blocked]
```
## Why Use Version Selectors?
Version selector rules help you:
* **Enforce release channels** - Only stable versions in production
* **Block bad versions** - Prevent known-bad releases from deploying
* **Naming conventions** - Require specific version formats
* **Feature flags** - Control rollout of experimental features
* **Context-aware filtering** - Use environment, resource, or deployment data
to make dynamic version decisions
## Configuration
```hcl theme={null}
resource "ctrlplane_policy" "production_stable" {
name = "Production Stable Only"
selector = "environment.name == 'production'"
version_selector {
selector = "!version.tag.contains('-rc')"
description = "Only stable versions (no release candidates)"
}
}
```
```bash theme={null}
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Stable Only",
"selector": "environment.name == '\''production'\''",
"rules": [
{
"versionSelector": {
"selector": "!version.tag.contains('\''-rc'\'')",
"description": "Only stable versions (no release candidates)"
}
}
]
}'
```
## Properties
A CEL expression (string) or JSON selector (object) to match allowed versions.
CEL expressions have access to `version`, `environment`, `resource`, and
`deployment` variables, enabling context-aware version filtering.
Human-readable explanation of the rule. Shown to users when a version is
blocked.
## CEL Expression Variables
When using CEL expressions, you have access to the following variables:
| Variable | Type | Description |
| ------------- | ---- | -------------------------------------- |
| `version` | map | The deployment version being evaluated |
| `environment` | map | The target environment |
| `resource` | map | The target resource |
| `deployment` | map | The deployment |
### Version Fields
| Field | Type | Description |
| ------------------- | ------ | ------------------------------ |
| `version.tag` | string | Version tag (e.g., "v1.2.3") |
| `version.metadata` | map | Custom metadata on the version |
| `version.createdAt` | string | When the version was created |
| `version.status` | string | Version status (e.g., "ready") |
### Environment Fields
| Field | Type | Description |
| ---------------------- | ------ | ---------------- |
| `environment.name` | string | Environment name |
| `environment.metadata` | map | Custom metadata |
### Resource Fields
| Field | Type | Description |
| ------------------- | ------ | --------------- |
| `resource.name` | string | Resource name |
| `resource.metadata` | map | Custom metadata |
### Deployment Fields
| Field | Type | Description |
| --------------------- | ------ | --------------- |
| `deployment.name` | string | Deployment name |
| `deployment.metadata` | map | Custom metadata |
The CEL environment includes standard extensions (string functions, math, etc.)
and all entity fields are accessible as map keys.
## Common Patterns
### Stable Versions Only
Block pre-release versions from production:
```json theme={null}
{
"name": "Production Stable",
"selector": "environment.name == 'production'",
"rules": [
{
"versionSelector": {
"selector": "!version.tag.contains('-')",
"description": "No pre-release versions (no hyphens in tag)"
}
}
]
}
```
### Semantic Version Pattern
Require semantic versioning format:
```json theme={null}
{
"versionSelector": {
"selector": "version.tag.matches('^v[0-9]+\\\\.[0-9]+\\\\.[0-9]+$')",
"description": "Must be semantic version (vX.Y.Z)"
}
}
```
### Release Channel by Metadata
Use version metadata for release channels:
```json theme={null}
[
{
"name": "Production Channel",
"selector": "environment.name == 'production'",
"rules": [
{
"versionSelector": {
"selector": "version.metadata['channel'] == 'stable'",
"description": "Only stable channel versions"
}
}
]
},
{
"name": "Staging Channels",
"selector": "environment.name == 'staging'",
"rules": [
{
"versionSelector": {
"selector": "version.metadata['channel'] in ['stable', 'beta']",
"description": "Stable and beta channels allowed"
}
}
]
}
]
```
### Major Version Restriction
Restrict major version changes:
```json theme={null}
{
"versionSelector": {
"selector": "version.tag.startsWith('v2.')",
"description": "Only v2.x versions allowed"
}
}
```
### Context-Aware Version Filtering
Use environment or resource data to dynamically filter versions. This is
uniquely powerful because the selector has access to all four entity types:
```json theme={null}
{
"name": "Region-Specific Versions",
"selector": "environment.name == 'production'",
"rules": [
{
"versionSelector": {
"selector": "resource.metadata['region'] != 'us-east-1' || version.metadata['us_east_certified'] == 'true'",
"description": "US-East-1 requires certified versions"
}
}
]
}
```
### Branch-Based Filtering
Only deploy versions from the main branch:
```json theme={null}
{
"versionSelector": {
"selector": "version.metadata['branch'] == 'main'",
"description": "Only deploy versions from main branch"
}
}
```
### Feature Flag Versions
Control feature rollout by version metadata and resource:
```json theme={null}
{
"name": "New UI Rollout",
"selector": "environment.name == 'production'",
"rules": [
{
"versionSelector": {
"selector": "resource.metadata['region'] == 'us-east-1' || !has(version.metadata['feature_new_ui']) || version.metadata['feature_new_ui'] != 'true'",
"description": "New UI only enabled for us-east-1"
}
}
]
}
```
## JSON Selector Format
As an alternative to CEL expressions, you can use JSON selectors that match
against the version object:
```json theme={null}
{
"versionSelector": {
"selector": {
"matchExpression": [
{
"key": "tag",
"operator": "DoesNotContain",
"value": "-rc"
}
]
},
"description": "Only stable versions (no release candidates)"
}
}
```
### JSON Selector Operators
| Operator | Description | Example |
| ---------------- | ----------------------- | ------------------------------- |
| `Equals` | Exact match | `tag Equals "v1.0.0"` |
| `NotEquals` | Not equal | `tag NotEquals "v1.0.0"` |
| `In` | Value in list | `tag In ["v1.0.0", "v1.0.1"]` |
| `NotIn` | Value not in list | `tag NotIn ["v1.0.0"]` |
| `Contains` | String contains | `tag Contains "beta"` |
| `DoesNotContain` | String does not contain | `tag DoesNotContain "rc"` |
| `StartsWith` | String starts with | `tag StartsWith "v2."` |
| `EndsWith` | String ends with | `tag EndsWith "-stable"` |
| `Matches` | Regex match | `tag Matches "^v[0-9]+"` |
| `Exists` | Field exists | `metadata.approved Exists` |
| `DoesNotExist` | Field does not exist | `metadata.blocked DoesNotExist` |
JSON selectors only match against the `version` object. For cross-entity
filtering (e.g., using environment or resource data), use CEL expressions
instead.
## Best Practices
### Environment Guidelines
| Environment | Version Policy |
| ----------- | ------------------ |
| Development | Allow all versions |
| QA | Allow all or beta+ |
| Staging | Stable and beta |
| Production | Stable only |
### Recommendations
* ✅ Use `description` to explain why versions are restricted
* ✅ Prefer CEL expressions for context-aware filtering (access to environment,
resource, deployment)
* ✅ Use metadata for release channels instead of parsing tags
* ✅ Document blocked versions with links to issues
* ✅ Test selectors in lower environments first
* ✅ Start permissive and tighten over time
### Anti-Patterns
* ❌ Overly complex regex patterns
* ❌ Blocking without documentation
* ❌ Inconsistent version tagging conventions
* ❌ Forgetting to update blocked version lists
## Next Steps
* [Policies Overview](./overview) - Learn about policy structure
* [Environment Progression](./environment-progression) - Control promotion flow
* [Version Cooldown](./version-cooldown) - Batch frequent releases
# Quickstart
Source: https://docs.ctrlplane.dev/quickstart
Set up deployment orchestration with environment promotion and verification in 15 minutes.
This guide walks you through setting up a complete deployment pipeline with
staging → production promotion and automated verification. By the end, you'll
have a working example of Ctrlplane's core capabilities.
## What You'll Build
```mermaid theme={null}
flowchart LR
CI["CI (build)"] --> Staging
subgraph Staging["Staging"]
direction TB
S1["Deploy"] --> S2["Verify"]
end
Staging --> Production
subgraph Production["Production"]
direction TB
P0["Approval"] --> P1["Deploy"] --> P2["Verify"]
end
```
* **Deployment** with automatic version creation from CI
* **Two environments** (staging, production) with resource selectors
* **Verification** that checks deployment health before promotion
* **Approval policy** requiring sign-off for production
## Prerequisites
* Ctrlplane account ([self-hosted](./installation))
* API key (Settings → API Keys)
* GitHub repository with CI workflow
## Step 1: Create a System
A system groups related deployments. This is typically a product, platform, or
bounded context.
```hcl Terraform theme={null}
resource "ctrlplane_system" "quickstart" {
name = "Tutorial Quickstart"
description = "A tutorial on how to use Ctrlplane found in the Quickstart guide."
}
```
## Step 2: Register Resources
Resources are your deployment targets. In production, you'd sync these from
Kubernetes or cloud providers. For this quickstart, we'll create them manually.
```yaml YAML theme={null}
# ctrlc apply -f resource.yaml
---
type: Resource
identifier: tutorial-quickstart-resource-staging
kind: KubernetesCluster
name: quickstart-staging-cluster
version: tutorial/quickstart/v1
metadata:
tutorial: quickstart
environment: staging
region: us-east-1
config:
number: 1
string: "one"
boolean: true
array:
- one
- two
- three
---
type: Resource
identifier: tutorial-quickstart-resource-production
kind: KubernetesCluster
name: quickstart-production-cluster
version: tutorial/quickstart/v1
metadata:
tutorial: quickstart
environment: production
region: us-east-1
config:
number: 1
string: "one"
boolean: true
array:
- one
- two
- three
```
```hcl Terraform theme={null}
resource "ctrlplane_resource" "quickstart_staging" {
identifier = "tutorial-quickstart-resource-staging"
kind = "KubernetesCluster"
name = "quickstart-staging-cluster"
version = "tutorial/quickstart/v1"
metadata = {
tutorial = "quickstart"
environment = "staging"
region = "us-east-1"
}
config = {
number = 1
string = "one"
boolean = true
array = ["one", "two", "three"]
}
}
resource "ctrlplane_resource" "quickstart_production" {
identifier = "tutorial-quickstart-resource-production"
kind = "KubernetesCluster"
name = "quickstart-production-cluster"
version = "tutorial/quickstart/v1"
metadata = {
tutorial = "quickstart"
environment = "production"
region = "us-east-1"
}
config = {
number = 1
string = "one"
boolean = true
array = ["one", "two", "three"]
}
}
```
```bash theme={null}
ctrlc apply -f https://raw.githubusercontent.com/ctrlplanedev/ctrlplane/main/examples/quickstart/2-resources.yaml
```
## Step 3: Create Environments
Environments use selectors to dynamically match resources. When you add new
clusters with matching metadata, they're automatically included.
```hcl Terraform theme={null}
resource "ctrlplane_environment" "staging" {
name = "Staging"
description = "Pre-production validation"
resource_selector = "resource.metadata['environment'] == 'staging'"
}
resource "ctrlplane_environment" "production" {
name = "Production"
description = "Live production environment"
resource_selector = "resource.metadata['environment'] == 'production'"
}
```
## Step 4: Create a Job Agent
Job agents execute your deployments. We'll use a simple test runner, but
Ctrlplane supports GitHub Actions, Kubernetes jobs, ArgoCD, or custom agents.
```hcl Terraform theme={null}
resource "ctrlplane_job_agent" "quickstart" {
name = "Tutorial Quickstart Job Agent"
description = "A tutorial on how to use Ctrlplane found in the Quickstart guide."
test_runner {
sleep = "5s"
}
}
```
```bash theme={null}
ctrlc apply -f https://raw.githubusercontent.com/ctrlplanedev/ctrlplane/main/examples/quickstart/4-job-agent.yaml
```
## Step 5: Create a Deployment
A deployment represents your application. The job agent config tells Ctrlplane
how to trigger deployments.
```hcl Terraform theme={null}
# Production environment
resource "ctrlplane_deployment" "quickstart" {
name = "Tutorial Quickstart Deployment"
description = "A tutorial on how to use Ctrlplane found in the Quickstart guide."
job_agent {
id = ctrlplane_job_agent.quickstart.id
}
}
```
## Step 6: Add Deployment Workflow
Create `.github/workflows/deploy.yml` in your repository:
```yaml theme={null}
name: Deploy
on:
workflow_dispatch:
inputs:
job_id:
description: "Ctrlplane Job ID"
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get deployment context
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
job_id: ${{ inputs.job_id }}
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Deploy to Kubernetes
run: |
echo "Deploying ${{ steps.job.outputs.version_tag }}"
echo "Environment: ${{ steps.job.outputs.environment_name }}"
echo "Cluster: ${{ steps.job.outputs.resource_identifier }}"
# Your deployment logic here
# kubectl set image deployment/api-gateway \
# api-gateway=${{ steps.job.outputs.version_tag }}
```
Add `CTRLPLANE_API_KEY` to your repository secrets.
## Step 7: Integrate CI Build
Add version creation to your build workflow (`.github/workflows/build.yml`):
```yaml theme={null}
- name: Install Ctrlplane CLI
uses: ctrlplanedev/cli@main
with:
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Create deployment version
if: github.ref == 'refs/heads/main'
run: |
ctrlc api upsert version \
--workspace \
--deployment ${{ secrets.CTRLPLANE_DEPLOYMENT_ID }} \
--tag ${{ github.sha }} \
--name "${{ github.sha::7 }}" \
--metadata github/owner=${{ github.repository_owner }} \
--metadata github/repo=${{ github.event.repository.name }} \
--metadata git/sha=${{ github.sha }} \
--metadata git/branch=${{ github.ref_name }} \
--metadata github/run-number=${{ github.run_number }} \
--metadata github/run-id=${{ github.run_id }} \
--metadata github/run-attempt=${{ github.run_attempt }}
```
## Step 8: Add Production Approval
Create a policy requiring approval before production deployments
```hcl Terraform theme={null}
resource "ctrlplane_policy" "production_approval" {
name = "Production Approval Policy"
description = "Production Approval Policy"
selector {
environments = "environment.metadata['requires-approval'] == 'true'"
}
approval {
required = 1
}
}
```
## Step 9: Test the Pipeline
1. Push a commit to `main`
2. CI builds and creates a version in Ctrlplane
3. Ctrlplane creates releases for staging and production
4. Staging deployment executes immediately
5. Verification runs health checks
6. Production waits for approval
7. After approval, production deploys and verifies
View the pipeline in the Ctrlplane UI:
* **Deployments** → See version progression across environments
* **Releases** → Track release progression across environments
* **Jobs** → View execution details and logs
## What You've Built
✅ **Deployment orchestration** with automatic environment progression\
✅ **Resource inventory** with metadata-based environment selectors\
✅ **Verification** ensuring deployment health before promotion\
✅ **Policy gates** requiring approval for production
## Next Steps
Configure gradual rollouts, concurrency limits, and custom gates
Add Datadog metrics, custom HTTP checks, and more
Sync resources from Kubernetes, AWS, or custom providers
Deploy with GitHub Actions, ArgoCD, or custom agents
## Troubleshooting
**Jobs not being created:**
* Verify resource metadata matches environment selectors
* Check deployment has a job agent configured
* Review policy denials in the Releases view
**Verification failing:**
* Test the health endpoint manually
* Check the success condition syntax
* Review measurement data in the verification details
**GitHub workflow not triggering:**
* Ensure job agent type is `github`
* Verify workflow filename matches `jobAgentConfig`
* Check GitHub App permissions
Need help?
[GitHub Discussions](https://github.com/ctrlplanedev/ctrlplane/discussions)
# CEL Expression Language
Source: https://docs.ctrlplane.dev/reference/cel
Reference guide for Common Expression Language (CEL) in Ctrlplane
Ctrlplane uses [CEL (Common Expression Language)](https://github.com/google/cel-spec)
for writing powerful selectors and matching expressions. CEL is a simple, fast,
and safe expression language developed by Google.
## Overview
CEL expressions are used in:
* **Resource selectors** — Match resources to environments
* **Policy selectors** — Target policies to specific releases
* **Relationship rules** — Define how entities connect
```yaml theme={null}
# Example: Environment resource selector
resourceSelector: resource.metadata["environment"] == "production"
# Example: Policy selector
selector: environment.name == "Production" && deployment.metadata["critical"] == "true"
```
## Basic Syntax
### Accessing Fields
```cel theme={null}
# Direct field access
resource.name
resource.kind
resource.identifier
# Metadata access (map)
resource.metadata["environment"]
resource.metadata["region"]
# Config access
resource.config["namespace"]
```
### Comparison Operators
| Operator | Description | Example |
| -------- | ---------------- | -------------------------------------- |
| `==` | Equal | `resource.kind == "KubernetesCluster"` |
| `!=` | Not equal | `resource.metadata["env"] != "dev"` |
| `<` | Less than | `resource.metadata["priority"] < "5"` |
| `>` | Greater than | `resource.metadata["replicas"] > "1"` |
| `<=` | Less or equal | `version.metadata["build"] <= "100"` |
| `>=` | Greater or equal | `resource.metadata["tier"] >= "2"` |
### Logical Operators
| Operator | Description | Example |
| -------- | ----------- | ---------------------------------- |
| `&&` | Logical AND | `a == "x" && b == "y"` |
| `\|\|` | Logical OR | `a == "x" \|\| a == "y"` |
| `!` | Logical NOT | `!resource.metadata["deprecated"]` |
### Arithmetic Operators
| Operator | Description | Example |
| -------- | ------------------------ | --------------------------- |
| `+` | Addition / Concatenation | `"prefix-" + resource.name` |
| `-` | Subtraction | `int(a) - int(b)` |
| `*` | Multiplication | `int(a) * 2` |
| `/` | Division | `int(a) / 2` |
| `%` | Modulo | `int(a) % 2 == 0` |
## Available Variables
### Resource Context
When writing resource selectors:
| Variable | Type | Description |
| --------------------- | ------ | ----------------------------------------- |
| `resource.id` | string | Unique resource ID |
| `resource.name` | string | Resource display name |
| `resource.kind` | string | Resource type (e.g., `KubernetesCluster`) |
| `resource.identifier` | string | External identifier |
| `resource.version` | string | Resource version |
| `resource.metadata` | map | Key-value metadata |
| `resource.config` | map | Resource configuration |
### Environment Context
When writing environment selectors:
| Variable | Type | Description |
| ---------------------- | ------ | -------------------- |
| `environment.id` | string | Environment ID |
| `environment.name` | string | Environment name |
| `environment.metadata` | map | Environment metadata |
### Deployment Context
When writing deployment selectors:
| Variable | Type | Description |
| --------------------- | ------ | ------------------- |
| `deployment.id` | string | Deployment ID |
| `deployment.name` | string | Deployment name |
| `deployment.metadata` | map | Deployment metadata |
### Version Context
When writing version selectors:
| Variable | Type | Description |
| ------------------ | ------ | ---------------- |
| `version.id` | string | Version ID |
| `version.tag` | string | Version tag |
| `version.name` | string | Version name |
| `version.metadata` | map | Version metadata |
## String Functions
### contains
Check if a string contains a substring:
```cel theme={null}
resource.name.contains("prod")
resource.metadata["tags"].contains("critical")
```
### startsWith
Check if a string starts with a prefix:
```cel theme={null}
resource.identifier.startsWith("k8s-")
resource.metadata["region"].startsWith("us-")
```
### endsWith
Check if a string ends with a suffix:
```cel theme={null}
resource.name.endsWith("-cluster")
resource.metadata["zone"].endsWith("a")
```
### matches
Regular expression matching:
```cel theme={null}
# Match identifiers like prod-cluster-1, prod-cluster-2
resource.identifier.matches("^prod-cluster-[0-9]+$")
# Match any US region
resource.metadata["region"].matches("^us-(east|west)-[0-9]$")
```
### size
Get string length:
```cel theme={null}
resource.name.size() > 0
resource.metadata["description"].size() < 100
```
### toLowerCase / toUpperCase
Case conversion:
```cel theme={null}
resource.metadata["env"].toLowerCase() == "production"
```
## List Operations
### in
Check if a value is in a list:
```cel theme={null}
resource.metadata["region"] in ["us-east-1", "us-west-2", "eu-west-1"]
resource.kind in ["KubernetesCluster", "KubernetesNamespace"]
```
### size
Get list length:
```cel theme={null}
resource.metadata["tags"].size() > 0
```
### exists
Check if any element matches:
```cel theme={null}
# Check if any tag starts with "team-"
resource.metadata["tags"].exists(t, t.startsWith("team-"))
```
### all
Check if all elements match:
```cel theme={null}
# Check if all regions are in US
resource.metadata["regions"].all(r, r.startsWith("us-"))
```
## Map Operations
### has
Check if a key exists in a map:
```cel theme={null}
has(resource.metadata["team"])
has(resource.config["namespace"])
```
This is safer than direct access when the key might not exist.
### Key Access
Access map values:
```cel theme={null}
resource.metadata["environment"]
resource.config["server"]
```
## Conditional Expressions
### Ternary Operator
```cel theme={null}
resource.metadata["tier"] == "critical" ? "high-priority" : "normal"
```
### Null-Safe Access
Use `has()` for optional fields:
```cel theme={null}
has(resource.metadata["deprecated"]) && resource.metadata["deprecated"] == "true"
```
Or use the default pattern:
```cel theme={null}
# Default to empty string if not present
(has(resource.metadata["team"]) ? resource.metadata["team"] : "") == "platform"
```
## Common Patterns
### Production Resources
```cel theme={null}
resource.metadata["environment"] == "production"
```
### Multi-Region Targeting
```cel theme={null}
resource.metadata["region"] in ["us-east-1", "us-west-2"]
```
### Kind Filtering
```cel theme={null}
resource.kind == "KubernetesCluster" &&
resource.metadata["environment"] == "production"
```
### Team-Based Selection
```cel theme={null}
resource.metadata["team"] == "platform" ||
resource.metadata["team"] == "infrastructure"
```
### Exclude Deprecated
```cel theme={null}
!has(resource.metadata["deprecated"]) ||
resource.metadata["deprecated"] != "true"
```
### Critical Tier in Production
```cel theme={null}
resource.metadata["environment"] == "production" &&
resource.metadata["tier"] == "critical"
```
### US Regions Only
```cel theme={null}
resource.metadata["region"].startsWith("us-")
```
### Canary Resources
```cel theme={null}
resource.metadata["environment"] == "production" &&
has(resource.metadata["canary"]) &&
resource.metadata["canary"] == "true"
```
### Complex Multi-Condition
```cel theme={null}
(resource.metadata["environment"] == "production" &&
resource.metadata["region"] in ["us-east-1", "us-west-2"]) ||
(resource.metadata["environment"] == "staging" &&
resource.metadata["region"] == "us-east-1")
```
## Policy Selector Examples
### Target Production Environment
```cel theme={null}
environment.name == "Production"
```
### Target Critical Deployments
```cel theme={null}
deployment.metadata["tier"] == "critical"
```
### Target Specific Environment + Deployment
```cel theme={null}
environment.name == "Production" &&
deployment.metadata["requires-approval"] == "true"
```
### Version Filtering
```cel theme={null}
version.tag.startsWith("v2.") &&
!version.tag.contains("beta")
```
## Relationship Rule Examples
### Match by Region
```cel theme={null}
# fromSelector for VPCs
resource.kind == "vpc"
# toSelector for clusters
resource.kind == "KubernetesCluster"
# Property matcher expression
from.metadata["region"] == to.metadata["region"]
```
### Match by Account
```cel theme={null}
from.metadata["account"] == to.metadata["account"] &&
from.metadata["region"] == to.metadata["region"]
```
## Type Coercion
CEL is strongly typed. Use these functions to convert types:
### String to Integer
```cel theme={null}
int(resource.metadata["replicas"]) > 3
```
### Integer to String
```cel theme={null}
string(42) == resource.metadata["count"]
```
### Type Checking
```cel theme={null}
type(resource.metadata["count"]) == string
```
## Error Handling
### Safe Field Access
Always use `has()` for optional fields to avoid runtime errors:
```cel theme={null}
# Bad: may error if "team" doesn't exist
resource.metadata["team"] == "platform"
# Good: safe access
has(resource.metadata["team"]) && resource.metadata["team"] == "platform"
```
### Default Values
Provide defaults for optional fields:
```cel theme={null}
# Use empty string as default
(has(resource.metadata["tier"]) ? resource.metadata["tier"] : "standard") == "critical"
```
## Performance Tips
### Prefer Simple Expressions
```cel theme={null}
# Fast: simple equality
resource.metadata["env"] == "production"
# Slower: regex matching
resource.identifier.matches("^prod-.*")
```
### Short-Circuit Evaluation
CEL uses short-circuit evaluation. Put cheap checks first:
```cel theme={null}
# Good: check env first (fast), then regex (slow)
resource.metadata["env"] == "production" &&
resource.identifier.matches("^prod-cluster-[0-9]+$")
```
### Avoid Redundant Checks
```cel theme={null}
# Bad: redundant
resource.kind == "KubernetesCluster" &&
resource.kind != "VM"
# Good: single check
resource.kind == "KubernetesCluster"
```
## Debugging
### Test Expressions
Use the API to test your CEL expressions:
```bash theme={null}
curl -X POST "https://your-ctrlplane-instance.com/api/v1/workspaces/{workspaceId}/resources/query" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filter": "resource.metadata[\"environment\"] == \"production\""
}'
```
### Common Errors
| Error | Cause | Fix |
| ---------------------- | ------------------------- | ---------------------- |
| `no such key` | Accessing missing map key | Use `has()` check |
| `type mismatch` | Comparing different types | Use type coercion |
| `syntax error` | Invalid CEL syntax | Check quotes, brackets |
| `undeclared reference` | Unknown variable | Check variable names |
### Escaping Quotes
In YAML, escape quotes properly:
```yaml theme={null}
# Single quotes around expression, double quotes inside
resourceSelector: 'resource.metadata["environment"] == "production"'
# Or use YAML literal block
resourceSelector: |
resource.metadata["environment"] == "production"
```
In JSON:
```json theme={null}
{
"filter": "resource.metadata[\"environment\"] == \"production\""
}
```
## Reference
### Reserved Keywords
These words are reserved in CEL:
* `true`, `false`, `null`
* `in`
* `as`
* `break`, `const`, `continue`, `else`, `for`, `function`, `if`, `import`, `let`, `loop`, `package`, `namespace`, `return`, `var`, `void`, `while`
### Operator Precedence
From highest to lowest:
1. `()` - Grouping
2. `.` `[]` - Member access
3. `-` `!` - Unary
4. `*` `/` `%` - Multiplicative
5. `+` `-` - Additive
6. `<` `<=` `>` `>=` `in` - Relational
7. `==` `!=` - Equality
8. `&&` - Logical AND
9. `||` - Logical OR
10. `?:` - Conditional
## Next Steps
Use CEL in selectors
CEL in relationship rules
Target policies with CEL
Dynamic environment membership
# Glossary & Reference
Source: https://docs.ctrlplane.dev/reference/glossary
Complete reference for all Ctrlplane entities and concepts
This page provides definitions and details for every entity in Ctrlplane. For a
gentler introduction, see [5-Minute Overview](/overview).
## Entity Overview
```
System
├── Environments (group resources via selectors)
├── Deployments (what to deploy)
│ └── Versions (specific builds)
└── Resources (deployment targets)
Release Target = Deployment × Environment × Resource
Release = Version deployed to a Release Target
└── Job (executed by Job Agent)
Policy → Rules that govern releases
```
## System
A **System** is a logical grouping of related deployments, environments, and
resources. Think of it as a workspace for a product or team.
| Property | Description |
| ------------- | ---------------------------- |
| `name` | Display name |
| `slug` | URL-friendly identifier |
| `description` | What this system encompasses |
**Example**: "E-commerce Platform" system containing API, Frontend, and Payment
deployments.
**When to create a System**: One per product, platform, or team boundary.
## Resource
A **Resource** is a deployment target—the actual infrastructure where your code
runs.
| Property | Description |
| ------------ | ---------------------------------------------- |
| `name` | Human-readable name |
| `kind` | Type (e.g., `KubernetesCluster`, `AWS/Lambda`) |
| `identifier` | Unique identifier |
| `metadata` | Key-value pairs for classification |
| `config` | Resource-specific configuration |
| `version` | Current version/state |
**Examples**: Kubernetes cluster, EC2 instance, Lambda function, VM.
**How created**: Via Resource Providers (auto-sync from K8s, AWS, GCP) or API.
```yaml theme={null}
name: prod-us-east-1
kind: KubernetesCluster
identifier: k8s-prod-use1
metadata:
region: us-east-1
environment: production
tier: critical
config:
server: https://k8s.example.com
```
## Environment
An **Environment** represents a logical deployment stage (dev, staging, prod)
that groups resources using selectors.
| Property | Description |
| ------------------ | ------------------------------------------- |
| `name` | Environment name |
| `systemId` | Parent system |
| `resourceSelector` | Selector determining which resources belong |
| `directory` | Optional path for hierarchical organization |
**Key concept**: Environments are *dynamic*. When you add a new resource
matching the selector, it automatically joins the environment.
```yaml theme={null}
name: Production
resourceSelector: resource.metadata["environment"] == "production"
```
## Deployment
A **Deployment** represents a service or application you want to deploy.
| Property | Description |
| ------------------ | ------------------------------------------------ |
| `name` | Deployment name |
| `slug` | URL-friendly identifier |
| `description` | What this deployment does |
| `systemId` | Parent system |
| `resourceSelector` | Optional filter for which resources can run this |
| `jobAgentId` | Which job agent executes deployments |
| `jobAgentConfig` | Configuration passed to the job agent |
**Examples**: "API Service", "Frontend App", "Payment Processor".
## Version
A **Version** is a specific build or release of a deployment, typically created
by your CI pipeline.
| Property | Description |
| ---------------- | ------------------------------------------------- |
| `deploymentId` | Parent deployment |
| `tag` | Version identifier (e.g., `v1.2.3`, `sha-abc123`) |
| `name` | Optional human-readable name |
| `status` | `building`, `ready`, or `failed` |
| `metadata` | Arbitrary metadata (git commit, build number) |
| `config` | Version-specific configuration |
| `jobAgentConfig` | Overrides deployment's job agent config |
**Status meanings**:
* `building` — Still being built, won't be deployed
* `ready` — Ready for deployment (default for policies)
* `failed` — Build failed, won't be deployed
```bash theme={null}
# CI creates a version after building
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v1.2.3",
"status": "ready",
"metadata": {"commit": "abc123"}
}'
```
## Release Target
A **Release Target** is the combination of a Deployment, Environment, and
Resource. It represents a specific place where a deployment can be released.
```
Release Target = Deployment × Environment × Resource
```
**Example**:
* Deployment: "API Service"
* Environment: "Production"
* Resource: "us-east-1 cluster"
* **Release Target**: "API Service on Production/us-east-1"
**Automatic creation**: Release targets are computed from the intersection of:
1. Environment's resource selector → which resources
2. Deployment's resource selector (if any) → further filtering
## Release
A **Release** is an instance of deploying a specific Version to a Release
Target.
| Property | Description |
| --------------- | ---------------------------- |
| `versionId` | Which version to deploy |
| `deploymentId` | Which deployment |
| `environmentId` | Which environment |
| `resourceId` | Which resource |
| `createdAt` | When the release was created |
Releases do not carry their own status. The state of a release is inferred from
the **Release Target State** — which tracks the desired release, current release,
and latest job for each target.
## Job
A **Job** is the actual deployment task executed by a Job Agent.
| Property | Description |
| ------------ | ----------------------------------------------- |
| `releaseId` | The release this job deploys |
| `jobAgentId` | Which agent executes this job |
| `status` | `pending`, `in_progress`, `completed`, `failed` |
| `externalId` | External identifier (e.g., GitHub run ID) |
| `message` | Status message or error details |
**Job lifecycle**:
1. Ctrlplane creates job for approved release
2. Job agent polls and receives the job
3. Agent acknowledges and executes
4. Agent updates status as it progresses
5. Agent marks completed or failed
## Job Agent
A **Job Agent** is the executor that performs deployments. It bridges Ctrlplane
to your infrastructure.
| Agent Type | What It Does |
| --------------- | -------------------------------------- |
| GitHub Actions | Triggers workflow dispatch |
| ArgoCD | Creates/syncs ArgoCD Applications |
| Terraform Cloud | Creates workspaces, triggers runs |
| Kubernetes | Applies manifests directly |
| ArgoWorkflows | Applies an inline workflow or template |
## Policy
A **Policy** defines rules governing when and how deployments happen.
| Component | Description |
| ----------- | -------------------------------------------- |
| `name` | Policy name |
| `selectors` | Which release targets this policy applies to |
| `rules` | The specific rules to enforce |
**Policy types**:
| Type | Description |
| ----------------------- | --------------------------------------------- |
| Approval | Requires manual sign-off |
| Environment Progression | Wait for another environment to succeed first |
| Gradual Rollout | Deploy to targets sequentially with delays |
| Deployment Window | Only deploy during certain hours |
| Version Selector | Filter which versions can deploy |
| Version Cooldown | Minimum time between deployments |
| Deployment Dependency | Wait for another deployment to complete |
| Verification | Check metrics after deployment |
**Policy evaluation**: When a release target needs deployment:
1. Find all policies matching the target
2. Evaluate each rule
3. All pass → create job
4. Any requires action → release is pending
5. Any denies → release is blocked
## Selector
**Selectors** are query expressions used to match resources, environments, or
deployments.
```yaml theme={null}
# Match resources in production
resource.metadata["environment"] == "production"
# Match critical deployments in any region
deployment.metadata["tier"] == "critical" &&
resource.metadata["region"] in ["us-east-1", "eu-west-1"]
```
Used in:
* Environment resource selectors
* Deployment resource selectors
* Policy target selectors
See [Selectors](/concepts/selectors) for full syntax.
## Variables
**Variables** provide dynamic configuration for deployments.
| Type | Description |
| -------------------- | ------------------------------------------- |
| Deployment Variables | Defined per deployment, vary by environment |
| Resource Variables | Defined on resources |
```json theme={null}
{
"deploymentVariable": "replicas",
"values": [
{ "environmentId": "dev", "value": "1" },
{ "environmentId": "prod", "value": "3" }
]
}
```
## Quick Reference Table
| Entity | What It Is |
| -------------- | ----------------------------------------------- |
| System | Workspace grouping deployments and environments |
| Resource | Deployment target (cluster, VM, function) |
| Environment | Logical stage grouping resources via selectors |
| Deployment | Service/app to deploy |
| Version | Specific build of a deployment |
| Release Target | Deployment × Environment × Resource |
| Release | Version being deployed to a release target |
| Job | Execution task sent to a job agent |
| Job Agent | Executor (ArgoCD, GitHub Actions, etc.) |
| Policy | Rules controlling deployments |
| Selector | Query expression matching entities |
## Next Steps
* [5-Minute Overview](/overview) — Understand the flow
* [Quickstart](/quickstart) — Hands-on tutorial
* [Selectors](/concepts/selectors) — Deep dive into selector syntax
* [Policies](/policies/overview) — Configure deployment rules
# RFC 0001: Scoped Versions
Source: https://docs.ctrlplane.dev/rfc/0001-scoped-versions
| Category | Status | Created | Author |
| -------- | -------------------- | ---------- | ------------- |
| Policies | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add an optional `targetSelector` field to deployment versions that limits which
release targets a version flows through the promotion lifecycle for. This allows
deployers to express "this version only affects these targets" at creation time,
so unaffected targets skip the full policy pipeline entirely.
## Motivation
Ctrlplane already handles two kinds of changes differently:
* **Variable changes** roll out instantly. When a deployment variable or
resource variable is updated, the affected release targets are re-reconciled
with the new variable values. The version hasn't changed, so policies that
already passed (approval, environment progression, verification) remain
satisfied. The new release is created with updated variables and a job is
dispatched immediately.
* **Version changes** go through the full promotion lifecycle. When a new
deployment version is created with `status: ready`, ctrlplane creates releases
for **every** release target in the deployment's matrix (Deployment x
Environment x Resource). Each release goes through environment progression,
approval gates, verification, gradual rollout, and cooldown before a job is
created.
The problem is that many version changes only affect a subset of release
targets. A hotfix for a single region, a config embedded in the version for one
service variant, a change to a Helm chart that only impacts certain clusters —
all of these trigger the full promotion lifecycle across **all** targets.
This creates unnecessary latency. A deployer who knows their change only impacts
3 out of 50 clusters must still wait for staging verification, production
approval, and gradual rollout to complete across all 50. Unlike variable
changes, there is no way to express "this version change is narrow" — every
version is treated as a full rollout.
### Why ctrlplane cannot derive version impact automatically
For variable changes, ctrlplane can detect impact mechanically: it resolves the
new variable values, compares them to the current release's variables, and only
creates new releases where the resolved values actually differ. This is why
variable changes can roll out instantly — ctrlplane knows exactly what changed.
Version changes are fundamentally different. A release is defined as
`Version + Environment + Resource + Resolved Variables`. When a new version is
created, the version component is always new — that is the entire reason the
release exists. Even if every resolved variable is identical across targets, the
version ID differs, so every release is "different" from ctrlplane's
perspective. You cannot diff away the version itself.
The knowledge of which targets are truly impacted by a version change comes from
the deployer's understanding of what the change *means* — which config files
changed in the Helm chart, which services are affected by the new image, which
regions need the update. This is semantic knowledge about the change that exists
outside ctrlplane's data model. Ctrlplane sees a new version and treats it as a
new version for all targets; it cannot know that "this Helm chart change only
affects the payment service" or "this image bump doesn't change behavior for
clusters running the old schema."
Scoped versions acknowledge this reality by giving the deployer a structured way
to express their knowledge, rather than trying to derive it mechanically. The
same way ctrlplane already trusts that variable selectors correctly express
which targets a variable value applies to, scoped versions let the deployer
express which targets a version applies to.
### Comparison with existing mechanisms
**Version Selectors** are policy rules that answer "is this version *allowed* to
deploy to this target?" They are eligibility gates — a version that fails a
selector shows as **blocked/denied** in the UI and in rule evaluations. This is
semantically wrong for the scoped version use case: the version is not *bad* for
unaffected targets, it is simply *irrelevant*. Version selectors also don't
exempt matching targets from other policy rules — a version that passes the
selector still goes through the full promotion chain.
**Policy Skips** allow bypassing individual policy rules for a version +
environment. They work today, but require the deployer to know specific rule
IDs, create skips per-rule per-environment, and the version still appears in the
evaluation pipeline for every target. They are an escape hatch, not a
first-class workflow.
**Scoped Versions** operate *before* the policy pipeline. The reconciler skips
the version entirely for non-matching targets — no releases created, no policy
evaluations run, no "denied" entries in the UI. The intent ("this version is for
these targets") lives on the version itself, making it auditable and
declarative.
## Proposal
### Schema
Add an optional `target_selector` column to the `deployment_version` table:
```sql theme={null}
ALTER TABLE deployment_version
ADD COLUMN target_selector TEXT;
```
When `NULL`, the version targets all release targets (current behavior). When
set, it contains a CEL expression evaluated against the release target's
resource, environment, and deployment.
### API
Extend the version creation endpoints to accept the new field.
**REST API:**
```
POST /v1/deployments/{deploymentId}/versions
```
```json theme={null}
{
"tag": "v1.2.3-hotfix",
"status": "ready",
"targetSelector": "resource.metadata['region'] == 'us-east-1'",
"metadata": {
"commit": "abc123",
"scope": "us-east-1 payment hotfix"
}
}
```
**Terraform:**
```hcl theme={null}
resource "ctrlplane_deployment_version" "hotfix" {
deployment_id = ctrlplane_deployment.api.id
tag = "v1.2.3-hotfix"
status = "ready"
target_selector = "resource.metadata['region'] == 'us-east-1'"
}
```
The CEL expression has access to the same variables as version selectors:
`resource`, `environment`, and `deployment`.
### Reconciler changes
In the desired release reconciler, the `findDeployableVersion` function iterates
candidate versions newest-first and evaluates policy rules. The target selector
check should be inserted **before** policy evaluation, as a pre-filter on the
candidate version list:
```
loadInput
→ getCandidateVersions
→ filterByTargetSelector ← NEW: remove versions whose targetSelector
→ findDeployableVersion does not match this release target
→ resolveVariables
→ persistRelease
```
Concretely, in `reconcile.go`, after `GetCandidateVersions` returns, filter the
list:
```go theme={null}
func (r *reconciler) filterByTargetSelector(ctx context.Context) error {
if len(r.versions) == 0 {
return nil
}
filtered := make([]*oapi.DeploymentVersion, 0, len(r.versions))
for _, v := range r.versions {
if v.TargetSelector == "" {
filtered = append(filtered, v)
continue
}
matches, err := selector.MatchCEL(ctx, v.TargetSelector, r.scope)
if err != nil {
log.Warn("target selector eval failed, including version",
"version", v.Id, "error", err)
filtered = append(filtered, v)
continue
}
if matches {
filtered = append(filtered, v)
}
}
r.versions = filtered
return nil
}
```
Versions with a `targetSelector` that does not match the current release target
are silently removed from the candidate list. The reconciler then proceeds as
normal with the remaining candidates. If no candidates remain, the release
target keeps its current state.
### UI
The web UI should surface scoped versions in a few places:
1. **Version list** — Show a badge or indicator when a version has a
`targetSelector`, with the expression visible on hover.
2. **Release target view** — When a version is scoped and doesn't match a
target, it should not appear in that target's version evaluation list at all
(as opposed to appearing as "denied").
3. **Version creation** — Optionally expose the `targetSelector` field in the UI
when creating versions manually.
### Behavior with other policy rules
Scoped versions interact cleanly with existing policy rules:
* **Environment progression:** Only evaluated for targets that match the scope.
If a scoped version targets production directly and no staging targets match,
the environment progression rule is only evaluated for production targets. The
deployer is responsible for ensuring this makes sense — the scope is an
explicit declaration of intent.
* **Approval:** Approvals are per-environment. Only environments with matching
targets will require approval.
* **Gradual rollout:** Rollout only applies across matching targets, naturally
reducing the rollout surface.
* **Version cooldown:** Evaluated per-target as before, but only for targets in
scope.
### Fallback behavior
If `targetSelector` evaluation fails (malformed CEL, missing fields), the
version should be **included** in the candidate list (fail-open). This prevents
a typo in a selector from silently dropping a version for all targets. The
failure should be logged as a warning.
## Examples
### Hotfix for a single region
```bash theme={null}
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v1.2.3-hotfix-use1",
"status": "ready",
"targetSelector": "resource.metadata[\"region\"] == \"us-east-1\""
}'
```
Only us-east-1 release targets enter the promotion pipeline. All other targets
remain on their current version undisturbed.
### Config change for a specific environment
```bash theme={null}
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v2.0.1-staging-config",
"status": "ready",
"targetSelector": "environment.name == \"staging\""
}'
```
Only staging targets are considered. This version never reaches production
targets, so no environment progression or production approval is triggered.
### Broad rollout (default behavior)
```bash theme={null}
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v2.1.0",
"status": "ready"
}'
```
No `targetSelector` — all release targets are considered. Identical to current
behavior.
## Migration
* The schema change is additive (`ADD COLUMN ... NULL`), requiring no data
migration.
* Existing versions have `target_selector = NULL`, preserving current behavior.
* No changes to existing policies or release targets are needed.
* The reconciler change is backwards-compatible: versions without a selector
pass through the filter unchanged.
## Open Questions
1. **Should scoped versions interact with environment progression?** If a
version scopes to production only, should environment progression rules block
it (staging hasn't seen it) or should the scope be treated as an explicit
override of progression? The current proposal lets the deployer handle this —
they can combine the scope with policy skips if needed.
2. **Should there be a permission or policy guard on scoping?** Scoped versions
let deployers bypass the normal promotion surface area. Organizations may
want to restrict who can create scoped versions, or require that scoped
versions still pass through certain gates.
3. **Should the selector support resource-only, or also environment and
deployment fields?** The proposal includes all three for flexibility, but
simpler scoping (resource-only) might be sufficient and easier to reason
about.
4. **Naming:** `targetSelector` vs `scope` vs `affectedTargets` — what conveys
the intent most clearly?
# RFC 0002: Plan-Based Diff Detection
Source: https://docs.ctrlplane.dev/rfc/0002-plan-based-diff-detection
| Category | Status | Created | Author |
| -------- | -------------------- | ---------- | ------------- |
| Policies | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add a `Plannable` interface to job agents that lets ctrlplane compute the
rendered deployment output for a release target *without* dispatching a job. By
comparing the rendered output hash of a proposed version against the hash of the
currently deployed release, ctrlplane can mechanically determine which release
targets are actually affected by a version change. Unaffected targets can then
be fast-tracked through the promotion lifecycle.
## Motivation
RFC 0001 (Scoped Versions) introduces a way for deployers to *declare* which
targets a version affects. This works well when the deployer knows the impact
upfront — a regional hotfix, a single-service config change. But it relies on
the deployer providing accurate scope. If the scope is wrong, targets are either
unnecessarily delayed (too broad) or silently skipped (too narrow).
The external systems ctrlplane dispatches to — ArgoCD, Terraform Cloud, Helm,
Kubernetes — already know how to compute what a deployment *would* produce
without actually applying it. ArgoCD renders Application manifests from
templates. Terraform produces execution plans. Helm has `helm template`. These
systems can answer the question "would this version change anything for this
target?" with mechanical precision.
Today, ctrlplane cannot leverage this knowledge. The rendering happens inside
the job agent at dispatch time, and the result is never captured or compared.
ctrlplane treats every new version as a change for every target because it
operates on version identity (version ID differs → release differs), not on
rendered output identity (rendered manifest is the same → nothing changed).
### Why version identity is insufficient
A release's content hash (`Release.ContentHash()`) includes the version ID and
tag:
```go theme={null}
func (r *Release) ContentHash() string {
var sb strings.Builder
sb.WriteString(r.Version.Id)
sb.WriteString(r.Version.Tag)
// ... variables and release target key
}
```
This means two releases with different versions *always* have different content
hashes, even if the rendered deployment output is byte-for-byte identical. The
content hash answers "is this the same release?" but not "would this produce the
same deployed state?"
### Why rendering is the right level to compare
The rendered output is what actually gets applied to the target system.
Crucially, this is not the intermediate representation ctrlplane produces (like
an ArgoCD Application CRD or a Terraform variable file) — it is the *final*
output that the external system produces after it processes that intermediate
input. For ArgoCD, this means the Kubernetes manifests after fetching the git
repo and rendering the Helm chart. For Terraform, this means the execution plan
after evaluating all modules and state.
ctrlplane's in-process template rendering (e.g., `TemplateApplication`) produces
the *input* to the external system, not the deployed output. A version change
almost always changes this input (the `targetRevision`, the image tag in Helm
values, etc.). But the external system may still produce identical output — for
example, when a git commit only modifies files for a different service than the
one this Helm chart deploys.
The only way to know whether the deployed state would actually change is to ask
the external system to render the final output. This is what `terraform plan`,
`argocd app diff`, and `helm template` do. Plan-based diff detection brings this
capability into ctrlplane's promotion lifecycle by delegating the rendering to
the system that owns it.
### Relationship to RFC 0001
Scoped versions (RFC 0001) and plan-based diff detection are complementary:
* **Scoped versions** are fast and explicit — the deployer states intent, the
reconciler filters instantly, no external calls needed.
* **Plan-based diffs** are accurate and automatic — the external system computes
impact, no deployer knowledge required, but adds latency from the plan call.
A typical workflow might use scoped versions as the primary mechanism and
plan-based diffs as a validation step or as the basis for auto-generating the
scope.
## Proposal
### New interface: `Plannable`
Add an optional interface to the job agent type system alongside the existing
`Dispatchable` and `Verifiable`:
```go theme={null}
// Plannable is optionally implemented by a Dispatchable to compute the
// rendered deployment output without dispatching a job. The reconciler
// uses this to detect whether a version change would produce a different
// deployed state for a given release target.
type Plannable interface {
Plan(ctx context.Context, dispatchCtx *oapi.DispatchContext) (*PlanResult, error)
}
type PlanResult struct {
// ContentHash is a deterministic hash of the rendered deployment output.
// Two plans with the same ContentHash produce identical deployed state.
ContentHash string
// HasChanges indicates whether the rendered output differs from the
// currently deployed state. When false, the target is unaffected.
HasChanges bool
// Diff is an optional human-readable summary of what changed. Stored
// for audit and displayed in the UI. May be empty for no-diff results.
Diff string
}
```
This follows the same pattern as `Verifiable`:
```go theme={null}
// Existing pattern in types/types.go:
type Dispatchable interface {
Type() string
Dispatch(ctx context.Context, job *oapi.Job) error
}
type Verifiable interface {
Verifications(config oapi.JobAgentConfig) ([]oapi.VerificationMetricSpec, error)
}
// New:
type Plannable interface {
Plan(ctx context.Context, dispatchCtx *oapi.DispatchContext) (*PlanResult, error)
}
```
### Registry extension
The job agent registry already checks for optional interfaces. Add a `Plan`
method following the same pattern as `AgentVerifications`:
```go theme={null}
func (r *Registry) Plan(
ctx context.Context,
agentType string,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
dispatcher, ok := r.dispatchers[agentType]
if !ok {
return nil, nil
}
p, ok := dispatcher.(types.Plannable)
if !ok {
return nil, nil
}
return p.Plan(ctx, dispatchCtx)
}
```
When an agent does not implement `Plannable`, the registry returns nil and the
reconciler falls back to treating the version as a change for all targets
(current behavior).
### Schema
Store the rendered content hash on the release target state so it can be
compared against future plan results:
```sql theme={null}
ALTER TABLE release_target
ADD COLUMN rendered_content_hash TEXT;
```
This column is updated when a job completes successfully. It represents the hash
of the output that was actually deployed.
Additionally, store plan results for audit and UI display:
```sql theme={null}
CREATE TABLE release_target_plan (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
deployment_id UUID NOT NULL REFERENCES deployment(id) ON DELETE CASCADE,
environment_id UUID NOT NULL REFERENCES environment(id) ON DELETE CASCADE,
resource_id UUID NOT NULL REFERENCES resource(id) ON DELETE CASCADE,
version_id UUID NOT NULL REFERENCES deployment_version(id) ON DELETE CASCADE,
content_hash TEXT NOT NULL,
has_changes BOOLEAN NOT NULL,
diff TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
### Reconciler integration
The plan step fits into the desired release reconciler as an optional phase
between candidate selection and policy evaluation:
```text theme={null}
loadInput
→ getCandidateVersions
→ filterByTargetSelector (RFC 0001)
→ computePlanForTopCandidate ← NEW
→ findDeployableVersion
→ resolveVariables
→ persistRelease
```
The plan is computed only for the top candidate version (newest first) to
minimize external calls. If the plan shows no changes, the reconciler can
either:
1. Skip the version for this target (move to the next candidate)
2. Fast-track the version through policy evaluation (auto-satisfy gates)
Which behavior applies depends on the policy configuration (see "Policy
integration" below).
```go theme={null}
func (r *reconciler) computePlan(ctx context.Context) error {
if len(r.versions) == 0 {
return nil
}
version := r.versions[0]
// Build a dispatch context for the candidate version
dispatchCtx, err := r.buildDispatchContext(ctx, version)
if err != nil {
log.Warn("failed to build dispatch context for plan", "error", err)
return nil // fail-open: proceed without plan
}
// Ask the job agent to compute the rendered output
result, err := r.planner.Plan(ctx, r.agentType, dispatchCtx)
if err != nil {
log.Warn("plan failed", "error", err)
return nil // fail-open
}
if result == nil {
return nil // agent does not support planning
}
// Store the plan result
r.planResult = result
// Compare against the currently deployed hash
if r.currentRenderedHash != "" && result.ContentHash == r.currentRenderedHash {
result.HasChanges = false
}
return nil
}
```
### Policy integration
Plan results feed into the policy pipeline through a new optional policy rule
type: `diffCheck`. This rule evaluates the plan result and can auto-satisfy
other gates when no diff is detected:
```hcl theme={null}
resource "ctrlplane_policy" "fast_track_no_diff" {
name = "Fast-track unchanged targets"
selector = "environment.name == 'production'"
diff_check {
skip_when_no_diff = [
"environment_progression",
"approval",
"verification",
]
}
}
```
When the plan result for a release target shows `HasChanges = false`, the rules
listed in `skip_when_no_diff` are automatically satisfied. The version still
advances through the pipeline (the release is created, the release target state
updates), but blocking gates are bypassed.
If no `diffCheck` policy is configured, plan results are informational only —
stored for audit and displayed in the UI but not used to alter promotion flow.
The `diffCheck` evaluator would be added to the evaluator set in `policyeval.go`
alongside the existing evaluators:
```go theme={null}
func RuleTypes() []string {
return []string{
(&versionselector.Evaluator{}).RuleType(),
(&approval.AnyApprovalEvaluator{}).RuleType(),
(&environmentprogression.EnvironmentProgressionEvaluator{}).RuleType(),
(&gradualrollout.GradualRolloutEvaluator{}).RuleType(),
(&deploymentdependency.DeploymentDependencyEvaluator{}).RuleType(),
(&deploymentwindow.DeploymentWindowEvaluator{}).RuleType(),
(&versioncooldown.VersionCooldownEvaluator{}).RuleType(),
// NEW:
(&diffcheck.DiffCheckEvaluator{}).RuleType(),
}
}
```
### Agent implementations
#### ArgoCD
The ArgoCD agent's in-process `TemplateApplication` function renders a Go
template into an ArgoCD Application CRD. This is **not** the right level to
diff. The Application spec contains fields like `targetRevision` and
`helm.values` that reference `version.tag` — so the rendered Application CRD
will always differ between versions, even when the final deployed manifests are
identical.
The actual deployed state is what ArgoCD produces *from* the Application spec:
it fetches the git repo at the specified revision, renders the Helm chart (or
kustomize overlay, or plain manifests), and produces the final Kubernetes
manifests that get applied to the cluster. Two different git revisions can
produce identical rendered manifests if the files that changed in the commit are
irrelevant to the chart or overlay being used.
To compute a real diff, the `Plan` implementation must call the ArgoCD API to
get the fully rendered manifests. The ArgoCD Go client already used by the agent
(`ApplicationServiceClient`) exposes `GetManifests` for exactly this:
```go theme={null}
func (a *ArgoApplication) Plan(
ctx context.Context,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
serverAddr, apiKey, template, err := ParseJobAgentConfig(
dispatchCtx.JobAgentConfig,
)
if err != nil {
return nil, err
}
// First, render the Application CRD from the dispatch context
// (same as dispatch time)
app, err := TemplateApplication(dispatchCtx, template)
if err != nil {
return nil, err
}
MakeApplicationK8sCompatible(app)
// Connect to ArgoCD
client, err := argocdclient.NewClient(&argocdclient.ClientOptions{
ServerAddr: serverAddr,
AuthToken: apiKey,
})
if err != nil {
return nil, fmt.Errorf("create ArgoCD client: %w", err)
}
ioCloser, appClient, err := client.NewApplicationClient()
if err != nil {
return nil, fmt.Errorf("create application client: %w", err)
}
defer ioCloser.Close()
// Get the fully rendered manifests that ArgoCD would produce
// for this Application spec. ArgoCD fetches the git repo,
// renders Helm/kustomize/plain manifests, and returns the
// final Kubernetes resources.
manifests, err := appClient.GetManifests(ctx,
&argocdapplication.ApplicationManifestQuery{
Name: &app.Name,
Revision: &app.Spec.Source.TargetRevision,
},
)
if err != nil {
return nil, fmt.Errorf("get manifests from ArgoCD: %w", err)
}
// Hash the rendered manifests (sorted for determinism)
rendered := sortAndJoinManifests(manifests.Manifests)
hash := sha256.Sum256([]byte(rendered))
return &types.PlanResult{
ContentHash: hex.EncodeToString(hash[:]),
HasChanges: true, // caller compares against stored hash
}, nil
}
func sortAndJoinManifests(manifests []string) string {
sorted := make([]string, len(manifests))
copy(sorted, manifests)
sort.Strings(sorted)
return strings.Join(sorted, "\n---\n")
}
```
This requires a network call to the ArgoCD server, which in turn fetches the git
repo and renders the chart. The latency depends on the size of the repo and
chart complexity, but is typically 1-10 seconds. ArgoCD caches rendered
manifests aggressively, so repeated plans for the same revision are fast.
For applications that don't yet exist in ArgoCD (first deploy), the plan step
can use ArgoCD's dry-run create or fall back to treating the version as changed.
**Why in-process rendering is insufficient:** The Go template in the job agent
config produces the Application CRD — it defines *where* to look for manifests
(which repo, which revision, which Helm values). It does not produce the actual
Kubernetes resources. A template like:
```yaml theme={null}
spec:
source:
repoURL: https://github.com/org/charts
targetRevisicon: "{{ .release.version.tag }}"
helm:
values: |
replicas: {{ .release.variables.REPLICA_COUNT }}
```
will always produce a different Application CRD when `version.tag` changes. But
if the Helm chart at the new tag only changed a values file for a different
service, the rendered Kubernetes manifests for *this* resource may be identical.
Only ArgoCD — which actually fetches and renders the chart — can tell you that.
#### Terraform Cloud
Terraform Cloud has native plan support. The `Plan` implementation would trigger
a speculative plan run via the API and return the plan's resource change
summary:
```go theme={null}
func (t *TerraformCloud) Plan(
ctx context.Context,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
// Trigger a speculative plan (does not apply)
run, err := t.client.CreateRun(ctx, RunConfig{
IsDestroy: false,
PlanOnly: true,
Variables: dispatchCtx.Variables,
})
if err != nil {
return nil, err
}
// Wait for plan to complete
plan, err := t.client.WaitForPlan(ctx, run.ID)
if err != nil {
return nil, err
}
hasChanges := plan.ResourceAdditions > 0 ||
plan.ResourceChanges > 0 ||
plan.ResourceDestructions > 0
return &types.PlanResult{
ContentHash: plan.StateHash,
HasChanges: hasChanges,
Diff: plan.Summary,
}, nil
}
```
This involves a network call and takes longer (seconds to minutes). The
reconciler should handle this asynchronously.
#### GitHub Actions
GitHub Actions does not have a native plan/dry-run concept. The agent would not
implement `Plannable`, and the registry returns nil. Targets using GitHub
Actions fall back to current behavior — every version is treated as a change.
### Storing the deployed hash
When a job completes successfully, the reconciler updates the release target's
`rendered_content_hash`:
```go theme={null}
func (s *Setter) UpdateRenderedHash(
ctx context.Context,
rt *ReleaseTarget,
hash string,
) error {
q := db.GetQueries(ctx)
return q.UpdateReleaseTargetRenderedHash(ctx, db.UpdateReleaseTargetRenderedHashParams{
ResourceID: rt.ResourceID,
EnvironmentID: rt.EnvironmentID,
DeploymentID: rt.DeploymentID,
RenderedContentHash: hash,
})
}
```
For the initial deployment (no previous hash), `HasChanges` defaults to true.
### UI
1. **Release target view** — When a plan result exists, show a "No changes
detected" or "Changes detected" indicator alongside the version evaluation.
For targets with no changes, display a muted state to signal the version is
advancing without operational impact.
2. **Diff viewer** — When `Diff` is populated, provide an expandable panel
showing the human-readable diff (YAML diff for ArgoCD, resource summary for
Terraform).
3. **Version detail** — Aggregate plan results across all release targets to
show "X of Y targets affected" on the version page.
### Async plan execution
All `Plannable` agents involve network calls — ArgoCD must fetch the git repo
and render charts, Terraform Cloud must run a speculative plan. Plans should run
asynchronously:
1. The reconciler enqueues a plan request when it encounters a new candidate
version for a release target.
2. A plan worker processes the request, calls the agent's `Plan` method, and
stores the result in `release_target_plan`.
3. On the next reconciliation pass, the stored plan result is available and the
reconciler uses it to determine diff status.
This follows the same async pattern used by verification metrics, which also
enqueue work items and store results that the reconciler picks up on subsequent
passes.
## Examples
### ArgoCD: Helm chart change affecting one service
A deployment manages 20 clusters across 4 environments. A new version points to
a new git commit that updates the Helm chart's `values.yaml` for the payment
service. The ArgoCD Application template sets `targetRevision` to the version
tag.
1. Version `v3.1.0` is created. The git commit behind this tag only modifies
`charts/payment/values.yaml`.
2. The reconciler enqueues a plan for each release target.
3. For each target, the plan worker renders the ArgoCD Application CRD (which
differs for every target because `targetRevision` changed), then calls
ArgoCD's `GetManifests` API to get the fully rendered Kubernetes manifests at
that revision.
4. For the 4 clusters that deploy the payment chart, ArgoCD's rendered manifests
differ from the stored hash — `HasChanges = true`.
5. For the 16 clusters that deploy other charts from the same repo, ArgoCD
renders the same manifests as the previous version (the files that changed
are irrelevant to their charts) — `HasChanges = false`.
6. The `diffCheck` policy auto-satisfies environment progression and approval
for the 16 unaffected clusters.
7. The 4 affected clusters go through the full promotion lifecycle.
### Terraform Cloud: Infrastructure change scoped to one region
A Terraform deployment manages infrastructure in 3 regions. A version changes an
IAM policy that only applies to us-east-1.
1. Version `v1.5.0` is created.
2. The reconciler triggers speculative plans for each region's release target.
3. The us-east-1 plan shows 1 resource change. The other two plans show 0
changes.
4. Only the us-east-1 target enters the full promotion pipeline.
### GitHub Actions: No plan support (fallback)
A deployment uses GitHub Actions as its job agent. GitHub Actions does not
implement `Plannable`.
1. Version `v2.0.0` is created.
2. The reconciler calls `registry.Plan()` — returns nil.
3. All release targets enter the promotion pipeline as usual.
4. No change from current behavior.
## Migration
* The `rendered_content_hash` column is additive and nullable. Existing release
targets start with `NULL`, meaning the first plan comparison always treats the
target as changed (fail-open).
* The `release_target_plan` table is new and requires no data migration.
* Agents that do not implement `Plannable` continue to work without changes.
* The `diffCheck` policy rule is optional. Without it, plan results are
informational only.
## Open Questions
1. **Should plan results block or only fast-track?** The current proposal only
uses plan results to *skip* policy gates (fast-track). An alternative is to
*block* versions that show no changes from creating releases at all, similar
to how scoped versions filter candidates. The risk is that a plan bug could
prevent legitimate deployments.
2. **Cost of Plans.** Each plan consumes resources. For deployments with many
release targets, the plan step could generate significant API load. Should
planning be opt-in per deployment, or rate-limited? For deployments with many
release targets, the plan step could generate significant API load. Should
planning be opt-in per deployment, or rate-limited?
3. **Interaction with RFC 0001.** If a version has a `targetSelector` (RFC 0001)
that excludes a target, should the plan still run for that target? The
proposed order (filter by target selector, then plan) means excluded targets
are never planned, which is efficient but means you cannot use plan results
to validate a target selector's correctness.
# RFC 0003: Lifecycle Brackets
Source: https://docs.ctrlplane.dev/rfc/0003-lifecycle-brackets
| Category | Status | Created | Author |
| -------- | -------------------- | ---------- | ------------- |
| Policies | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add two new policy rule types — **deployment bracket** and **resource
concurrency** — that together enable coordinated multi-deployment rollouts with
cluster-wide capacity limits. Deployment brackets group related deployments so
they execute as a unit against each resource (drain a node once, apply all
upgrades, uncordon). Resource concurrency limits how many resources in a group
can be simultaneously undergoing deployment (only 20% of nodes offline at a
time).
Both are policy rules, not new entity types. They slot into the existing
evaluator pipeline alongside gradual rollout, deployment dependency, and the
other rule types.
## Motivation
When deploying software to Kubernetes nodes, two operational constraints exist
that ctrlplane's current policy engine cannot express:
### Drain cost amortization
Deploying to a node requires draining pods first — evicting workloads,
respecting PodDisruptionBudgets, waiting for graceful termination. This is slow
(minutes to tens of minutes) and disruptive. If multiple deployments target the
same node (kubelet upgrade, containerd upgrade, OS patch), each deployment today
triggers its own independent drain/uncordon cycle because each release target is
evaluated independently.
For 3 deployments across 10 nodes, this means 30 drain cycles instead of 10. The
overhead scales linearly with the number of bracketed deployments.
### Cluster-wide concurrency limits
Only a percentage of nodes in a cluster can be offline simultaneously. If 20% of
a 10-node cluster goes down, the remaining 8 nodes must absorb all workloads.
Exceeding this threshold risks cascading failures.
The existing concurrency control (`ReleaseTargetConcurrencyEvaluator`) enforces
a hard limit of 1 active job per release target:
```go theme={null}
// releasetargetconcurrency.go
func (e *ReleaseTargetConcurrencyEvaluator) Evaluate(
ctx context.Context, release *oapi.Release,
) *oapi.RuleEvaluation {
processingJobs := e.store.Jobs.GetJobsInProcessingStateForReleaseTarget(
&release.ReleaseTarget,
)
if len(processingJobs) != 0 {
return results.NewDeniedResult("Release target has an active job")
}
return results.NewAllowedResult("Release target has no active jobs")
}
```
This prevents overlapping jobs on the *same* release target but provides no
cross-resource limit. There is no way to say "at most 2 of these 10 nodes can be
draining at once."
### Why existing rules are insufficient
**Gradual rollout** staggers deployments over time using hash-based position
ordering. It controls *when* each target's turn arrives but not *how many* can
be active simultaneously. If multiple targets' rollout times pass while earlier
targets are still executing (e.g., a slow drain), all of them proceed at once.
**Deployment dependency** checks whether an upstream deployment has succeeded
for the same resource. It controls *ordering* (drain before upgrade, upgrade
before uncordon) but not *grouping*. Each deployment's policy pipeline runs
independently — there is no mechanism to hold all deployments for a resource
until a collection of new versions is ready.
**Version cooldown** batches frequent releases for a *single* deployment. It
does not coordinate across deployments.
These three rules address different dimensions (time, order, frequency) but none
addresses the two missing dimensions: cross-deployment grouping and
cross-resource capacity limits.
## Proposal
### New policy rule: deployment bracket
A deployment bracket groups deployments that should execute as a coordinated
unit per resource. The bracket rule gates deployment until all member
deployments have versions ready, using a configurable collection window.
#### Schema
```sql theme={null}
CREATE TABLE policy_rule_deployment_bracket (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID NOT NULL REFERENCES policy(id) ON DELETE CASCADE,
-- CEL selector defining which deployments are co-bracketed.
-- Evaluated against deployment context.
deployment_selector TEXT NOT NULL,
-- How to batch versions across the bracket group.
readiness_mode TEXT NOT NULL DEFAULT 'collection_window',
-- Duration of collection/wait window in seconds (e.g., 86400 = 24h).
readiness_window_seconds INTEGER,
-- What to do with members that have no new version during window.
unchanged_member_strategy TEXT NOT NULL DEFAULT 'skip_unchanged',
-- What to do when a new version group is ready while a previous one
-- is still executing on a resource.
overlap_strategy TEXT NOT NULL DEFAULT 'queue',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**`readiness_mode`** controls when a bracket starts executing:
| Mode | Behavior |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `collection_window` | Wait `readiness_window_seconds` after the first member version is published. Take the latest version of each member at window close. |
| `wait_for_all` | Wait until every member deployment has a new version. Fall back to `unchanged_member_strategy` after `readiness_window_seconds` timeout. |
| `immediate` | Proceed as soon as any member has a new version. Use latest available version for all members. |
**`unchanged_member_strategy`** controls members with no new version when the
window closes:
| Strategy | Behavior |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `skip_unchanged` | Only deploy members that have new versions. Others are no-ops. |
| `redeploy_current` | Re-deploy the currently running version for all members. Useful when hooks have side effects. |
| `require_all` | Don't close the window until all members have a new version. Fall back to `skip_unchanged` after timeout. |
**`overlap_strategy`** controls what happens when a new version group becomes
ready while a previous group is still executing on a resource:
| Strategy | Behavior |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `queue` | Wait for the active group to fully complete (including post-hooks), then start a fresh cycle. Safe default. |
| `merge` | If the resource is already in a pre-hook state (e.g., drained), deploy the new group's versions before running post-hooks. Avoids double drain cycles. |
#### Version group state
The bracket evaluator needs to track which versions belong to an active
collection window or executing group. A lightweight state table supports this:
```sql theme={null}
CREATE TABLE bracket_version_group (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bracket_rule_id UUID NOT NULL
REFERENCES policy_rule_deployment_bracket(id) ON DELETE CASCADE,
resource_id UUID NOT NULL,
environment_id UUID NOT NULL,
-- When collection started (first triggering version)
collection_started_at TIMESTAMPTZ NOT NULL,
-- When collection should end
collection_ends_at TIMESTAMPTZ NOT NULL,
-- collecting | ready | executing | completed | failed
status TEXT NOT NULL DEFAULT 'collecting',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE bracket_version_group_member (
group_id UUID NOT NULL
REFERENCES bracket_version_group(id) ON DELETE CASCADE,
deployment_id UUID NOT NULL,
-- NULL while still collecting, set when window closes
version_id UUID,
-- When this member's version was locked
locked_at TIMESTAMPTZ,
PRIMARY KEY (group_id, deployment_id)
);
```
#### Evaluator
The bracket evaluator implements the standard `Evaluator` interface:
```go theme={null}
type DeploymentBracketEvaluator struct {
getters Getters
ruleId string
rule *oapi.DeploymentBracketRule
timeGetter func() time.Time
}
func (e *DeploymentBracketEvaluator) ScopeFields() evaluator.ScopeFields {
return evaluator.ScopeDeployment | evaluator.ScopeVersion | evaluator.ScopeReleaseTarget
}
func (e *DeploymentBracketEvaluator) RuleType() string {
return evaluator.RuleTypeDeploymentBracket
}
```
The `Evaluate` method performs three checks:
1. **Overlap check** — if a version group is executing for this bracket +
resource, and the candidate version is not part of that group, return
`Pending` (for `queue` strategy) or `Allowed` (for `merge`, if pre-hooks are
complete).
2. **Collection window check** — if no active group exists, find or create a
`bracket_version_group`. If the window hasn't closed, return `Pending` with
`NextEvaluationTime` set to `collection_ends_at`.
3. **Readiness check** — if the window has closed, verify the candidate version
matches the locked version for this deployment in the version group. If so,
return `Allowed`.
```go theme={null}
func (e *DeploymentBracketEvaluator) Evaluate(
ctx context.Context,
scope evaluator.EvaluatorScope,
) *oapi.RuleEvaluation {
resourceId := scope.ReleaseTarget().ResourceId
now := e.timeGetter()
// 1. Check for active executing group
activeGroup := e.getters.GetActiveVersionGroup(e.ruleId, resourceId)
if activeGroup != nil && activeGroup.Status == "executing" {
lockedVersion := activeGroup.GetLockedVersion(scope.Deployment.Id)
if lockedVersion != nil && lockedVersion.Id == scope.Version.Id {
return results.NewAllowedResult(
"Version is part of active bracket group")
}
if e.rule.OverlapStrategy == "merge" {
if e.getters.IsPreHookComplete(activeGroup.Id, resourceId) {
return results.NewAllowedResult(
"Merging into active bracket — resource already prepared")
}
}
return results.NewPendingResult(results.ActionTypeWait,
fmt.Sprintf("Bracket group %s executing on this resource",
activeGroup.Id))
}
// 2. Find or create collection window
group := e.getters.GetOrCreateVersionGroup(
e.ruleId, resourceId, scope.ReleaseTarget().EnvironmentId,
e.rule.ReadinessWindowSeconds, now,
)
if now.Before(group.CollectionEndsAt) {
return results.NewPendingResult(results.ActionTypeWait,
fmt.Sprintf("Collection window closes at %s",
group.CollectionEndsAt.Format(time.RFC3339)),
).WithNextEvaluationTime(group.CollectionEndsAt)
}
// 3. Window closed — check version lock
lockedVersion := group.GetLockedVersion(scope.Deployment.Id)
if lockedVersion == nil {
switch e.rule.UnchangedMemberStrategy {
case "skip_unchanged":
return results.NewAllowedResult(
"No new version for this member — skipping")
case "redeploy_current":
return results.NewAllowedResult(
"Redeploying current version as part of bracket")
default:
return results.NewDeniedResult(
"No version available for this bracket member")
}
}
if lockedVersion.Id != scope.Version.Id {
return results.NewDeniedResult(
"Version does not match locked bracket version")
}
return results.NewAllowedResult(
fmt.Sprintf("Bracket ready — version locked at window close"))
}
```
### New policy rule: resource concurrency
A resource concurrency rule limits how many resources in a group can
simultaneously be undergoing deployment. The group is defined by a CEL selector,
and the limit can be a percentage or absolute count.
#### Schema
```sql theme={null}
CREATE TABLE policy_rule_resource_concurrency (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID NOT NULL REFERENCES policy(id) ON DELETE CASCADE,
-- CEL selector defining the concurrency group.
-- e.g., "resource.metadata['cluster'] == 'prod-east'"
group_selector TEXT NOT NULL,
-- 'percentage' or 'count'
limit_type TEXT NOT NULL,
-- The limit value (e.g., 20 for 20% or 5 for 5 nodes)
limit_value INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
#### Evaluator
```go theme={null}
type ResourceConcurrencyEvaluator struct {
getters Getters
ruleId string
rule *oapi.ResourceConcurrencyRule
}
func (e *ResourceConcurrencyEvaluator) ScopeFields() evaluator.ScopeFields {
return evaluator.ScopeResource | evaluator.ScopeReleaseTarget
}
func (e *ResourceConcurrencyEvaluator) Evaluate(
ctx context.Context,
scope evaluator.EvaluatorScope,
) *oapi.RuleEvaluation {
groupResources := e.getters.GetResourcesBySelector(ctx, e.rule.GroupSelector)
totalInGroup := len(groupResources)
activeCount := e.getters.CountResourcesWithActiveJobs(ctx, groupResources)
var maxConcurrent int
if e.rule.LimitType == "percentage" {
maxConcurrent = int(math.Ceil(
float64(totalInGroup) * float64(e.rule.LimitValue) / 100.0,
))
} else {
maxConcurrent = int(e.rule.LimitValue)
}
if activeCount >= maxConcurrent {
return results.NewPendingResult(results.ActionTypeWait,
fmt.Sprintf("Concurrency limit reached: %d/%d resources active (max %d)",
activeCount, maxConcurrent, maxConcurrent))
}
return results.NewAllowedResult(
fmt.Sprintf("Within concurrency limit: %d/%d active (max %d)",
activeCount, maxConcurrent, maxConcurrent))
}
```
The "active" count includes any resource that has at least one job in a
processing state across any deployment matched by the parent policy's selector.
For brackets, this means a resource in any phase (pre-hook, deploying,
post-hook) counts against the limit.
### Enhancement: scoped deployment dependencies
Today, a deployment dependency rule on a policy applies to every release target
matched by the policy's `selector`. This forces separate policies for each
ordering constraint in a bracket. Adding an optional `applies_to` field lets
multiple dependency rules coexist on one policy:
```sql theme={null}
ALTER TABLE policy_rule_deployment_dependency
ADD COLUMN applies_to TEXT;
```
When `NULL`, the rule applies to all matched release targets (current behavior).
When set, the rule is only evaluated for release targets whose deployment
matches the CEL expression:
```go theme={null}
func (e *DeploymentDependencyEvaluator) Evaluate(
ctx context.Context, scope evaluator.EvaluatorScope,
) *oapi.RuleEvaluation {
if e.rule.AppliesTo != nil {
matched, err := selector.Match(ctx,
celToSelector(*e.rule.AppliesTo), scope.Deployment)
if err != nil || !matched {
return results.NewAllowedResult(
"Dependency rule does not apply to this deployment")
}
}
// ...existing evaluation logic unchanged...
}
```
### Bracket-aware gradual rollout
When a bracket rule exists on the same policy as a gradual rollout rule, the
rollout evaluator should hash on `resourceId + bracketRuleId` instead of the
individual release target key. This ensures all deployments in a bracket for a
given resource receive the same rollout position.
The gradual rollout evaluator already receives the full policy context through
the store. The change is in the hash input for rollout position calculation:
```go theme={null}
func (e *GradualRolloutEvaluator) getHashKey(
releaseTarget *oapi.ReleaseTarget,
versionId string,
) string {
if e.bracketRuleId != "" {
// All bracket members for this resource get the same position
return releaseTarget.ResourceId + e.bracketRuleId
}
return releaseTarget.Key() + versionId
}
```
Without this, kubelet-upgrade and containerd-upgrade on the same node would get
different rollout positions and arrive at different times, defeating the purpose
of bracketing.
### Evaluator chain integration
Both new evaluators slot into the existing factory:
```go theme={null}
func EvaluatorsForPolicy(store *store.Store, rule *oapi.PolicyRule) []evaluator.Evaluator {
return evaluator.CollectEvaluators(
deployableversions.NewEvaluatorFromStore(store),
approval.NewEvaluatorFromStore(store, rule),
environmentprogression.NewEvaluatorFromStore(store, rule),
deploymentbracket.NewEvaluatorFromStore(store, rule),
resourceconcurrency.NewEvaluatorFromStore(store, rule),
gradualrollout.NewEvaluatorFromStore(store, rule),
versionselector.NewEvaluator(rule),
deploymentdependency.NewEvaluator(store, rule),
deploymentwindow.NewEvaluatorFromStore(store, rule),
versioncooldown.NewEvaluatorFromStore(store, rule),
)
}
```
For any release target, all evaluators must return `Allowed` for a job to be
created. The evaluators gate at different levels:
| Evaluator | Gates at | Question it answers |
| --------------------- | ------------------ | ------------------------------------ |
| Deployment bracket | Version group | Are all sibling deployments ready? |
| Gradual rollout | Resource ordering | Is it this resource's turn? |
| Resource concurrency | Cluster capacity | Is there a concurrency slot? |
| Deployment dependency | Execution ordering | Have upstream deployments succeeded? |
### Pre/post hooks as deployments
Rather than introducing a separate hook mechanism, lifecycle hooks (drain,
uncordon) are modeled as regular deployments that are bracket members with
dependency ordering. A "drain" deployment's job agent runs `kubectl drain`. An
"uncordon" deployment's job agent runs `kubectl uncordon`. Deployment dependency
rules control execution order within the bracket.
This reuses the existing job agent, job dispatch, retry, rollback, and
verification infrastructure. Hooks get observability (traces, job status) and
policy controls (retry on failure, rollback) for free.
## Examples
### Node upgrade with ordered bracket
A cluster has 10 nodes. Three workload deployments (kubelet, containerd,
os-patch) plus two lifecycle deployments (drain, uncordon) are tagged with
`metadata.layer = "node"`.
os-patch must run before kubelet and containerd. All five share a 24-hour
collection window and a 20% concurrency limit.
**Policy configuration:**
```json theme={null}
{
"name": "Node Lifecycle",
"selector": "deployment.metadata['layer'] == 'node'",
"rules": [
{
"deploymentBracket": {
"deploymentSelector": "deployment.metadata['layer'] == 'node'",
"readinessMode": "collection_window",
"readinessWindowSeconds": 86400,
"unchangedMemberStrategy": "skip_unchanged",
"overlapStrategy": "queue"
}
},
{
"resourceConcurrency": {
"groupSelector": "resource.metadata['cluster'] == 'prod-east'",
"limitType": "percentage",
"limitValue": 20
}
},
{
"gradualRollout": {
"rolloutType": "linear",
"timeScaleInterval": 300
}
},
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'node-drain'",
"appliesTo": "deployment.name in ['os-patch', 'kubelet-upgrade', 'containerd-upgrade']"
}
},
{
"deploymentDependency": {
"dependsOn": "deployment.name == 'os-patch'",
"appliesTo": "deployment.name in ['kubelet-upgrade', 'containerd-upgrade']"
}
},
{
"deploymentDependency": {
"dependsOn": "deployment.name in ['kubelet-upgrade', 'containerd-upgrade']",
"appliesTo": "deployment.name == 'node-uncordon'"
}
}
]
}
```
This expresses the execution DAG:
```
node-drain
│
▼
os-patch
│
├──────────┐
▼ ▼
kubelet containerd (parallel)
│ │
└────┬─────┘
▼
node-uncordon
```
**Execution trace for node-3 (position 1 in rollout):**
```
Day 1, 09:00 kubelet v1.29.2 published
Collection window opens, closes Day 2 09:00
Day 1, 14:00 containerd v1.7.3 published
Day 1, 22:00 os-patch 2026-03 published
Day 2, 09:00 Collection window closes. Versions locked.
Gradual rollout: node-3 is position 1, offset 300s.
Resource concurrency: 0/2 active.
Day 2, 09:05 node-3's rollout position reached. Concurrency slot available.
5 release targets evaluated:
node-drain: bracket ✓ rollout ✓ concurrency ✓ deps: none ✓
→ JOB CREATED: kubectl drain node-3
os-patch: bracket ✓ rollout ✓ concurrency ✓ deps: drain? ✗
→ Pending
kubelet-upgrade: deps: os-patch? ✗ → Pending
containerd: deps: os-patch? ✗ → Pending
node-uncordon: deps: kubelet+containerd? ✗ → Pending
Day 2, 09:13 node-drain succeeds. Reconciliation triggers.
os-patch: deps: drain ✓ → JOB CREATED
Day 2, 09:16 os-patch succeeds.
kubelet: deps: drain ✓, os-patch ✓ → JOB CREATED
containerd: deps: drain ✓, os-patch ✓ → JOB CREATED
(parallel execution)
Day 2, 09:19 kubelet + containerd succeed.
node-uncordon: deps: kubelet ✓, containerd ✓ → JOB CREATED
Day 2, 09:20 node-uncordon succeeds. node-3 complete. Concurrency slot freed.
```
### Different clusters, different policies
The same deployments can have different bracket configurations per cluster by
using the policy selector to scope rules:
```json theme={null}
[
{
"name": "Staging Node Lifecycle",
"selector": "deployment.metadata['layer'] == 'node' AND resource.metadata['cluster'] == 'staging'",
"rules": [
{
"deploymentBracket": {
"readinessMode": "immediate",
"deploymentSelector": "deployment.metadata['layer'] == 'node'"
}
},
{
"resourceConcurrency": {
"groupSelector": "resource.metadata['cluster'] == 'staging'",
"limitType": "percentage",
"limitValue": 50
}
}
]
},
{
"name": "Prod Node Lifecycle",
"selector": "deployment.metadata['layer'] == 'node' AND resource.metadata['cluster'] == 'prod-east'",
"rules": [
{
"deploymentBracket": {
"readinessMode": "collection_window",
"readinessWindowSeconds": 86400,
"deploymentSelector": "deployment.metadata['layer'] == 'node'"
}
},
{
"resourceConcurrency": {
"groupSelector": "resource.metadata['cluster'] == 'prod-east'",
"limitType": "percentage",
"limitValue": 20
}
}
]
}
]
```
Staging deploys immediately with 50% concurrency. Production waits 24 hours and
limits to 20%. Same deployments, different operational posture.
### Partial readiness
Only kubelet gets a new version within the 24-hour window. containerd and
os-patch have no updates.
```
Day 1, 09:00 kubelet v1.29.2 published. Window opens.
Day 2, 09:00 Window closes. Only kubelet has a new version.
unchangedMemberStrategy = "skip_unchanged":
kubelet-upgrade → deploys v1.29.2
containerd, os-patch → desired version = current version → no-op
node-drain → runs (prepares the node)
node-uncordon → runs (restores the node)
Net effect: one drain cycle for one deployment upgrade.
```
### Overlapping version groups
A new version group becomes ready while the previous group is still executing.
```
Day 2, 09:00 Group A starts executing on node-3.
[drain → os-patch → kubelet v1.29.2 + containerd v1.7.3 → uncordon]
Day 2, 09:10 Group B collection window closes (new versions ready).
kubelet v1.29.3, containerd v1.7.4, os-patch 2026-04.
overlapStrategy = "queue":
Group B status for node-3: "queued"
Bracket evaluator: "Group A executing → Pending"
Group B CAN proceed on nodes that finished Group A.
Day 2, 09:20 Group A completes on node-3.
Group B promoted to "ready".
node-3 starts a fresh bracket cycle with Group B versions:
[drain → os-patch 2026-04 → kubelet v1.29.3 + containerd v1.7.4 → uncordon]
overlapStrategy = "merge":
Node is already drained from Group A.
Group B skips drain, deploys new versions, then uncordons.
One drain cycle covers both groups.
```
### Failure mid-bracket
kubelet fails on node-3 while containerd succeeds.
```
Day 2, 09:16 kubelet-upgrade FAILS on node-3.
containerd-upgrade succeeds.
node-uncordon: deps = kubelet + containerd
kubelet failed → dependency not met → Pending
With retry rule on the same policy:
kubelet retries with backoff → succeeds on retry 2
node-uncordon: all deps met → uncordons
Without retry rule:
node-3 stays drained. Concurrency slot held.
Remaining nodes blocked behind concurrency limit.
Operator must intervene (policy skip, manual fix).
```
This is why retry and/or rollback rules should always accompany bracket
policies. A stuck resource holds a concurrency slot indefinitely.
## Migration
* The `policy_rule_deployment_bracket` and `policy_rule_resource_concurrency`
tables are new. No data migration required.
* The `bracket_version_group` and `bracket_version_group_member` tables are new.
* The `applies_to` column on `policy_rule_deployment_dependency` is additive and
nullable. Existing rules have `applies_to = NULL`, preserving current behavior
(rule applies to all matched release targets).
* The new evaluators return `nil` from their factory functions when the policy
rule does not contain the relevant configuration, following the same pattern
as all existing evaluators.
* Agents that execute lifecycle hooks (drain, uncordon) are standard job agents.
No new agent interfaces are needed.
## Open Questions
1. **Collection window trigger semantics.** Should the collection window start
when the first member's version is *published* or when the first member's
version *passes other policy rules* (approval, version selector)? Starting at
publication is simpler but means the window runs concurrently with approval —
a 24h window with a 20h approval process only leaves 4h of actual collection
time.
2. **Gradual rollout interaction.** The proposal changes the hash input when a
bracket rule is present. This means adding or removing a bracket rule changes
the rollout order for all targets. Should the bracket-aware hashing be opt-in
to avoid surprising rollout order changes?
3. **Bracket membership dynamism.** The `deploymentSelector` on the bracket rule
is evaluated dynamically. If a new deployment is added mid-rollout that
matches the selector, should in-progress version groups absorb it? The
simplest behavior is to only affect future version groups.
4. **Merge strategy completeness.** The `merge` overlap strategy avoids double
drain cycles but requires the dependency evaluator to distinguish between
"upstream succeeded with Group A's version" and "upstream succeeded with
Group B's version." The current evaluator checks success status but not which
version succeeded. This may need a version-aware dependency check for merge
to work correctly.
5. **Failure blast radius.** A bracket failure on one resource holds a
concurrency slot. With 20% concurrency on a 10-node cluster, 2 stuck nodes
block the entire rollout. Should there be a configurable timeout that
auto-releases concurrency slots after a bracket has been stuck for too long,
even if the resource is in an unknown state?
6. **Hook idempotency.** The `queue` overlap strategy runs drain → uncordon →
drain → uncordon for consecutive groups. This assumes drain and uncordon are
idempotent. The `merge` strategy assumes the resource remains in a drained
state between groups. Should the bracket rule have an explicit field
declaring whether hooks are idempotent and/or whether the prepared state
persists?
7. **Auto rollback interaction.** If a rollback policy triggers for one member
deployment mid-bracket (e.g., kubelet fails health checks and auto-rolls
back to v1.28.x), the resource is in a partially-upgraded state — some
bracket members succeeded with new versions, others rolled back. Several
sub-questions arise:
* Should a rollback of any bracket member trigger a rollback of *all*
bracket members on that resource to restore a consistent version set?
This is the safe default for tightly coupled components (kubelet +
containerd), but overly aggressive for loosely coupled ones.
* If only the failed member rolls back, do post-hooks (uncordon) still
run? The dependency graph may be satisfied (kubelet "completed" via
rollback, containerd succeeded), but the resource is in a mixed state
that the operator may not have intended to uncordon.
* Should the bracket rule have a `rollback_strategy` field (e.g.,
`individual`, `all_members`, `halt_and_notify`) that controls whether
rollback is scoped to the failing member, cascaded to the full bracket,
or paused for manual intervention?
* How does a bracket-wide rollback interact with the concurrency limit?
Rolling back N members on a resource means N additional jobs — does
each count against the concurrency slot, or does the resource's
existing slot cover the rollback work?
# RFC 0004: Dry-Run Deployment Plans
Source: https://docs.ctrlplane.dev/rfc/0004-dry-run-plans
| Category | Status | Created | Author |
| -------- | -------------------- | ---------- | ------------- |
| Policies | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add an ephemeral plan API that CI pipelines call on pull requests to compute
**full rendered diffs** for each release target — showing exactly what
Kubernetes manifests, Terraform resources, or other deployed artifacts would
change, like `terraform plan` output. Results can optionally be posted back to
GitHub as PR comments or check runs. No version is created; the plan is computed
on the fly and returned to the caller.
## Motivation
When a developer opens a pull request that will eventually become a new
deployment version, two questions arise before merging:
1. **Which release targets will this version affect?**
2. **What exactly will change on each affected target?**
Today, neither question can be answered without merging the PR, creating the
version, and letting the full promotion lifecycle run. The deployer may have
intuition about the impact, but there is no way to get a concrete, rendered diff
— the kind of output `terraform plan` provides — before committing to a
deployment.
RFC 0002 introduces the `Plannable` interface on job agents, which can compute
rendered output without dispatching a job. But RFC 0002 focuses on the
reconciler: plans are computed during the promotion lifecycle to detect no-diff
targets and fast-track them. There is no way to trigger a plan *before* a
version exists.
### The PR workflow gap
The typical CI workflow for ctrlplane today:
```text theme={null}
Developer opens PR
→ CI builds artifact
→ PR is reviewed and merged
→ CI creates version (POST /v1/.../versions, status: ready)
→ ctrlplane creates releases for ALL release targets
→ full promotion lifecycle runs (staging → verification → approval → production)
→ deployer discovers which targets were actually affected
```
The deployer only learns what changed *after* committing to deployment. For
large deployments with tens or hundreds of release targets, this is a
significant blind spot. A PR that changes a single Helm values file for one
service triggers releases across every cluster, and the deployer won't know
which clusters are truly impacted until the pipeline is running.
`terraform plan` solved this for infrastructure: before applying, you see the
full execution plan with resource-level diffs. The same pattern should exist for
ctrlplane deployments.
### What "plan" means in this context
A dry-run plan computes, for each release target, the **full rendered output**
that the external system (ArgoCD, Terraform Cloud, etc.) would produce for the
proposed version — then diffs it against the current deployed state. This is not
a hash comparison (RFC 0002) or an affected/unaffected classification. It is the
actual diff content:
* For **ArgoCD**: the per-resource Kubernetes manifest diff (like
`argocd app diff`)
* For **Terraform Cloud**: the resource-level before/after diff (like
`terraform plan`)
* For **Helm**: the rendered template diff (like `helm diff upgrade`)
The diff is what the deployer reviews on the PR, the same way they review
`terraform plan` output today.
### Relationship to prior RFCs
* **RFC 0001 (Scoped Versions)** — The deployer declares which targets a version
affects. Dry-run plans can inform that decision: review the plan on the PR,
then create the version with a `targetSelector` that matches only the affected
targets.
* **RFC 0002 (Plan-Based Diff Detection)** — Provides the `Plannable` interface
and agent implementations that this RFC consumes. RFC 0002 runs plans inside
the reconciler; this RFC exposes plans via an API endpoint before any version
exists.
## Proposal
### API
Add a new endpoint that accepts proposed version data and returns rendered diffs
per release target. Nothing is persisted — the plan is ephemeral.
**Endpoint:**
```text theme={null}
POST /v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan
```
**Request body:**
```json theme={null}
{
"tag": "pr-123-abc123",
"config": {},
"jobAgentConfig": {},
"metadata": {
"pr": "123",
"commit": "abc123"
}
}
```
The fields mirror the version creation endpoint but no version row is inserted.
The API constructs a transient version object in memory and uses it to build
dispatch contexts.
**Synchronous response** (when all agents complete quickly):
```json theme={null}
{
"id": "plan_abc123",
"status": "completed",
"summary": {
"total": 50,
"changed": 3,
"unchanged": 47,
"errored": 0,
"resourceChanges": {
"add": 1,
"modify": 4,
"delete": 0
}
},
"targets": [
{
"environmentId": "env_prod",
"environmentName": "production",
"resourceId": "res_use1",
"resourceName": "us-east-1-cluster",
"hasChanges": true,
"diff": {
"raw": "--- current\n+++ proposed\n@@ -12,3 +12,3 @@\n- image: payments:v1.2.3\n+ image: payments:v1.2.4\n",
"resources": [
{
"kind": "Deployment",
"name": "payment-api",
"namespace": "payments",
"action": "modify",
"diff": "--- current\n+++ proposed\n@@ -12,3 +12,3 @@\n- image: payments:v1.2.3\n+ image: payments:v1.2.4\n"
}
]
}
},
{
"environmentId": "env_prod",
"environmentName": "production",
"resourceId": "res_euw1",
"resourceName": "eu-west-1-cluster",
"hasChanges": false,
"diff": null
}
]
}
```
Each target in the response includes:
* `hasChanges` — whether the rendered output differs from the current state
* `diff.raw` — human-readable unified diff of the full rendered output
* `diff.resources` — structured breakdown of per-resource changes with `kind`,
`name`, `namespace`, `action` (add/modify/delete), and a per-resource `diff`
**Async response** (when agents require slow external calls):
```json theme={null}
{
"id": "plan_abc123",
"status": "computing",
"summary": null,
"targets": []
}
```
The CI polls
`GET /v1/workspaces/{workspaceId}/deployments/{deploymentId}/plan/{planId}`
until `status` transitions to `completed` or `failed`.
### Extended `PlanResult` type
RFC 0002 defines `PlanResult` with `ContentHash`, `HasChanges`, and a simple
`Diff` string. The dry-run plan requires richer diff data. The type is extended:
```go theme={null}
type PlanResult struct {
ContentHash string
HasChanges bool
RenderedOutput string
Diff *PlanDiff
}
type PlanDiff struct {
Raw string
Resources []ResourceChange
}
type ResourceChange struct {
Kind string // "Deployment", "Service", "aws_iam_policy"
Name string // "payment-api", "module.vpc.aws_subnet"
Namespace string // Kubernetes namespace, empty for non-k8s
Action string // "add", "modify", "delete", "no-op"
Before string // Rendered YAML/JSON before (empty for adds)
After string // Rendered YAML/JSON after (empty for deletes)
Diff string // Unified diff for this resource
}
```
RFC 0002's reconciler integration only uses `ContentHash` and `HasChanges`. The
additional fields (`RenderedOutput`, `Diff`) are populated by agents when called
through the dry-run plan API and ignored by the reconciler path.
### How agents produce diffs
The `Plannable` interface from RFC 0002 is unchanged — agents return a
`PlanResult`. The difference is what the caller does with it:
* **Reconciler (RFC 0002):** Only inspects `ContentHash` and `HasChanges`.
* **Dry-run plan API (this RFC):** Inspects the full `PlanDiff` and returns it
to the caller.
Agents that want to participate in dry-run plans must populate the `Diff` field.
Agents that only implement hash-based comparison (no diff capability) can still
participate — the API response will show `hasChanges: true/false` but `diff`
will be null.
#### ArgoCD
The ArgoCD agent calls the ArgoCD API to produce a real diff. The in-process
`TemplateApplication` function only renders the Application CRD (which always
differs because `targetRevision` changes). The actual diff lives in the
Kubernetes manifests that ArgoCD produces after fetching the git repo and
rendering the Helm chart or kustomize overlay.
The `Plan` implementation uses a **temporary Application** strategy. Calling
`GetManifests` on the existing Application only overrides the revision — it does
not pick up changes to Helm values, parameters, kustomize patches, or any other
spec field derived from deployment variables. To get a fully accurate manifest
diff for *any* kind of change (revision, variables, config), the agent creates a
short-lived Application with auto-sync disabled, waits for ArgoCD to render
manifests for it, fetches those manifests, then cleans it up.
The flow:
1. Renders the proposed Application CRD from the dispatch context (same as
dispatch time). This CRD reflects all variable and config changes.
2. Strips any auto-sync policy and sets the sync policy to manual, so the
temporary Application will never deploy to the cluster.
3. Creates the temporary Application in ArgoCD with a deterministic plan-scoped
name (e.g., `-plan-`).
4. Waits for ArgoCD to compute the desired manifests for the temporary
Application. ArgoCD fetches the git repo, renders Helm/kustomize with the
full proposed spec (including new values, parameters, revisions), and
populates the manifest cache.
5. Calls `GetManifests` on the temporary Application to retrieve the fully
rendered proposed manifests.
6. Calls `GetManifests` on the original Application to retrieve the current
manifests.
7. Deletes the temporary Application.
8. Computes a per-resource unified diff between the two manifest sets.
**Multi-source Applications.** ArgoCD v2.6+ supports `spec.sources` (plural) for
Applications that pull from multiple Git repos or Helm charts (e.g., a chart
from one repo and values from another). Because the temporary Application is
created from the full rendered spec, multi-source applications are handled
naturally — the proposed spec's `sources` list (with all target revisions) is
preserved as-is.
```go theme={null}
const (
planLabelKey = "ctrlplane.dev/plan"
planCreatedAtKey = "ctrlplane.dev/plan-created-at"
planTTL = 10 * time.Minute
)
func planAppName(originalName string) string {
h := sha256.Sum256([]byte(originalName + time.Now().String()))
return fmt.Sprintf("%s-plan-%s", originalName, hex.EncodeToString(h[:4]))
}
func prepareTmpApp(app *v1alpha1.Application, tmpName string) *v1alpha1.Application {
tmp := app.DeepCopy()
tmp.Name = tmpName
tmp.ResourceVersion = ""
if tmp.Labels == nil {
tmp.Labels = map[string]string{}
}
tmp.Labels[planLabelKey] = "true"
if tmp.Annotations == nil {
tmp.Annotations = map[string]string{}
}
tmp.Annotations[planCreatedAtKey] = time.Now().UTC().Format(time.RFC3339)
tmp.Spec.SyncPolicy = &v1alpha1.SyncPolicy{Automated: nil}
tmp.Operation = nil
return tmp
}
func (a *ArgoApplication) Plan(
ctx context.Context,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
serverAddr, apiKey, template, err := ParseJobAgentConfig(
dispatchCtx.JobAgentConfig,
)
if err != nil {
return nil, err
}
proposedApp, err := TemplateApplication(dispatchCtx, template)
if err != nil {
return nil, err
}
MakeApplicationK8sCompatible(proposedApp)
client, err := argocdclient.NewClient(&argocdclient.ClientOptions{
ServerAddr: serverAddr,
AuthToken: apiKey,
})
if err != nil {
return nil, fmt.Errorf("create ArgoCD client: %w", err)
}
ioCloser, appClient, err := client.NewApplicationClient()
if err != nil {
return nil, fmt.Errorf("create application client: %w", err)
}
defer ioCloser.Close()
originalName := proposedApp.Name
tmpName := planAppName(originalName)
tmpApp := prepareTmpApp(proposedApp, tmpName)
upsert := true
_, err = appClient.Create(ctx, &argocdapplication.ApplicationCreateRequest{
Application: tmpApp,
Upsert: &upsert,
})
if err != nil {
return nil, fmt.Errorf("create temporary plan application: %w", err)
}
defer func() {
cascade := false
_, _ = appClient.Delete(ctx, &argocdapplication.ApplicationDeleteRequest{
Name: &tmpName,
Cascade: &cascade,
})
}()
if err := waitForManifests(ctx, appClient, tmpName); err != nil {
return nil, fmt.Errorf("wait for temporary app manifests: %w", err)
}
proposedManifests, err := appClient.GetManifests(ctx,
&argocdapplication.ApplicationManifestQuery{Name: &tmpName},
)
if err != nil {
return nil, fmt.Errorf("get proposed manifests: %w", err)
}
currentManifests, err := appClient.GetManifests(ctx,
&argocdapplication.ApplicationManifestQuery{Name: &originalName},
)
if err != nil {
return buildAddAllResult(proposedManifests)
}
return diffManifestSets(currentManifests.Manifests, proposedManifests.Manifests)
}
```
The `waitForManifests` helper polls the temporary Application until ArgoCD
reports a non-empty manifest set or the context deadline expires:
```go theme={null}
func waitForManifests(
ctx context.Context,
appClient argocdapplication.ApplicationServiceClient,
name string,
) error {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
resp, err := appClient.GetManifests(ctx,
&argocdapplication.ApplicationManifestQuery{Name: &name},
)
if err != nil {
continue
}
if len(resp.Manifests) > 0 {
return nil
}
}
}
}
```
**Cleanup guarantees.** The temporary Application is deleted in a `defer` with
`cascade: false` (the Application never synced, so there are no cluster
resources to remove). If the agent crashes before cleanup, orphaned Applications
remain in ArgoCD. ArgoCD has no native TTL mechanism for Applications — cleanup
of orphans is ctrlplane's responsibility.
Every temporary Application is labelled `ctrlplane.dev/plan: "true"` and
annotated with `ctrlplane.dev/plan-created-at: `. A
background goroutine in the workspace engine periodically lists Applications
matching the plan label, parses the created-at annotation, and deletes any older
than `planTTL` (default 10 minutes):
```go theme={null}
func (gc *PlanAppGC) Run(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
gc.cleanup(ctx)
}
}
}
func (gc *PlanAppGC) cleanup(ctx context.Context) {
selector := fmt.Sprintf("%s=true", planLabelKey)
apps, err := gc.appClient.List(ctx, &argocdapplication.ApplicationQuery{
Selector: &selector,
})
if err != nil {
log.Warn("plan GC: list failed", "error", err)
return
}
for _, app := range apps.Items {
createdAt, err := time.Parse(time.RFC3339, app.Annotations[planCreatedAtKey])
if err != nil || time.Since(createdAt) < planTTL {
continue
}
cascade := false
name := app.Name
_, _ = gc.appClient.Delete(ctx, &argocdapplication.ApplicationDeleteRequest{
Name: &name,
Cascade: &cascade,
})
log.Info("plan GC: deleted orphaned plan app", "name", name,
"age", time.Since(createdAt))
}
}
```
The GC runs per ArgoCD server. When the workspace engine starts, it registers a
`PlanAppGC` instance for each configured ArgoCD connection.
The `diffManifestSets` function parses each manifest as a Kubernetes resource,
matches resources by `apiVersion/kind/namespace/name`, and produces a
`ResourceChange` for each:
* Resources in proposed but not current → `action: "add"`
* Resources in current but not proposed → `action: "delete"`
* Resources in both with different content → `action: "modify"` with unified
diff
* Resources in both with identical content → omitted (no-op)
#### Terraform Cloud
Terraform Cloud speculative plans already produce structured diff output. The
`Plan` implementation triggers a speculative plan run and maps the result:
```go theme={null}
func (t *TerraformCloud) Plan(
ctx context.Context,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
run, err := t.client.CreateRun(ctx, RunConfig{
IsDestroy: false,
PlanOnly: true,
Variables: dispatchCtx.Variables,
})
if err != nil {
return nil, err
}
plan, err := t.client.WaitForPlan(ctx, run.ID)
if err != nil {
return nil, err
}
resources := make([]types.ResourceChange, 0, len(plan.ResourceChanges))
for _, rc := range plan.ResourceChanges {
resources = append(resources, types.ResourceChange{
Kind: rc.Type,
Name: rc.Address,
Action: mapTerraformAction(rc.Change.Actions),
Before: rc.Change.Before,
After: rc.Change.After,
Diff: rc.Change.Diff,
})
}
hasChanges := plan.ResourceAdditions > 0 ||
plan.ResourceChanges > 0 ||
plan.ResourceDestructions > 0
return &types.PlanResult{
ContentHash: plan.StateHash,
HasChanges: hasChanges,
Diff: &types.PlanDiff{
Raw: plan.HumanReadableOutput,
Resources: resources,
},
}, nil
}
```
#### GitHub Actions / unsupported agents
Agents that do not implement `Plannable` return nil from the registry's `Plan`
method. The dry-run plan endpoint reports these targets as:
```json theme={null}
{
"resourceName": "some-target",
"hasChanges": null,
"diff": null,
"status": "unsupported"
}
```
The CI can still post a PR comment noting that some targets could not be
planned.
### Plan execution flow
The plan endpoint does not create a version or trigger the reconciler. It
constructs the necessary context in-memory and calls agents directly:
```text theme={null}
1. Parse request body into transient version object
2. Look up deployment and its job agents
3. Find all release targets for this deployment
(same query as enqueueReleaseTargetsForDeployment)
4. For each release target:
a. Resolve variables (reuse variableresolver.Resolve)
b. Build DispatchContext (reuse jobs.Factory.BuildDispatchContext)
c. Call registry.Plan(agentType, dispatchCtx)
d. Collect PlanResult
5. Aggregate results into response
6. If github field present, post results to PR
```
For agents with fast plan steps (ArgoCD with cached manifests), the endpoint can
complete synchronously. For slow agents (Terraform Cloud speculative plans
taking minutes), the endpoint:
1. Creates a plan record in a lightweight `deployment_plan` table with
`status: "computing"`.
2. Enqueues plan computation as background work.
3. Returns the plan ID immediately.
4. The CI polls `GET .../plan/{planId}` until status is `completed`.
```sql theme={null}
CREATE TABLE deployment_plan (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
deployment_id UUID NOT NULL REFERENCES deployment(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'computing',
request JSONB NOT NULL,
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '1 hour'
);
```
Plans are ephemeral — the `expires_at` column enables periodic cleanup. No
long-term storage is needed.
### GitHub integration
When the plan request includes a `github` field, ctrlplane posts results back to
the PR using the GitHub App that is already configured for workflow dispatch:
**Request with GitHub integration:**
```json theme={null}
{
"tag": "pr-123-abc123",
"config": {},
"metadata": { "pr": "123", "commit": "abc123" },
"github": {
"owner": "org",
"repo": "myapp",
"sha": "abc123def456",
"prNumber": 123
}
}
```
**PR comment format:**
The comment follows the pattern established by Atlantis and Terraform Cloud,
adapted for ctrlplane's multi-target model:
````markdown theme={null}
### Ctrlplane Deployment Plan
**Deployment:** API Service **Version:** pr-123-abc123
| Environment | Resource | Changes | Details |
| ----------- | ------------------ | ---------- | ------------------------ |
| production | us-east-1-cluster | 1 modified | `Deployment/payment-api` |
| production | eu-west-1-cluster | No changes | — |
| production | ap-south-1-cluster | No changes | — |
| staging | staging-cluster | 1 modified | `Deployment/payment-api` |
**Summary:** 2 of 4 targets affected (1 resource modified)
us-east-1-cluster diff
\```diff --- Deployment/payments/payment-api (current) +++
Deployment/payments/payment-api (proposed) @@ -15,3 +15,3 @@ containers: - name:
payment-api
- image: payments:v1.2.3
* image: payments:v1.2.4
\```
````
**GitHub Check Run** (alternative or complement to PR comment):
The plan can also be reported as a GitHub Check Run with status
`success`/`neutral`/`failure` and structured annotations per changed resource.
Check runs integrate with branch protection rules, allowing teams to require a
passing plan before merge.
**Implementation:** The existing GitHub App integration in the workspace engine
uses the ArgoCD Go client pattern for API calls. The PR comment/check run
posting uses the GitHub App's installation token (the same token acquisition
flow used by `GoGitHubWorkflowDispatcher` in
`apps/workspace-engine/svc/controllers/jobdispatch/jobagents/github/`).
### Optional: `pull_request` webhook handler
As a convenience layer, ctrlplane can optionally react to GitHub `pull_request`
webhook events to auto-trigger plans without CI changes.
The GitHub webhook handler in `apps/api/src/routes/github/index.ts` currently
only handles `workflow_run` events:
```typescript theme={null}
if (eventType === "workflow_run")
await handleWorkflowRunEvent(req.body as WorkflowRunEvent);
```
Adding a `pull_request` handler:
```typescript theme={null}
if (eventType === "workflow_run")
await handleWorkflowRunEvent(req.body as WorkflowRunEvent);
else if (eventType === "pull_request")
await handlePullRequestEvent(req.body as PullRequestEvent);
```
The PR metadata types already exist in `packages/validators/src/github/index.ts`
(`GithubPullRequestVersion`, `PullRequestMetadataKey`, `PullRequestConfigKey`)
but are not wired up to any handler.
The `handlePullRequestEvent` function would:
1. Extract the repo owner/name and head SHA from the event payload.
2. Find deployments whose job agent config references this repo (by matching
`owner` and `repo` fields in the GitHub job agent config).
3. For each matching deployment, trigger a plan using the head SHA as the
proposed version tag.
4. Post results back as a PR comment or check run.
This is optional — the CI-triggered API is the primary integration path. The
webhook handler is a convenience for teams that want automatic plans without
modifying their CI pipelines.
## Examples
### ArgoCD: Helm chart change on a PR
A deployment manages 20 clusters across 4 environments using ArgoCD with a
monorepo Helm chart. A developer opens a PR that modifies
`charts/payment/values.yaml`.
```bash theme={null}
# In the CI pipeline triggered by the PR:
curl -X POST \
"https://api.ctrlplane.dev/v1/workspaces/$WS/deployments/$DEPLOY/plan" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"tag": "pr-456-'$(git rev-parse --short HEAD)'",
"config": {},
"metadata": {
"commit": "'$(git rev-parse HEAD)'",
"pr": "456",
"branch": "'$(git branch --show-current)'"
},
"github": {
"owner": "myorg",
"repo": "platform",
"sha": "'$(git rev-parse HEAD)'",
"prNumber": 456
}
}'
```
ctrlplane:
1. Builds a transient version with the PR's head commit.
2. For each of the 20 release targets, calls ArgoCD's `GetManifests` API with
the PR commit as the target revision.
3. Diffs the proposed manifests against the currently deployed manifests.
4. Returns: 4 targets show changes (the clusters running the payment service),
16 show no changes.
5. Posts a PR comment showing the diff table with expandable per-target diffs.
The developer sees exactly which clusters are affected and what Kubernetes
resources change — before merging.
### Terraform Cloud: Infrastructure PR
A deployment manages Terraform infrastructure across 3 regions. A PR changes an
IAM policy module.
```bash theme={null}
curl -X POST \
"https://api.ctrlplane.dev/v1/workspaces/$WS/deployments/$DEPLOY/plan" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"tag": "pr-789-abc123",
"config": {},
"metadata": { "pr": "789" }
}'
```
Response (after async completion):
```json theme={null}
{
"id": "plan_xyz",
"status": "completed",
"summary": {
"total": 3,
"changed": 1,
"unchanged": 2,
"errored": 0,
"resourceChanges": { "add": 0, "modify": 2, "delete": 0 }
},
"targets": [
{
"environmentName": "production",
"resourceName": "us-east-1",
"hasChanges": true,
"diff": {
"raw": "Terraform will perform the following actions:\n\n # aws_iam_policy.service_policy will be updated in-place\n ~ resource \"aws_iam_policy\" \"service_policy\" {\n ~ policy = jsonencode(\n ~ {\n ~ Statement = [\n ~ {\n ~ Action = [\n + \"s3:GetObject\",\n ]\n },\n ]\n }\n )\n }\n\nPlan: 0 to add, 2 to change, 0 to destroy.",
"resources": [
{
"kind": "aws_iam_policy",
"name": "module.auth.aws_iam_policy.service_policy",
"action": "modify",
"diff": "..."
},
{
"kind": "aws_iam_role_policy_attachment",
"name": "module.auth.aws_iam_role_policy_attachment.service",
"action": "modify",
"diff": "..."
}
]
}
},
{
"environmentName": "production",
"resourceName": "eu-west-1",
"hasChanges": false,
"diff": null
},
{
"environmentName": "production",
"resourceName": "ap-south-1",
"hasChanges": false,
"diff": null
}
]
}
```
The PR shows that only us-east-1 is affected, with exactly 2 IAM resources
changing.
### GitHub Actions: Unsupported agent
A deployment uses GitHub Actions (no `Plannable` implementation). The plan
endpoint still runs but cannot produce diffs:
```json theme={null}
{
"id": "plan_def",
"status": "completed",
"summary": {
"total": 5,
"changed": 0,
"unchanged": 0,
"errored": 0,
"unsupported": 5
},
"targets": [
{
"environmentName": "production",
"resourceName": "cluster-1",
"hasChanges": null,
"diff": null,
"status": "unsupported"
}
]
}
```
The CI can still post a PR comment noting that plan output is not available for
this deployment type.
## Migration
* The `deployment_plan` table is new and requires no data migration.
* Plans are ephemeral with a 1-hour TTL by default. No long-term storage
concerns.
* The `Plannable` interface (RFC 0002) is unchanged. Agents that already
implement it gain dry-run plan support automatically; they only need to
populate the `Diff` field for rich output.
* The `pull_request` webhook handler is additive. The existing `workflow_run`
handler is unchanged.
* No changes to the version creation flow, reconciler, or promotion lifecycle.
## Open Questions
1. **Rate limiting.** Plans involve external API calls (ArgoCD manifest
rendering, Terraform speculative plans). For deployments with many release
targets, a single PR could trigger hundreds of external calls. Should there
be a per-deployment or per-workspace rate limit on plan requests? Should
callers be able to scope the plan to specific environments or resources?
2. **Plan scope.** The proposal plans against all release targets. For large
deployments, the caller may want to plan only for specific environments or
resources. Should the request body accept an optional filter
(`environmentSelector`, `resourceSelector`) to narrow the plan scope?
3. **Diff format standardization.** ArgoCD produces YAML diffs, Terraform
produces HCL-style diffs. Should the `raw` field in `PlanDiff` be
agent-specific (each agent returns its native format), or should ctrlplane
normalize to a common diff format?
4. **Cost of plans.** Each plan consumes Terraform Cloud compute resources. For
deployments with many targets across many PRs, this could become expensive.
Should Terraform plans require explicit opt-in per deployment?
5. **Temporary Application permissions.** Creating and deleting Applications
requires write access to the ArgoCD API. Some teams restrict Application
creation to specific ArgoCD projects or RBAC roles. Should the plan
Application be created in a dedicated ArgoCD project (e.g.,
`ctrlplane-plans`) with limited permissions, or inherit the project from the
original Application?
6. **ArgoCD rendering latency.** After creating the temporary Application, the
agent polls until ArgoCD renders manifests. For large Helm charts or slow git
repos this could take significant time. Should there be a configurable
timeout per agent, and how should the plan endpoint report rendering timeouts
vs. real errors?
# RFC 0005: Argo Workflows Job Agent
Source: https://docs.ctrlplane.dev/rfc/0005-argo-workflows-job-agent
| Category | Status | Created | Author |
| ---------- | -------------------- | ---------- | ------------- |
| Job Agents | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add a new `argo-workflows` job agent type that submits Argo Workflow CRDs to
Kubernetes, templates workflow specs from the dispatch context, and monitors
workflow execution to completion. This enables teams to use Argo Workflows as a
native deployment execution engine within ctrlplane's promotion lifecycle.
## Motivation
Ctrlplane's job agent model today covers three execution patterns:
* **ArgoCD** — declarative GitOps sync of Kubernetes Applications
* **GitHub Actions** — CI-triggered workflow dispatch via the GitHub API
* **Terraform Cloud** — speculative and apply runs via the TFC API
These patterns share a common shape: ctrlplane constructs a dispatch context,
the agent translates it into an external system call, and the external system
reports back when done. But a significant class of deployment operations does
not fit neatly into any of them.
### The gap: orchestrated multi-step deployments
Many deployment procedures involve multiple steps that must execute in sequence
or in a DAG structure on a Kubernetes cluster:
* **Database migrations** before application rollout
* **Canary analysis** with traffic splitting, metric collection, and rollback
* **Blue/green cutover** with health checks between steps
* **Infrastructure provisioning** (create namespace, install CRDs, deploy app)
* **Integration test suites** that run against a freshly deployed environment
* **Custom scripts** (data backfixes, cache warming, feature flag toggling)
Today, teams using these patterns have two options:
1. **GitHub Actions** — The workflow runs outside the cluster, requiring
kubeconfig secrets, network access to the cluster API, and manual status
reporting back to ctrlplane. Multi-cluster deployments need per-cluster
credentials. The workflow has no native access to in-cluster resources.
2. **ArgoCD sync hooks** — ArgoCD supports PreSync/Sync/PostSync hooks, but
these are limited to single Jobs or Pods with linear ordering. Complex DAGs,
conditional branching, retries with backoff, artifact passing between steps,
and parameterized templates are not expressible.
Argo Workflows fills this gap. It is a Kubernetes-native workflow engine that
runs inside the cluster, supports DAG and step-based orchestration, has
first-class retry/backoff semantics, handles artifact passing between steps, and
is already widely deployed alongside ArgoCD in GitOps environments.
### Why not use GitHub Actions for everything?
GitHub Actions can technically orchestrate any deployment, but it operates
outside the cluster boundary:
```text theme={null}
GitHub Actions (external) Kubernetes cluster
┌──────────────────────┐ ┌──────────────────────┐
│ deploy.yml │ │ │
│ step 1: migrate db ─┼──kubectl──┼─→ run migration pod │
│ step 2: deploy app ─┼──kubectl──┼─→ update deployment │
│ step 3: run tests ─┼──kubectl──┼─→ create test pod │
│ step 4: report ─┼──api─────┼─→ ctrlplane callback │
└──────────────────────┘ └──────────────────────┘
```
Every step crosses the network boundary. This requires:
* Kubeconfig or service account token stored as GitHub secrets
* Network connectivity from GitHub's runners to the cluster API
* Per-cluster credential management for multi-cluster deployments
* Manual status reporting back to ctrlplane's API
With Argo Workflows, the entire execution stays in-cluster:
```text theme={null}
Kubernetes cluster
┌────────────────────────────────────────┐
│ Argo Workflow (submitted by ctrlplane) │
│ step 1: migrate db → migration pod │
│ step 2: deploy app → kubectl apply │
│ step 3: run tests → test pod │
│ status: reported via workflow CRD │
└────────────────────────────────────────┘
```
No external credentials. No network boundary crossings. Native access to
in-cluster resources. Status is read from the Workflow CRD status field.
### Why not extend the ArgoCD agent?
ArgoCD and Argo Workflows are separate projects with different APIs, CRDs, and
operational models:
* **ArgoCD** is declarative: you describe a desired state (Application CRD) and
ArgoCD continuously reconciles toward it. The agent upserts an Application and
verifies it reaches Healthy+Synced.
* **Argo Workflows** is imperative: you submit a workflow (Workflow CRD) and it
runs to completion. Each submission is a discrete execution with a start and
end.
The dispatch lifecycle is fundamentally different. ArgoCD's `UpsertApplication`
→ poll health model does not map to Argo Workflows' submit → watch completion
model. Combining them in one agent would conflate two distinct execution
semantics behind a single `argo-cd` type, making configuration confusing and
error handling ambiguous.
## Proposal
### Agent type and config
Register a new agent type `argo-workflows` in the workspace engine's job agent
registry. The job agent config provides cluster access and a workflow template:
```json theme={null}
{
"type": "argo-workflows",
"serverUrl": "https://argo-workflows.example.com",
"token": "argo-token-or-service-account",
"namespace": "argo",
"template": "apiVersion: argoproj.io/v1alpha1\nkind: Workflow\n..."
}
```
| Field | Required | Description |
| ----------- | -------- | ----------------------------------------------------------- |
| `serverUrl` | Yes | Argo Workflows server URL (API endpoint) |
| `token` | Yes | Bearer token or service account token for authentication |
| `namespace` | No | Default namespace for workflow submission (default: `argo`) |
| `template` | Yes | Go template rendering an Argo Workflow YAML |
The `template` field follows the same pattern as the ArgoCD agent's template: a
Go template string that receives the dispatch context and produces a valid Argo
Workflow CRD. This is rendered at dispatch time using the `templatefuncs`
pipeline with custom delimiters `{[` / `]}` instead of Go's default `{{` / `}}`
(see "Template delimiters" below).
### Workflow template
The template renders a complete Argo Workflow spec from the dispatch context.
The dispatch context provides deployment, environment, resource, version, and
variable data — the same data available to all job agent templates.
**Example template for a database migration + deploy workflow:**
```yaml theme={null}
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: deploy-{[.deployment.slug]}-
namespace: { [.resource.config.namespace | default "argo"] }
labels:
ctrlplane.dev/job-id: "{[.job.id]}"
ctrlplane.dev/deployment: "{[.deployment.slug]}"
ctrlplane.dev/environment: "{[.environment.name]}"
spec:
entrypoint: deploy
serviceAccountName: argo-deployer
arguments:
parameters:
- name: image-tag
value: "{[.release.version.tag]}"
- name: target-namespace
value: "{[.resource.config.namespace]}"
- name: replica-count
value: "{[.release.variables.REPLICA_COUNT]}"
templates:
- name: deploy
dag:
tasks:
- name: migrate-db
template: run-migration
- name: deploy-app
template: apply-manifests
dependencies: [migrate-db]
- name: smoke-test
template: run-tests
dependencies: [deploy-app]
- name: run-migration
container:
image: "{[.release.variables.MIGRATION_IMAGE]}:{[.release.version.tag]}"
command: ["/migrate", "--target", "latest"]
- name: apply-manifests
container:
image: bitnami/kubectl:latest
command:
- kubectl
- set
- image
- "deployment/{[.deployment.slug]}"
- "app={[.release.variables.APP_IMAGE]}:{[.release.version.tag]}"
- "-n"
- "{{workflow.parameters.target-namespace}}"
- name: run-tests
container:
image: "{[.release.variables.TEST_IMAGE]}:latest"
command:
[
"/run-tests",
"--endpoint",
"http://{[.deployment.slug]}.{{workflow.parameters.target-namespace}}.svc",
]
retryStrategy:
limit: 3
backoff:
duration: "10s"
factor: 2
```
### Template delimiters
Argo Workflows uses `{{` / `}}` for its own parameter substitution at runtime
(e.g., `{{workflow.parameters.target-namespace}}`). Go's `text/template` uses
the same delimiters by default. With standard Go templates, users must escape
every Argo expression — an error-prone and unreadable approach.
Instead, ctrlplane templates use **`{[` / `]}`** as delimiters. This is a clean
separation: `{[ ]}` is ctrlplane's template language, `{{ }}` is Argo's. Both
coexist in the same YAML file without escaping:
```yaml theme={null}
# {[ ]} — resolved by ctrlplane at dispatch time
image: "{[.release.version.tag]}"
# {{ }} — resolved by Argo Workflows at workflow runtime
namespace: "{{workflow.parameters.target-namespace}}"
```
The `templatefuncs` package already provides a `New` function that configures
template options. The custom delimiters are set via Go's `Delims` method:
```go theme={null}
func NewWithDelims(name string) *template.Template {
return template.New(name).
Delims("{[", "]}").
Funcs(funcs).
Option("missingkey=zero")
}
```
This delimiter change applies to **all** ctrlplane job agent templates, not just
Argo Workflows. The ArgoCD agent benefits too — ArgoCD Application specs that
embed Helm value overrides containing `{{ }}` expressions currently require
escaping, and custom delimiters eliminate that. Existing templates using `{{` /
`}}` would be migrated to `{[` / `]}` as part of this change.
### Implementation
#### Go types
```go theme={null}
package argoworkflows
type ArgoWorkflows struct {
setter Setter
submitter WorkflowSubmitter
}
// WorkflowSubmitter submits and monitors Argo Workflows.
type WorkflowSubmitter interface {
SubmitWorkflow(
ctx context.Context,
serverAddr, token, namespace string,
workflow *unstructured.Unstructured,
) (workflowName string, err error)
GetWorkflowStatus(
ctx context.Context,
serverAddr, token, namespace, name string,
) (*WorkflowStatus, error)
}
type WorkflowStatus struct {
Phase string // Pending, Running, Succeeded, Failed, Error
Message string
StartedAt *time.Time
FinishedAt *time.Time
Nodes map[string]NodeStatus
}
type NodeStatus struct {
Name string
Phase string
Message string
StartedAt *time.Time
FinishedAt *time.Time
}
```
#### Dispatchable implementation
```go theme={null}
var (
_ types.Dispatchable = &ArgoWorkflows{}
_ types.Verifiable = &ArgoWorkflows{}
)
func (a *ArgoWorkflows) Type() string {
return "argo-workflows"
}
func (a *ArgoWorkflows) Dispatch(ctx context.Context, job *oapi.Job) error {
dispatchCtx := job.DispatchContext
if dispatchCtx == nil {
return fmt.Errorf("job %s has no dispatch context", job.Id)
}
serverAddr, token, namespace, template, err := ParseJobAgentConfig(
dispatchCtx.JobAgentConfig,
)
if err != nil {
return fmt.Errorf("parse job agent config: %w", err)
}
wf, err := TemplateWorkflow(dispatchCtx, job, template)
if err != nil {
return fmt.Errorf("template workflow: %w", err)
}
EnsureLabels(wf, job)
go func() {
parentSpanCtx := trace.SpanContextFromContext(ctx)
asyncCtx, span := tracer.Start(context.Background(),
"ArgoWorkflows.AsyncDispatch",
trace.WithLinks(trace.Link{SpanContext: parentSpanCtx}),
)
defer span.End()
name, err := a.submitter.SubmitWorkflow(
asyncCtx, serverAddr, token, namespace, wf,
)
if err != nil {
_ = a.setter.UpdateJob(asyncCtx, job.Id,
oapi.JobStatusFailure,
fmt.Sprintf("failed to submit workflow: %s", err.Error()),
nil,
)
return
}
metadata := map[string]string{
"ctrlplane/links": fmt.Sprintf(
`{"Argo Workflow":"%s/workflows/%s/%s"}`,
serverAddr, namespace, name,
),
"argo-workflows/name": name,
"argo-workflows/namespace": namespace,
}
_ = a.setter.UpdateJob(asyncCtx, job.Id,
oapi.JobStatusInProgress, "", metadata,
)
a.pollUntilComplete(asyncCtx, job.Id, serverAddr, token, namespace, name)
}()
return nil
}
```
#### Workflow templating
The template rendering follows the same pattern as ArgoCD but uses `{[` / `]}`
delimiters and produces an unstructured Kubernetes object instead of a typed
Application CRD. This avoids importing Argo Workflows' full type system as a
dependency:
```go theme={null}
func TemplateWorkflow(
dispatchCtx *oapi.DispatchContext,
job *oapi.Job,
tmpl string,
) (*unstructured.Unstructured, error) {
t, err := templatefuncs.NewWithDelims("argoWorkflowsAgentConfig").Parse(tmpl)
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
data := dispatchCtx.Map()
data["job"] = structToMap(job)
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
obj := &unstructured.Unstructured{}
if err := yaml.Unmarshal(buf.Bytes(), &obj.Object); err != nil {
return nil, fmt.Errorf("unmarshal workflow: %w", err)
}
if obj.GetAPIVersion() != "argoproj.io/v1alpha1" {
return nil, fmt.Errorf(
"expected apiVersion argoproj.io/v1alpha1, got %s",
obj.GetAPIVersion(),
)
}
if obj.GetKind() != "Workflow" {
return nil, fmt.Errorf("expected kind Workflow, got %s", obj.GetKind())
}
return obj, nil
}
func EnsureLabels(wf *unstructured.Unstructured, job *oapi.Job) {
labels := wf.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
labels["ctrlplane.dev/job-id"] = job.Id
labels["ctrlplane.dev/managed-by"] = "ctrlplane"
wf.SetLabels(labels)
}
```
#### Completion polling
After submission, the agent polls the Argo Workflows API for workflow status.
The polling follows an exponential backoff pattern capped at 30 seconds:
```go theme={null}
func (a *ArgoWorkflows) pollUntilComplete(
ctx context.Context,
jobID, serverAddr, token, namespace, name string,
) {
backoff := 2 * time.Second
maxBackoff := 30 * time.Second
timeout := 2 * time.Hour
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
status, err := a.submitter.GetWorkflowStatus(
ctx, serverAddr, token, namespace, name,
)
if err != nil {
backoff = min(backoff*2, maxBackoff)
continue
}
switch status.Phase {
case "Succeeded":
_ = a.setter.UpdateJob(ctx, jobID,
oapi.JobStatusSuccessful, "", nil)
return
case "Failed", "Error":
_ = a.setter.UpdateJob(ctx, jobID,
oapi.JobStatusFailure, status.Message, nil)
return
}
backoff = min(backoff*2, maxBackoff)
}
_ = a.setter.UpdateJob(ctx, jobID,
oapi.JobStatusFailure, "workflow timed out", nil)
}
```
#### Verification
The agent implements `Verifiable` to provide a health check that monitors the
Workflow CRD's status. Unlike ArgoCD's continuous health check (which polls
indefinitely because ArgoCD applications are long-lived), the Argo Workflows
verification checks that the workflow reaches a terminal state:
```go theme={null}
func (a *ArgoWorkflows) Verifications(
config oapi.JobAgentConfig,
) ([]oapi.VerificationMetricSpec, error) {
serverAddr, ok := config["serverUrl"].(string)
if !ok || serverAddr == "" {
return nil, nil
}
token, ok := config["token"].(string)
if !ok || token == "" {
return nil, nil
}
baseURL := serverAddr
if !strings.HasPrefix(baseURL, "https://") {
baseURL = "https://" + baseURL
}
workflowURL := fmt.Sprintf("%s/api/v1/workflows", baseURL)
method := oapi.GET
timeout := "10s"
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", token),
}
var provider oapi.MetricProvider
if err := provider.FromHTTPMetricProvider(oapi.HTTPMetricProvider{
Url: workflowURL,
Method: &method,
Timeout: &timeout,
Headers: &headers,
Type: oapi.Http,
}); err != nil {
return nil, fmt.Errorf("build argo workflows health check provider: %w", err)
}
successThreshold := 1
failureCondition := "result.statusCode != 200 || result.json.status.phase == 'Failed' || result.json.status.phase == 'Error'"
spec := oapi.VerificationMetricSpec{
Name: "argo-workflow-status",
IntervalSeconds: 30,
Count: 120,
SuccessThreshold: &successThreshold,
SuccessCondition: "result.statusCode == 200 && result.json.status.phase == 'Succeeded'",
FailureCondition: &failureCondition,
Provider: provider,
}
return []oapi.VerificationMetricSpec{spec}, nil
}
```
#### Cancellation
When ctrlplane cancels a job, the agent should stop the Argo Workflow. The
`WorkflowSubmitter` interface includes a stop method:
```go theme={null}
type WorkflowSubmitter interface {
SubmitWorkflow(...) (string, error)
GetWorkflowStatus(...) (*WorkflowStatus, error)
StopWorkflow(
ctx context.Context,
serverAddr, token, namespace, name string,
) error
}
```
The stop call uses Argo Workflows'
`PUT /api/v1/workflows/{namespace}/{name}/stop` endpoint, which terminates
running nodes and marks the workflow as failed. This integrates with the
existing job cancellation flow — when the reconciler transitions a job to
`cancelled`, the agent's poll loop detects this and calls `StopWorkflow`.
### Registry registration
The agent is registered alongside the existing agents in the job dispatch
controller:
```go theme={null}
func New(workerID string, pgxPool *pgxpool.Pool) *reconcile.Worker {
// ...existing setup...
dispatcher := jobagents.NewRegistry(&PostgresGetter{})
dispatcher.Register(
argo.New(&argo.GoApplicationUpserter{}, &PostgresSetter{Queue: enqueueQueue}),
)
dispatcher.Register(testrunner.New(&PostgresSetter{Queue: enqueueQueue}))
dispatcher.Register(
github.New(&github.GoGitHubWorkflowDispatcher{}, &PostgresSetter{Queue: enqueueQueue}),
)
dispatcher.Register(
argoworkflows.New(
&argoworkflows.HTTPWorkflowSubmitter{},
&PostgresSetter{Queue: enqueueQueue},
),
)
// ...rest unchanged...
}
```
### API communication
The agent communicates with Argo Workflows via its REST API rather than
Kubernetes client-go. This matches the pattern established by the ArgoCD agent
(which uses the ArgoCD API, not the Kubernetes API) and avoids requiring
in-cluster access from the workspace engine:
| Operation | Method | Endpoint |
| --------------- | ------ | ------------------------------------------- |
| Submit workflow | POST | `/api/v1/workflows/{namespace}` |
| Get status | GET | `/api/v1/workflows/{namespace}/{name}` |
| Stop workflow | PUT | `/api/v1/workflows/{namespace}/{name}/stop` |
| Get logs | GET | `/api/v1/workflows/{namespace}/{name}/log` |
The `HTTPWorkflowSubmitter` implementation makes standard HTTP calls with the
bearer token:
```go theme={null}
type HTTPWorkflowSubmitter struct{}
func (s *HTTPWorkflowSubmitter) SubmitWorkflow(
ctx context.Context,
serverAddr, token, namespace string,
workflow *unstructured.Unstructured,
) (string, error) {
body, err := json.Marshal(map[string]any{
"workflow": workflow.Object,
})
if err != nil {
return "", fmt.Errorf("marshal workflow: %w", err)
}
url := fmt.Sprintf("%s/api/v1/workflows/%s", serverAddr, namespace)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("submit workflow: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("submit workflow: status %d: %s",
resp.StatusCode, string(respBody))
}
var result struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
return result.Metadata.Name, nil
}
```
### TRPC and UI integration
#### Job agent config type
Add the `argo-workflows` type to the job agent config discriminated union in the
TRPC router:
```typescript theme={null}
const jobAgentConfig = z.discriminatedUnion("type", [
// ...existing types...
z.object({
type: z.literal("argo-workflows"),
serverUrl: z.string().url(),
token: z.string(),
namespace: z.string().optional(),
template: z.string(),
}),
]);
```
#### Deployment configuration
In the Terraform provider and CLI:
```hcl theme={null}
resource "ctrlplane_deployment" "api" {
name = "API Service"
slug = "api-service"
job_agent {
id = ctrlplane_job_agent.argo_wf.id
argo_workflows {
server_url = "https://argo-workflows.prod.example.com"
token = var.argo_workflows_token
namespace = "deployments"
template = file("${path.module}/deploy-workflow.yaml")
}
}
}
```
```yaml theme={null}
# CLI
type: Deployment
name: API Service
slug: api-service
jobAgent:
ref: argo-workflows-agent
jobAgentConfig:
serverUrl: https://argo-workflows.prod.example.com
token: "${ARGO_WORKFLOWS_TOKEN}"
namespace: deployments
template: |
apiVersion: argoproj.io/v1alpha1
kind: Workflow
...
```
### Plannable implementation (RFC 0002 integration)
The Argo Workflows agent can optionally implement `Plannable` by performing a
dry-run submission. Argo Workflows supports `--dry-run` and `--server-dry-run`
flags that validate and render the workflow without executing it. The rendered
output includes the fully resolved template with all parameter substitutions
applied:
```go theme={null}
func (a *ArgoWorkflows) Plan(
ctx context.Context,
dispatchCtx *oapi.DispatchContext,
) (*types.PlanResult, error) {
serverAddr, token, namespace, template, err := ParseJobAgentConfig(
dispatchCtx.JobAgentConfig,
)
if err != nil {
return nil, err
}
wf, err := TemplateWorkflow(dispatchCtx, nil, template)
if err != nil {
return nil, err
}
rendered, err := json.Marshal(wf.Object)
if err != nil {
return nil, err
}
hash := sha256.Sum256(rendered)
return &types.PlanResult{
ContentHash: hex.EncodeToString(hash[:]),
HasChanges: true,
}, nil
}
```
This enables plan-based diff detection (RFC 0002) and dry-run deployment plans
(RFC 0004) for Argo Workflows deployments. The hash comparison detects when a
version change produces an identical workflow spec — for example, when a
monorepo version bump only affects files unrelated to this deployment's workflow
template.
## Examples
### Database migration + application deploy
A deployment uses Argo Workflows to run a database migration before updating the
application. The workflow has a DAG structure: migrate → deploy → smoke test.
```yaml theme={null}
# Deployment config
type: Deployment
name: Payment Service
slug: payment-service
jobAgent:
ref: argo-workflows
jobAgentConfig:
serverUrl: https://argo.internal.example.com
token: "${ARGO_TOKEN}"
namespace: deploy-workflows
template: |
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: payment-deploy-
labels:
ctrlplane.dev/job-id: "{[.job.id]}"
spec:
entrypoint: deploy-pipeline
serviceAccountName: deploy-sa
templates:
- name: deploy-pipeline
dag:
tasks:
- name: migrate
template: db-migrate
- name: deploy
template: rolling-update
dependencies: [migrate]
- name: verify
template: smoke-test
dependencies: [deploy]
- name: db-migrate
container:
image: "payment-service/migrator:{[.release.version.tag]}"
command: ["/migrate", "up"]
env:
- name: DATABASE_URL
value: "{[.release.variables.DATABASE_URL]}"
- name: rolling-update
container:
image: bitnami/kubectl:latest
command:
- sh
- -c
- |
kubectl set image deployment/payment-service \
app=payment-service:{[.release.version.tag]} \
-n {[.resource.config.namespace]} \
--record
kubectl rollout status deployment/payment-service \
-n {[.resource.config.namespace]} \
--timeout=300s
- name: smoke-test
container:
image: payment-service/tests:latest
command: ["/run-smoke-tests"]
retryStrategy:
limit: 3
backoff:
duration: "5s"
factor: 2
```
When version `v2.3.1` is deployed to the `us-east-1-cluster` resource in the
`production` environment:
1. Ctrlplane creates a job and dispatches to the `argo-workflows` agent.
2. The agent renders the template with the dispatch context (version tag
`v2.3.1`, resource config, environment, variables).
3. The rendered Workflow CRD is submitted to Argo Workflows at
`https://argo.internal.example.com`.
4. Argo Workflows executes: migrate → deploy → smoke test.
5. The agent polls workflow status. On `Succeeded`, it marks the ctrlplane job
as successful.
6. Ctrlplane's promotion lifecycle advances to the next resource.
### Canary deployment with traffic splitting
A more complex workflow that performs a canary rollout with metric-based
validation:
```yaml theme={null}
# template (abbreviated)
spec:
entrypoint: canary-rollout
templates:
- name: canary-rollout
steps:
- - name: deploy-canary
template: deploy
arguments:
parameters:
- name: variant
value: canary
- name: replicas
value: "1"
- - name: shift-traffic
template: traffic-split
arguments:
parameters:
- name: canary-weight
value: "10"
- - name: validate-metrics
template: check-metrics
- - name: promote
template: deploy
arguments:
parameters:
- name: variant
value: stable
- name: replicas
value: "{[.release.variables.REPLICA_COUNT]}"
- - name: full-traffic
template: traffic-split
arguments:
parameters:
- name: canary-weight
value: "0"
```
This pattern is not expressible with ArgoCD sync hooks or GitHub Actions without
significant external tooling. Argo Workflows handles it natively.
### Lifecycle bracket hooks (RFC 0003 integration)
Argo Workflows is well-suited for the lifecycle hook deployments described in
RFC 0003. A "drain" deployment can use an Argo Workflow that runs
`kubectl drain` with proper PDB handling and timeout logic:
```yaml theme={null}
# drain deployment's job agent config
type: Deployment
name: node-drain
jobAgent:
ref: argo-workflows
jobAgentConfig:
template: |
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: drain-{[.resource.name]}-
spec:
entrypoint: drain
templates:
- name: drain
container:
image: bitnami/kubectl:latest
command:
- kubectl
- drain
- "{[.resource.name]}"
- --ignore-daemonsets
- --delete-emptydir-data
- --timeout=600s
activeDeadlineSeconds: 900
```
This gives the drain operation full workflow semantics: timeout handling, retry
on transient failure, observable status via the Argo Workflows UI, and job
metadata linking back to ctrlplane.
## Migration
* No schema changes required. The `job_agent` table already supports arbitrary
agent types via the `type` column.
* The new agent type is registered in the workspace engine's controller. No
changes to the API, reconciler, or promotion lifecycle.
* The `argo-workflows` type is added to the TRPC job agent config union. This is
an additive change — existing types are unaffected.
* No dependency on the Argo Workflows Go SDK. The agent uses the REST API via
standard `net/http` and `k8s.io/apimachinery/pkg/apis/meta/v1/unstructured`
for CRD manipulation.
* Existing deployments using ArgoCD are unaffected. The `argo-cd` and
`argo-workflows` types are fully independent.
* **Template delimiter migration.** Changing from `{{ }}` to `{[ ]}` delimiters
affects all existing job agent templates. Existing ArgoCD and GitHub Actions
deployment configs that use `{{ }}` must be updated to `{[ ]}`. This can be
done in two phases: (1) add `{[ ]}` support alongside `{{ }}` with
auto-detection of which delimiter style is present, (2) deprecate `{{ }}`
after a migration window. The auto-detection checks whether the template
contains `{[` — if so, use `{[ ]}` delimiters; otherwise fall back to `{{ }}`.
## Open Questions
1. **Authentication model.** The proposal uses a bearer token for Argo Workflows
API access. In production, teams often use Kubernetes service account tokens
with RBAC, OIDC tokens via Dex, or SSO. Should the agent support multiple
auth methods (bearer token, kubeconfig, OIDC client credentials), or is
bearer token sufficient as the initial implementation with others added
later?
2. **Workflow cleanup.** Argo Workflows supports TTL-based cleanup
(`ttlStrategy`) on the Workflow CRD. Should ctrlplane set a default TTL on
submitted workflows to prevent accumulation, or leave this to the user's
template? A sensible default (e.g., 24 hours) prevents resource leaks but may
surprise users who want to inspect completed workflows.
3. **Log streaming.** The Argo Workflows API supports streaming logs from
individual workflow nodes. Should the agent capture and surface step-level
logs in ctrlplane's job detail view, or is linking to the Argo Workflows UI
sufficient? Log streaming adds complexity but improves observability for
teams that don't have direct access to the Argo Workflows dashboard.
4. **Restorable semantics.** The workspace engine supports `Restorable` agents
that can re-establish in-flight jobs after a process restart. The Argo
Workflows agent should implement this by querying workflow status on restore
and resuming the poll loop. Should the initial implementation include restore
support, or is it acceptable to mark orphaned jobs as failed on restart?
5. **Template validation.** The ArgoCD agent validates templates at dispatch
time. Should the Argo Workflows agent provide pre-submission validation
(e.g., via Argo's `--dry-run` API) to catch template errors before
submission, or is post-submission error handling sufficient?
6. **Cluster-scoped vs namespaced Workflows.** Argo Workflows supports both
`Workflow` (namespaced) and `ClusterWorkflowTemplate` (cluster-scoped)
resources. The proposal only supports `Workflow`. Should `WorkflowTemplate`
references be supported, where the template field specifies a
`workflowTemplateRef` instead of inline specs? This would enable reuse of
pre-defined cluster workflow templates.
7. **Template delimiter migration scope.** The `{[` / `]}` delimiter change
benefits all agents but requires migrating every existing template. Should
this be scoped to the Argo Workflows agent only (using a per-agent delimiter
config), or applied globally across all agents? A global change is cleaner
long-term but has a larger migration surface. The auto-detection fallback
mitigates breakage, but dual-delimiter support adds complexity to the
template engine.
# RFC 0006: Secret Provider Integration
Source: https://docs.ctrlplane.dev/rfc/0006-secret-provider-integration
| Category | Status | Created | Author |
| -------------- | -------------------- | ---------- | ------------- |
| Infrastructure | Draft | 2026-03-06 | Justin Brooks |
## Summary
Add a pluggable secret provider system that lets workspaces connect to external
secret managers (Doppler, HashiCorp Vault, AWS Secrets Manager, etc.) and
reference secrets by path rather than storing sensitive values in ctrlplane. A
new workspace-scoped `secret_provider` entity holds encrypted provider
credentials. A `SecretReference` type describes a secret's location. The
workspace-engine resolves references at variable resolution or job dispatch time
by calling the external provider.
## Motivation
Ctrlplane currently stores sensitive credentials at two levels:
**Job agent configs** — When an operator creates an ArgoCD runner, the API token
is stored in the `job_agent.config` JSON column. The same applies to Terraform
Cloud tokens.
**Deployment variables** — The `SensitiveValue` type exists in the OpenAPI
schema and the variable resolver recognizes it as a distinct value type, but
resolution is explicitly rejected:
```go theme={null}
case "sensitive":
return nil, fmt.Errorf("sensitive values are not resolved by the variable resolver")
```
The `release_variable.encrypted` column, `job_variable.sensitive` flag, and
`@ctrlplane/secrets` AES-256 service all exist but are not wired end-to-end. The
infrastructure for handling secrets was scaffolded but never completed.
### Why this matters
1. **Compliance** — SOC 2, ISO 27001, and similar frameworks require that
secrets are encrypted at rest and access-controlled.
2. **Secret rotation** — When credentials are stored directly in ctrlplane,
rotation requires updating every agent config and variable value that
references the credential. With an external provider, rotation happens in
Doppler/Vault/AWS and ctrlplane picks up the new value on the next
resolution.
3. **Separation of concerns** — Platform teams manage secrets in their existing
secret management infrastructure. Application teams reference secrets by name
in ctrlplane without needing access to the actual values.
4. **Multi-workspace** — Each workspace may use a different secret provider or
account. The provider credentials themselves need per-workspace encrypted
storage.
### Existing mechanisms and their limitations
**`@ctrlplane/secrets` AES-256** — A TypeScript encryption service exists in
`packages/secrets/` but is not imported anywhere in the application code. It
provides encrypt/decrypt with a 256-bit key from `VARIABLES_AES_256_KEY`. This
could handle encryption at rest but does not solve external provider
integration.
**`SensitiveValue` type** — Defined in the OpenAPI schema with a `valueHash`
field. The variable resolver detects it but refuses to resolve it, returning an
error. The intent was that a separate decryption path would handle these values,
but that path was never built.
**Go template interpolation** — The ArgoCD agent config already supports Go
templates, and the docs show `apiKey: "{{.variables.argocd_token}}"`. This means
credentials can technically flow through the variable system into agent configs
at dispatch time. The missing piece is a way to resolve variables whose values
come from an external source.
## Proposal
### New entity: `secret_provider`
A workspace-scoped entity that holds the credentials for connecting to an
external secret management service:
```sql theme={null}
CREATE TABLE secret_provider (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
config BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(workspace_id, name)
);
```
| Column | Description |
| -------- | ---------------------------------------------------------------------------- |
| `type` | Provider type: `doppler`, `vault`, `aws-secretsmanager`, `kubernetes`, `env` |
| `config` | AES-256 encrypted JSON blob with provider-specific credentials |
| `name` | Human-readable name, unique per workspace |
The `config` column uses `BYTEA` for the encrypted payload rather than `JSON`,
since the ciphertext is opaque binary. The workspace-engine decrypts it in
memory using the instance-level `VARIABLES_AES_256_KEY` when a secret resolution
is needed.
Example configs (before encryption):
```json theme={null}
// Doppler
{ "serviceToken": "dp.st.xxxxxxxxxxxx" }
// Vault
{ "address": "https://vault.internal.company.com", "authMethod": "kubernetes", "role": "ctrlplane" }
// AWS Secrets Manager
{ "region": "us-east-1", "accessKeyId": "AKIA...", "secretAccessKey": "..." }
// Kubernetes
{ "namespace": "default" }
// Environment variables (instance-level, no config needed)
{}
```
### `SecretReference` type
A value object that describes where a secret lives:
```go theme={null}
type SecretReference struct {
Provider string `json:"provider"`
Path string `json:"path,omitempty"`
Key string `json:"key"`
}
```
| Field | Description |
| ---------- | -------------------------------------------------------------------------------------------------- |
| `Provider` | Matches `secret_provider.name` within the workspace |
| `Path` | Provider-specific path (e.g., `my-project/production` for Doppler, `secret/data/argocd` for Vault) |
| `Key` | The specific secret key within the path |
The `Provider` field matches by `name`, not by `type`. This allows a workspace
to have multiple providers of the same type (e.g., two Doppler connections for
different teams).
### Go interfaces
A new package `pkg/secrets/` in the workspace-engine:
```go theme={null}
// Provider resolves secret references against an external secret store.
type Provider interface {
Name() string
Resolve(ctx context.Context, workspaceID string, ref SecretReference) (string, error)
}
// ProviderConfig holds the decrypted configuration for a workspace's
// secret provider integration.
type ProviderConfig struct {
ID string `json:"id"`
WorkspaceID string `json:"workspaceId"`
Type string `json:"type"`
Name string `json:"name"`
Config map[string]any `json:"config"`
}
// ProviderConfigStore retrieves decrypted provider configuration for a
// workspace. The implementation handles AES-256 decryption transparently.
type ProviderConfigStore interface {
GetProviderConfig(ctx context.Context, workspaceID string, providerName string) (*ProviderConfig, error)
ListProviderConfigs(ctx context.Context, workspaceID string) ([]*ProviderConfig, error)
}
// Resolver holds a registry of provider implementations and resolves
// references by dispatching to the appropriate one.
type Resolver struct {
store ProviderConfigStore
implementations map[string]ProviderFactory
}
// ProviderFactory creates a Provider instance from decrypted config.
type ProviderFactory func(config map[string]any) (Provider, error)
```
The `Resolver` uses the `ProviderConfigStore` to look up the workspace's
provider credentials, then passes them to the appropriate `ProviderFactory` to
construct a `Provider`, then calls `Resolve`. Provider instances can be cached
per workspace with a TTL to avoid repeated decryption and construction.
### Resolution points
The secret resolver integrates at two points in the workspace-engine:
#### 1. Variable resolution (deployment variables)
The `SensitiveValue` type is extended to carry a `SecretReference`:
```jsonnet theme={null}
SensitiveValue: {
type: 'object',
required: ['valueHash'],
properties: {
valueHash: { type: 'string' },
secretRef: {
type: 'object',
required: ['provider', 'key'],
properties: {
provider: { type: 'string' },
path: { type: 'string' },
key: { type: 'string' },
},
},
},
},
```
In `variableresolver/value.go`, the `"sensitive"` case changes from an error to
a resolution call:
```go theme={null}
case "sensitive":
return resolveSensitive(ctx, secretResolver, workspaceID, value)
```
The resolved value becomes a `LiteralValue` and flows into the release like any
other variable. The variable's key is added to `release.EncryptedVariables` so
downstream consumers know it originated from a sensitive source.
#### 2. Job agent config resolution
For agent configs that use Go template interpolation (the existing mechanism),
no changes are needed. The credential flows through the variable system:
1. Deployment variable `argocd_token` is a `SensitiveValue` with a
`SecretReference` pointing to Doppler
2. Variable resolver calls the secret provider, gets the plaintext token
3. Token lands in `release.Variables` and then `DispatchContext.Variables`
4. The agent config template `{{.variables.argocd_token}}` renders the value
This reuses the existing template interpolation rather than adding a second
resolution path in the agent config.
### Provider implementations
Each provider is a Go package under `pkg/secrets//`:
**Doppler** (`pkg/secrets/doppler/`)
```
Path format: /
Key: secret name
API: GET /v3/configs/config/secret?project=X&config=Y&name=Z
Auth: Bearer from ProviderConfig
```
**HashiCorp Vault** (`pkg/secrets/vault/`)
```
Path format: / (e.g., secret/data/argocd)
Key: field within the secret data
API: Vault KV v2 read
Auth: Kubernetes auth, AppRole, or token from ProviderConfig
```
**AWS Secrets Manager** (`pkg/secrets/awssm/`)
```
Path format: secret ARN or name
Key: JSON field within the secret string
API: GetSecretValue
Auth: accessKeyId/secretAccessKey from ProviderConfig, or IAM role
```
**Kubernetes** (`pkg/secrets/kubernetes/`)
```
Path format: /
Key: data key within the Secret
API: K8s client-go, in-cluster or kubeconfig
Auth: service account or kubeconfig from ProviderConfig
```
**Environment** (`pkg/secrets/env/`)
```
Path: unused
Key: environment variable name
Auth: none (reads from the workspace-engine process env)
```
The `env` provider is special — it does not use `ProviderConfigStore` and does
not require a `secret_provider` row. It reads directly from the process
environment and is available in every workspace by default. This is useful for
development and for instance-level secrets that are the same across all
workspaces.
### Bootstrap chain
The bootstrap dependency chain for secrets is:
```
Operator deploys workspace-engine
→ sets VARIABLES_AES_256_KEY as env var (one key per instance)
Workspace admin connects a secret provider via the UI
→ enters Doppler service token / Vault address / AWS credentials
→ API encrypts with AES-256, stores in secret_provider.config
User creates a deployment variable as SensitiveValue
→ references provider by name + path + key
→ no secret value touches the API or database
Workspace-engine resolves at release time
→ decrypts secret_provider.config in memory
→ calls external provider to fetch the actual secret
→ resolved value exists only in memory during resolution
```
One symmetric key on the process (`VARIABLES_AES_256_KEY`) gates access to all
provider credentials. This key is the same one that `@ctrlplane/secrets` in the
TypeScript layer uses, so both sides can encrypt/decrypt interchangeably.
### API
**REST endpoints:**
```
PUT /v1/workspaces/{workspaceId}/secret-providers/{providerId}
GET /v1/workspaces/{workspaceId}/secret-providers
GET /v1/workspaces/{workspaceId}/secret-providers/{providerId}
DELETE /v1/workspaces/{workspaceId}/secret-providers/{providerId}
```
The `PUT` body:
```json theme={null}
{
"name": "doppler-production",
"type": "doppler",
"config": {
"serviceToken": "dp.st.xxxxxxxxxxxx"
}
}
```
The API layer encrypts `config` before storing. The `GET` response never
includes the decrypted config — it returns the provider metadata only:
```json theme={null}
{
"id": "...",
"name": "doppler-production",
"type": "doppler",
"createdAt": "...",
"updatedAt": "..."
}
```
### Web UI
**Settings > Secret Providers** — A workspace-level settings page to manage
provider connections. CRUD for `secret_provider` entities. The config form is
type-specific (Doppler shows a service token field, Vault shows address + auth
method, etc.).
**Variable editor** — When creating a deployment variable value, a "Secret"
option is available alongside "Literal" and "Reference". Selecting it shows a
provider dropdown (populated from the workspace's `secret_provider` entries) and
path/key inputs. The submitted value is a `SensitiveValue` with a `secretRef`.
**Runner creation** — The ArgoCD dialog can suggest using a variable reference
for the API key instead of a direct value, linking to the variable editor
workflow.
### Caching
External provider calls add latency to variable resolution. The resolver should
cache resolved values with a configurable TTL (default: 5 minutes). The cache is
keyed by `(workspaceID, providerName, path, key)` and held in memory on the
workspace-engine instance. Cache entries are invalidated when:
* The `secret_provider` config is updated (provider credentials changed)
* The TTL expires
* The workspace-engine restarts
This means secret rotation in the external provider takes effect within the TTL
window. For immediate rotation, the operator can update the `secret_provider`
entity to flush the cache.
### Audit
Secret resolution events should be recorded in the existing `event` table:
```json theme={null}
{
"action": "secret.resolved",
"workspaceId": "...",
"payload": {
"provider": "doppler-production",
"path": "backend/production",
"key": "ARGOCD_TOKEN",
"releaseId": "...",
"releaseTargetId": "..."
}
}
```
The resolved value is never included in the audit event — only the reference
metadata.
## Examples
### ArgoCD API token from Doppler
1. Workspace admin creates a secret provider:
```
PUT /v1/workspaces/{wsId}/secret-providers/{id}
{
"name": "doppler-platform",
"type": "doppler",
"config": { "serviceToken": "dp.st.xxxx" }
}
```
2. Deployment author creates a variable:
```
PUT /v1/workspaces/{wsId}/deployment-variable-values/{id}
{
"deploymentVariableId": "...",
"value": {
"valueHash": "sha256:abcdef...",
"secretRef": {
"provider": "doppler-platform",
"path": "backend/production",
"key": "ARGOCD_TOKEN"
}
}
}
```
3. The ArgoCD agent config template uses the variable:
```yaml theme={null}
serverUrl: argocd.example.com:443
apiKey: "{{.variables.argocd_token}}"
template: |
apiVersion: argoproj.io/v1alpha1
kind: Application
...
```
4. At release time, the variable resolver hits the `SensitiveValue`, calls the
Doppler provider, and the resolved token flows through `release.Variables`
into the dispatch context.
### Vault for database credentials
```json theme={null}
{
"valueHash": "sha256:...",
"secretRef": {
"provider": "vault-prod",
"path": "database/creds/api-service",
"key": "password"
}
}
```
The Vault provider reads the dynamic credential, and it resolves as a regular
`LiteralValue` in the release variables.
### Environment variable fallback (development)
For local development where no external provider is configured, the `env`
provider reads from the workspace-engine process:
```json theme={null}
{
"valueHash": "sha256:...",
"secretRef": {
"provider": "env",
"path": "",
"key": "ARGOCD_TOKEN"
}
}
```
## Migration
* The `secret_provider` table is additive — no existing tables are modified.
* Existing job agent configs with plaintext credentials continue to work. The
migration path is to create a secret provider, create a deployment variable
with a `SensitiveValue` reference, update the agent config template to use
`{{.variables.xxx}}`, and remove the plaintext credential from the agent
config.
* The `SensitiveValue` schema extension (`secretRef` field) is additive.
Existing `SensitiveValue` entries without a `secretRef` will fail resolution
with a clear error message.
* The `EncryptedVariables` field on releases, currently always `[]string{}`,
will begin to be populated for variables resolved from secret providers.
## Open questions
1. **Should the `env` provider require a `secret_provider` row?** The current
proposal makes it available by default without configuration. This is
convenient for development but means any variable can read arbitrary
environment variables from the workspace-engine process.
2. **Secret versioning** — Should the `SecretReference` support pinning to a
specific secret version (e.g., Vault lease ID, AWS version stage)? This would
improve reproducibility but complicates the reference format.
3. **Cross-workspace providers** — Should an instance admin be able to define
providers at the instance level that are available to all workspaces? This
avoids duplicating provider configs across workspaces but adds a
multi-tenancy concern.
4. **Release snapshotting** — Should the resolved secret value be stored
(encrypted) in `release_variable` for reproducibility, or should it always be
re-resolved from the external provider? Snapshotting means a release is fully
reproducible; re-resolving means secrets are never in the database but a
provider outage blocks dispatch.
5. **RBAC** — Should creating `SensitiveValue` variables that reference a
provider require a specific permission? This would prevent unprivileged users
from reading arbitrary secrets from the workspace's providers.
# RFC 0009: Manual Action Job Agent
Source: https://docs.ctrlplane.dev/rfc/0007-manual-action-job-agent
| Category | Status | Created | Author |
| ---------- | -------------------- | ---------- | ------------- |
| Job Agents | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add a `manual-action` job agent type that represents a human task within a
deployment pipeline. When dispatched, the agent transitions the job to an
"action required" state, notifies assignees through configured channels (Slack,
email, webhook), and waits indefinitely until a human explicitly marks the task
as completed. This enables teams to embed manual operational steps — hardware
swaps, vendor coordination, compliance sign-offs, manual DNS changes — directly
into ctrlplane's promotion lifecycle, ensuring downstream deployments do not
proceed until the manual work is confirmed done.
## Motivation
### Automated agents assume automated execution
Ctrlplane's job agent model is built around dispatching work to external
systems that execute autonomously: ArgoCD syncs an Application, GitHub Actions
runs a workflow, Terraform Cloud applies a plan, Argo Workflows orchestrates a
DAG. In each case, ctrlplane sends a dispatch, the external system does the
work, and the agent reports back when it finishes.
But not every step in a deployment pipeline can be automated. Real-world
deployment procedures frequently include steps that require a human to
physically do something:
* **Hardware provisioning** — rack and cable a new server before software
deployment can target it.
* **Manual DNS changes** — update DNS records in a provider that lacks API
access or is managed by a different team.
* **Vendor coordination** — contact a third-party provider to enable a feature
flag, update a firewall rule, or rotate a certificate.
* **Compliance checkpoints** — obtain a sign-off from a security or compliance
officer that a change has been reviewed and meets regulatory requirements.
* **Customer communication** — notify a customer before a maintenance window
begins, and confirm they have acknowledged.
* **Manual database operations** — run a migration in a restricted production
environment where automated access is prohibited by policy.
* **Physical verification** — inspect that a deployment to an edge device or
kiosk is functioning correctly before proceeding to the next location.
Today, teams handle these steps outside of ctrlplane — a Slack message, a Jira
ticket, a verbal confirmation — and then manually advance the pipeline by
updating the job status via the API or UI. This works but has three problems:
1. **No orchestration signal.** Ctrlplane does not know that a manual step
exists. The pipeline appears stalled with no indication of what is being
waited on or who is responsible.
2. **No notification routing.** There is no mechanism to automatically notify
the right person when a manual step is ready. The deployer must remember to
ping someone.
3. **No audit trail.** There is no record of who completed the manual step,
when, or what evidence they provided. The job status update only records that
the job transitioned to success.
### Distinct from the approval policy
The existing approval policy (see `policies/approval`) gates whether a release
*should proceed* — it is a governance checkpoint. A user reviews the proposed
change and approves or rejects it. The release itself has not started executing;
the approval decides whether it will.
A manual action is different. It represents *work that must be performed* as
part of the deployment execution. The deployment has already been approved and
is in progress. The manual action is a step within that execution that happens
to require a human instead of a machine:
```text theme={null}
Approval policy Manual action agent
───────────────── ────────────────────
"Should we deploy v2.3.1 "Swap the failed disk in
to production?" rack-7-slot-3 before we
deploy to this node."
Gate before execution. Step during execution.
Evaluator in policy pipeline. Job agent in dispatch pipeline.
Blocks job creation. Blocks job completion.
```
Conflating the two creates semantic confusion. An approval is a policy decision.
A manual action is an execution step. They have different lifecycles, different
actors, different notification requirements, and different audit semantics.
### Why not use an external ticketing system?
Teams could model manual steps as GitHub Actions workflows that create a Jira
ticket and poll for its resolution. But this requires:
* A CI runner continuously polling an external system.
* Credential management for the ticketing system API.
* Custom logic to map ticket state transitions to ctrlplane job status updates.
* No native integration with ctrlplane's notification system, audit log, or UI.
The manual action agent keeps the orchestration within ctrlplane. The external
integration is limited to notification delivery (Slack, email, webhook) rather
than execution tracking.
## Proposal
### Agent type and config
Register a new agent type `manual-action` in the workspace engine's job agent
registry. The job agent config describes what the human needs to do and who
should be notified:
```json theme={null}
{
"type": "manual-action",
"name": "Swap failed disk",
"description": "Replace the failed disk in {[.resource.name]} before deployment proceeds.",
"assignees": ["ops-team"],
"channels": [
{
"type": "slack",
"channelId": "C04XXXXXX"
}
],
"timeout": "PT24H",
"requireEvidence": true
}
```
| Field | Required | Description |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Short name for the manual task, displayed in the UI and notifications. |
| `description` | Yes | Go template string describing what the human needs to do. Receives the dispatch context. |
| `assignees` | No | List of team slugs or user emails to notify. If omitted, the notification goes to the configured channel. |
| `channels` | No | Notification channels for this task. Falls back to workspace notification defaults if omitted. |
| `timeout` | No | ISO 8601 duration after which the job is marked as failed if not completed. Default: no timeout. |
| `requireEvidence` | No | If true, the completion request must include an `evidence` field (URL, description, or attachment reference). |
The `description` field is a Go template rendered with `{[ ]}` delimiters
(matching the convention from RFC 0005). This allows the task description to
include deployment-specific context:
```text theme={null}
Replace the failed disk in {[.resource.name]} (rack {[.resource.metadata.rack]},
slot {[.resource.metadata.slot]}). After replacement, verify the disk is online
with `lsblk` and confirm the RAID array is rebuilding.
Deployment: {[.deployment.slug]}
Environment: {[.environment.name]}
Version: {[.release.version.tag]}
```
### Dispatch lifecycle
When the workspace engine dispatches a job to the `manual-action` agent, the
following sequence occurs:
```text theme={null}
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ workspace-engine│ │ manual-action│ │ notification │
│ (dispatch) │ │ agent │ │ system │
└────────┬────────┘ └──────┬───────┘ └────────┬────────┘
│ Dispatch(job) │ │
│────────────────────►│ │
│ │ render description │
│ │ from template │
│ │ │
│ │ UpdateJob( │
│ │ action_required) │
│ │ │
│ │ Send notifications │
│ │─────────────────────►│
│ │ │ Slack message
│ │ │ with action button
│ │ │
│ │ (waiting for human)
│ │ │
│ │ ◄── human clicks │
│ │ "Complete" in │
│ │ Slack / UI / API│
│ │ │
│ │ UpdateJob(successful)│
│ │ │
▼ ▼ ▼
```
The key difference from other agents: after dispatch, there is no polling loop.
The agent transitions the job to `action_required` and returns. The job remains
in this state until an external signal (API call, Slack interaction, UI button)
advances it. There is no background goroutine watching an external system.
### Job status: `action_required`
A new job status `action_required` is added to the `JobStatus` enum. This
status indicates that the job has been dispatched and is waiting for a human to
complete a task. It is semantically distinct from:
* `pending` — job has not been dispatched yet.
* `in_progress` — job has been dispatched and an external system is actively
executing it.
* `action_required` — job has been dispatched but requires a human to do
something before it can complete.
The workspace engine treats `action_required` the same as `in_progress` for
promotion lifecycle purposes: downstream deployments wait for the job to reach
a terminal state (`successful` or `failure`).
```sql theme={null}
ALTER TYPE job_status ADD VALUE 'action_required' AFTER 'in_progress';
```
The UI renders `action_required` jobs with a distinct visual treatment — an
amber indicator with a call-to-action button — to differentiate them from
automated jobs that are still running.
### Implementation
#### Go types
```go theme={null}
package manualaction
type ManualAction struct {
setter Setter
notifier Notifier
}
type Setter interface {
UpdateJob(
ctx context.Context,
jobID string,
status oapi.JobStatus,
message string,
metadata map[string]string,
) error
}
type Notifier interface {
SendManualActionNotification(
ctx context.Context,
notification ManualActionNotification,
) error
}
type ManualActionNotification struct {
JobID string
WorkspaceID string
Name string
Description string
Assignees []string
Channels []NotificationChannel
DeploymentCtx *oapi.DispatchContext
CallbackURL string
}
type NotificationChannel struct {
Type string // "slack", "email", "webhook"
ChannelID string
}
```
#### Dispatchable implementation
```go theme={null}
var _ types.Dispatchable = &ManualAction{}
func New(setter Setter, notifier Notifier) *ManualAction {
return &ManualAction{setter: setter, notifier: notifier}
}
func (a *ManualAction) Type() string {
return "manual-action"
}
func (a *ManualAction) Dispatch(ctx context.Context, job *oapi.Job) error {
dispatchCtx := job.DispatchContext
if dispatchCtx == nil {
return fmt.Errorf("job %s has no dispatch context", job.Id)
}
cfg, err := ParseConfig(dispatchCtx.JobAgentConfig)
if err != nil {
return fmt.Errorf("parse manual-action config: %w", err)
}
description, err := RenderDescription(cfg.Description, dispatchCtx, job)
if err != nil {
return fmt.Errorf("render description: %w", err)
}
metadata := map[string]string{
"manual-action/name": cfg.Name,
"manual-action/description": description,
}
if cfg.RequireEvidence {
metadata["manual-action/require-evidence"] = "true"
}
if err := a.setter.UpdateJob(
ctx, job.Id, oapi.JobStatusActionRequired, "", metadata,
); err != nil {
return fmt.Errorf("update job to action_required: %w", err)
}
callbackURL := fmt.Sprintf(
"/api/v1/jobs/%s/complete",
job.Id,
)
notification := ManualActionNotification{
JobID: job.Id,
WorkspaceID: dispatchCtx.WorkspaceId,
Name: cfg.Name,
Description: description,
Assignees: cfg.Assignees,
Channels: cfg.Channels,
DeploymentCtx: dispatchCtx,
CallbackURL: callbackURL,
}
go func() {
asyncCtx := context.WithoutCancel(ctx)
if err := a.notifier.SendManualActionNotification(
asyncCtx, notification,
); err != nil {
_ = a.setter.UpdateJob(
asyncCtx, job.Id, oapi.JobStatusActionRequired,
fmt.Sprintf("notification delivery failed: %s", err.Error()),
nil,
)
}
}()
if cfg.Timeout != "" {
go a.enforceTimeout(context.WithoutCancel(ctx), job.Id, cfg.Timeout)
}
return nil
}
```
#### Timeout enforcement
If a timeout is configured, a background goroutine waits for the duration and
then checks whether the job is still in `action_required` state. If so, it
transitions the job to `failure`:
```go theme={null}
func (a *ManualAction) enforceTimeout(
ctx context.Context,
jobID string,
timeoutStr string,
) {
duration, err := iso8601.ParseDuration(timeoutStr)
if err != nil {
return
}
select {
case <-ctx.Done():
return
case <-time.After(duration):
}
job, err := a.getter.GetJob(ctx, uuid.MustParse(jobID))
if err != nil {
return
}
if job.Status == oapi.JobStatusActionRequired {
_ = a.setter.UpdateJob(
ctx, jobID, oapi.JobStatusFailure,
fmt.Sprintf("manual action timed out after %s", timeoutStr),
nil,
)
}
}
```
#### Description rendering
The description template is rendered using the same `templatefuncs` pipeline
as other job agents, with `{[` / `]}` delimiters:
```go theme={null}
func RenderDescription(
tmpl string,
dispatchCtx *oapi.DispatchContext,
job *oapi.Job,
) (string, error) {
t, err := templatefuncs.NewWithDelims("manualActionDescription").Parse(tmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
data := dispatchCtx.Map()
data["job"] = structToMap(job)
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
```
### Completion API
A new endpoint allows humans (or integrations) to mark a manual action job as
completed:
```
POST /v1/jobs/{jobId}/complete
```
**Request body:**
```json theme={null}
{
"status": "successful",
"message": "Disk replaced and RAID rebuild verified.",
"evidence": "https://runbook.internal/disk-swap/RUN-4521"
}
```
| Field | Required | Description |
| ---------- | ----------- | ------------------------------------------------------------------------------------- |
| `status` | No | `successful` (default) or `failure`. Allows the human to report that the task failed. |
| `message` | No | Free-text message describing what was done or why it failed. |
| `evidence` | Conditional | Required if `requireEvidence = true` in the agent config. URL or description. |
The endpoint validates:
1. The job exists and is in `action_required` status.
2. The caller has permission to complete jobs in this workspace.
3. If `requireEvidence` is configured, the `evidence` field is present and
non-empty.
On success, the job transitions to the requested terminal status and the
promotion lifecycle advances.
```go theme={null}
func (h *Handler) CompleteJob(w http.ResponseWriter, r *http.Request) {
jobID := chi.URLParam(r, "jobId")
var req CompleteJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
job, err := h.getter.GetJob(r.Context(), uuid.MustParse(jobID))
if err != nil {
http.Error(w, "job not found", http.StatusNotFound)
return
}
if job.Status != oapi.JobStatusActionRequired {
http.Error(w,
fmt.Sprintf("job is in %s status, expected action_required", job.Status),
http.StatusConflict,
)
return
}
requireEvidence := job.Metadata["manual-action/require-evidence"] == "true"
if requireEvidence && req.Evidence == "" {
http.Error(w, "evidence is required for this manual action", http.StatusBadRequest)
return
}
status := oapi.JobStatusSuccessful
if req.Status == "failure" {
status = oapi.JobStatusFailure
}
metadata := map[string]string{
"manual-action/completed-by": r.Context().Value(ctxUserID).(string),
"manual-action/completed-at": time.Now().UTC().Format(time.RFC3339),
}
if req.Evidence != "" {
metadata["manual-action/evidence"] = req.Evidence
}
if err := h.setter.UpdateJob(
r.Context(), jobID, status, req.Message, metadata,
); err != nil {
http.Error(w, "failed to update job", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"jobId": jobID,
"status": string(status),
})
}
```
### Slack integration
The Slack integration is the primary notification channel for manual actions.
When a manual action job is dispatched, a Slack message is sent to the
configured channel with an interactive Block Kit layout:
#### Message format
```json theme={null}
{
"channel": "C04XXXXXX",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🔧 Manual Action Required"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Swap failed disk*\n\nReplace the failed disk in us-east-1-node-7 (rack 7, slot 3). After replacement, verify the disk is online with `lsblk` and confirm the RAID array is rebuilding."
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Deployment:*\ninfra-rollout"
},
{
"type": "mrkdwn",
"text": "*Environment:*\nproduction"
},
{
"type": "mrkdwn",
"text": "*Resource:*\nus-east-1-node-7"
},
{
"type": "mrkdwn",
"text": "*Version:*\nv1.4.2"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "✅ Mark as Completed"
},
"style": "primary",
"action_id": "manual_action_complete",
"value": ""
},
{
"type": "button",
"text": {
"type": "plain_text",
"text": "❌ Report Failure"
},
"style": "danger",
"action_id": "manual_action_fail",
"value": ""
},
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View in Ctrlplane"
},
"url": "https://your-ctrlplane-instance.com/workspaces/.../jobs/"
}
]
}
]
}
```
#### Interaction handler
When a user clicks a button in Slack, the Slack API sends an interaction
payload to ctrlplane's Slack integration endpoint. The handler:
1. Verifies the Slack request signature.
2. Extracts the `action_id` and `value` (job ID).
3. Resolves the Slack user to a ctrlplane user via the workspace's Slack
integration mapping.
4. Calls the completion API internally.
5. Updates the original Slack message to reflect the new status.
```go theme={null}
func (h *SlackInteractionHandler) HandleInteraction(
ctx context.Context,
payload slack.InteractionCallback,
) error {
action := payload.ActionCallback.BlockActions[0]
jobID := action.Value
slackUserID := payload.User.ID
ctrlplaneUser, err := h.userMapper.ResolveSlackUser(ctx, slackUserID)
if err != nil {
return fmt.Errorf("resolve slack user %s: %w", slackUserID, err)
}
var status oapi.JobStatus
var message string
switch action.ActionID {
case "manual_action_complete":
status = oapi.JobStatusSuccessful
message = fmt.Sprintf(
"Completed by %s via Slack", ctrlplaneUser.Name,
)
case "manual_action_fail":
status = oapi.JobStatusFailure
message = fmt.Sprintf(
"Reported as failed by %s via Slack", ctrlplaneUser.Name,
)
default:
return fmt.Errorf("unknown action: %s", action.ActionID)
}
metadata := map[string]string{
"manual-action/completed-by": ctrlplaneUser.Id,
"manual-action/completed-at": time.Now().UTC().Format(time.RFC3339),
"manual-action/completed-via": "slack",
"manual-action/slack-user-id": slackUserID,
"manual-action/slack-channel-id": payload.Channel.ID,
}
if err := h.setter.UpdateJob(
ctx, jobID, status, message, metadata,
); err != nil {
return fmt.Errorf("update job: %w", err)
}
return h.updateSlackMessage(ctx, payload, status, ctrlplaneUser.Name)
}
```
#### Message update on completion
After the job is completed (via Slack or any other method), the original Slack
message is updated to show the resolved state. The action buttons are removed
and replaced with a status block:
```json theme={null}
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "✅ Completed by @jane.doe at 2026-03-13 14:32 UTC"
}
]
}
```
This prevents double-completion and provides an at-a-glance record in the Slack
channel.
#### Evidence collection via Slack modal
When `requireEvidence = true`, clicking "Mark as Completed" opens a Slack modal
instead of immediately completing the job. The modal prompts for:
* A text description of what was done.
* An optional URL to supporting evidence (runbook, screenshot, monitoring
dashboard).
```json theme={null}
{
"type": "modal",
"title": {
"type": "plain_text",
"text": "Complete Manual Action"
},
"submit": {
"type": "plain_text",
"text": "Complete"
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Swap failed disk*\nReplace the failed disk in us-east-1-node-7..."
}
},
{
"type": "input",
"block_id": "evidence_message",
"label": {
"type": "plain_text",
"text": "What was done?"
},
"element": {
"type": "plain_text_input",
"action_id": "evidence_message_input",
"multiline": true,
"placeholder": {
"type": "plain_text",
"text": "Describe the action taken..."
}
}
},
{
"type": "input",
"block_id": "evidence_url",
"optional": true,
"label": {
"type": "plain_text",
"text": "Evidence URL"
},
"element": {
"type": "url_text_input",
"action_id": "evidence_url_input",
"placeholder": {
"type": "plain_text",
"text": "https://..."
}
}
}
],
"private_metadata": "{\"job_id\": \"\"}"
}
```
The modal submission handler extracts the evidence, calls the completion API
with the evidence payload, and updates the Slack message.
### Notification channels
The manual action agent supports multiple notification channels through the
existing notification system. Each channel type has a specific renderer:
| Channel | Behavior |
| --------- | ---------------------------------------------------------------------------------- |
| `slack` | Sends a Block Kit message with interactive buttons. Supports completion via Slack. |
| `email` | Sends an email with task description and a deep link to the ctrlplane UI. |
| `webhook` | POSTs a JSON payload to a configured URL. Used for custom integrations. |
The notification is sent once at dispatch time. Reminders can be configured to
re-send the notification at intervals while the job remains in
`action_required`:
```json theme={null}
{
"type": "manual-action",
"name": "Update DNS records",
"description": "...",
"channels": [
{
"type": "slack",
"channelId": "C04XXXXXX"
}
],
"reminder": {
"interval": "PT1H",
"maxReminders": 3
}
}
```
| Field | Description |
| ----------------------- | ------------------------------------------------------------------------------ |
| `reminder.interval` | ISO 8601 duration between reminders. |
| `reminder.maxReminders` | Maximum number of reminders to send before stopping. Default 0 (no reminders). |
### Registry registration
```go theme={null}
func New(workerID string, pgxPool *pgxpool.Pool) *reconcile.Worker {
// ...existing setup...
dispatcher := jobagents.NewRegistry(&PostgresGetter{})
dispatcher.Register(
argo.New(&argo.GoApplicationUpserter{}, &PostgresSetter{Queue: enqueueQueue}),
)
dispatcher.Register(testrunner.New(&PostgresSetter{Queue: enqueueQueue}))
dispatcher.Register(
github.New(
&github.GoGitHubWorkflowDispatcher{},
&PostgresSetter{Queue: enqueueQueue},
),
)
dispatcher.Register(
manualaction.New(
&PostgresSetter{Queue: enqueueQueue},
&NotificationSender{},
),
)
// ...rest unchanged...
}
```
### TRPC and UI integration
#### Job agent config type
```typescript theme={null}
const jobAgentConfig = z.discriminatedUnion("type", [
// ...existing types...
z.object({
type: z.literal("manual-action"),
name: z.string(),
description: z.string(),
assignees: z.array(z.string()).optional(),
channels: z
.array(
z.object({
type: z.enum(["slack", "email", "webhook"]),
channelId: z.string(),
}),
)
.optional(),
timeout: z.string().optional(),
requireEvidence: z.boolean().optional(),
reminder: z
.object({
interval: z.string(),
maxReminders: z.number().int().min(0).optional(),
})
.optional(),
}),
]);
```
#### Job completion tRPC route
```typescript theme={null}
job.complete: protectedProcedure
.input(
z.object({
jobId: z.string().uuid(),
status: z.enum(["successful", "failure"]).default("successful"),
message: z.string().optional(),
evidence: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const job = await ctx.db
.select()
.from(schema.job)
.where(eq(schema.job.id, input.jobId))
.then(takeFirstOrNull);
if (job == null)
throw new TRPCError({ code: "NOT_FOUND" });
if (job.status !== "action_required")
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Job is ${job.status}, expected action_required`,
});
const requireEvidence =
job.metadata?.["manual-action/require-evidence"] === "true";
if (requireEvidence && !input.evidence)
throw new TRPCError({
code: "BAD_REQUEST",
message: "Evidence is required for this manual action",
});
await ctx.db
.update(schema.job)
.set({
status: input.status,
message: input.message ?? "",
metadata: {
...job.metadata,
"manual-action/completed-by": ctx.session.user.id,
"manual-action/completed-at": new Date().toISOString(),
"manual-action/completed-via": "ui",
...(input.evidence
? { "manual-action/evidence": input.evidence }
: {}),
},
})
.where(eq(schema.job.id, input.jobId));
await enqueuePolicyEval(ctx.db, job.releaseTargetId);
})
```
#### UI: job detail view
When a job has status `action_required`, the job detail view displays:
1. **Task description** — the rendered description from the agent config,
formatted as markdown.
2. **Assignees** — who is responsible for completing the task.
3. **Status timeline** — when the job was dispatched, when notifications were
sent, when reminders were sent.
4. **Action buttons** — "Mark as Completed" and "Report Failure" buttons.
5. **Evidence field** — if `requireEvidence` is true, a text input and URL
field that must be filled before completion.
6. **Timeout indicator** — if a timeout is configured, a countdown showing
remaining time.
The release target overview shows `action_required` jobs with an amber badge
and the task name, making it immediately visible which deployments are waiting
on human action.
### Deployment configuration
#### Terraform
```hcl theme={null}
resource "ctrlplane_deployment" "infra_rollout" {
name = "Infrastructure Rollout"
slug = "infra-rollout"
job_agent {
id = ctrlplane_job_agent.manual.id
manual_action {
name = "Hardware verification"
description = <<-EOT
Verify that node {[.resource.name]} has been physically
provisioned and is network-reachable.
1. Confirm the node is racked and cabled.
2. Verify IPMI connectivity: ping {[.resource.metadata.ipmi_ip]}
3. Confirm the node appears in the inventory system.
EOT
assignees = ["platform-ops"]
channel {
type = "slack"
channel_id = "C04XXXXXX"
}
timeout = "PT8H"
require_evidence = true
}
}
}
```
#### CLI YAML
```yaml theme={null}
type: Deployment
name: Infrastructure Rollout
slug: infra-rollout
jobAgent:
ref: manual-action-agent
jobAgentConfig:
name: Hardware verification
description: |
Verify that node {[.resource.name]} has been physically
provisioned and is network-reachable.
assignees:
- platform-ops
channels:
- type: slack
channelId: C04XXXXXX
timeout: PT8H
requireEvidence: true
```
## Examples
### Multi-step deployment with manual checkpoint
A system has three deployments in sequence: database migration (automated),
hardware verification (manual), and application deploy (automated). The manual
step ensures a human confirms the target node is ready before the application
is deployed to it:
```yaml theme={null}
# System: edge-rollout
# Environment: production
# Deployments (ordered by dependency):
# 1. Automated — runs database migration via Argo Workflows
type: Deployment
name: Database Migration
slug: db-migration
jobAgent:
ref: argo-workflows
jobAgentConfig:
template: |
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: migrate-
spec:
entrypoint: migrate
templates:
- name: migrate
container:
image: "db-migrator:{[.release.version.tag]}"
---
# 2. Manual — human verifies the edge device
type: Deployment
name: Device Verification
slug: device-verification
jobAgent:
ref: manual-action
jobAgentConfig:
name: "Verify edge device {[.resource.name]}"
description: |
The edge device {[.resource.name]} at location
{[.resource.metadata.location]} needs physical verification
before the v{[.release.version.tag]} firmware is deployed.
Checklist:
- Device is powered on and network-reachable
- Current firmware version matches expected baseline
- No hardware alerts in the device management console
- Device storage has >20% free space
assignees:
- field-ops
channels:
- type: slack
channelId: C04FIELD_OPS
timeout: PT48H
requireEvidence: true
reminder:
interval: PT4H
maxReminders: 3
---
# 3. Automated — deploys firmware via ArgoCD
type: Deployment
name: Firmware Deploy
slug: firmware-deploy
jobAgent:
ref: argo-cd
jobAgentConfig:
# ...standard ArgoCD config...
```
The deployment dependency policy ensures these run in order. When the database
migration completes, ctrlplane dispatches the device verification job. A Slack
message appears in `#field-ops`:
```
🔧 Manual Action Required
Verify edge device us-west-2-kiosk-14
The edge device us-west-2-kiosk-14 at location "Portland Store #42"
needs physical verification before the v3.1.0 firmware is deployed.
Checklist:
- Device is powered on and network-reachable
- Current firmware version matches expected baseline
- No hardware alerts in the device management console
- Device storage has >20% free space
Deployment: device-verification
Environment: production
Resource: us-west-2-kiosk-14
Version: v3.1.0
[✅ Mark as Completed] [❌ Report Failure] [View in Ctrlplane]
```
A field technician visits the kiosk, verifies the checklist, clicks "Mark as
Completed" in Slack, enters "All checks passed. Device firmware at v3.0.2
baseline. 45% storage free." as evidence, and the firmware deploy proceeds
automatically.
### Customer notification gate
Before deploying a breaking API change to a customer's dedicated environment,
the account team must confirm the customer has been notified and has
acknowledged the maintenance window:
```yaml theme={null}
type: Deployment
name: Customer Notification
slug: customer-notification
jobAgent:
ref: manual-action
jobAgentConfig:
name: "Notify customer for {[.environment.name]}"
description: |
Contact the customer for environment {[.environment.name]}
regarding the upcoming v{[.release.version.tag]} deployment.
This version includes breaking API changes documented at:
https://docs.example.com/changelog/{[.release.version.tag]}
Steps:
1. Send the maintenance notification email using the template
in the runbook.
2. Wait for customer acknowledgment (email reply or portal
confirmation).
3. Mark as completed only after receiving acknowledgment.
assignees:
- account-management
channels:
- type: slack
channelId: C04ACCOUNTS
- type: email
channelId: account-team@example.com
timeout: PT72H
requireEvidence: true
```
### Compliance sign-off
A regulated deployment requires a compliance officer to review and sign off
before proceeding:
```yaml theme={null}
type: Deployment
name: Compliance Review
slug: compliance-review
jobAgent:
ref: manual-action
jobAgentConfig:
name: "Compliance review for {[.deployment.slug]} v{[.release.version.tag]}"
description: |
Review the deployment of {[.deployment.slug]} version
{[.release.version.tag]} to {[.environment.name]} for
compliance with SOC 2 change management requirements.
Review items:
- Change request ticket has been approved
- Rollback plan is documented
- Monitoring alerts are configured
- Change window is within approved schedule
Provide the change request ticket URL as evidence.
assignees:
- compliance-team
timeout: PT24H
requireEvidence: true
```
## Migration
* The `action_required` value is added to the `job_status` enum. This is an
additive change — existing jobs are unaffected.
* No schema changes to existing tables. The manual action metadata is stored
in the job's existing `metadata` JSONB column.
* The completion API endpoint is new. No changes to existing endpoints.
* The Slack interaction handler is new. It is registered alongside the existing
Slack integration webhook handlers.
* The `manual-action` agent type is registered in the workspace engine's
controller. No changes to the reconciler or promotion lifecycle beyond
recognizing the new `action_required` status.
* The notification system must support the `SendManualActionNotification`
method. This extends the existing `Notifier` interface. If the notification
system is not configured, the agent still transitions the job to
`action_required` — the task is visible in the UI but no external
notification is sent.
## Open Questions
1. **Reassignment.** The initial proposal assigns the task at dispatch time via
the `assignees` field in the agent config. Should the UI and API support
reassigning a manual action to a different user or team after dispatch? This
is useful when the original assignee is unavailable, but adds complexity to
the notification flow (the new assignee needs to be notified, the original
assignee's notification should be updated).
2. **Escalation.** If a manual action is not completed within a configurable
period (shorter than the timeout), should the system escalate to a different
set of assignees? For example, after 2 hours notify the team lead, after 4
hours notify the on-call manager. This is a common pattern in incident
management tools but adds significant complexity.
3. **Partial completion.** Some manual tasks have multiple steps (a checklist).
Should the agent support partial completion where each checklist item is
tracked independently, or is a single "completed/failed" status sufficient?
Partial completion provides better visibility but the checklist structure
must be defined in the agent config and rendered in both the UI and Slack.
4. **Restorable semantics.** After a workspace-engine restart, `action_required`
jobs with configured timeouts need their timeout goroutines restarted. The
agent should implement `Restorable` to query for `action_required` jobs on
startup and re-establish timeout enforcement. Should the initial
implementation include restore support, or is it acceptable to lose timeout
enforcement on restart (the job remains in `action_required` indefinitely
until manually completed or failed)?
5. **Slack app permissions.** The interactive Slack integration requires the
ctrlplane Slack app to have `chat:write`, `commands`, and
`interactions` scopes. If the workspace does not have a Slack integration
configured, should the agent fall back to a non-interactive notification
(plain message without buttons), or should it fail at dispatch time with a
configuration error?
6. **Idempotent completion.** If multiple people click "Complete" in Slack
simultaneously, the second request should be a no-op (the job is already in
a terminal state). The current proposal handles this via the status check in
the completion endpoint. Should the UI also show who else attempted to
complete the task, or is the first completion sufficient?
7. **Webhook completion.** The `webhook` notification channel sends a JSON
payload with the task details. Should the webhook payload include a callback
URL and a signed token that allows the external system to call the
completion API without separate authentication? This enables "complete via
webhook callback" for systems that can process and respond programmatically
(e.g., a ServiceNow integration that auto-completes the ctrlplane job when
a change request is approved).
8. **Interaction with deployment freeze.** If a deployment freeze (RFC 0008) is
activated while a manual action job is in `action_required` state, should
the freeze prevent the job from being completed? The freeze blocks new job
creation, but an `action_required` job has already been dispatched. The
safe default is to allow completion (the freeze prevents downstream jobs,
not in-flight ones), but some organizations may want the freeze to also
prevent manual action completion.
# RFC 0008: Deployment Freeze / Emergency Lock
Source: https://docs.ctrlplane.dev/rfc/0008-deployment-freeze
| Category | Status | Created | Author |
| ---------- | -------------------- | ---------- | ------------- |
| Operations | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add a first-class deployment freeze primitive that instantly halts all
deployments within a configurable scope (workspace, system, environment, or
deployment). Freezes are imperative operations — created and lifted via API or
UI — with an audit trail recording who activated the freeze, why, and when it
was lifted. An optional TTL auto-thaws the freeze after a configurable duration
to prevent forgotten freezes from blocking deployments indefinitely.
## Motivation
### No emergency halt exists
Ctrlplane's deployment window evaluator (`policy_rule_deployment_window`)
provides scheduled allow/deny windows using rrule patterns. This covers planned
maintenance windows and business-hours-only deployment policies. But there is no
mechanism for an operator to say "stop everything now" during an incident.
When a production incident occurs, the response today requires one of:
1. **Disabling policies.** Setting `enabled = false` on every relevant policy.
This stops deployments but also disables approval requirements, version
selectors, and every other policy rule. Re-enabling them requires remembering
which policies were active. There is no audit trail of the freeze itself.
2. **Creating deny windows.** Adding a `policy_rule_deployment_window` with
`allow_window = false` that covers the incident duration. This requires
knowing the duration in advance, does not surface clearly as an emergency
action in the UI, and leaves orphan policy rules that must be cleaned up.
3. **Manual intervention.** Telling the team on Slack to stop pushing versions
and hoping no automation triggers. This provides no system-level enforcement.
None of these are satisfactory for incident response. The operator needs a
single action that:
* Takes effect immediately across the target scope.
* Does not disable other policy rules (approval, verification, etc. remain
configured for when the freeze lifts).
* Records who activated it, why, and links to an incident.
* Automatically lifts after a TTL if not manually thawed.
* Notifies relevant stakeholders when activated and when lifted.
### Deployment windows are the wrong abstraction
Deployment windows are **scheduled, recurring patterns** — "deploy only on
weekdays 9am–5pm." They are defined ahead of time and repeat on a cadence. An
emergency freeze is an **imperative, one-shot action** — "stop deploying right
now because production is on fire." Overloading the window concept for emergency
freezes creates several problems:
* **Discoverability.** An emergency freeze buried in policy rules is hard to
find. Operators need a top-level indicator — a banner in the UI, a status
endpoint — that shows whether a freeze is active.
* **Audit semantics.** A deployment window rule has no concept of "who activated
this" or "why." It's a configuration, not an action.
* **Scope mismatch.** Deployment windows are scoped to policies, which are
scoped by selector. An emergency freeze often needs to cover an entire
workspace or environment regardless of which policies are configured.
* **Lifecycle mismatch.** Windows are permanent configuration. Freezes are
transient — they are created and destroyed. TTL-based auto-expiry makes no
sense for a recurring window rule.
### RFC 0003 does not address this
RFC 0003 introduces resource concurrency limits that cap how many resources can
be simultaneously undergoing deployment. This addresses capacity concerns (don't
overwhelm the cluster) but not the "stop everything" incident response case.
Concurrency limits still allow deployments — just fewer at a time. A freeze
allows zero.
## Proposal
### Deployment freeze as a standalone entity
A deployment freeze is not a policy rule. It is a workspace-level entity with
its own lifecycle (create, extend, thaw), its own API surface, and its own
audit trail. The workspace-engine checks for active freezes early in the
evaluator pipeline and denies all matching deployments while a freeze is active.
This separation means:
* Freezes can be managed by anyone with the appropriate permission without
touching policy configuration.
* The policy configuration remains unchanged during a freeze — approval rules,
gradual rollout settings, deployment windows, etc. are all preserved.
* When the freeze lifts, deployments resume exactly where they left off in the
policy pipeline.
### Schema
```sql theme={null}
CREATE TYPE deployment_freeze_scope AS ENUM (
'workspace',
'system',
'environment',
'deployment'
);
CREATE TABLE deployment_freeze (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
-- What scope this freeze covers.
scope deployment_freeze_scope NOT NULL,
-- The specific entity ID for non-workspace scopes.
-- NULL when scope = 'workspace' (the workspace_id is the scope).
scope_entity_id UUID,
-- Human-readable reason for the freeze.
reason TEXT NOT NULL,
-- Optional link to an incident tracker (PagerDuty, Jira, etc.)
incident_url TEXT,
-- Who activated the freeze.
created_by UUID NOT NULL REFERENCES "user"(id),
-- When the freeze was activated.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- When the freeze should auto-thaw (NULL = no auto-thaw).
expires_at TIMESTAMPTZ,
-- When the freeze was manually thawed (NULL = still active or expired).
thawed_at TIMESTAMPTZ,
-- Who manually thawed the freeze (NULL if auto-expired or still active).
thawed_by UUID REFERENCES "user"(id),
-- Optional note when thawing (e.g., "Incident resolved, RCA pending").
thaw_reason TEXT,
-- CEL selector to further narrow the freeze within the scope.
-- e.g., within an environment scope, only freeze deployments matching
-- "deployment.metadata['tier'] == 'critical'"
selector TEXT,
CONSTRAINT valid_scope_entity CHECK (
(scope = 'workspace' AND scope_entity_id IS NULL) OR
(scope != 'workspace' AND scope_entity_id IS NOT NULL)
)
);
CREATE INDEX idx_deployment_freeze_workspace
ON deployment_freeze (workspace_id);
CREATE INDEX idx_deployment_freeze_active
ON deployment_freeze (workspace_id)
WHERE thawed_at IS NULL;
```
A freeze is **active** when:
* `thawed_at IS NULL` (not manually thawed), AND
* `expires_at IS NULL OR expires_at > now()` (no TTL, or TTL not yet reached).
The `selector` field is optional and allows narrowing within a scope. A
workspace-wide freeze with `selector = "deployment.metadata['tier'] == 'critical'"`
freezes only critical-tier deployments across the workspace, leaving non-critical
deployments unaffected.
### Freeze audit log
Every state change is recorded in a dedicated audit table:
```sql theme={null}
CREATE TYPE deployment_freeze_action AS ENUM (
'activated',
'extended',
'thawed',
'expired'
);
CREATE TABLE deployment_freeze_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
freeze_id UUID NOT NULL
REFERENCES deployment_freeze(id) ON DELETE CASCADE,
action deployment_freeze_action NOT NULL,
actor_id UUID REFERENCES "user"(id),
note TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deployment_freeze_event_freeze
ON deployment_freeze_event (freeze_id, created_at);
```
The `expired` action is written by the workspace-engine when it detects a freeze
past its `expires_at`. The `actor_id` is NULL for system-initiated actions
(auto-expiry).
### API
#### REST
```
POST /v1/workspaces/{workspaceId}/freezes Create a freeze
GET /v1/workspaces/{workspaceId}/freezes List freezes (active + recent)
GET /v1/workspaces/{workspaceId}/freezes/active List only active freezes
GET /v1/workspaces/{workspaceId}/freezes/{id} Get freeze details + events
PATCH /v1/workspaces/{workspaceId}/freezes/{id} Extend or modify a freeze
POST /v1/workspaces/{workspaceId}/freezes/{id}/thaw Thaw a freeze
```
**Create:**
```json theme={null}
POST /v1/workspaces/{workspaceId}/freezes
{
"scope": "environment",
"scopeEntityId": "",
"reason": "Production incident INC-4521 — elevated error rates on payment service",
"incidentUrl": "https://pagerduty.com/incidents/INC-4521",
"expiresIn": "PT4H",
"selector": null
}
```
`expiresIn` is an ISO 8601 duration. The server computes `expires_at = now() +
duration`. If omitted, the freeze has no auto-thaw and must be manually lifted.
**Thaw:**
```json theme={null}
POST /v1/workspaces/{workspaceId}/freezes/{id}/thaw
{
"reason": "Incident resolved. RCA scheduled for Monday."
}
```
**Extend:**
```json theme={null}
PATCH /v1/workspaces/{workspaceId}/freezes/{id}
{
"expiresIn": "PT8H",
"reason": "Extending freeze — incident still under investigation"
}
```
Extending resets the TTL from `now()`, not from the original `created_at`.
#### tRPC
```typescript theme={null}
deploymentFreeze.create
deploymentFreeze.list
deploymentFreeze.listActive
deploymentFreeze.get
deploymentFreeze.extend
deploymentFreeze.thaw
```
### Evaluator integration
A `DeploymentFreezeEvaluator` is added to the evaluator pipeline with
`Complexity() = 0` (cheapest possible) so it runs before all other evaluators.
If a matching freeze is active, it short-circuits with a `Denied` result — the
remaining evaluators are never called.
```go theme={null}
type DeploymentFreezeEvaluator struct {
getters Getters
ruleId string
}
func (e *DeploymentFreezeEvaluator) ScopeFields() evaluator.ScopeFields {
return evaluator.ScopeReleaseTarget
}
func (e *DeploymentFreezeEvaluator) RuleType() string {
return evaluator.RuleTypeDeploymentFreeze
}
func (e *DeploymentFreezeEvaluator) Complexity() int {
return 0
}
```
The `Evaluate` method checks for any active freeze matching the release
target's workspace, system, environment, and deployment:
```go theme={null}
func (e *DeploymentFreezeEvaluator) Evaluate(
ctx context.Context,
scope evaluator.EvaluatorScope,
) *oapi.RuleEvaluation {
freezes := e.getters.GetActiveFreezes(ctx, scope)
if len(freezes) == 0 {
return results.NewAllowedResult("No active deployment freeze")
}
freeze := freezes[0] // Most specific or most recent
result := results.NewDeniedResult(
fmt.Sprintf("Deployment frozen: %s", freeze.Reason),
).
WithDetail("freeze_id", freeze.Id).
WithDetail("frozen_by", freeze.CreatedBy).
WithDetail("frozen_at", freeze.CreatedAt.Format(time.RFC3339)).
WithDetail("scope", string(freeze.Scope)).
WithDetail("reason", freeze.Reason)
if freeze.IncidentUrl != "" {
result = result.WithDetail("incident_url", freeze.IncidentUrl)
}
if freeze.ExpiresAt != nil {
result = result.
WithDetail("expires_at", freeze.ExpiresAt.Format(time.RFC3339)).
WithNextEvaluationTime(*freeze.ExpiresAt)
}
return result
}
```
The `GetActiveFreezes` query checks all scopes that could apply to the release
target:
```sql theme={null}
SELECT * FROM deployment_freeze
WHERE workspace_id = $1
AND thawed_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
AND (
scope = 'workspace'
OR (scope = 'system' AND scope_entity_id = $2)
OR (scope = 'environment' AND scope_entity_id = $3)
OR (scope = 'deployment' AND scope_entity_id = $4)
)
ORDER BY created_at DESC;
```
After fetching candidate freezes, those with a non-null `selector` are
post-filtered by evaluating the CEL expression against the release target
context. This keeps the SQL query simple while supporting fine-grained
filtering.
### Evaluator pipeline placement
The freeze evaluator does not originate from a policy rule — it is injected
unconditionally for every evaluation:
```go theme={null}
func CollectEvaluators(
ctx context.Context,
getter Getter,
workspaceId string,
policies []*oapi.PolicyWithRules,
) []evaluator.Evaluator {
evals := []evaluator.Evaluator{
deploymentfreeze.NewEvaluator(getter, workspaceId),
}
for _, policy := range policies {
for _, rule := range policy.Rules {
evals = append(evals, ruleEvaluators(ctx, getter, rule)...)
}
}
slices.SortFunc(evals, func(a, b evaluator.Evaluator) int {
return cmp.Compare(a.Complexity(), b.Complexity())
})
return evals
}
```
Because it has `Complexity() = 0` and the evaluators are sorted cheapest-first,
the freeze check always runs first. If a freeze is active, the `Denied` result
prevents job creation without evaluating any policy rules.
### Override mechanism
Some deployments must proceed even during a freeze — a hotfix for the very
incident that caused the freeze, or a rollback to a known-good version. The
freeze supports an explicit override via a `bypass_freeze` field on the version
or a policy skip:
```sql theme={null}
ALTER TABLE deployment_version
ADD COLUMN bypass_freeze BOOLEAN NOT NULL DEFAULT FALSE;
```
When `bypass_freeze = true`, the freeze evaluator returns `Allowed` with a
detail noting the bypass:
```go theme={null}
if scope.Version != nil && scope.Version.BypassFreeze {
return results.NewAllowedResult(
"Deployment freeze bypassed for this version",
).
WithDetail("freeze_id", freeze.Id).
WithDetail("bypass_reason", "version marked bypass_freeze=true")
}
```
Creating a version with `bypass_freeze = true` requires a specific permission
(`deployment_freeze.bypass`) and is recorded in the freeze event log.
### Auto-thaw
The workspace-engine runs a periodic check (every 60 seconds) for freezes past
their `expires_at`:
```go theme={null}
func (w *Worker) expireFreezes(ctx context.Context) error {
expired, err := w.store.ExpireActiveFreezes(ctx)
if err != nil {
return err
}
for _, freeze := range expired {
w.store.CreateFreezeEvent(ctx, FreezeEvent{
FreezeId: freeze.Id,
Action: "expired",
Note: fmt.Sprintf("Auto-expired after TTL (%s)",
freeze.ExpiresAt.Sub(freeze.CreatedAt).String()),
})
w.notifications.Send(ctx, FreezeExpiredNotification{
Freeze: freeze,
Workspace: w.workspace,
})
}
return nil
}
```
The `ExpireActiveFreezes` query atomically sets `thawed_at = now()` on all
freezes where `expires_at <= now() AND thawed_at IS NULL`:
```sql theme={null}
UPDATE deployment_freeze
SET thawed_at = now()
WHERE expires_at IS NOT NULL
AND expires_at <= now()
AND thawed_at IS NULL
RETURNING *;
```
After auto-thaw, the workspace-engine triggers a reconciliation cycle for all
release targets in the affected scope so that pending deployments resume
immediately.
### UI
#### Freeze banner
When any freeze is active in the current workspace, a persistent banner appears
at the top of the workspace layout:
```
⚠ Deployment freeze active — "Production incident INC-4521" — Expires in 3h 22m
[View details] [Thaw now]
```
The banner is dismissible per-session but reappears on page refresh if the
freeze is still active. Multiple active freezes show as a count with a dropdown.
#### Freeze management page
A new page at `/workspaces/{id}/freezes` shows:
* **Active freezes** — scope, reason, who activated, when, TTL remaining, thaw
button.
* **Recent freezes** — last 30 days, with full event history (activated,
extended, thawed/expired).
* **Create freeze** button — opens a form with scope picker, reason, incident
URL, TTL selector, and optional CEL selector.
#### Environment and deployment views
The environment detail page and deployment detail page show a freeze indicator
when a freeze is active that covers them. Frozen release targets display the
freeze reason in their status column instead of the normal policy evaluation
status.
### Notifications
Freeze lifecycle events trigger notifications through the existing notification
system:
| Event | Recipients | Content |
| --------- | ---------------------------------------- | ----------------------------------------------- |
| Activated | Workspace members with deploy permission | Scope, reason, incident URL, TTL, who activated |
| Extended | Same as activated | New TTL, extension reason |
| Thawed | Same as activated | Who thawed, thaw reason, duration |
| Expired | Same as activated | Original TTL, total duration |
| Bypassed | Workspace admins | Which version bypassed, who created the version |
Notifications are sent via configured channels (Slack, webhook, email) based on
workspace notification settings.
## Examples
### Workspace-wide emergency freeze
An SRE detects elevated error rates across all services:
```bash theme={null}
curl -X POST ".../workspaces/{id}/freezes" \
-d '{
"scope": "workspace",
"reason": "Elevated 5xx rates across all services — investigating root cause",
"incidentUrl": "https://pagerduty.com/incidents/INC-4521",
"expiresIn": "PT2H"
}'
```
All deployments in the workspace are frozen. The evaluator denies every release
target evaluation with the freeze reason. After 2 hours, the freeze auto-thaws
and pending deployments resume.
### Environment-scoped freeze during rollback
A bad deployment reaches production. The operator freezes production while
rolling back:
```bash theme={null}
# Freeze production
curl -X POST ".../workspaces/{id}/freezes" \
-d '{
"scope": "environment",
"scopeEntityId": "",
"reason": "Rolling back payment-service v2.3.1 — customer-facing errors",
"expiresIn": "PT1H"
}'
# Push rollback version with bypass
curl -X POST ".../deployments/{id}/versions" \
-d '{
"tag": "v2.3.0-rollback",
"status": "ready",
"bypassFreeze": true
}'
```
The rollback version proceeds through the policy pipeline normally. All other
deployments to production are blocked until the freeze is lifted.
### Freeze with selector
A database migration is running and only deployments that write to the database
should be frozen:
```bash theme={null}
curl -X POST ".../workspaces/{id}/freezes" \
-d '{
"scope": "workspace",
"reason": "Database migration in progress — freezing DB-dependent services",
"selector": "deployment.metadata[\"depends_on_db\"] == \"true\"",
"expiresIn": "PT30M"
}'
```
Deployments without `depends_on_db: true` in their metadata continue
unaffected.
### Extending a freeze
The incident is taking longer than expected:
```bash theme={null}
curl -X PATCH ".../workspaces/{id}/freezes/{freezeId}" \
-d '{
"expiresIn": "PT6H",
"reason": "Root cause identified but fix requires database migration — extending freeze"
}'
```
The TTL resets to 6 hours from now. An `extended` event is recorded.
### Manual thaw
The incident is resolved:
```bash theme={null}
curl -X POST ".../workspaces/{id}/freezes/{freezeId}/thaw" \
-d '{
"reason": "Incident resolved. Payment service stable at v2.3.2. RCA: https://wiki/RCA-4521"
}'
```
The freeze is lifted immediately. The workspace-engine triggers reconciliation
and pending deployments resume.
### Multiple overlapping freezes
A workspace freeze is active when an environment-specific freeze is also
created:
```
Active freezes:
1. Workspace — "Company-wide change freeze for Q1 close" (expires in 47h)
2. Environment (prod) — "Hotfix in progress" (expires in 1h)
Release target evaluation:
- Staging deployment → Denied by freeze #1 (workspace scope)
- Prod deployment → Denied by freeze #1 AND #2
- Prod deployment with bypass_freeze version → Denied by freeze #1
(workspace freeze still applies; bypass only exempts the version
from freeze evaluation, not from workspace-level freezes)
```
The `bypass_freeze` field on a version exempts it from **all** matching
freezes. If the operator intends for the bypass to respect workspace-level
freezes, they should scope the bypass to a specific freeze via metadata
convention (see Open Questions).
## Migration
* The `deployment_freeze` and `deployment_freeze_event` tables are new. No data
migration required.
* The `bypass_freeze` column on `deployment_version` is additive with a default
of `false`. Existing versions are unaffected.
* The `deployment_freeze_scope` and `deployment_freeze_action` enum types are
new.
* The freeze evaluator is injected unconditionally and returns `Allowed` when
no freezes are active. Existing behavior is preserved.
* No changes to existing policy rules or evaluators.
## Open Questions
1. **Bypass granularity.** The current proposal has a boolean `bypass_freeze` on
the version that bypasses all matching freezes. Should bypass be scoped to a
specific freeze ID instead? This would allow a version to bypass an
environment freeze but still respect a workspace freeze. The trade-off is
complexity — the deployer must know the freeze ID at version creation time.
2. **Freeze inheritance.** If a workspace freeze is active, should an
environment-level thaw override it for that environment? The current proposal
says no — a workspace freeze blocks everything regardless of
environment-level freeze state. This is the safe default but may be too
rigid for organizations that want hierarchical freeze management.
3. **In-flight jobs.** A freeze prevents new jobs from being created. Should it
also cancel or pause jobs that are already running? Cancellation is
destructive and may leave resources in an inconsistent state. The safe
default is to let in-flight jobs complete but prevent new ones. However, for
severe incidents, the operator may want to stop everything including
in-flight work.
4. **Freeze permissions.** Who can create and thaw freezes? The proposal assumes
a `deployment_freeze.create` and `deployment_freeze.thaw` permission. Should
thawing require higher privileges than freezing (to prevent accidental
thaws)? Should there be a "break glass" thaw that requires admin approval?
5. **Notification timing.** Should the system send warning notifications before
a freeze auto-expires (e.g., "Freeze expires in 30 minutes — extend or thaw
manually")? This prevents surprise resumption of deployments if the operator
intended to extend.
6. **Interaction with deployment windows.** If a freeze is active during a
deployment window's allow period, the freeze takes precedence (denied). When
the freeze lifts, should the system check if the deployment window is still
open? The evaluator pipeline handles this naturally — after the freeze
evaluator allows, the deployment window evaluator runs next and checks the
current time. But this means a freeze that lifts 5 minutes before a window
closes gives only 5 minutes of deployment time. Should the window be
extended to compensate?
7. **Terraform / IaC representation.** Should freezes be expressible as
Terraform resources? Freezes are inherently imperative and transient, which
maps poorly to Terraform's declarative model. A `ctrlplane_deployment_freeze`
resource would be created on `apply` and destroyed on `destroy`, which
technically works but feels semantically odd for an incident response action.
8. **Cascading thaw.** When a workspace freeze is thawed, should all
narrower-scope freezes within that workspace also be thawed? Or should they
remain active independently? The current proposal treats each freeze as
independent — thawing the workspace freeze does not affect the environment
freeze.
## Future Considerations
### PagerDuty integration
The most natural extension of deployment freezes is automatic activation from an
incident management system. PagerDuty is the primary target, with the pattern
generalizing to Opsgenie, Grafana OnCall, and similar tools.
**Auto-freeze on incident creation.** A PagerDuty webhook listener receives
incident events and creates a deployment freeze when an incident is triggered.
The mapping from incident to freeze scope could be configured per-service:
```json theme={null}
{
"pagerduty": {
"serviceMapping": [
{
"pdServiceId": "PABC123",
"freezeScope": "environment",
"scopeEntityId": "",
"minSeverity": "P1"
},
{
"pdServiceId": "PXYZ789",
"freezeScope": "deployment",
"scopeEntityId": "",
"minSeverity": "P2"
}
],
"defaultTtl": "PT4H",
"defaultScope": "workspace",
"minSeverity": "P1"
}
}
```
A `minSeverity` threshold prevents low-priority alerts from triggering freezes.
P1 incidents could freeze the workspace, P2 freeze the affected environment,
P3/P4 are informational only.
The freeze's `reason` and `incident_url` are populated automatically from the
PagerDuty incident title and URL. The `created_by` is set to a service account
representing the PagerDuty integration, with the PagerDuty incident responder
recorded in event metadata.
**Auto-thaw on incident resolution.** When PagerDuty sends a `resolved` webhook,
the integration finds freezes linked to that incident (via `incident_url` or a
`pagerduty_incident_id` metadata field) and thaws them. The thaw reason is
populated from the PagerDuty resolution note.
A configurable `thaw_delay` (e.g., 15 minutes after resolution) provides a
buffer — the incident may be resolved in PagerDuty before the system is fully
stable. During the delay, the freeze remains active but a notification warns
that auto-thaw is imminent.
**Bidirectional timeline.** The PagerDuty integration posts timeline entries on
the incident when freezes are activated, extended, or thawed. This gives
incident responders visibility into deployment state directly from their
incident management tool:
```
10:03 AM — Deployment freeze activated (scope: production)
10:45 AM — Freeze extended to 6 hours
11:30 AM — Hotfix v2.3.2 bypassed freeze
1:15 PM — Deployment freeze thawed (incident resolved)
```
### Slack integration
Beyond notifications (covered in the main proposal), Slack could provide
interactive freeze management:
* **Slash commands.** `/ctrlplane freeze production "Payment service incident"`
creates a freeze. `/ctrlplane thaw ` lifts one. Useful during
incident response when switching to the ctrlplane UI is a context switch.
* **Interactive messages.** Freeze notifications include "Extend" and "Thaw"
buttons that trigger API calls directly from Slack.
* **Incident channel binding.** When a freeze is created with an incident URL
that maps to a Slack channel (e.g., `#inc-4521`), freeze lifecycle
notifications are posted to that channel specifically, not just the default
notification channel.
### Statuspage integration
Active workspace-wide or environment-wide freezes could automatically update an
external status page (Atlassian Statuspage, Instatus, etc.) to reflect that
deployments are paused. This is relevant for platform teams that publish
deployment status to internal consumers:
* Freeze activated → status component set to "Degraded Performance" or
"Maintenance" with the freeze reason.
* Freeze thawed → status component restored to "Operational."
### CI/CD pipeline gating
Freezes could be exposed as a check endpoint that CI/CD systems query before
proceeding with deployment steps:
```
GET /v1/workspaces/{id}/freezes/check?scope=environment&entityId={envId}
200 OK: { "frozen": false }
200 OK: { "frozen": true, "freezeId": "...", "reason": "..." }
```
GitHub Actions, GitLab CI, and Jenkins pipelines could poll this endpoint as a
gate step, failing the pipeline early rather than pushing a version that the
evaluator will deny. This provides faster feedback to developers.
### Calendar-based planned freezes
While the current proposal focuses on emergency freezes, the same primitive
could support planned change freezes (e.g., end-of-quarter code freezes, holiday
freezes). These would be created ahead of time with a future `created_at`
(or a separate `effective_at` field) and a known `expires_at`. The integration
with Google Calendar or Outlook could auto-create freezes from calendar events
tagged with a specific label.
### Incident management post-mortem
On freeze thaw, the system could auto-create a post-mortem template in the
configured project management tool (Jira, Linear, etc.) pre-populated with:
* Freeze duration and scope.
* Which deployments were blocked and for how long.
* Which versions bypassed the freeze.
* Timeline of freeze events.
This reduces the manual effort of gathering deployment context for incident
retrospectives.
# RFC 0009: Global Variable Sets
Source: https://docs.ctrlplane.dev/rfc/0009-global-variable-sets
| Category | Status | Created | Author |
| --------- | -------------------- | ---------- | ------------- |
| Variables | Draft | 2026-03-13 | Justin Brooks |
## Summary
Add global variable sets — named, reusable collections of key-value pairs that
can be scoped to a workspace, system, or environment and are automatically
injected into deployment variable resolution. Variable sets eliminate the need
to duplicate shared configuration across deployments and provide a single place
to manage cross-cutting variables like database endpoints, feature flags, region
metadata, and shared credentials references.
## Motivation
### Shared configuration is duplicated across deployments
A typical workspace has configuration that spans many deployments: database
connection strings, message queue endpoints, feature flags, regional settings,
cloud account IDs, and API keys. Today, each deployment must define its own
deployment variable for each of these values.
Consider a workspace with 15 deployments that all need `DATABASE_URL`,
`REDIS_URL`, `LOG_LEVEL`, and `REGION`. The operator must:
1. Create 4 deployment variables on each of the 15 deployments (60 variables).
2. For each variable, create deployment variable values with the correct
resource selectors to differentiate production from staging.
3. When the staging database endpoint rotates, update the value across all 15
deployments individually.
This is tedious, error-prone, and scales poorly. A forgotten update leaves one
deployment pointing at a stale endpoint. There is no mechanism to express "these
variables are the same across all deployments in this system."
### No hierarchy for variable inheritance
The current variable model is flat. Deployment variables live on deployments.
Resource variables live on resources. There is no intermediate layer where an
operator can say "all deployments in this system inherit these variables" or
"all deployments in this workspace get these defaults unless overridden."
Other deployment platforms solve this with variable groups (Azure DevOps),
variable sets (Terraform Cloud), environment variables (Vercel), or config maps
(Kubernetes). Ctrlplane's closest analog is resource variables, but those are
scoped to the resource — they express "this resource has this property," not
"this configuration should flow to all deployments targeting this resource."
### Variable changes need consistent rollout
When a shared value changes, the operator wants all affected deployments to
pick up the change atomically. Today, updating 15 deployment variables is 15
separate mutations, each triggering its own release reconciliation. There is no
way to batch the update and have all affected release targets reconcile with the
new values simultaneously.
### The deployment variable model is the wrong abstraction for shared config
Deployment variables answer the question "what configuration does this
deployment need?" They are owned by the deployment and vary per deployment.
Shared configuration answers a different question: "what configuration exists
in this environment/system/workspace that multiple deployments consume?"
Overloading deployment variables for shared config creates problems:
* **Ownership ambiguity.** Who owns the `DATABASE_URL` deployment variable — the
deployment owner or the platform team that manages the database? When it lives
on every deployment, there is no single owner.
* **Drift.** Without a single source of truth, values drift between deployments.
Deployment A gets updated, deployment B does not.
* **Onboarding cost.** Adding a new deployment requires copying all shared
variables from an existing deployment. There is no template or inheritance.
* **Audit difficulty.** Answering "which deployments use this database endpoint?"
requires scanning every deployment's variables.
## Proposal
### Variable sets as a first-class entity
A **variable set** is a named collection of key-value pairs with a scope
(workspace, system, or environment) and an optional selector that further
narrows which release targets receive the variables. Variable sets are resolved
during the variable evaluation phase alongside deployment variables and resource
variables.
Variable sets are **not** deployment variables. They are a separate entity with
their own lifecycle, ownership, and API surface. They are injected into the
variable resolution pipeline as an additional source of values, sitting between
deployment variable defaults and deployment variable values in the resolution
priority chain.
### Schema
```sql theme={null}
CREATE TABLE variable_set (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
-- Scope determines at which level this set applies.
-- 'workspace' = all deployments in the workspace.
-- 'system' = all deployments in a specific system.
-- 'environment' = all deployments targeting a specific environment.
scope TEXT NOT NULL CHECK (scope IN ('workspace', 'system', 'environment')),
-- The entity ID for system/environment scopes. NULL for workspace scope.
scope_entity_id UUID,
-- Optional CEL selector for fine-grained filtering within the scope.
-- Evaluated against the release target context (resource, deployment,
-- environment metadata).
selector TEXT,
-- Priority for ordering when multiple sets define the same key.
-- Higher priority wins. Default 0.
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT valid_scope_entity CHECK (
(scope = 'workspace' AND scope_entity_id IS NULL) OR
(scope != 'workspace' AND scope_entity_id IS NOT NULL)
),
CONSTRAINT unique_name_per_workspace UNIQUE (workspace_id, name)
);
CREATE INDEX idx_variable_set_workspace ON variable_set (workspace_id);
CREATE INDEX idx_variable_set_scope ON variable_set (workspace_id, scope);
```
Each variable set contains variables:
```sql theme={null}
CREATE TABLE variable_set_variable (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
variable_set_id UUID NOT NULL
REFERENCES variable_set(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value JSONB NOT NULL,
-- Whether this variable is sensitive (masked in UI and API responses).
sensitive BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_key_per_set UNIQUE (variable_set_id, key)
);
CREATE INDEX idx_variable_set_variable_set
ON variable_set_variable (variable_set_id);
```
The `value` column uses the same JSONB format as existing deployment variable
values, supporting both literal values and reference values:
```json theme={null}
// Literal string
"us-east-1"
// Literal number
42
// Literal boolean
true
// Reference value
{
"reference": "workspace",
"path": ["metadata", "database_url"]
}
```
### Resolution priority
The variable resolution priority is extended to include variable sets. The
full priority chain, from highest to lowest:
1. **Resource variable** — a variable defined directly on the resource with a
matching key. This is the most specific override and always wins.
2. **Deployment variable value** — a deployment variable value whose resource
selector matches the target resource, sorted by priority (highest first).
3. **Variable set (environment scope)** — a variable set scoped to the
specific environment in the release target, sorted by set priority.
4. **Variable set (system scope)** — a variable set scoped to the system
containing the deployment, sorted by set priority.
5. **Variable set (workspace scope)** — a variable set scoped to the
workspace, sorted by set priority.
6. **Deployment variable default** — the default value defined on the
deployment variable.
This ordering follows the principle of specificity: the most specific source
wins. Resource variables are the most specific (set on the exact resource),
variable sets widen from environment to system to workspace, and deployment
variable defaults are the fallback.
Within the same scope level, multiple variable sets may define the same key.
The set with the highest `priority` value wins. If two sets at the same scope
have the same priority, the one created most recently wins (deterministic
tiebreaker).
**Only keys declared as deployment variables are resolved.** Variable sets do
not introduce new keys into the release — they provide values for keys that
the deployment has declared via deployment variables. A variable set with
`DATABASE_URL = "postgres://..."` has no effect on a deployment that does not
declare a `DATABASE_URL` deployment variable. This preserves the deployment's
contract: the deployment declares what variables it needs, and variable sets
(along with other sources) provide the values.
### Variable manager changes
The `Manager.Evaluate` method in the workspace-engine is extended to query
variable sets after deployment variable values and before deployment variable
defaults:
```go theme={null}
func (m *Manager) Evaluate(
ctx context.Context,
releaseTarget *oapi.ReleaseTarget,
relatedEntities map[string][]*oapi.EntityRelation,
) (map[string]*oapi.LiteralValue, error) {
ctx, span := tracer.Start(ctx, "VariableManager.Evaluate")
defer span.End()
resolvedVariables := make(map[string]*oapi.LiteralValue)
resource, exists := m.store.Resources.Get(releaseTarget.ResourceId)
if !exists {
return nil, fmt.Errorf("resource %q not found", releaseTarget.ResourceId)
}
entity := relationships.NewResourceEntity(resource)
resourceVariables := m.store.Resources.Variables(releaseTarget.ResourceId)
deploymentVariables := m.store.Deployments.Variables(releaseTarget.DeploymentId)
// Load variable sets in scope order (environment > system > workspace).
variableSets := m.store.VariableSets.ForReleaseTarget(ctx, releaseTarget)
for key, deploymentVar := range deploymentVariables {
// 1. Resource variable
resolved := m.tryResolveResourceVariable(
ctx, key, resourceVariables, entity, relatedEntities,
)
if resolved != nil {
resolvedVariables[key] = resolved
continue
}
// 2. Deployment variable value (selector + priority)
resolved = m.tryResolveDeploymentVariableValue(
ctx, deploymentVar, resource, entity, relatedEntities,
)
if resolved != nil {
resolvedVariables[key] = resolved
continue
}
// 3. Variable sets (environment > system > workspace)
resolved = m.tryResolveFromVariableSets(
ctx, key, variableSets, entity, relatedEntities,
)
if resolved != nil {
resolvedVariables[key] = resolved
continue
}
// 4. Deployment variable default
if deploymentVar.DefaultValue != nil {
resolvedVariables[key] = deploymentVar.DefaultValue
}
}
return resolvedVariables, nil
}
```
The `tryResolveFromVariableSets` method iterates through variable sets in
scope order. Sets are pre-sorted: environment-scoped first, then system-scoped,
then workspace-scoped. Within each scope level, sets are sorted by priority
(descending), then by `created_at` (descending):
```go theme={null}
func (m *Manager) tryResolveFromVariableSets(
ctx context.Context,
key string,
variableSets []*VariableSetWithVariables,
entity *oapi.RelatableEntity,
relatedEntities map[string][]*oapi.EntityRelation,
) *oapi.LiteralValue {
for _, vs := range variableSets {
variable, exists := vs.Variables[key]
if !exists {
continue
}
result, err := m.store.Variables.ResolveValue(
ctx, entity, &variable.Value, relatedEntities,
)
if err != nil {
continue
}
return result
}
return nil
}
```
### Reconciliation on variable set changes
When a variable set is created, updated, or deleted, the system must
re-evaluate all release targets that could be affected. The scope determines
the blast radius:
* **Workspace scope**: all release targets in the workspace.
* **System scope**: all release targets for deployments in the system.
* **Environment scope**: all release targets in the environment.
If the variable set has a `selector`, only release targets matching the
selector are re-evaluated. This is the same reconciliation pattern used when
deployment variables change — the workspace-engine detects that variable
inputs have changed, computes the new resolved variables, and creates a new
release if the resolved values differ from the current release.
```go theme={null}
func (w *Worker) handleVariableSetChange(
ctx context.Context,
event VariableSetChangeEvent,
) error {
affectedTargets := w.store.ReleaseTargets.InScope(
ctx,
event.VariableSet.WorkspaceId,
event.VariableSet.Scope,
event.VariableSet.ScopeEntityId,
event.VariableSet.Selector,
)
for _, target := range affectedTargets {
w.enqueueReleaseTargetEvaluation(ctx, target)
}
return nil
}
```
### API
#### REST
```
POST /v1/workspaces/{workspaceId}/variable-sets Create a variable set
GET /v1/workspaces/{workspaceId}/variable-sets List variable sets
GET /v1/workspaces/{workspaceId}/variable-sets/{id} Get variable set with variables
PATCH /v1/workspaces/{workspaceId}/variable-sets/{id} Update variable set metadata
DELETE /v1/workspaces/{workspaceId}/variable-sets/{id} Delete a variable set
PUT /v1/workspaces/{workspaceId}/variable-sets/{id}/variables Upsert variables (bulk)
DELETE /v1/workspaces/{workspaceId}/variable-sets/{id}/variables/{key} Delete a variable
```
**Create:**
```json theme={null}
POST /v1/workspaces/{workspaceId}/variable-sets
{
"name": "production-database",
"description": "Database connection details for production",
"scope": "environment",
"scopeEntityId": "",
"priority": 10,
"variables": [
{
"key": "DATABASE_URL",
"value": "postgres://prod-db.internal:5432/app",
"sensitive": true
},
{
"key": "DATABASE_POOL_SIZE",
"value": 20
},
{
"key": "DATABASE_SSL_MODE",
"value": "verify-full"
}
]
}
```
**Upsert variables:**
```json theme={null}
PUT /v1/workspaces/{workspaceId}/variable-sets/{id}/variables
{
"variables": [
{ "key": "DATABASE_URL", "value": "postgres://new-db.internal:5432/app", "sensitive": true },
{ "key": "DATABASE_POOL_SIZE", "value": 25 }
]
}
```
This is a partial upsert — keys included in the request are created or updated,
keys not included are left unchanged. To remove a key, use the DELETE endpoint.
**List with scope filtering:**
```
GET /v1/workspaces/{workspaceId}/variable-sets?scope=environment&scopeEntityId=
```
Returns all variable sets that apply to the given scope, including
workspace-scoped sets that apply everywhere.
#### tRPC
```typescript theme={null}
variableSet.create
variableSet.list
variableSet.get
variableSet.update
variableSet.delete
variableSet.upsertVariables
variableSet.deleteVariable
```
### Terraform provider
```hcl theme={null}
resource "ctrlplane_variable_set" "production_db" {
workspace_id = ctrlplane_workspace.main.id
name = "production-database"
description = "Database connection details for production"
scope = "environment"
scope_entity_id = ctrlplane_environment.production.id
priority = 10
}
resource "ctrlplane_variable_set_variable" "db_url" {
variable_set_id = ctrlplane_variable_set.production_db.id
key = "DATABASE_URL"
literal_value = "postgres://prod-db.internal:5432/app"
sensitive = true
}
resource "ctrlplane_variable_set_variable" "db_pool" {
variable_set_id = ctrlplane_variable_set.production_db.id
key = "DATABASE_POOL_SIZE"
literal_value = "20"
}
```
### UI
#### Variable sets management page
A new page at `/workspaces/{id}/settings/variable-sets` lists all variable sets
in the workspace:
| Name | Scope | Target | Variables | Priority |
| --------------------- | ----------- | --------------- | --------- | -------- |
| production-database | Environment | production | 3 | 10 |
| staging-database | Environment | staging | 3 | 10 |
| shared-feature-flags | Workspace | All deployments | 8 | 0 |
| payment-system-config | System | payment | 5 | 5 |
Clicking a variable set opens an editor showing:
* Name, description, scope, priority.
* A table of variables with key, value (masked if sensitive), and actions.
* An "Add variable" form.
* A "Used by" section showing which deployments declare variables with matching
keys and would receive values from this set.
#### Variable resolution preview
The deployment detail page gains a "Variable Resolution" panel that shows, for
each deployment variable, where its value comes from for a selected resource:
| Variable | Value | Source |
| ---------------- | ------------------------------------ | --------------------------- |
| DATABASE\_URL | `postgres://prod-db.internal:5432/…` | Variable Set: production-db |
| REPLICA\_COUNT | `5` | Deployment Variable Value |
| LOG\_LEVEL | `info` | Variable Set: defaults |
| FEATURE\_NEW\_UI | `true` | Resource Variable |
| CACHE\_TTL | `300` | Deployment Variable Default |
This makes the resolution chain visible and debuggable. Each source is a link
to the entity that provided the value.
#### System and environment detail pages
The system detail page and environment detail page show variable sets scoped
to them, with a quick-add button to create a new set at that scope.
## Examples
### Shared database configuration
A platform team manages database endpoints. They create variable sets per
environment:
```bash theme={null}
# Production database config
curl -X POST ".../workspaces/{id}/variable-sets" \
-d '{
"name": "production-database",
"scope": "environment",
"scopeEntityId": "",
"priority": 10,
"variables": [
{ "key": "DATABASE_URL", "value": "postgres://prod-db:5432/app", "sensitive": true },
{ "key": "DATABASE_POOL_SIZE", "value": 20 },
{ "key": "DATABASE_SSL_MODE", "value": "verify-full" }
]
}'
# Staging database config
curl -X POST ".../workspaces/{id}/variable-sets" \
-d '{
"name": "staging-database",
"scope": "environment",
"scopeEntityId": "",
"priority": 10,
"variables": [
{ "key": "DATABASE_URL", "value": "postgres://staging-db:5432/app", "sensitive": true },
{ "key": "DATABASE_POOL_SIZE", "value": 5 },
{ "key": "DATABASE_SSL_MODE", "value": "prefer" }
]
}'
```
Every deployment that declares a `DATABASE_URL` deployment variable
automatically receives the correct value based on which environment the
release target is in. When the production database endpoint changes, the
operator updates one variable set and all deployments pick up the change.
### Workspace-wide defaults
A workspace admin sets sensible defaults that apply everywhere:
```bash theme={null}
curl -X POST ".../workspaces/{id}/variable-sets" \
-d '{
"name": "workspace-defaults",
"scope": "workspace",
"priority": 0,
"variables": [
{ "key": "LOG_LEVEL", "value": "info" },
{ "key": "METRICS_ENABLED", "value": true },
{ "key": "OTEL_EXPORTER_ENDPOINT", "value": "https://otel.internal:4317" }
]
}'
```
Individual deployments can override these by setting deployment variable values
or deployment variable defaults, which have higher priority. A deployment that
needs `LOG_LEVEL=debug` sets its own deployment variable value — the
workspace-wide default is ignored for that deployment.
### System-specific configuration
A payment system has configuration shared across its deployments (payment-api,
payment-worker, payment-webhook):
```bash theme={null}
curl -X POST ".../workspaces/{id}/variable-sets" \
-d '{
"name": "payment-system-config",
"scope": "system",
"scopeEntityId": "",
"priority": 5,
"variables": [
{ "key": "STRIPE_API_VERSION", "value": "2025-12-01" },
{ "key": "PAYMENT_TIMEOUT_MS", "value": 30000 },
{ "key": "IDEMPOTENCY_KEY_TTL", "value": 86400 }
]
}'
```
All three deployments in the payment system receive these variables without
any per-deployment configuration.
### Layered overrides
Multiple variable sets at different scopes can coexist. The resolution
chain handles precedence naturally:
```
Workspace set (priority 0): LOG_LEVEL = "warn"
System set (priority 5): LOG_LEVEL = "info"
Environment set (priority 10): LOG_LEVEL = "debug"
```
For a release target in the matching environment, the environment-scoped set
wins (it is more specific). For a release target in a different environment
within the same system, the system-scoped set provides `LOG_LEVEL = "info"`.
For a release target in a different system entirely, the workspace-scoped set
provides `LOG_LEVEL = "warn"`.
If a resource has a resource variable `LOG_LEVEL = "trace"`, that overrides
everything — resource variables are always the highest priority.
### Variable set with selector
A variable set can be further narrowed with a CEL selector:
```bash theme={null}
curl -X POST ".../workspaces/{id}/variable-sets" \
-d '{
"name": "gpu-cluster-config",
"scope": "workspace",
"selector": "resource.metadata[\"gpu_enabled\"] == \"true\"",
"priority": 10,
"variables": [
{ "key": "GPU_MEMORY_LIMIT", "value": "16Gi" },
{ "key": "CUDA_VERSION", "value": "12.4" }
]
}'
```
Only release targets whose resource has `gpu_enabled: true` in metadata
receive these variables. Other release targets are unaffected.
## Migration
* The `variable_set` and `variable_set_variable` tables are new. No data
migration required.
* Existing deployment variables, resource variables, and their resolution logic
are unchanged. The variable set layer is additive.
* The workspace-engine's variable manager gains a new resolution step between
deployment variable values and deployment variable defaults. Existing
resolution behavior is preserved — variable sets only provide values when
higher-priority sources (resource variables, deployment variable values) do
not.
* No changes to existing API endpoints. New endpoints are additive.
## Open Questions
1. **Variable set assignment vs. scope-based matching.** The current proposal
uses scope-based matching: a variable set with `scope = environment` and
`scope_entity_id = ` automatically applies to all release targets in
production. An alternative is explicit assignment: the operator attaches
variable sets to deployments, systems, or environments manually. Assignment
gives more control but requires more configuration. Scope-based matching is
simpler but less flexible. Should we support both?
2. **Key collision across sets.** When two variable sets at the same scope level
define the same key, priority determines the winner. Should collisions be
surfaced as warnings in the UI? Should there be a strict mode that rejects
ambiguous resolutions?
3. **Sensitive variable handling.** The `sensitive` flag masks values in the UI
and API list responses. Should sensitive variables also be excluded from
audit logs? Should they require a separate permission to read the plaintext
value? How does this interact with the secret provider integration from
RFC 0006?
4. **Variable set versioning.** Should variable sets support versioning or
change history? When a variable is updated, should the system record the
previous value for audit purposes? This adds complexity but is valuable
for debugging "what changed."
5. **Bulk update atomicity.** When updating multiple variables in a set, should
the operation be atomic (all-or-nothing)? The current proposal uses upsert
semantics where each key is updated independently. An atomic bulk update
would prevent partial updates but requires transactional semantics.
6. **Cross-workspace variable sets.** Should variable sets be shareable across
workspaces? This is relevant for organizations with multiple workspaces that
share infrastructure. The current proposal scopes sets to a single workspace.
7. **Interaction with variable set selectors and deployment variable
selectors.** A variable set with a selector and a deployment variable value
with a resource selector are conceptually similar. Should we unify the
filtering mechanism or keep them separate?
## Future Considerations
### Variable set templates
Pre-built variable set templates for common patterns:
* **Cloud provider defaults**: AWS region, account ID, VPC settings.
* **Observability stack**: OTEL endpoints, log levels, metrics configuration.
* **Database connection**: URL, pool size, SSL mode, timeout.
Templates could be shared across workspaces or published as community
templates.
### Environment cloning
When creating a new environment (e.g., a new staging environment for a feature
branch), variable sets scoped to a reference environment could be cloned
automatically with modified values. This supports dynamic environment workflows
where environments are created and destroyed frequently.
### Secret provider integration
RFC 0006 proposes secret provider integration for resolving secrets from
external stores (Vault, AWS Secrets Manager, etc.). Variable sets are a natural
place to reference external secrets:
```json theme={null}
{
"key": "DATABASE_PASSWORD",
"value": {
"secretRef": {
"provider": "vault",
"path": "secret/data/production/database",
"key": "password"
}
},
"sensitive": true
}
```
The variable resolution pipeline would resolve the secret reference at
evaluation time, fetching the current value from the secret provider. This
keeps secrets out of the database while allowing variable sets to manage
which secrets each deployment receives.
### Variable set policies
Policies could enforce rules on variable sets:
* **Required variables**: a policy that denies deployment if certain keys are
not provided by any variable set (e.g., "all deployments must have
`LOG_LEVEL` defined").
* **Value constraints**: a policy that validates variable values against a
schema (e.g., "`DATABASE_POOL_SIZE` must be between 1 and 100").
* **Sensitive enforcement**: a policy that requires certain keys to be marked
sensitive (e.g., any key containing `PASSWORD`, `SECRET`, or `TOKEN`).
### Variable set drift detection
Detect and alert when variable sets that should be consistent across
environments have diverged. For example, if the staging and production database
variable sets should have the same keys (but different values), drift detection
would flag when production has a key that staging does not. This prevents
configuration gaps from reaching production.
# RFC 0010: Unified Variable & Secret Resolution System
Source: https://docs.ctrlplane.dev/rfc/0010-variable-storage
| Category | Status | Created | Author |
| -------------- | -------------------- | ---------- | ---------- |
| Infrastructure | Draft | 2026-04-01 | Mike Leone |
## **Summary**
This RFC proposes a unified data model for managing variables across resources, deployments, and deployment job agents. It consolidates the current fragmented schema into a single, extensible system that supports:
* Multiple scopes (resource, deployment, deployment job agent)
* Multiple value types (literal, reference, secret reference)
* Override semantics via selectors and priority
* First-class support for secret providers without duplicating schema
The proposal replaces multiple duplicated tables with two core tables: variable and variable\_value.
***
## **Motivation**
The current schema exhibits significant duplication across two dimensions:
1. **Scope duplication**
* Separate handling for resource, deployment, and job-agent variables
2. **Value-type duplication**
* Separate tables for literal values and reference values
This results in:
* Schema explosion and maintenance overhead
* Repeated logic in queries and resolution code
* Increased risk of inconsistency and bugs
* Difficulty extending the system (e.g., adding secrets)
Additionally, introducing secrets under the current model would require duplicating the entire table structure again, further compounding complexity.
We need a model that:
* Treats scope and value type as data, not schema
* Supports extensibility without table proliferation
* Centralizes resolution logic
***
## **Goals**
* Eliminate duplicated tables across scopes and value types
* Provide a single resolution model for all variable types
* Support secret references without storing raw secrets
* Maintain strong data integrity constraints
* Enable future extensibility (new value types, new scopes)
***
## **Non-Goals**
* Implementing secret storage (this system references external providers)
* Defining a full selector language
* Enforcing cross-variable resolution correctness at the database level
***
## **Proposal**
```jsx theme={null}
CREATE TYPE variable_scope as ENUM (
'resource',
'deployment',
'deployment_job_agent'
);
CREATE TYPE variable_value_kind as ENUM (
'literal',
'ref',
'secret_ref'
);
CREATE TABLE IF NOT EXISTS variable (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
scope variable_scope not null,
resource_id uuid references resource(id) on delete cascade,
deployment_id uuid references deployment(id) on delete cascade,
deployment_job_agent_id uuid references deployment_job_agent(id) on delete cascade,
key text not null,
-- metadata
is_sensitive boolean not null default false,
description text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- exactly one owner target must be set, and it must match scope
CONSTRAINT variable_scope_target_check check (
(
scope = 'resource'
and resource_id is not null
and deployment_id is null
and deployment_job_agent_id is null
)
or
(
scope = 'deployment'
and deployment_id is not null
and resource_id is null
and deployment_job_agent_id is null
)
or
(
scope = 'deployment_job_agent'
and deployment_job_agent_id is not null
and resource_id is null
and deployment_id is null
)
)
);
create unique index if not exists variable_resource_key_uniq
on variable(resource_id, key)
where resource_id is not null;
create unique index if not exists variable_deployment_key_uniq
on variable(deployment_id, key)
where deployment_id is not null;
create unique index if not exists variable_dja_key_uniq
on variable(deployment_job_agent_id, key)
where deployment_job_agent_id is not null;
create index if not exists variable_scope_idx
on variable(scope);
create index if not exists variable_resource_lookup_idx
on variable(resource_id, key)
where resource_id is not null;
create index if not exists variable_deployment_lookup_idx
on variable(deployment_id, key)
where deployment_id is not null;
create index if not exists variable_dja_lookup_idx
on variable(deployment_job_agent_id, key)
where deployment_job_agent_id is not null;
CREATE TABLE IF NOT EXISTS variable_value (
id uuid primary key default uuid_generate_v4(),
variable_id uuid not null references variable(id) on delete cascade,
resource_selector text,
priority bigint not null default 0,
kind variable_value_kind not null,
literal_value jsonb,
ref_key text,
ref_path text[],
secret_provider text,
secret_key text,
secret_path text[],
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
CONSTRAINT variable_value_kind_shape_check check (
(
kind = 'literal'
and literal_value is not null
and ref_key is null
and ref_path is null
and secret_provider is null
and secret_key is null
and secret_path is null
)
or
(
kind = 'ref'
and literal_value is null
and ref_key is not null
and secret_provider is null
and secret_key is null
and secret_path is null
)
or
(
kind = 'secret_ref'
and literal_value is null
and ref_key is null
and ref_path is null
and secret_provider is not null
and secret_key is not null
)
)
);
create index if not exists variable_value_variable_priority_idx
on variable_value(variable_id, priority desc, id);
create index if not exists variable_value_selector_idx
on variable_value(variable_id, resource_selector, priority desc);
create index if not exists variable_value_kind_idx
on variable_value(kind);
create unique index if not exists variable_value_resolution_uniq
on variable_value (
variable_id,
coalesce(resource_selector, ''),
priority
);
```
### **Core Concepts**
The system is built around two primary entities:
1. **Variable**
* Defines a key within a specific scope
2. **Variable Value**
* Defines one or more candidate values for a variable
* Supports override semantics via priority and selectors
***
### **Variable**
Represents a named configuration key scoped to a specific owner.
Key properties:
* scope: one of resource, deployment, deployment\_job\_agent
* Exactly one owner reference is set
* key: variable identifier
* is\_sensitive: indicates whether the variable contains sensitive data
***
### **Variable Value**
Represents a candidate value for a variable.
Supports three value types:
* literal: JSON value stored directly
* ref: reference to another variable
* secret\_ref: reference to an external secret provider
Also includes:
* resource\_selector: optional matching condition
* priority: determines precedence
***
### **Value Types**
### **Literal**
Stores a JSON value directly in the database.
Example:
```
{ "host": "db.internal", "port": 5432 }
```
### **Reference**
References another variable by key, optionally with a path.
Example:
```
{ "ref": "db.config", "path": ["host"] }
```
### **Secret Reference**
References a value stored in an external secret manager.
Example:
```
{
"provider": "vault",
"key": "kv/data/prod/db",
"path": ["password"]
}
```
***
### **Resolution Model**
To resolve a variable:
1. Identify the variable by scope and key
2. Retrieve all associated variable\_value rows
3. Filter by selector (if applicable)
4. Sort by priority (descending)
5. Select the highest-priority match
6. Resolve based on value type:
* literal → return value
* ref → recursively resolve referenced variable
* secret\_ref → fetch from external provider
***
### **Why This Design**
### **Eliminates Duplication**
* One table for variables instead of per-scope tables
* One table for values instead of per-type tables
### **Extensible**
Adding a new value type (e.g., computed, templated) requires:
* Adding a new enum value
* Adding optional columns or extending logic
No new tables required.
### **Unified Resolution Logic**
All variables follow the same resolution pipeline regardless of scope or type.
### **Secret Handling**
Secrets are treated as a value source, not a separate system:
* Avoids duplicating schema
* Keeps resolution consistent
* Prevents storing sensitive data directly in the DB
***
## **Alternatives Considered**
### **1. Separate Tables per Scope**
Rejected because:
* Leads to schema duplication
* Requires duplicating logic
* Hard to extend
### **2. Separate Tables per Value Type**
Rejected because:
* Introduces join complexity
* Makes adding new types expensive
### **3. Separate Secret Tables**
Rejected because:
* Secrets participate in the same resolution semantics
* Only the value source differs
* Duplication would increase system complexity
***
## **Tradeoffs**
### **Pros**
* Dramatically simpler schema
* Centralized resolution logic
* Easier to extend
* Reduces duplication
### **Cons**
* More nullable columns in variable\_value
* Some validation shifts to application logic
* Slightly more complex constraints
***
## **Future Work**
* Replace ref\_key with referenced\_variable\_id for stronger integrity
* Introduce structured selector model (e.g., JSON-based matching)
* Add expression-based value model (single JSON expression column)
* Add audit logging and versioning
***
## **Migration Strategy**
1. Create new tables alongside existing schema
2. Backfill variables and values
3. Update read paths to use new schema
4. Deprecate old tables
5. Remove old schema after validation
***
## **Conclusion**
This proposal replaces a fragmented and duplicated schema with a unified, extensible model for variable management.
By treating scope and value type as data rather than schema, the system becomes:
* Easier to maintain
* Easier to extend
* More consistent in behavior
It also provides a clean path to integrate secrets without introducing additional structural complexity.
# RFC 0011: Auto-create Versions from GitHub Releases
Source: https://docs.ctrlplane.dev/rfc/0011-github-releases
| Category | Status | Created | Author |
| ------------ | -------------------- | ---------- | ---------------- |
| Integrations | Draft | 2026-04-20 | Aditya Choudhari |
**Issue:** [#993](https://github.com/ctrlplanedev/ctrlplane/issues/993)
## Problem
How do we know which Ctrlplane deployment a GitHub release belongs to?
`owner/repo` alone fails for monorepos (e.g. `ctrlplanedev/deployments` produces releases for `wandb`, `shared-tenant`, `k8s`, etc.). Naming-convention approaches (`/v`) fight existing tools — release-please emits `-v`, changesets emits `@`, semantic-release emits `v`.
## Proposal
A single CEL selector on deployment metadata.
```
git/release-selector: "repository.full_name == 'ctrlplanedev/deployments' && changedPaths.exists(p, p.startsWith('wandb/'))"
```
### Flow
1. `release` webhook hits `apps/api/src/routes/github/release.ts`.
2. Verify signature, resolve installation.
3. Build CEL context.
4. For every deployment with `git/release-selector` in metadata, evaluate.
5. Match → create deployment version with `release.tag_name` as the version.
### CEL context
```
action: string
release: ReleaseObject // webhook payload
repository: RepositoryObject // webhook payload (topics, custom_properties included)
sender: User // webhook payload
previousTag: string | null // compare API
changedPaths: string[] // compare API
commits: { sha, message, author }[] // compare API
```
Only the bottom block requires an API call (`GET /repos/{o}/{r}/compare/{prev}...{tag}`).
### Metadata keys
| Key | Required | Purpose |
| ---------------------- | -------- | ------------------ |
| `git/release-selector` | yes | CEL returning bool |
No `git/repo` key — repo check lives inside the selector.
### GitHub App permissions
* Metadata: Read
* Contents: Read
* Events: `Release`, `Installation`, `Installation repositories`
### Error handling
* Selector throws → log, skip deployment.
* Compare API fails → evaluate without enriched fields (selectors referencing them evaluate false).
* Zero matches → log + 200.
## Out of scope (v1)
* Selector prefilter / indexing
* Draft / prerelease handling beyond what selectors express
* Backfilling historical releases
# RFC 0012: First-Class GitHub Installations
Source: https://docs.ctrlplane.dev/rfc/0012-github-installations
| Category | Status | Created | Author |
| ------------ | -------------------- | ---------- | ---------------- |
| Integrations | Draft | 2026-04-20 | Aditya Choudhari |
## Summary
Introduce a first-class `github_installation` table that binds a GitHub App
installation to a ctrlplane workspace, with creation restricted to a verified
OAuth flow. This closes a multi-tenancy gap where any workspace on a shared
ctrlplane instance can currently impersonate any GitHub installation by typing
its numeric ID into a job agent config. The RFC covers the schema, the linking
flow, and the migration of the existing job agent integration to reference
installations by foreign key.
## Motivation
### The current state
Ctrlplane runs a single GitHub App (`GITHUB_BOT_APP_ID`, `GITHUB_BOT_PRIVATE_KEY`)
that users install on their GitHub org. The installation is represented inside
`job_agent.config` as an untyped JSON blob:
```json theme={null}
{
"type": "github-app",
"installationId": 12345678,
"owner": "acme-corp"
}
```
There is no dedicated schema for GitHub installations. The zod validator in
`packages/trpc/src/routes/job-agents.ts` accepts any `installationId:
z.number()` without cross-checking it against the calling workspace or the
calling user's GitHub identity. At runtime, the workspace engine mints an
installation token using the App's private key and whatever `installationId`
the job agent config carries — the server has no way to know whether the
workspace should legitimately have access to that installation.
### The multi-tenancy gap
Installation IDs are not secret. They are visible in the URL of any GitHub App
installation settings page (`https://github.com/organizations//settings/installations/`)
and are sequential integers.
On a shared ctrlplane instance, the following attack is trivial:
1. A user in Workspace B discovers (or guesses) the installation ID of
Workspace A's GitHub org.
2. The user creates a job agent in Workspace B with that installation ID.
3. Ctrlplane's server, holding the App's private key, successfully mints an
installation token for Workspace A's org.
4. Workspace B can now list repos, dispatch workflows, and read repo metadata
for Workspace A's org.
The only implicit defense today is "you have to know the installation ID,"
which is not an auth boundary.
### Why the fix has to happen at link-time
The workspace engine's GitHub dispatcher at
`apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go` is a thin
wrapper that calls `gh.CreateClientForInstallation(ctx, cfg.InstallationId)`
with whatever `InstallationId` is in the config. Runtime validation in the
engine is the wrong layer — by then, the config has already been persisted and
the damage is done. The check must happen at the point of creation, i.e.
when a workspace first claims an installation.
## Goals
* Give GitHub installations a first-class table scoped to a workspace.
* Eliminate free-text `installationId` input from every user-facing surface.
* Require GitHub-side proof of admin rights (via OAuth + `GET /user/installations`)
before a link is accepted.
* Allow one installation to be linked by multiple workspaces, as long as each
link is independently verified.
* Migrate the existing GitHub Actions job agent to reference installations by
FK instead of duplicating the ID in JSON.
## Non-Goals
* A CLI flow for linking. The UI flow is the v1 surface; CLI is a follow-up
that plugs into the same backend handler.
* Supporting user-level GitHub OAuth as a general-purpose login mechanism. The
OAuth token acquired during linking is used only to verify installation
access for that single request and is discarded immediately after.
* Terraform-based creation of installation rows. Linking requires a human
browser session; a data-source or CLI-assisted workflow will follow.
## Proposal
### Schema
```sql theme={null}
CREATE TABLE github_installation (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
installation_id bigint NOT NULL, -- GitHub's numeric ID
owner text NOT NULL, -- GitHub org or user login
account_type text NOT NULL, -- 'Organization' | 'User'
created_by_user_id uuid NOT NULL REFERENCES "user"(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, installation_id)
);
CREATE INDEX github_installation_installation_id_idx
ON github_installation(installation_id);
```
Key points:
* **`UNIQUE (workspace_id, installation_id)`** — one installation can be
linked to many workspaces, but each `(workspace, installation)` pair exists
at most once. A shared-org scenario (e.g., dev workspace and prod workspace
both watching the same repos) is supported natively.
* **No global uniqueness on `installation_id`** — multiple workspaces
legitimately share a real-world org installation.
* **`created_by_user_id`** — records which ctrlplane user completed the link.
Used for audit and for the per-user OAuth verification flow.
### Linking flow
The **only** backend entrypoint that creates rows in `github_installation` is a
single Setup URL handler. No tRPC mutation, no REST endpoint, no job-agent
config path accepts a raw `installationId`.
```
apps/api/src/routes/github/setup.ts
```
**Step 1 — User initiates the link from the UI.**
A "Connect GitHub" action in workspace settings opens:
```
https://github.com/apps//installations/new?state=
```
The `state` is a short-lived (≤5 min), single-use, HMAC-signed token containing
`{ workspaceId, userId, nonce }`. This binds the subsequent redirect to the
initiating ctrlplane session and prevents CSRF.
**Step 2 — GitHub install UX happens on github.com.**
The user picks an org, selects which repos to grant access to, and installs
(or reconfigures) the App. GitHub redirects the browser to ctrlplane's Setup
URL with `?installation_id=&setup_action=install&state=`.
**Step 3 — Ctrlplane verifies state and requires GitHub user OAuth.**
1. Verify `state` JWT signature, expiry, and single-use nonce.
2. Confirm the signed-in ctrlplane session matches `userId` in the state.
3. Redirect the user through the GitHub OAuth authorize endpoint to obtain a
GitHub user access token (scope: minimal, enough to call
`GET /user/installations`). The token is held only for the duration of the
current linking request — it is **not persisted**. Once verification
completes (success or failure), the token is discarded along with the
request context.
4. Call `GET /user/installations` with the user's OAuth token. If the
`installation_id` from the redirect is not in the returned list, **reject
the link**. This is the teeth of the check: even if a malicious user
replays a redirect with someone else's `installation_id`, the API will not
list an installation they don't admin.
**Step 4 — Fetch installation metadata and insert.**
Call `GET /app/installations/` with App JWT auth to fetch `account.login`
(`owner`), `account.type` (`accountType`), and other metadata. Insert one row
into `github_installation` scoped to the workspace.
### Why this flow is sufficient
* **No free-text input path exists.** The API never accepts `installationId`
as user input. There is nothing to spoof.
* **`state` handles CSRF.** A user in Workspace B cannot mint a valid `state`
for Workspace A.
* **User-level OAuth handles authorization.** Even if a `state` is somehow
intercepted, the final `GET /user/installations` check fails unless the
signed-in user is an admin of the target org — in which case they could
install the App on that org themselves anyway.
* **`UNIQUE (workspace_id, installation_id)`** blocks duplicate claims within a
workspace and permits legitimate cross-workspace sharing.
### Migration of the existing job agent
Today, `job_agent.config` for type `github-app` contains
`{ installationId, owner }` inline. After this RFC:
1. Add column `github_installation_id uuid NULL REFERENCES github_installation(id)`
to `job_agent` (or continue storing it inside `config` as a UUID — TBD
during implementation).
2. Update the zod schema in `packages/trpc/src/routes/job-agents.ts` to accept
`{ type: "github-app", githubInstallationId: z.string().uuid() }` instead
of `{ installationId, owner }`. The dropdown in the job agent creation UI
becomes "pick from your workspace's linked installations" rather than a
free-text ID field.
3. Update the workspace-engine dispatcher to resolve the `installationId`
through a getter keyed on `githubInstallationId`, scoped to the job's
workspace. The engine never sees a raw installation ID supplied by user
input.
### UI changes
* **Workspace settings → Integrations → GitHub**: a new page listing linked
installations, with "Connect GitHub" triggering the flow above and
"Disconnect" removing the workspace's row (without uninstalling the App on
GitHub).
* **Job agent creation (GitHub App type)**: the `installationId` free-text
input is removed. Replace with a dropdown populated from
`github_installation` rows in the current workspace. If the list is empty,
link to the GitHub integrations page.
### Webhook handling
Unchanged by this RFC. The existing `workflow_run` handler at
`apps/api/src/routes/github/workflow_run.ts` continues to operate on
`installation.id` from the webhook payload. Since `installation.id` comes from
GitHub (not from a workspace), no trust change is needed there.
Lifecycle events (`installation.deleted`, `installation_repositories.*`) will
be handled alongside this RFC to keep `github_installation` rows in sync when
users uninstall the App or change repo access on GitHub.
## Alternatives Considered
### 1. Keep installation data in `job_agent.config`, add a validation step
Rejected. Layering validation on top of a free-text schema means every new
caller must remember to apply the check. A malformed or forgotten validation
in any future code path reopens the gap. Making the schema itself the source
of truth eliminates the class of bug.
### 2. Use installation ID as a globally unique primary key
Rejected. Forbids the legitimate case of one GitHub org serving multiple
ctrlplane workspaces (dev/staging/prod separation, multi-team monorepos).
`UNIQUE (workspace_id, installation_id)` captures the right invariant.
### 3. Rely on the `state` CSRF token alone, skip GitHub OAuth
Rejected. The `state` parameter proves the redirect was initiated from a
ctrlplane session but does not prove the ctrlplane user has admin rights on
the target org. Without the `GET /user/installations` check, a determined
user can still initiate an install flow, swap the `installation_id` in the
redirect (if they intercept it), and claim an installation they don't own.
OAuth verification is what makes the boundary real.
### 4. Allow Terraform / API creation of installation rows
Rejected for v1. The verification fundamentally requires a browser session
for GitHub OAuth; any programmatic path would need to either skip the check
(defeating the purpose) or accept pre-issued OAuth tokens (significant added
complexity and credential-handling surface). IaC users can still manage
everything *downstream* of the installation (job agents, deployments,
deployment sources) via Terraform once the installation row exists.
## Tradeoffs
### Pros
* Closes the cross-workspace installation access gap.
* Existing `job_agent.config` shape is simplified.
* Clear audit trail: `created_by_user_id` + timestamp on every link.
### Cons
* Introduces a user-level GitHub OAuth flow that ctrlplane did not previously
need. Additional client ID/secret management for the App's OAuth surface.
* Terraform and API users cannot create installations programmatically; they
must complete the UI flow once per workspace per installation.
## Migration Strategy
Ctrlplane is still in an internal-only phase, so the set of existing GitHub
installations in the wild is small and known. There is no need for an
automated backfill.
1. Add the `github_installation` table as a purely additive schema change.
Nothing else in the system references it yet.
2. Ship the UI + Setup URL handler so new links go through the verified flow.
3. **Manually re-link each existing installation through the UI.** For the
handful of existing internal workspaces that have a GitHub installation
in their `job_agent.config` today, an authorized user in each workspace
clicks "Connect GitHub" and completes the same verified flow. This
produces a `github_installation` row with correct `created_by_user_id`
and audit metadata — no raw SQL inserts, no nullable-column carve-outs.
4. Once all known installations have corresponding `github_installation`
rows, flip the job agent config schema to require `githubInstallationId`
(the FK) and update the workspace-engine dispatcher to resolve via getter.
At this point the raw `installationId` free-text path is removed.
5. Remove the old `{ installationId, owner }` JSON shape from `job_agent.config`.
Manual re-linking is acceptable precisely because the population is tiny and
each re-link takes less than a minute. If ctrlplane were public or had many
installations, we'd want an automated backfill instead; at current scale,
going through the UI is simpler than writing migration code.
## Open Questions
1. **OAuth client.** Does the GitHub App already have OAuth credentials
configured (`GITHUB_BOT_CLIENT_ID`/`_CLIENT_SECRET`), or do we need a
separate OAuth app registration? If the App's OAuth surface is sufficient,
we avoid an extra credential.
2. **Unlink semantics.** When a workspace disconnects an installation, should
linked job agents be disabled, deleted, or left in a broken state pointing
at a now-missing FK? Soft-disable seems safest.
3. **Reinstall handling.** If a user uninstalls the App on GitHub and
reinstalls it later, GitHub may issue a new `installation_id`. Should
ctrlplane detect this (via `installation.deleted` + subsequent
`installation.created` events from the same `account.id`) and offer a
"relink" UX, or require the user to re-run the flow manually?
## Conclusion
The GitHub integration today conflates "the App can reach this installation"
with "this workspace is entitled to use this installation." Adding a
first-class `github_installation` table, with a verified link flow as the
only creation path, separates the two and closes a real multi-tenancy gap on
shared instances. The work is small, contained, and additive.
# RFC 0013: Multi-Kind Plan Results
Source: https://docs.ctrlplane.dev/rfc/0013-multi-kind-plan-results
| Category | Status | Created | Author |
| -------- | -------------------- | ---------- | ---------------- |
| Engine | Draft | 2026-05-12 | Aditya Choudhari |
## Summary
A job agent's `Plan` returns a single `(current, proposed)` diff today, stored
in one result row per agent invocation. Generalize so an agent can return
multiple labeled diffs per invocation, distinguished by a new `kind` column.
This unblocks #1075 (surface the rendered ArgoCD Application CR alongside the
existing rendered manifest diff) and gives future agents a uniform shape for
shipping multiple kinds of output.
## Motivation
Issue #1075 asks for the rendered ArgoCD Application CR to be shown in plan
output. The CR is already computed by the planner today (`proposedApp` at
`argocd_plan.go:111`) — used to create a temp Application, then discarded
after the downstream manifest diff is extracted.
More broadly, there's no shape in the current model for an agent to express
"I have multiple distinct diffs the deployer should review." The `Plannable`
interface and `deployment_plan_target_result` are 1:1.
## Proposal
### Storage
Add a `kind` column. Each agent invocation produces N rows, one per kind.
```sql theme={null}
ALTER TABLE deployment_plan_target_result
ADD COLUMN kind TEXT NOT NULL DEFAULT '';
```
Single statement. Existing rows get `''`; new rows specify a kind explicitly
or fall back to `''`. Section vocabulary is agent-defined — the schema
doesn't enumerate kinds.
```text theme={null}
deployment_plan_target (one RT)
├─ result (kind="manifest", agent=argo-cd) current/proposed = rendered manifests
├─ result (kind="cr", agent=argo-cd) current/proposed = Application CR YAML
└─ result (kind="plan", agent=tfc) current/proposed = TF plan output
```
### Plan interface
`Plannable.Plan` returns `[]PlanResult`. Each result carries a `Kind`.
```go theme={null}
type PlanResult struct {
Kind string
Current string
Proposed string
HasChanges bool
ContentHash string
Status, Message, State, CompletedAt // unchanged
}
Plan(ctx, dispatchCtx, state) ([]PlanResult, error)
```
The ArgoCD planner emits two results — `manifest` (existing flow) and `cr`
(marshal `proposedApp` + fetch current Application from ArgoCD by name). TFC
emits one (`plan`). Agents that don't implement `Plannable` are skipped as
today. All kinds for an invocation complete together; if any is incomplete,
the agent returns `CompletedAt == nil` and the worker requeues per the
existing pattern.
### Stage-2 controller
The work-queue item still represents one agent invocation. Stage-1 inserts
one row (the work item's anchor). Stage-2 calls `Plan`, gets `[]PlanResult`,
writes the first result into the anchor row and inserts additional rows for
the remaining kinds. Validation runs once across all rows for the invocation.
### Validation: one run per invocation against flat input
Validation runs once per agent invocation, not per row. The OPA input is
built by combining all kind-rows into a flat shape:
```json theme={null}
{
"manifest": { "current": , "proposed": , "has_changes": true },
"cr": { "current": , "proposed": , "has_changes": true },
"agent_type": "argo-cd",
"deployment": { ... }, "environment": { ... }, "resource": { ... },
"proposed_version": { ... }, "current_version": { ... }
}
```
Rules self-select via `input.manifest.proposed` / `input.cr.proposed`. No
DB-layer routing, no per-rule `applies_to_kind` declaration. Reserved
top-level keys — `agent_type`, `deployment`, `environment`, `resource`,
`proposed_version`, `current_version` — are documented; agents shouldn't use
these as section names.
Violations attach to the anchor row (the original stage-1 row). The
`deployment_plan_target_result_validation` schema is unchanged.
### Aggregation: kind → agent → target
Rows are kind-level. Aggregates are agent-level. Target totals roll up over
agents — same as today, just one extra rollup step.
```text theme={null}
target
└─ agent
├─ kind "manifest" ← row
└─ kind "cr" ← row
```
Per-agent rollup:
| Field | Rule across the agent's kinds |
| ------------ | ----------------------------------------------------------------------- |
| `status` | worst kind wins (`errored` > `computing` > `unsupported` > `completed`) |
| `hasChanges` | OR across kinds |
`aggregateResults` then runs over agent states. Existing target-level counts
(`Total`, `Completed`, `Errored`, `Changed`, …) keep their semantics — they
just source from agent rollups instead of raw rows.
### UI
* **Plan results table:** the existing Changes column shows the total
`+N -M` summed across all kinds for the row. A new column surfaces the
number of diff kinds for that row (e.g. `2` for an ArgoCD row producing
manifest + CR), so the deployer knows there's more than one diff behind
the row before clicking in.
* **Detail modal:** when a row is opened, the modal contains a select whose
options are populated dynamically from whatever kinds the agent returned
for that release target. Picking an option renders that kind's diff.
Single-kind rows show the select with one option (or hide it entirely).
### GitHub check rendering
`formatAgentSection` iterates the agent's kinds and renders each as its own
labeled `diff` block. `aggregate.checkTitle` follows the worst-kind / OR
rules above. Existing `MaybeUpdateTargetCheck` flow is otherwise unchanged.
## Migration
* One ALTER adds `kind TEXT NOT NULL DEFAULT ''`.
* Existing rows keep `kind=''`. The renderer maps `''` → "Manifest" for
legacy display (existing rows are all manifest diffs by construction).
* No production validation rules depend on the current flat OPA input shape,
so the input restructure has no rule breakage.
* Other Plannable agents (TFC, TestRunner) wrap their existing single
`PlanResult` in a one-element slice and set `Kind` to a chosen string.
## Out of scope
* Plan triggers other than version publish (no `deployment_plan` snapshot
rework, no resource/environment plan kinds).
* Stage-1 controller fan-out, variable resolution, release-target snapshot.
* Anything beyond plan-result diff content.
## Open Questions
1. **Per-kind vs invocation-level violation attribution.** Validation runs
once per invocation with all sections visible, so a violation logically
describes the whole invocation. UI displays violations at the agent
level. Could be revisited if rule authors want to tag violations with a
specific kind for per-section display (e.g. "this denial is about the CR,
not the manifest"). Default: invocation-level for v1.
2. **Backfill legacy `kind=''` rows or leave them.** Backfilling existing
rows to `kind='manifest'` is more honest but costs a single UPDATE.
Leaving them as `''` works but requires the renderer to special-case
legacy. Lean: leave as `''`; rows drain quickly via `expires_at`.
# RFC 0014: Pull-Based Job Agent API
Source: https://docs.ctrlplane.dev/rfc/0014-pull-based-job-agent
| Category | Status | Created | Author |
| ---------- | -------------------- | ---------- | ---------------- |
| Job Agents | Draft | 2026-06-02 | Aditya Choudhari |
## Summary
Provide a REST API that lets an external provider act as a job agent by
**pulling** the jobs assigned to it, executing them, and reporting status back —
rather than ctrlplane pushing work into the provider's environment.
The work is split into two parts:
* **V1** delivers the pull contract: an agent polls for queued jobs, claims one
atomically (at most once), runs it, and reports status. A new `queued` job
status marks a job as claimable. Polling is a side-effect-free list; a separate
claim call transitions the job and returns its execution context.
* **V2** adds crash recovery: a lease, a heartbeat endpoint, and a reaper that
returns abandoned jobs to the queue. V2 is purely additive — V1 is shippable
and useful on its own.
## Motivation
Ctrlplane's existing job agents are **push / dispatch-style**. The workspace
engine initiates execution inside the agent's system: ArgoCD syncs an
Application, GitHub Actions runs a workflow, Terraform Cloud applies a plan. In
each case ctrlplane reaches outbound into the agent's environment.
This does not fit an external provider that:
* cannot (or should not) be reached inbound by ctrlplane, and
* wants to integrate generically over HTTP rather than through a bespoke,
per-system integration.
There is currently no generic way for such a provider to pull the jobs assigned
to its job agent and run them. This RFC adds that path while reusing the
existing job model, status-reporting endpoint, and verification flow.
## Proposal
### Model: producer / consumer
A push agent's dispatch step both *produces* the job and *delivers* it (fires
the workflow). A pull agent splits these:
* ctrlplane **produces** the job and marks it claimable.
* the external agent **consumes** it by polling, claiming, and running it.
The job row in Postgres is the queue. The dispatch controller is the producer;
the agent's poll discovers work and its claim takes delivery.
### Job status: `queued`
A new value `queued` is added to the `job_status` enum
(`packages/db/src/schema/job.ts`). It means: ctrlplane has finished preparing
the job, and it is available for an agent to claim.
```sql theme={null}
ALTER TYPE job_status ADD VALUE 'queued' AFTER 'pending';
```
The lifecycle for a pull-agent job:
```text theme={null}
queued ───claim (poll)───► in_progress ───report───► successful / failure
```
`queued` is semantically distinct from the existing states:
* `pending` — created, not yet processed by the dispatch controller.
* `queued` — prepared, waiting for an agent to claim it.
* `in_progress` — claimed by an agent and executing.
The new value must be mirrored everywhere the enum is represented: the
`@ctrlplane/validators` job statuses, the `dbToOapiStatus` / `oapiToDbStatus`
maps in `apps/api/src/routes/v1/workspaces/jobs.ts`, the OpenAPI `JobStatus`
schema, and the workspace-engine `oapi` enum plus its sqlc mappings.
### Agent type: `http-pull`
A new agent type `http-pull` is registered in the workspace engine's job agent
registry (`apps/workspace-engine/pkg/jobagents/`, registered in
`apps/workspace-engine/svc/controllers/jobdispatch/controller.go`). It
implements `types.Dispatchable`. Its `Dispatch` does not push to an external
system; it transitions the job to `queued`:
```go theme={null}
package httppull
var _ types.Dispatchable = &HttpPull{}
func (a *HttpPull) Type() string { return "http-pull" }
func (a *HttpPull) Dispatch(ctx context.Context, job *oapi.Job) error {
return a.setter.UpdateJob(ctx, job.Id, oapi.JobStatusQueued, "", nil)
}
```
This keeps the dispatch pipeline uniform. Eligibility and the dispatch
controller are otherwise **unchanged**: a job is created `pending`, enqueued for
dispatch, the controller creates verification specs as it does for every agent,
and the `Dispatch` call marks the job `queued` instead of pushing.
### Verifications
Verifications are created by the dispatch controller at dispatch time, exactly
as they are for the ArgoCD and Terraform Cloud agents. No change is made to the
verification flow. As with those agents, verification metrics begin measuring
when created rather than when execution starts. For `http-pull` this means
measurements can begin before an agent claims the job; this matches existing
behavior and is accepted for V1. See Open Questions.
### Poll endpoint (V1)
```
GET /v1/workspaces/{workspaceId}/job-agents/{jobAgentId}/jobs?status=queued
```
Returns all jobs for the agent in the requested status. This is a plain,
side-effect-free poll: it lists what is claimable but claims nothing. The agent
picks a job from the list and claims it with a separate call. Added to
`apps/api/src/routes/v1/workspaces/job-agents.ts`.
The list response is intentionally lightweight — job id, deployment,
environment, resource, and `created_at` — and **omits `dispatch_context`**.
Resolved variables (including secret-flagged ones) are not returned here, so a
poll never broadcasts secrets for every queued job to every agent. Context is
returned only on claim, and only to the agent that wins it.
### Claim endpoint (V1)
```
POST /v1/workspaces/{workspaceId}/job-agents/{jobAgentId}/jobs/{jobId}/claim
```
Atomically transitions a specific job `queued → in_progress` and returns the
full job, including `dispatch_context`. Because the poll has no side effects,
this is the single mutating step that hands a job to an agent.
The claim is a conditional update guarded on the current status. Postgres row
locking — not the transaction boundary — provides the at-most-once guarantee:
```sql theme={null}
UPDATE job
SET status = 'in_progress', started_at = now()
WHERE id = $1 AND status = 'queued' AND job_agent_id = $2
RETURNING *;
```
If two agents claim the same job id concurrently, the row lock serializes them
and only the first still sees `status = 'queued'`; the second matches zero rows
and receives `409 Conflict`. No `SELECT ... FOR UPDATE SKIP LOCKED` scan is
needed because the agent names the job id explicitly — the `status = 'queued'`
predicate does the work the locking scan did in the next-job design. The
reconcile work queue uses the same conditional-claim shape
(`ClaimReconcileWorkItems`).
### Job payload
The **claim** response returns the job as-is; the **poll** response omits it.
The job's `dispatch_context` column is a self-contained execution snapshot
already populated at job creation — deployment, environment, resource, release,
version, resolved inputs, and variables. No joins or additional assembly are
required; the existing `toJobResponse` shape already emits `jobAgentConfig` and
`dispatchContext`.
Note: `dispatch_context` includes resolved variable values, so secret-flagged
variables are returned to the external agent. Returning context only on claim —
not on poll — limits this exposure to the one job the agent actually runs,
rather than every queued job a poll would list. This data otherwise never leaves
ctrlplane for push agents. The endpoint must be served over TLS; per-agent
authentication is addressed under V2.
### Status reporting
Status reporting reuses the existing endpoint:
```
PUT /v1/workspaces/{workspaceId}/jobs/{jobId}/status
```
It already records the status, sets `completed_at` on terminal states, and
enqueues a desired-release evaluation to advance the release. No new endpoint is
required for V1.
### Authentication (V1)
V1 uses the existing `x-api-key` authentication and verifies that the target job
agent belongs to the authenticated workspace. Per-agent credentials are
addressed under V2.
### Concurrency
The issue identifies two failure modes. V1 addresses the first; V2 addresses the
second.
1. **Double-pickup** — handled by the conditional claim above. A job is handed
out at most once, even under overlapping claims or client retries; losers get
`409 Conflict`.
2. **Crash mid-job** — not handled in V1. If an agent claims a job and dies, the
job remains `in_progress`. Recovery is a manual transition back to `queued`
(the same transition V2 automates). V2 adds automatic recovery.
### V1 implementation surface
| Area | Change |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| `job_status` enum | add `queued` (schema + migration, validators, API status maps, OpenAPI, oapi/sqlc) |
| Agent type | new `http-pull` package; `Dispatch` sets `queued`; register in `jobdispatch` |
| Poll endpoint | `GET .../job-agents/{id}/jobs?status=queued`; lists queued jobs, no context; OpenAPI |
| Claim endpoint | `POST .../job-agents/{id}/jobs/{jobId}/claim`; conditional `queued→in_progress`, returns context; OpenAPI |
| Status reporting | reuse `PUT .../jobs/{jobId}/status` |
| Eligibility | unchanged |
| Dispatch flow | unchanged except the `http-pull` `Dispatch` body |
| Auth | reuse `x-api-key` + workspace ownership check |
## V2: Lease, Heartbeat, and Reclaim (add-on)
V2 adds crash recovery. It is additive in the strongest sense: a new table, two
endpoints, and a periodic sweep. **The `job` table is not modified at all** —
the `queued` enum value was already added in V1.
### Claim table
Lease state lives in a dedicated `job_claim` table rather than as columns on
`job`:
```sql theme={null}
CREATE TABLE job_claim (
job_id uuid PRIMARY KEY REFERENCES job(id) ON DELETE CASCADE,
job_agent_id uuid NOT NULL,
claimed_at timestamptz NOT NULL DEFAULT now(),
claim_expires_at timestamptz NOT NULL,
claim_id uuid NOT NULL DEFAULT gen_random_uuid()
);
```
The job's `status` remains the state machine — the claim still flips
`queued → in_progress` on `job` — but the lease lifecycle and the high-frequency
heartbeat writes are isolated to this narrow table. The motivation is write
locality: heartbeats are the most frequent write in this feature (every in-flight
job, every interval), and `job` is a hot, heavily-joined table with several
indexes and an `updated_at` trigger. Keeping heartbeats off `job` avoids index
churn and MVCC bloat on the read path. `claim_id` is a fencing token, populated
for free.
### Lease
The claim records lease state in `job_claim` in the same statement that flips
the job to `in_progress`, using a CTE so it remains a single atomic operation:
```sql theme={null}
WITH claimed AS (
UPDATE job SET status = 'in_progress', started_at = now()
WHERE id = $1 AND status = 'queued' AND job_agent_id = $2
RETURNING id
)
INSERT INTO job_claim (job_id, job_agent_id, claim_expires_at)
SELECT id, $2, now() + make_interval(secs => $lease_seconds) FROM claimed
RETURNING *;
```
The lease is a liveness window, not an execution deadline. A job may run far
longer than the lease as long as the agent keeps the claim alive. The claim
response advertises `lease_seconds` so the agent can choose a heartbeat interval.
### Heartbeat
```
POST /v1/workspaces/{workspaceId}/jobs/{jobId}/heartbeat
```
Extends the lease. This touches only `job_claim`, never `job`:
```sql theme={null}
UPDATE job_claim
SET claim_expires_at = now() + make_interval(secs => $lease_seconds)
WHERE job_id = $1;
```
The agent calls this periodically while executing. The interval is the agent's
choice (a fraction of the advertised lease); the server does not store it.
### Reaper
A periodic sweep returns abandoned claims to the queue — deleting the expired
claim and flipping the job back to `queued` in one statement:
```sql theme={null}
WITH expired AS (
DELETE FROM job_claim WHERE claim_expires_at < now() RETURNING job_id
)
UPDATE job SET status = 'queued'
WHERE id IN (SELECT job_id FROM expired) AND status = 'in_progress';
```
Expiry is detected by this sweep, not by an event at the exact expiry time. The
sweep mirrors the reconcile queue's `CleanupExpiredClaims`.
Reclaim is **opt-in by construction**: only jobs that have a `job_claim` row are
ever swept. A job claimed without recording lease state — or any V1-era agent
that never engages the lease protocol — has no claim row and is never reclaimed,
preserving V1 behavior after V2 ships. When a job reaches a terminal status, its
`job_claim` row is removed.
### Reclaim and double-run
When a lease expires, the job returns to `queued` and becomes claimable again.
The reaper cannot distinguish a crashed agent from one that is alive but quiet
for longer than the lease, so a long pause can cause a job to be reclaimed and
run twice. A generous lease relative to the heartbeat interval reduces this
window but does not close it.
If exactly-once execution is required, the `claim_id` fencing token is returned
on claim, echoed by the agent on heartbeat and status, and a write carrying a
stale `claim_id` (one whose claim row was already reclaimed and superseded) is
rejected. The token exists in the schema from the start; enforcing it is
optional.
### V2 implementation surface
| Area | Change |
| --------------- | ---------------------------------------------------------------------------- |
| `job` schema | none — `job` is not modified |
| `job_claim` | new table (single `CREATE TABLE`, no change to `job`) |
| Claim | record `job_claim` row in the claim CTE; return `lease_seconds` + `claim_id` |
| Heartbeat | new `POST .../jobs/{jobId}/heartbeat`, writes only `job_claim`; OpenAPI path |
| Reaper | periodic sweep deleting expired claims and returning jobs to `queued` |
| Terminal status | remove the `job_claim` row when a job reaches a terminal state |
| Optional | per-agent lease config, `claim_id` fencing enforcement, per-agent tokens |
## Migration
* V1 adds the `queued` value to the `job_status` enum. V2 adds a new `job_claim`
table and does not modify `job`. Both are additive; existing jobs are
unaffected.
* The dispatch controller, eligibility logic, and promotion lifecycle are
unchanged except for recognizing the `queued` status and the `http-pull`
agent's `Dispatch` body.
* The status-reporting endpoint is reused unchanged. The poll and claim
endpoints (V1) and heartbeat endpoint (V2) are new and do not alter existing
endpoints.
* The V2 reaper only acts on jobs that have a `job_claim` row, so introducing it
does not change the behavior of any agent that does not heartbeat.
## Open Questions
1. **Long-poll vs. plain poll.** V1 uses a plain poll: the list endpoint returns
immediately with the current set of queued jobs (possibly empty). A long-poll
variant (hold the request open until a job appears or a timeout elapses,
bounded by a server-enforced maximum) reduces idle polling and is a candidate
for V2. Backpressure and fairness limits on held connections are open.
2. **Verification timing.** Verifications begin measuring when created (at
dispatch), which for a pull agent can precede the claim by an unbounded
queue wait. For long verification windows this is harmless; a short window
could complete before the agent claims the job. If this becomes a problem,
verification creation can be moved to the claim transition, or measurement
can be gated on the job reaching `in_progress`. Deferred until needed.
3. **Lease configuration.** Should the lease duration be per-agent
(`job_agent.config`, bounded) or a single global default? A global default is
the V2 starting point; per-agent is a later refinement for agents with
different reliability characteristics.
## AI Generated Questions
1. **Agent registration.** A job is only routed to an agent that already exists
and is matched by a deployment's `jobAgentSelector`. Should an external agent
be able to self-register its `job_agent` row and credentials via the API, or
must agents be pre-provisioned by an operator?
2. **Per-agent authentication.** V1 reuses `x-api-key`. V2 should issue a
per-agent credential at registration so an agent authenticates as itself and
can claim only its own jobs. What is the token model and rotation story?
3. **Fencing.** Should V2 include a fencing token from the start, or add it only
if double-run under lease expiry proves to be a real problem for the
workloads pull agents run?
# Deployment Verification
Source: https://docs.ctrlplane.dev/use-cases/deployment-verification
Automatically verify deployments are healthy before proceeding
## The Scenario
Your deployment pipeline says "success" but the service is actually broken. You want:
* Automatic health checks after every deployment
* Real metrics from Datadog, Prometheus, or your monitoring stack
* Automatic rollback if verification fails
* No more "deploy succeeded, service is down" situations
## Without Ctrlplane
**The typical flow:**
1. CI/CD deploys the service
2. Pipeline says ✅ because kubectl apply succeeded
3. Service is actually broken (bad config, missing dependency, etc.)
4. Someone notices 30 minutes later
5. Frantic rollback ensues
**What goes wrong:**
* "Deployed successfully" ≠ "Working correctly"
* Health checks are bolted on, not built in
* Rollback is manual and error-prone
* No verification between regions during multi-cluster deploys
## With Ctrlplane
Add a verification policy:
```yaml theme={null}
type: Policy
name: Deployment Health Check
selectors:
- environment: environment.name in ["Staging", "Production"]
rules:
- verification:
metrics:
- name: error-rate
provider:
type: datadog
apiKey: "{{.secrets.DD_API_KEY}}"
appKey: "{{.secrets.DD_APP_KEY}}"
query: "sum:trace.http.request.errors{service:{{.deployment.name}},env:{{.environment.name}}}.as_rate()"
successCondition: result.value < 0.01
failureThreshold: 2
intervalSeconds: 60
count: 5
- name: latency-p99
provider:
type: datadog
apiKey: "{{.secrets.DD_API_KEY}}"
appKey: "{{.secrets.DD_APP_KEY}}"
query: "p99:trace.http.request.duration{service:{{.deployment.name}},env:{{.environment.name}}}"
successCondition: result.value < 500 # 500ms
failureThreshold: 2
intervalSeconds: 60
count: 5
```
## What Happens
```mermaid theme={null}
flowchart LR
Deploy["Deploy"] --> M1["Check 1"]
M1 --> M2["Check 2"]
M2 --> M3["Check 3"]
M3 --> M4["Check 4"]
M4 --> M5["Check 5"]
M5 -->|"all pass"| Success["Success"]
M3 -->|"2 failures"| Rollback["Rollback"]
```
1. **Deployment completes** — Job agent reports success
2. **Verification starts** — First metric check at 60 seconds
3. **Metrics queried** — Datadog returns error rate and latency
4. **Success condition evaluated** — Is error rate \< 1%? Is p99 \< 500ms?
5. **Continue or fail** — If 2+ checks fail, trigger rollback
6. **Rollback executes** — Previous version is deployed automatically
## Key Benefits
| Benefit | How It Works |
| --------------------------- | ----------------------------------------------------- |
| **Real metrics** | Use your actual monitoring data, not synthetic checks |
| **Automatic rollback** | No manual intervention when verification fails |
| **Configurable thresholds** | Define what "healthy" means for your service |
| **Multiple metrics** | Check error rate AND latency AND custom metrics |
| **Per-environment rules** | Stricter verification in production |
## Verification Providers
### Datadog
```yaml theme={null}
provider:
type: datadog
apiKey: "{{.secrets.DD_API_KEY}}"
appKey: "{{.secrets.DD_APP_KEY}}"
query: "sum:errors{service:api}.as_rate()"
```
### Prometheus
```yaml theme={null}
provider:
type: prometheus
address: "http://prometheus.monitoring:9090"
query: "rate(http_requests_total{status=~\"5..\"}[5m])"
```
### HTTP Endpoint
```yaml theme={null}
provider:
type: http
url: "https://{{.resource.config.host}}/health"
method: GET
headers:
Authorization: "Bearer {{.secrets.HEALTH_TOKEN}}"
```
### Custom Script
```yaml theme={null}
provider:
type: http
url: "https://internal-api/verify"
method: POST
body: |
{
"deployment": "{{.deployment.name}}",
"version": "{{.version.tag}}",
"resource": "{{.resource.identifier}}"
}
```
## Variations
### Progressive Verification
Start with quick checks, then extend to thorough verification:
```yaml theme={null}
metrics:
# Quick smoke test (first 2 minutes)
- name: health-endpoint
provider:
type: http
url: "https://{{.resource.config.host}}/health"
successCondition: result.statusCode == 200
intervalSeconds: 30
count: 4
# Extended error rate check (next 5 minutes)
- name: error-rate
provider:
type: datadog
query: "sum:errors{service:api}"
successCondition: result.value < 0.01
intervalSeconds: 60
count: 5
```
### Environment-Specific Thresholds
```yaml theme={null}
# Staging: Lenient
- name: Staging Verification
selectors:
- environment: environment.name == "Staging"
rules:
- verification:
metrics:
- name: error-rate
successCondition: result.value < 0.05 # 5% error rate OK
# Production: Strict
- name: Production Verification
selectors:
- environment: environment.name == "Production"
rules:
- verification:
metrics:
- name: error-rate
successCondition: result.value < 0.01 # Only 1% allowed
failureThreshold: 1 # Fail fast
```
### Multi-Metric Verification
```yaml theme={null}
metrics:
- name: error-rate
successCondition: result.value < 0.01
- name: latency-p99
successCondition: result.value < 500
- name: saturation
successCondition: result.value < 0.8 # CPU < 80%
- name: availability
successCondition: result.value > 0.999 # 99.9% uptime
```
## Rollback Behavior
When verification fails:
1. **Rollback triggered** — Ctrlplane initiates rollback
2. **Previous version deployed** — The last successful version is redeployed
3. **Verification runs again** — Confirms rollback was successful
4. **Release marked failed** — Full audit trail preserved
You can also configure rollback behavior:
```yaml theme={null}
rules:
- verification:
rollback:
enabled: true
toVersion: previous # or specific version tag
```
## Next Steps
Configure Datadog metrics
Set up HTTP health checks
Gate promotions on verification
Verify between regions
# Dynamic Environments
Source: https://docs.ctrlplane.dev/use-cases/dynamic-environments
Automatically include new infrastructure in deployments
## The Scenario
You're adding infrastructure regularly—new clusters, new regions, new services. You want:
* New clusters to automatically receive deployments
* No config file updates when infrastructure changes
* Environment membership based on resource attributes
* Different policies for different resource types
## Without Ctrlplane
**The typical approach:**
1. Add new cluster to Kubernetes
2. Update CI/CD pipeline config
3. Update deployment manifests
4. Update ArgoCD ApplicationSet
5. Update monitoring config
6. Forget one thing, wonder why deploys don't work
**What goes wrong:**
* Adding infrastructure requires multiple config changes
* Easy to miss something
* Config files become the source of truth (and drift)
* Manual process doesn't scale
## With Ctrlplane
### Define Environments with Selectors
Instead of listing clusters, define what "Production" means:
```yaml theme={null}
type: Environment
name: Production
resourceSelector: resource.metadata["env"] == "production"
```
### Tag Resources Appropriately
When you add a new cluster:
```yaml theme={null}
type: Resource
name: prod-ap-south-1
kind: KubernetesCluster
metadata:
env: production # ← This makes it "Production"
region: ap-south-1
cloud: aws
```
That's it. The new cluster:
* Is automatically part of the "Production" environment
* Receives deployments for all services targeting Production
* Inherits policies that apply to Production
* Shows up in the inventory immediately
## What Happens
```mermaid theme={null}
flowchart TB
subgraph Before["Before: 3 Clusters"]
R1["us-east-1 env: production"]
R2["us-west-2 env: production"]
R3["eu-west-1 env: production"]
end
Env["Production Environment selector: env == production"]
R1 -.->|"matches"| Env
R2 -.->|"matches"| Env
R3 -.->|"matches"| Env
subgraph After["After: 4 Clusters"]
R4["ap-south-1 env: production"]
end
R4 -.->|"auto-joins"| Env
```
1. **Environment defined** — "Production" = all resources where `env == production`
2. **3 clusters exist** — All have `env: production`, all are in Production
3. **New cluster added** — ap-south-1 tagged with `env: production`
4. **Automatic membership** — New cluster is now part of Production
5. **Deployments flow** — Next release automatically targets all 4 clusters
## Key Benefits
| Benefit | How It Works |
| -------------------------- | ---------------------------------------------- |
| **Zero config changes** | Tag the resource, it joins automatically |
| **Declarative membership** | Environment definition is the source of truth |
| **Immediate effect** | New resources are included in next deployment |
| **Scales infinitely** | 4 clusters or 400, same environment definition |
| **Consistent policies** | New resources inherit existing policies |
## Selector Patterns
### By Environment Label
```yaml theme={null}
# Simple environment matching
name: Production
resourceSelector: resource.metadata["env"] == "production"
name: Staging
resourceSelector: resource.metadata["env"] == "staging"
```
### By Region
```yaml theme={null}
# US regions only
name: Production US
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("us-")
# EU regions only
name: Production EU
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("eu-")
```
### By Team
```yaml theme={null}
# Platform team's infrastructure
name: Platform Production
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["team"] == "platform"
```
### By Resource Type
```yaml theme={null}
# Only Kubernetes clusters
name: K8s Production
resourceSelector: |
resource.kind == "KubernetesCluster" &&
resource.metadata["env"] == "production"
# Only Lambda functions
name: Lambda Production
resourceSelector: |
resource.kind == "AWS/Lambda" &&
resource.metadata["env"] == "production"
```
### By Tier
```yaml theme={null}
# Critical infrastructure only
name: Critical Production
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["tier"] == "critical"
```
### Compound Selectors
```yaml theme={null}
# Critical K8s clusters in US production
name: Critical US K8s
resourceSelector: |
resource.kind == "KubernetesCluster" &&
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("us-") &&
resource.metadata["tier"] == "critical"
```
## Variations
### Hierarchical Environments
```yaml theme={null}
# Global production
name: Production
resourceSelector: resource.metadata["env"] == "production"
# Regional sub-environments (inherit from Production)
name: Production/US
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("us-")
directory: Production
name: Production/EU
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("eu-")
directory: Production
```
### Canary Environment
```yaml theme={null}
# Canary gets deploys first
name: Canary
resourceSelector: resource.metadata["canary"] == "true"
# Production excludes canary
name: Production
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["canary"] != "true"
```
### Deployment-Specific Targeting
Deployments can also filter which resources they target:
```yaml theme={null}
type: Deployment
name: api-gateway
# Only deploy to clusters with api workloads
resourceSelector: resource.metadata["workloads"].contains("api")
```
Combined with environment:
```
Release Target = Deployment Filter ∩ Environment Filter
```
## Common Metadata Schema
Standardize your metadata for consistent environment targeting:
```yaml theme={null}
metadata:
# Required for environment membership
env: production | staging | development
# Geographic
region: us-east-1 | eu-west-1 | ap-south-1
cloud: aws | gcp | azure | onprem
# Organizational
team: platform | backend | data
cost-center: engineering | infrastructure
# Operational
tier: critical | standard | experimental
canary: "true" | "false"
# Workload hints
workloads: ["api", "worker", "cron"]
```
## Next Steps
Full selector syntax reference
Environment configuration details
Sync and manage resources
Deploy across dynamic environments
# Environment Promotion
Source: https://docs.ctrlplane.dev/use-cases/environment-promotion
Automatically promote releases from staging to production
## The Scenario
You have a staging environment and a production environment. You want:
* Every version to deploy to staging first
* Production to only receive versions that passed staging
* Optional approval before production deploys
* No manual "okay, staging looks good, now deploy to prod" steps
## Without Ctrlplane
**The typical flow:**
1. CI deploys to staging
2. Someone checks if staging is healthy (maybe)
3. Someone remembers to trigger the prod deploy
4. Hopefully they deploy the right version
5. Hopefully staging actually passed
**What goes wrong:**
* "Staging passed" is based on vibes, not metrics
* The manual promotion step gets forgotten
* Wrong version deployed to prod
* No audit trail of what was verified
## With Ctrlplane
Define your environments:
```yaml theme={null}
type: Environment
name: Staging
resourceSelector: resource.metadata["env"] == "staging"
---
type: Environment
name: Production
resourceSelector: resource.metadata["env"] == "production"
```
Add an environment progression policy:
```yaml theme={null}
type: Policy
name: Staging Before Production
selectors:
- environment: environment.name == "Production"
rules:
- environmentProgression:
waitFor: Staging
```
Add verification to staging:
```yaml theme={null}
type: Policy
name: Staging Verification
selectors:
- environment: environment.name == "Staging"
rules:
- verification:
metrics:
- name: health-check
provider:
type: http
url: "https://{{.resource.config.host}}/health"
successCondition: result.statusCode == 200
intervalSeconds: 30
count: 10 # 5 minutes of health checks
```
Add approval for production:
```yaml theme={null}
type: Policy
name: Production Approval
selectors:
- environment: environment.name == "Production"
rules:
- anyApproval:
minApprovals: 1
```
## What Happens
```mermaid theme={null}
flowchart LR
V["v1.2.3"] --> S["Staging"]
S --> SV["Verify 5min"]
SV -->|"pass"| PA["Approval"]
PA -->|"approved"| P["Production"]
P --> PV["Verify"]
SV -->|"fail"| Block["Blocked"]
```
1. **CI creates v1.2.3** — Version is marked ready
2. **Staging release created** — Deploys immediately (no gates)
3. **Staging verification runs** — 5 minutes of health checks
4. **Staging passes** → **Production is unblocked**
5. **Approval requested** — Notification sent to approvers
6. **Approval granted** — Team lead approves
7. **Production deploys** — Version goes live
8. **Production verification** — Confirms health in prod
If staging verification **fails**, production never receives the version. No manual intervention needed to block it.
## Key Benefits
| Benefit | How It Works |
| ---------------------- | -------------------------------------------------- |
| **Enforced ordering** | Production physically cannot deploy before staging |
| **Verified promotion** | Only versions that pass verification can promote |
| **Audit trail** | Full history of what was verified and when |
| **Optional approval** | Add human gates where needed |
| **Automatic flow** | No manual "deploy to prod" step |
## Variations
### Multiple Pre-Production Environments
```yaml theme={null}
# QA → Staging → Production
- name: Staging After QA
selectors:
- environment: environment.name == "Staging"
rules:
- environmentProgression:
waitFor: QA
- name: Production After Staging
selectors:
- environment: environment.name == "Production"
rules:
- environmentProgression:
waitFor: Staging
```
### Different Verification per Environment
```yaml theme={null}
# Light verification in QA
- name: QA Verification
selectors:
- environment: environment.name == "QA"
rules:
- verification:
metrics:
- name: smoke-test
count: 3 # Quick check
# Thorough verification in Staging
- name: Staging Verification
selectors:
- environment: environment.name == "Staging"
rules:
- verification:
metrics:
- name: integration-tests
count: 10 # Longer check
- name: performance-baseline
count: 5
```
### Skip Staging for Hotfixes
Use version metadata to bypass staging for critical fixes:
```yaml theme={null}
- name: Hotfix Direct to Prod
selectors:
- environment: environment.name == "Production"
- version: version.metadata["hotfix"] == "true"
rules:
- anyApproval:
minApprovals: 2 # Require more approvals for hotfixes
# No environment progression rule = skip staging
```
## Next Steps
Configure progression rules
Set up health checks
Configure approval workflows
Add gradual rollouts within production
# Infrastructure Inventory
Source: https://docs.ctrlplane.dev/use-cases/infrastructure-inventory
A single source of truth for what exists and what's running
## The Scenario
You need to answer questions like:
* "What version of the API is running in eu-west-1 prod?"
* "How many clusters do we have? What's deployed to each?"
* "Which services are running on the new cluster we added last week?"
* "Show me all resources owned by the platform team"
## Without Ctrlplane
**The typical approach:**
1. Check Kubernetes dashboard (but which cluster?)
2. Check AWS console (but which region?)
3. Check the team's spreadsheet (but is it up to date?)
4. Ask in Slack (but who knows?)
**What goes wrong:**
* No single source of truth
* Information is scattered across tools
* Spreadsheets become stale immediately
* "What version is running?" requires multiple lookups
## With Ctrlplane
### Sync Resources Automatically
Resources are synced from your infrastructure via resource providers:
```yaml theme={null}
# Kubernetes provider syncs all clusters
type: ResourceProvider
name: kubernetes-clusters
provider:
type: kubernetes
config:
kubeconfig: "{{.secrets.KUBECONFIG}}"
sync:
interval: 5m
```
Or register resources via API:
```bash theme={null}
curl -X POST "https://api.ctrlplane.dev/resources" \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "prod-us-east-1",
"kind": "KubernetesCluster",
"identifier": "k8s-prod-use1",
"metadata": {
"env": "production",
"region": "us-east-1",
"team": "platform",
"tier": "critical"
},
"config": {
"server": "https://k8s-prod-use1.example.com",
"namespace": "default"
}
}'
```
### Query Your Infrastructure
Once synced, query resources with selectors:
```yaml theme={null}
# All production clusters
resource.metadata["env"] == "production"
# Critical services in us-east
resource.metadata["tier"] == "critical" &&
resource.metadata["region"] == "us-east-1"
# Everything owned by platform team
resource.metadata["team"] == "platform"
```
### See What's Deployed
Each resource shows its current deployed versions:
```
prod-us-east-1 (KubernetesCluster)
├── api-gateway: v1.2.3 (deployed 2h ago)
├── user-service: v2.0.1 (deployed 1d ago)
└── payment-service: v3.1.0 (deployed 3h ago)
prod-eu-west-1 (KubernetesCluster)
├── api-gateway: v1.2.2 (deployed 1d ago) ← behind!
├── user-service: v2.0.1 (deployed 1d ago)
└── payment-service: v3.1.0 (deployed 3h ago)
```
## What You Get
```mermaid theme={null}
flowchart TB
subgraph Sources["Infrastructure Sources"]
K8s["Kubernetes"]
AWS["AWS"]
GCP["GCP"]
Custom["Custom"]
end
subgraph Inventory["Ctrlplane Inventory"]
Resources["Resources"]
Metadata["Metadata"]
Versions["Current Versions"]
Relations["Relationships"]
end
subgraph Views["Query & View"]
Env["By Environment"]
Team["By Team"]
Region["By Region"]
Service["By Service"]
end
Sources -->|"sync"| Inventory
Inventory -->|"query"| Views
```
## Key Benefits
| Benefit | How It Works |
| -------------------------- | ---------------------------------------- |
| **Single source of truth** | All resources in one place |
| **Real-time sync** | Providers keep inventory up to date |
| **Rich metadata** | Tag resources with any attributes |
| **Version tracking** | See what's deployed where |
| **Cross-provider** | Kubernetes, AWS, GCP, custom—all unified |
## Resource Metadata
Tag resources with metadata for powerful querying:
```yaml theme={null}
metadata:
# Environment classification
env: production
# Geographic location
region: us-east-1
cloud: aws
# Ownership
team: platform
cost-center: engineering
# Operational
tier: critical
on-call: platform-oncall@company.com
# Custom
compliance: soc2
data-classification: pii
```
## Dynamic Environments
Environments use selectors to automatically group resources:
```yaml theme={null}
type: Environment
name: Production US
resourceSelector: |
resource.metadata["env"] == "production" &&
resource.metadata["region"].startsWith("us-")
```
When you add a new cluster with matching metadata, it automatically joins the environment:
```yaml theme={null}
# New cluster added
type: Resource
name: prod-us-west-2
metadata:
env: production # ← matches "production"
region: us-west-2 # ← starts with "us-"
# Automatically part of "Production US" environment!
```
## Resource Relationships
Model dependencies between resources:
```yaml theme={null}
type: Resource
name: api-gateway
relationships:
- type: depends-on
target: user-database
- type: depends-on
target: cache-cluster
```
Query relationships:
```yaml theme={null}
# Find all resources that api-gateway depends on
resource.relationships.any(r, r.type == "depends-on")
```
## Variations
### Multi-Cloud Inventory
```yaml theme={null}
# AWS resources
- type: ResourceProvider
name: aws-resources
provider:
type: aws
config:
region: us-east-1
# GCP resources
- type: ResourceProvider
name: gcp-resources
provider:
type: gcp
config:
project: my-project
# On-prem Kubernetes
- type: ResourceProvider
name: onprem-k8s
provider:
type: kubernetes
config:
kubeconfig: "{{.secrets.ONPREM_KUBECONFIG}}"
```
### Custom Resource Provider
Sync from any source using HTTP:
```yaml theme={null}
type: ResourceProvider
name: custom-inventory
provider:
type: http
config:
url: "https://internal-api/resources"
method: GET
headers:
Authorization: "Bearer {{.secrets.INTERNAL_TOKEN}}"
sync:
interval: 10m
```
### Version Tracking Queries
Find resources running old versions:
```yaml theme={null}
# Resources where api-gateway is behind latest
resource.deployments["api-gateway"].version != "v1.2.3"
# Resources not updated in 7 days
resource.deployments["api-gateway"].deployedAt < now() - 7d
```
## Next Steps
Deep dive into resource configuration
Set up automatic syncing
Learn the selector query language
Group resources automatically
# Multi-Region Deployments
Source: https://docs.ctrlplane.dev/use-cases/multi-region
Deploy to multiple clusters and regions with confidence
## The Scenario
You have 8 Kubernetes clusters across 3 regions (us-east, us-west, eu-west). When you release a new version, you need to:
* Deploy to all 8 clusters
* Not break everything at once
* Verify each cluster before moving to the next
* Roll back if something goes wrong
## Without Ctrlplane
**Option A: Deploy to all at once**
* Fast, but risky
* One bad deploy breaks all regions simultaneously
* Rollback is chaotic
**Option B: Manual sequential deploys**
* Safe, but slow
* Someone has to babysit each deployment
* Easy to forget a cluster
* Inconsistent timing between regions
**Option C: Complex CI/CD matrix**
* Build intricate pipeline logic
* Hard to maintain
* Verification is bolted on, not built in
## With Ctrlplane
Define your resources with region metadata:
```yaml theme={null}
# Resources are synced from your infrastructure
type: Resource
name: prod-us-east-1
metadata:
env: production
region: us-east-1
---
type: Resource
name: prod-us-west-2
metadata:
env: production
region: us-west-2
---
type: Resource
name: prod-eu-west-1
metadata:
env: production
region: eu-west-1
```
Create an environment that matches all production clusters:
```yaml theme={null}
type: Environment
name: Production
resourceSelector: resource.metadata["env"] == "production"
```
Add a gradual rollout policy:
```yaml theme={null}
type: Policy
name: Production Gradual Rollout
selectors:
- environment: environment.name == "Production"
rules:
- gradualRollout:
rolloutType: linear
timeScaleInterval: 600 # 10 minutes between clusters
- verification:
metrics:
- name: error-rate
provider:
type: datadog
query: "sum:errors{env:prod,cluster:{{.resource.name}}}"
successCondition: result.value < 0.01
intervalSeconds: 60
count: 5
```
## What Happens
```mermaid theme={null}
flowchart LR
V["v1.2.3"] --> C1["us-east-1"]
C1 -->|"verify"| W1["wait 10m"]
W1 --> C2["us-west-2"]
C2 -->|"verify"| W2["wait 10m"]
W2 --> C3["eu-west-1"]
C3 -->|"verify"| Done["Done"]
```
1. **Version created** — CI builds v1.2.3 and tells Ctrlplane
2. **First cluster deploys** — us-east-1 receives the deployment
3. **Verification runs** — Datadog metrics are checked for 5 minutes
4. **Wait interval** — 10 minutes pass before next cluster
5. **Next cluster deploys** — us-west-2 receives the deployment
6. **Repeat** — Continue until all clusters are updated
7. **Auto-rollback** — If any verification fails, roll back that cluster
## Key Benefits
| Benefit | How It Works |
| ------------------------- | ----------------------------------------------- |
| **Automatic sequencing** | No manual intervention between clusters |
| **Built-in verification** | Each cluster is verified before proceeding |
| **Configurable timing** | Control how fast the rollout proceeds |
| **Auto-rollback** | Failed verification triggers immediate rollback |
| **Full visibility** | See progress across all clusters in real-time |
## Variations
### By Region First
Deploy to all clusters in us-east first, then us-west, then eu-west:
```yaml theme={null}
rules:
- gradualRollout:
rolloutType: linear
sortBy:
- resource.metadata["region"]
```
### Canary Then Full
Deploy to 1 cluster first, verify for an hour, then deploy to the rest:
```yaml theme={null}
# First policy: canary cluster
- name: Canary
selectors:
- resource: resource.metadata["canary"] == "true"
rules:
- verification:
# Extended verification for canary
metrics:
- name: error-rate
intervalSeconds: 300
count: 12 # 1 hour of checks
# Second policy: remaining clusters wait for canary
- name: Post-Canary Rollout
selectors:
- resource: resource.metadata["canary"] != "true"
rules:
- deploymentDependency:
waitFor: canary-cluster
- gradualRollout:
rolloutType: linear
```
## Next Steps
Configure rollout timing and ordering
Set up health checks with Datadog, Prometheus, HTTP
# Why Ctrlplane?
Source: https://docs.ctrlplane.dev/why-ctrlplane
The deployment problems you're already dealing with—and how to solve them
## Sound Familiar?
**The pain**: You check Kubernetes, then AWS console, then your team's spreadsheet. 15 minutes later, you're still not 100% sure.
**With Ctrlplane**: Single inventory showing every resource and its current version, updated in real-time.
**The pain**: Slack message sent. Thread dies. Deploy sits for hours. You ping again. Finally someone approves at 6pm.
**With Ctrlplane**: Built-in approval workflows with notifications. Approvers see pending releases in one place.
**The pain**: Manual step someone forgets. Or worse, staging didn't actually pass but prod got deployed anyway.
**With Ctrlplane**: Auto-promote to production only when staging verification succeeds. No manual intervention needed.
**The pain**: Update 6 config files, 3 CI pipelines, and remember to tell the team. Miss one and deploys fail silently.
**With Ctrlplane**: New clusters auto-join environments via selectors. Tag it `env: production` and it starts receiving deployments.
**The pain**: Scramble to find the right commands. Which version was stable? Did we roll back all regions? Did we miss one?
**With Ctrlplane**: Automatic rollback when verification fails. One-click manual rollback when needed.
**The pain**: Either deploy to all at once (risky) or manually babysit each region (slow and error-prone).
**With Ctrlplane**: Gradual rollouts deploy one region at a time, verify health, then continue—automatically.
## The Problems at Scale
When you have 5 services and 2 environments, everything is manageable. When you hit 20+ services across 4 environments and 8 clusters, you start feeling the pain:
| Challenge | What Teams Do Today | What Goes Wrong |
| ----------------------------- | --------------------------------------------- | ---------------------------------------------------- |
| **Environment promotion** | Manual deploy after checking staging | Someone forgets, or staging wasn't actually verified |
| **Deployment verification** | "It deployed successfully!" | Service is broken, but pipeline says green |
| **Infrastructure visibility** | Spreadsheets + multiple dashboards | Outdated info, no source of truth |
| **Adding new infrastructure** | Update configs everywhere | Miss something, deploys fail |
| **Multi-region deploys** | Sequential manual deploys or YOLO all-at-once | Too slow or too risky |
| **Rollbacks** | Ad-hoc scripts and hope | Inconsistent, slow, error-prone |
## Two Core Systems
Controls **when** and **where** releases happen:
* Auto-promote after verification
* Approval gates for production
* Gradual rollouts across regions
* Automatic rollback on failure
Tracks **what exists** and **what's running**:
* Real-time resource inventory
* Dynamic environment membership
* Version tracking across all targets
* Works with K8s, AWS, GCP, custom
## What Ctrlplane Is NOT
Ctrlplane doesn't replace your existing tools—it coordinates them:
* **Not a CI system** — Your CI (GitHub Actions, GitLab, Jenkins) still builds code
* **Not a GitOps engine** — ArgoCD/Flux still syncs manifests to clusters
* **Not infrastructure provisioning** — Terraform still creates your resources
Ctrlplane decides *when* deployments should happen, *where* they should go, and *whether* they passed verification.
## Next Steps
Understand the mental model
Set up your first deployment pipeline
See specific scenarios solved
How Ctrlplane compares to other tools
# Workflows
Source: https://docs.ctrlplane.dev/workflows
Run ad-hoc operations across your infrastructure using parameterized, multi-agent workflows
Workflows let you run on-demand operations — restarts, migrations, backfills, data exports — across one or more job agents. Unlike deployments, workflows are not tied to versions or release targets; they accept typed inputs at runtime and fan out jobs to whichever agents match the conditions you specify.
```
User triggers run
→ Inputs resolved (provided + defaults)
→ For each job agent whose selector matches:
→ Job created with dispatch context
→ Job agent executes with access to inputs
→ Status tracked
```
## Core Concepts
| Concept | Description |
| ---------------- | ---------------------------------------------------------------------------------------- |
| **Workflow** | A named template with input definitions and a list of job agents |
| **Input** | A typed parameter declared on the workflow (string, number, boolean, object, or array) |
| **Job Agent** | An executor (GitHub Actions, ArgoCD, Terraform Cloud, etc.) referenced by ID |
| **Selector** | A CEL expression evaluated against the dispatch context to decide whether the agent runs |
| **Workflow Run** | A single execution of a workflow with a specific set of input values |
| **Workflow Job** | A job dispatched to one agent during a run |
## Specifying Inputs
Inputs are declared in the `inputs` array of a workflow. Each input has a `key`, a `type`, and an optional `default`.
### Scalar inputs
```json theme={null}
{
"inputs": [
{ "key": "environment", "type": "string", "default": "staging" },
{ "key": "replicas", "type": "number", "default": 2 },
{ "key": "dry_run", "type": "boolean", "default": false },
{ "key": "config", "type": "object", "default": { "timeout": 30 } }
]
}
```
| Type | JSON type | Notes |
| --------- | --------- | --------------------- |
| `string` | `string` | |
| `number` | `number` | Integer or float |
| `boolean` | `boolean` | |
| `object` | `object` | Arbitrary JSON object |
| `array` | `array` | See below |
### Array inputs
There are two kinds of array inputs:
**Manual array** — the caller provides a list of items directly:
```json theme={null}
{ "key": "hosts", "type": "array" }
```
**Selector array** — dynamically resolved from your inventory using a CEL expression. Instead of the caller listing items, Ctrlplane queries entities matching the selector:
```json theme={null}
{
"key": "targets",
"type": "array",
"selector": {
"entityType": "resource",
"default": "resource.metadata['environment'] == 'staging'"
}
}
```
`entityType` can be `resource`, `environment`, or `deployment`. The `default` is a CEL expression used when the caller does not override the selector.
### Input resolution
When a run is created, Ctrlplane merges the caller's provided values with the workflow's defaults:
1. Start with the values provided by the caller.
2. For each input defined on the workflow, if no value was provided, apply the `default` (if set).
3. The final merged map is stored on the `WorkflowRun` record and passed to every dispatched job.
If the caller omits an input and no default is defined, that key will not appear in the resolved inputs.
## Wiring in Job Agents
The `jobAgents` array defines which executors the workflow uses. Each entry has:
```json theme={null}
{
"jobAgents": [
{
"name": "my-github-agent",
"ref": "",
"config": {
"installationId": "12345678",
"owner": "my-org",
"repo": "my-repo",
"workflowId": "deploy.yml"
},
"selector": "true"
}
]
}
```
| Field | Required | Description |
| ---------- | -------- | ---------------------------------------------------------------------------------- |
| `name` | Yes | Display name for the agent within this workflow |
| `ref` | Yes | The UUID of a registered job agent |
| `config` | Yes | Agent-specific configuration (see [Templates](#templating-inputs-into-job-agents)) |
| `selector` | Yes | CEL expression — the agent only runs if this evaluates to `true` |
### Selector evaluation
Before dispatching, Ctrlplane evaluates each agent's `selector` against the **dispatch context**:
```
{
"workflow": { ... }, // workflow definition
"inputs": { ... } // resolved input values
}
```
Use `"true"` to always dispatch to this agent, or write a CEL expression to make it conditional:
```json theme={null}
{ "selector": "inputs.environment == 'production'" }
```
This lets a single workflow definition fan out differently depending on what the caller provides — e.g., only trigger a manual approval agent for production.
Multiple agents can run in the same workflow run: every agent whose selector evaluates to `true` receives its own job.
## Templating Inputs into Job Agents
Both `config` values and the `selector` field support **Go `text/template`** syntax. Templates are rendered before dispatch using the same dispatch context:
| Variable | Description |
| --------------------- | ------------------------------------------------------------ |
| `{{.inputs.}}` | Resolved input values |
| `{{.workflow}}` | The workflow definition |
| `{{.jobAgentConfig}}` | This agent's rendered config (available in nested templates) |
### Example: GitHub Actions
```json theme={null}
{
"name": "github-deploy",
"ref": "",
"config": {
"installationId": "12345678",
"owner": "my-org",
"repo": "{{.inputs.repo}}",
"workflowId": "workflow-dispatch.yml",
"ref": "{{.inputs.branch}}"
},
"selector": "true"
}
```
When the run is created with `{ "repo": "api-service", "branch": "main" }`, the rendered config becomes:
```json theme={null}
{
"installationId": "12345678",
"owner": "my-org",
"repo": "api-service",
"workflowId": "workflow-dispatch.yml",
"ref": "main"
}
```
This rendered config is stored on the `Job` record and passed to the job agent dispatcher. The agent then uses it to construct the actual dispatch call (e.g., the GitHub API `workflow_dispatch` event).
### Conditional agent selection
```json theme={null}
{
"name": "prod-approvals",
"ref": "",
"config": {},
"selector": "inputs.environment == 'production'"
}
```
The approval agent only runs when `environment` is `"production"`.
## Full Example: Database Migration Workflow
This workflow runs a database migration via GitHub Actions, with a dry-run option and environment targeting.
### Workflow definition (API)
```json theme={null}
{
"name": "Run Database Migration",
"inputs": [
{ "key": "environment", "type": "string", "default": "staging" },
{ "key": "migration_id", "type": "string" },
{ "key": "dry_run", "type": "boolean", "default": true }
],
"jobAgents": [
{
"name": "migration-runner",
"ref": "",
"config": {
"installationId": "12345678",
"owner": "my-org",
"repo": "my-app",
"workflowId": "migrate.yml",
"ref": "main"
},
"selector": "true"
}
]
}
```
### GitHub Actions workflow
The GitHub Actions workflow receives a `job_id` from Ctrlplane. Use the `ctrlplanedev/get-job-inputs` action to fetch the resolved inputs:
```yaml theme={null}
# .github/workflows/migrate.yml
name: Database Migration
on:
workflow_dispatch:
inputs:
job_id:
description: "Ctrlplane Job ID"
required: true
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch workflow inputs
uses: ctrlplanedev/get-job-inputs@v1
id: job
with:
base_url: ${{ secrets.CTRLPLANE_BASE_URL }}
job_id: ${{ inputs.job_id }}
api_key: ${{ secrets.CTRLPLANE_API_KEY }}
- name: Run migration
env:
DB_URL: ${{ secrets[format('DB_URL_{0}', steps.job.outputs.inputs_environment)] }}
run: |
echo "Environment: ${{ steps.job.outputs.inputs_environment }}"
echo "Migration: ${{ steps.job.outputs.inputs_migration_id }}"
echo "Dry run: ${{ steps.job.outputs.inputs_dry_run }}"
if [ "${{ steps.job.outputs.inputs_dry_run }}" = "true" ]; then
make migrate-dry-run ID=${{ steps.job.outputs.inputs_migration_id }}
else
make migrate ID=${{ steps.job.outputs.inputs_migration_id }}
fi
```
Workflow inputs are surfaced on the job as `inputs_` outputs by `get-job-inputs`.
### Triggering a run (API)
```bash theme={null}
curl -X POST "https://ctrlplane.example.com/api/v1/workspaces/{workspaceId}/workflows/{workflowId}/runs" \
-H "Authorization: Bearer $CTRLPLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inputs": {
"environment": "production",
"migration_id": "20240115_add_user_index",
"dry_run": false
}
}'
```
If `dry_run` were omitted, Ctrlplane would fall back to the default `true`.
## Example: Multi-Agent Workflow (GitHub + Terraform Cloud)
A single workflow can dispatch to multiple agents simultaneously. Here, a restart workflow notifies Slack via GitHub Actions and triggers a Terraform Cloud run to scale down and back up:
```json theme={null}
{
"name": "Rolling Restart",
"inputs": [
{ "key": "service", "type": "string" },
{ "key": "environment", "type": "string", "default": "staging" }
],
"jobAgents": [
{
"name": "notify",
"ref": "",
"config": {
"installationId": "12345678",
"owner": "my-org",
"repo": "ops-tooling",
"workflowId": "notify-restart.yml",
"ref": "main"
},
"selector": "true"
},
{
"name": "terraform-restart",
"ref": "",
"config": {
"organization": "my-org",
"address": "https://app.terraform.io",
"token": "",
"webhookUrl": "https://ctrlplane.example.com/api/tfe/webhook",
"template": "name: restart-{{.inputs.service}}-{{.inputs.environment}}\nauto_apply: true\n"
},
"selector": "inputs.environment == 'production'"
}
]
}
```
The `notify` agent runs for every environment. The `terraform-restart` agent only runs when targeting production.
## Terraform Provider
Use the `ctrlplane` Terraform provider to manage workflows as code.
### Create a workflow
```hcl theme={null}
resource "ctrlplane_workflow" "db_migration" {
name = "Run Database Migration"
input {
key = "environment"
type = "string"
default = "staging"
}
input {
key = "migration_id"
type = "string"
}
input {
key = "dry_run"
type = "boolean"
default = "true"
}
job_agent {
name = "migration-runner"
ref = ctrlplane_job_agent.github.id
selector = "true"
config = {
installationId = var.github_installation_id
owner = "my-org"
repo = "my-app"
workflowId = "migrate.yml"
ref = "main"
}
}
}
```
### Reference other resources
```hcl theme={null}
resource "ctrlplane_job_agent" "github" {
name = "github-actions"
github_app {
installation_id = var.github_installation_id
owner = "my-org"
}
}
resource "ctrlplane_workflow" "rolling_restart" {
name = "Rolling Restart"
input {
key = "service"
type = "string"
}
input {
key = "environment"
type = "string"
default = "staging"
}
job_agent {
name = "notify"
ref = ctrlplane_job_agent.github.id
selector = "true"
config = {
installationId = var.github_installation_id
owner = "my-org"
repo = "ops-tooling"
workflowId = "notify-restart.yml"
ref = "main"
}
}
job_agent {
name = "terraform-restart"
ref = ctrlplane_job_agent.tfc.id
selector = "inputs.environment == 'production'"
config = {
organization = var.tfc_org
address = "https://app.terraform.io"
token = var.tfc_token
webhookUrl = "https://ctrlplane.example.com/api/tfe/webhook"
template = <<-EOT
name: restart-{{"{{"}.inputs.service{{"}}"}}-{{"{{"}.inputs.environment{{"}}"}}
auto_apply: true
EOT
}
}
}
```
## API Reference
| Method | Endpoint | Description |
| -------- | ---------------------------------------------------------- | ---------------------- |
| `GET` | `/v1/workspaces/{workspaceId}/workflows` | List all workflows |
| `POST` | `/v1/workspaces/{workspaceId}/workflows` | Create a workflow |
| `GET` | `/v1/workspaces/{workspaceId}/workflows/{workflowId}` | Get a workflow |
| `PUT` | `/v1/workspaces/{workspaceId}/workflows/{workflowId}` | Update a workflow |
| `DELETE` | `/v1/workspaces/{workspaceId}/workflows/{workflowId}` | Delete a workflow |
| `POST` | `/v1/workspaces/{workspaceId}/workflows/{workflowId}/runs` | Trigger a workflow run |
### Create workflow run request body
```json theme={null}
{
"inputs": {
"environment": "production",
"migration_id": "20240115_add_index",
"dry_run": false
}
}
```
Only `inputs` is required. Omit keys to use their defaults; keys with no default and no provided value will be absent from the dispatch context.
## How It Works Internally
```mermaid theme={null}
sequenceDiagram
participant U as Caller
participant A as API
participant E as Workspace Engine
participant J as Job Agent
U->>A: POST /workflows/{id}/runs { inputs }
A->>E: Forward run request
E->>E: Resolve inputs (provided + defaults)
E->>E: For each jobAgent: evaluate CEL selector
E->>E: For matching agents: create Job + enqueue dispatch
E->>J: Dispatch job (rendered config + dispatch context)
J->>J: Execute operation
J-->>A: Status update (webhook or API)
A-->>U: Job status visible in UI
```
1. **Input resolution** — user-provided values are merged with defaults declared on the workflow.
2. **Selector evaluation** — each agent's `selector` CEL expression is evaluated against `{ workflow, inputs }`. Only agents whose selector returns `true` receive a job.
3. **Job creation** — a `Job` is inserted with the rendered `jobAgentConfig` and the full `DispatchContext` (which includes the resolved inputs).
4. **Job dispatch** — the workspace engine picks up the job and calls the agent-specific dispatcher (GitHub, TFC, ArgoCD, etc.).
5. **Status tracking** — agents report back via webhooks or the Ctrlplane API, updating job status in real time.
## Next Steps
Wire up GitHub Actions as a job agent
Trigger Terraform Cloud runs from workflows
Sync ArgoCD applications on demand
Execute Argo Workflow templates
# Domain Matching
Source: https://docs.ctrlplane.dev/workspaces/domain-matching
Automatically assign workspace roles to users based on their email domain.
**Domain matching** lets you configure rules that automatically assign roles to
users when they sign in with an email address matching a specified domain. This
simplifies onboarding for teams by removing the need to manually invite every
user.
## Overview
```mermaid theme={null}
flowchart TD
A[User Signs In] --> B[Extract Email Domain]
B --> C{Domain Rule Exists?}
C -->|No| D[No Action]
C -->|Yes| E[Assign Role in Workspace]
E --> F[User Has Workspace Access]
```
When a user signs in, Ctrlplane extracts the domain portion of their email
address (e.g., `acme.com` from `alice@acme.com`) and checks it against all
configured domain matching rules. If a match is found, the user is automatically
assigned the specified role in the corresponding workspace.
## Why Use Domain Matching?
Domain matching helps you:
* **Automate onboarding** - New team members get workspace access instantly on
first sign-in
* **Enforce consistency** - Everyone from the same domain gets the same baseline
role
* **Reduce admin work** - No need to send individual invitations for every user
* **Support multiple workspaces** - A single domain can map to multiple
workspaces with different roles
## Configuration
Domain matching is configured in **Workspace Settings > General**.
### Adding a Rule
To create a domain matching rule, provide:
| Field | Description |
| ---------------------- | ------------------------------------------------------- |
| **Domain** | The email domain to match (e.g., `acme.com`) |
| **Role** | The workspace role to assign to matching users |
| **Verification Email** | An email address used to verify ownership of the domain |
Go to your workspace settings by clicking the gear icon, then select
**General**.
Scroll to the **Domain Matching** card.
Enter the domain (e.g., `acme.com`), select a role, and provide a
verification email address. Click **Add**.
### Verification
Each domain matching rule includes a verification step to confirm ownership of
the domain. When a rule is created, a verification code is generated. The domain
owner must verify the rule before it is considered fully active.
### Deleting a Rule
To remove a domain matching rule, click the trash icon next to the rule in the
Domain Matching card. Users who were previously assigned via the rule will retain
their existing role assignments.
## How It Works
1. **User signs in** via any configured authentication provider (Google, email
and password, or custom OAuth).
2. **Domain extraction** - The domain is extracted from the user's email address
and converted to lowercase.
3. **Rule lookup** - Ctrlplane queries all domain matching rules for the
extracted domain.
4. **Role assignment** - For each matching rule, the user is assigned the
configured role in the corresponding workspace. If the user already has the
role, no duplicate is created.
Domain matching runs on every sign-in, so if you add a new rule, existing
users will be assigned the role the next time they sign in.
## Constraints
* Each workspace can only have **one rule per domain**. You cannot create two
rules for the same domain within the same workspace.
* Domain matching is **case-insensitive** - `Acme.com` and `acme.com` are
treated as the same domain.
* Role assignments created by domain matching behave the same as manually
assigned roles and can be removed individually if needed.
## Common Patterns
### Company-Wide Access
Give everyone at your company viewer access:
| Domain | Role |
| ---------- | ------ |
| `acme.com` | Viewer |
### Team-Based Roles
Assign different roles based on team domains:
| Domain | Role |
| ---------------- | ------ |
| `engineering.co` | Admin |
| `contractors.co` | Viewer |
### Multi-Workspace Setup
The same domain can be configured across different workspaces with different
roles. For example, `acme.com` could map to an **Admin** role in the
`infrastructure` workspace and a **Viewer** role in the `production` workspace.
## Best Practices
* Use domain matching for broad organizational access and supplement with
individual role assignments for elevated permissions.
* Verify domain rules promptly to ensure they are active.
* Review domain matching rules periodically as teams and organizational
structures change.
* Combine domain matching with workspace-level RBAC for fine-grained access
control.
## Next Steps
* [Concepts Overview](/concepts/overview) - Understand core Ctrlplane concepts
* [Quickstart](/quickstart) - Get started with Ctrlplane