Skip to content

Commit 0f8fb42

Browse files
docs: opa plan validation docs (#1151)
1 parent 7a7edd9 commit 0f8fb42

3 files changed

Lines changed: 285 additions & 1 deletion

File tree

‎docs/docs.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@
9292
"policies/version-selector",
9393
"policies/version-cooldown",
9494
"policies/deployment-window",
95-
"policies/retry"
95+
"policies/retry",
96+
"policies/plan-validation"
9697
]
9798
},
9899
{

‎docs/policies/overview.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,20 @@ Run automated checks after deployment:
240240

241241
See [Verification](./verification) for detailed configuration options.
242242

243+
### Plan Validation Rule
244+
245+
Run OPA/Rego policies against a deployment plan to preview proposed changes.
246+
This rule is **non-blocking** — it reports violations but does not gate
247+
deployments.
248+
249+
| API Field | Terraform Attribute | Description |
250+
| ------------------------------ | ------------------------- | --------------------------------- |
251+
| `planValidationOpa.name` | `plan_validation_opa.name` | Rule name shown in violations |
252+
| `planValidationOpa.description`| `plan_validation_opa.description` | Human-readable description |
253+
| `planValidationOpa.rego` | `plan_validation_opa.rego` | Rego v1 source code |
254+
255+
See [Plan Validation](./plan-validation) for details.
256+
243257
## Terraform Provider Reference
244258

245259
The Terraform provider supports the following policy blocks:
@@ -253,6 +267,7 @@ The Terraform provider supports the following policy blocks:
253267
| `deployment_window` | Time-based deployment scheduling |
254268
| `version_cooldown` | Batch frequent releases |
255269
| `verification` | Automated health checks |
270+
| `plan_validation_opa` | OPA/Rego plan validation (non-blocking preview)|
256271

257272
<Note>
258273
`retry` and `version_selector` are not yet available in the Terraform

‎docs/policies/plan-validation.mdx‎

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
---
2+
title: Plan Validation (OPA)
3+
description:
4+
Learn how to use OPA/Rego plan validation rules to inspect a deployment plan
5+
before you roll it out and surface policy violations as a preview.
6+
---
7+
8+
**Plan validation rules** run [OPA](https://www.openpolicyagent.org/) (Open
9+
Policy Agent) policies against a deployment **plan** — the diff between what is
10+
currently deployed and what a new version proposes — and report any violations
11+
back to you.
12+
13+
<Note>
14+
Plan validation is **non-blocking**. It does **not** gate or stop a
15+
deployment. It exists to give you a preview: when a new version is planned,
16+
you can see whether the proposed changes pass your policies before you decide
17+
to roll them out. Violations are surfaced on the deployment's GitHub check and
18+
in the ctrlplane UI, but the deployment is never automatically held back.
19+
</Note>
20+
21+
## Overview
22+
23+
```mermaid
24+
flowchart TD
25+
A[Version Planned] --> B[Agent computes plan<br/>current vs. proposed]
26+
B --> C{Policy selector<br/>matches target?}
27+
C -->|No| D[No validation run]
28+
C -->|Yes| E[Evaluate Rego deny rules]
29+
E --> F{Any denials?}
30+
F -->|No| G[Reported as passed]
31+
F -->|Yes| H[Reported as violations]
32+
```
33+
34+
A plan validation rule is a normal **policy rule**, so it shares the policy's
35+
CEL `selector` that decides which release targets it applies to. The difference
36+
is that instead of approvals or windows, the rule carries a snippet of **Rego**
37+
that inspects the computed plan.
38+
39+
## When does it run?
40+
41+
Validation runs after an agent finishes computing a plan. Only agents that
42+
produce a plan are evaluated:
43+
44+
- **ArgoCD** — the rendered manifest diff
45+
- **Terraform Cloud** — the speculative plan output
46+
47+
If a deployment uses an agent that does not support plan operations, no
48+
validation is run for that target.
49+
50+
## Why use plan validation?
51+
52+
- **Preview before you ship** — confirm a proposed change looks right before
53+
promoting it.
54+
- **Catch risky diffs** — flag deletions, replacements, or scaling changes in a
55+
Terraform plan.
56+
- **Guard against secrets or forbidden values** in rendered manifests.
57+
- **Codify review checklists** that your team would otherwise eyeball by hand.
58+
59+
## Configuration
60+
61+
<Tabs>
62+
<Tab title="Terraform">
63+
```hcl
64+
resource "ctrlplane_policy" "no_destroy_in_prod" {
65+
name = "No destructive changes in production"
66+
selector = "environment.name == 'production'"
67+
68+
plan_validation_opa {
69+
name = "no-resource-deletions"
70+
description = "Flag plans that delete resources"
71+
rego = <<-EOT
72+
package ctrlplane
73+
74+
import rego.v1
75+
76+
deny contains msg if {
77+
input.agentType == "terraform-cloud"
78+
plan := json.unmarshal(input.proposed)
79+
change := plan.resource_changes[_]
80+
change.change.actions[_] == "delete"
81+
msg := sprintf("resource %q would be deleted", [change.address])
82+
}
83+
EOT
84+
}
85+
}
86+
```
87+
</Tab>
88+
<Tab title="API">
89+
```bash
90+
curl -X POST https://api.ctrlplane.com/v1/workspaces/{workspaceId}/policies \
91+
-H "Authorization: Bearer $TOKEN" \
92+
-H "Content-Type: application/json" \
93+
-d '{
94+
"name": "No destructive changes in production",
95+
"selector": "environment.name == '\''production'\''",
96+
"rules": [
97+
{
98+
"planValidationOpa": {
99+
"name": "no-resource-deletions",
100+
"description": "Flag plans that delete resources",
101+
"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}"
102+
}
103+
}
104+
]
105+
}'
106+
```
107+
</Tab>
108+
</Tabs>
109+
110+
## Properties
111+
112+
<ParamField path="planValidationOpa.name" type="string" required>
113+
Human-readable rule name. Shown in the check output to identify which rule
114+
produced a violation.
115+
</ParamField>
116+
117+
<ParamField path="planValidationOpa.description" type="string">
118+
Optional human-readable explanation of what the rule does.
119+
</ParamField>
120+
121+
<ParamField path="planValidationOpa.rego" type="string" required>
122+
Rego v1 source code. Must define a `deny` rule set following the
123+
[Conftest](https://www.conftest.dev/) convention
124+
(`deny contains msg if { ... }`). Each member of the `deny` set becomes one
125+
violation message. An empty `deny` set means the plan passed.
126+
</ParamField>
127+
128+
## Writing the Rego
129+
130+
Policies must be **Rego v1** (include `import rego.v1`) and define a `deny` rule
131+
set. The package name can be anything — ctrlplane detects it automatically.
132+
133+
```rego
134+
package ctrlplane
135+
136+
import rego.v1
137+
138+
deny contains msg if {
139+
input.environment.name == "production"
140+
msg := "changes to production require manual review"
141+
}
142+
```
143+
144+
- The set is **empty by default** → the plan passes.
145+
- Every string you add to `deny` becomes a separate violation line.
146+
- Use the OPA standard library (`json.unmarshal`, `yaml.unmarshal`, `contains`,
147+
`sprintf`, regex, etc.) to parse and inspect the plan.
148+
149+
<Warning>
150+
If a policy contains invalid Rego (for example, a syntax error), it cannot be
151+
evaluated and no validation result is recorded for that plan. Test your
152+
policies with the [OPA playground](https://play.openpolicyagent.org/) or
153+
`opa eval` before saving them.
154+
</Warning>
155+
156+
## The `input` document
157+
158+
Your Rego policy is evaluated against an `input` document with the following
159+
fields:
160+
161+
| Field | Type | Description |
162+
| ---------------------- | ------- | ------------------------------------------------------------------------ |
163+
| `input.current` | string | The currently deployed state (raw, agent-specific format) |
164+
| `input.proposed` | string | The proposed state from the new version (raw, agent-specific format) |
165+
| `input.hasChanges` | boolean | Whether the plan contains any changes |
166+
| `input.agentType` | string | The agent that produced the plan (e.g. `argo-cd`, `terraform-cloud`) |
167+
| `input.environment` | object | The target environment |
168+
| `input.resource` | object | The target resource |
169+
| `input.deployment` | object | The deployment |
170+
| `input.proposedVersion`| object | The deployment version being planned (the new version) |
171+
| `input.currentVersion` | object | The version currently deployed to this target (may be null) |
172+
173+
<Note>
174+
`input.current` and `input.proposed` are **strings**, not parsed objects,
175+
because the format is agent-specific (Terraform JSON plan, rendered Kubernetes
176+
YAML, etc.). Parse them inside your policy with `json.unmarshal` or
177+
`yaml.unmarshal`.
178+
</Note>
179+
180+
## Examples
181+
182+
### Flag resource deletions in a Terraform plan
183+
184+
```rego
185+
package ctrlplane
186+
187+
import rego.v1
188+
189+
deny contains msg if {
190+
input.agentType == "terraform-cloud"
191+
plan := json.unmarshal(input.proposed)
192+
change := plan.resource_changes[_]
193+
change.change.actions[_] == "delete"
194+
msg := sprintf("resource %q would be deleted", [change.address])
195+
}
196+
```
197+
198+
### Forbid hard-coded secrets in a rendered manifest
199+
200+
```rego
201+
package ctrlplane
202+
203+
import rego.v1
204+
205+
deny contains msg if {
206+
contains(input.proposed, "SECRET=")
207+
msg := "proposed manifest appears to contain a hard-coded secret"
208+
}
209+
```
210+
211+
### Require that a production change actually has a diff
212+
213+
```rego
214+
package ctrlplane
215+
216+
import rego.v1
217+
218+
deny contains "no changes detected for a production deploy" if {
219+
input.environment.name == "production"
220+
not input.hasChanges
221+
}
222+
```
223+
224+
### Flag a major version jump
225+
226+
```rego
227+
package ctrlplane
228+
229+
import rego.v1
230+
231+
deny contains msg if {
232+
current := input.currentVersion.tag
233+
proposed := input.proposedVersion.tag
234+
startswith(current, "v1.")
235+
startswith(proposed, "v2.")
236+
msg := sprintf("major version jump %s -> %s", [current, proposed])
237+
}
238+
```
239+
240+
## Viewing results
241+
242+
When a plan completes, ctrlplane records each rule's result against the plan.
243+
Results surface in two places:
244+
245+
- **GitHub check run** — if the version carries GitHub metadata, the deployment
246+
check shows the plan diff and a **Policy violations** section listing each
247+
failing rule and its messages. A violation marks the check as failed so it is
248+
visible on the pull request, but it does not block the ctrlplane deployment.
249+
- **ctrlplane UI** — the plan preview for a release target shows the diff
250+
alongside any validation results.
251+
252+
## Best practices
253+
254+
- ✅ Use a clear `name` — it is how violations are labeled in the output.
255+
- ✅ Scope rules with the policy `selector` so they only run where they matter
256+
(e.g. production only).
257+
- ✅ Branch on `input.agentType` when a policy is specific to Terraform or
258+
ArgoCD output.
259+
- ✅ Test Rego in the OPA playground before saving.
260+
- ❌ Don't rely on plan validation to *stop* a deployment — it is a preview, not
261+
a gate. Use [Approval](./approval) or
262+
[Environment Progression](./environment-progression) for gating.
263+
264+
## Next Steps
265+
266+
- [Policies Overview](./overview) - Learn about policy structure
267+
- [Approval](./approval) - Require sign-off before deploying
268+
- [Verification](./verification/overview) - Check metrics after deploying

0 commit comments

Comments
 (0)