Skip to content

Commit 6fcabf4

Browse files
authored
Release 1.4.0 (#198)
* feat(tf-policy): Added models and resources for tf-policy evaluations and set outcomes * feat(tf-policy): Added examples and unit testcases * removed redacted url at log * updated format string at _http file logger * Added tf-policy scenario doc * docs(tfpolicy): Updated the scenario docs for tf-policy evaluation * fix(stack): updated speculative enabled attribute alias in stack * updated changelog
1 parent 4a62861 commit 6fcabf4

17 files changed

Lines changed: 1632 additions & 12 deletions

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,49 @@
11
# Unreleased
22

33
# Released
4+
# v1.4.0
5+
6+
## Enhancements
7+
8+
### New resources
9+
10+
* Added `client.tf_policy_evaluations` — read and override tf-policy evaluations
11+
attached to a run.
12+
* `list(run_id, options=None)` (`GET /runs/{run_id}/tf-policy-evaluations`) returns
13+
an `Iterator[TfPolicyEvaluation]`.
14+
* `read(tf_policy_evaluation_id, options=None)` (`GET /tf-policy-evaluations/{id}`)
15+
returns a single `TfPolicyEvaluation`; pass
16+
`TfPolicyEvaluationListOptions(include="tf_policy_set_outcomes")` to sideload
17+
outcomes in one request.
18+
* `override(tf_policy_evaluation_id, options=None)` (`POST
19+
/tf-policy-evaluations/{id}/actions/override`) overrides an evaluation in
20+
`awaiting_override` status and returns the updated resource.
21+
* `list_set_outcomes(tf_policy_evaluation_id, options=None)` (`GET
22+
/tf-policy-evaluations/{id}/tf-policy-set-outcomes`) returns an
23+
`Iterator[TfPolicySetOutcome]`; supports `filter_status` and
24+
`filter_enforcement_level` via `TfPolicySetOutcomeListOptions`.
25+
* New models: `TfPolicyEvaluation`, `TfPolicyEvaluationStatusTimestamps`,
26+
`TfPolicyResultCount`, `TfPolicyEvaluationError`, `TfPolicyEvaluationPermissions`,
27+
`TfPolicyEvaluationActions`, `TfPolicyEvaluationListOptions`,
28+
`TfPolicyEvaluationOverrideOptions`.
29+
* New errors: `InvalidTfPolicyEvaluationIDError`.
30+
* Added `client.tf_policy_set_outcomes` — read a single tf-policy set outcome.
31+
* `read(tf_policy_set_outcome_id)` (`GET /tf-policy-set-outcomes/{id}`) returns a
32+
`TfPolicySetOutcome` with its nested `outcomes` array (snake_case inner keys, as
33+
stored by atlas).
34+
* New models: `TfPolicySetOutcome`, `PolicyOutcome`, `Diagnostic`, `OutcomeResource`,
35+
`TraversalValue`, `PassedResource`, `TfPolicySetOutcomeListOptions`.
36+
* New error: `InvalidTfPolicySetOutcomeIDError`.
37+
38+
### New enum values
39+
40+
* `PolicyKind.TFPOLICY = "tfpolicy"` — enables creating and reading tf-policy sets
41+
with the existing `client.policy_sets` resource.
42+
* New enums: `TfPolicyEvaluationStatus`, `TfPolicyStage`, `TfPolicyEnforcementLevel`.
43+
44+
## Bug Fixes
45+
* Updated model attributes of speculative enabled attribute with correct alias name at stack models.
46+
447
# v1.3.1
548

649
## Security Fixes
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Scenario: tf-policy evaluation and override
2+
3+
This scenario covers reading tf-policy compliance results for a run, filtering
4+
policy-set outcomes, and overriding a `mandatory_overridable` failure. tf-policy
5+
is HCP Terraform's native policy-as-code engine (distinct from Sentinel and
6+
OPA) — evaluations are attached to a run's stages (Init/Plan/Apply) rather
7+
than created directly, so this scenario reads and reacts to results rather
8+
than authoring them.
9+
10+
> tf-policy is HCP Terraform only and gated behind an organization-level
11+
> feature flag while in private beta. If policy-set creation with
12+
> `kind=PolicyKind.TFPOLICY` fails validation, confirm the flag is enabled for
13+
> your organization before assuming a client-side issue.
14+
15+
## Prerequisites
16+
17+
```bash
18+
export TFE_TOKEN="your-api-token"
19+
export TFE_ADDRESS="https://app.terraform.io"
20+
```
21+
22+
The workspace whose runs you're inspecting must be running a Terraform
23+
version tf-policy supports (`>= 1.16.0-alpha20260626` at the time of writing —
24+
check with your organization admin, since this is a fast-moving minimum on a
25+
beta feature). Evaluations on an older version come back `errored` with an
26+
`incompatible_terraform_version_error`, not a client-side exception.
27+
28+
## Step 1: List a run's tf-policy evaluations
29+
30+
A run has one evaluation per applicable stage, and each stage evaluates a
31+
different scope (see [Evaluation and enforcement](https://developer.hashicorp.com/terraform/policy#evaluation-and-enforcement)):
32+
33+
- **Init** — provider and module policies, evaluated during workspace
34+
initialization, before Terraform installs providers or modules.
35+
- **Plan** — resource policies whose referenced attributes are known at plan
36+
time, evaluated against the proposed plan before any infrastructure
37+
changes.
38+
- **Apply** — resource policies that reference computed values (ARNs, IDs,
39+
and similar) that stay unknown until Terraform actually applies; these
40+
necessarily run after infrastructure changes are made.
41+
42+
An Init-stage evaluation only has an empty result if your policy set has no
43+
provider/module policies — don't assume `list()[0]` is the interesting one
44+
for a resource-policy scenario; find the evaluation whose stage matches what
45+
your policy actually targets.
46+
47+
```python
48+
from pytfe import TFEClient
49+
50+
client = TFEClient()
51+
run_id = "run-abc123"
52+
53+
evaluations = list(client.tf_policy_evaluations.list(run_id))
54+
for e in evaluations:
55+
print(e.id, e.stage_type, e.status, e.result_count)
56+
```
57+
58+
## Step 2: Read one evaluation, with outcomes sideloaded
59+
60+
```python
61+
from pytfe.models import TfPolicyEvaluationListOptions
62+
63+
opts = TfPolicyEvaluationListOptions(include="tf_policy_set_outcomes")
64+
evaluation = client.tf_policy_evaluations.read(evaluations[0].id, options=opts)
65+
66+
print(evaluation.status, evaluation.actions, evaluation.permissions)
67+
```
68+
69+
`evaluation.actions.is_overridable` and `evaluation.permissions.can_override`
70+
both need to be `True` before an override call will succeed — check them
71+
before attempting one rather than relying on the error path.
72+
73+
## Step 3: Inspect policy-set outcomes and diagnostics
74+
75+
```python
76+
for outcome in client.tf_policy_evaluations.list_set_outcomes(evaluation.id):
77+
print(outcome.policy_set_name, outcome.result_count)
78+
for policy in outcome.outcomes:
79+
print(" ", policy.policy_name, policy.status, policy.enforcement_level)
80+
for diag in policy.diagnostics:
81+
print(" diag:", diag.summary, [r.resource_name for r in diag.resources])
82+
```
83+
84+
Filter to just the failures, or just one enforcement level:
85+
86+
```python
87+
from pytfe.models import TfPolicySetOutcomeListOptions
88+
89+
failed = client.tf_policy_evaluations.list_set_outcomes(
90+
evaluation.id,
91+
options=TfPolicySetOutcomeListOptions(filter_status="failed"),
92+
)
93+
94+
mandatory_overridable = client.tf_policy_evaluations.list_set_outcomes(
95+
evaluation.id,
96+
options=TfPolicySetOutcomeListOptions(
97+
filter_enforcement_level="mandatory_overridable"
98+
),
99+
)
100+
```
101+
102+
You can also read a single outcome directly if you already have its ID (e.g.
103+
from a webhook payload) without listing through the evaluation:
104+
105+
```python
106+
outcome = client.tf_policy_set_outcomes.read("tfpsout-abc123")
107+
```
108+
109+
## Step 4: Override a `mandatory_overridable` failure
110+
111+
```python
112+
from pytfe.models import TfPolicyEvaluationOverrideOptions
113+
114+
result = client.tf_policy_evaluations.override(
115+
evaluation.id,
116+
TfPolicyEvaluationOverrideOptions(comment="Approved by platform-team — ticket OPS-123"),
117+
)
118+
print(result.status) # "overridden"
119+
```
120+
121+
`comment` is optional — omit `options` entirely to override with no comment.
122+
The override only succeeds while the evaluation is in `awaiting_override`
123+
status; calling it again on an already-overridden evaluation raises `TFEError`
124+
rather than silently no-op'ing, so guard on `status` first if you're looping
125+
over a batch.
126+
127+
Override is only available for **Plan-stage** evaluations. Init and Apply
128+
stage evaluations are never overridable, regardless of enforcement level or
129+
`AWAITING_OVERRIDE` status — check `stage_type == TfPolicyStage.PLAN` (in
130+
addition to `actions.is_overridable`) before attempting one:
131+
132+
```python
133+
from pytfe.models import TfPolicyEvaluationStatus, TfPolicyStage
134+
135+
overridable = [
136+
e for e in evaluations
137+
if e.status == TfPolicyEvaluationStatus.AWAITING_OVERRIDE
138+
and e.stage_type == TfPolicyStage.PLAN
139+
and e.actions and e.actions.is_overridable
140+
]
141+
for e in overridable:
142+
client.tf_policy_evaluations.override(e.id)
143+
```
144+
145+
## Step 5: Gate a downstream workflow on compliance
146+
147+
The read-only surface above is enough to build a pre-flight compliance gate —
148+
this is the pattern the `hashicorp.terraform` Ansible collection's
149+
`tf_policy_evaluation_info` module wraps:
150+
151+
```python
152+
evaluations = list(client.tf_policy_evaluations.list(run_id))
153+
non_compliant = [
154+
e for e in evaluations
155+
if e.status in (
156+
TfPolicyEvaluationStatus.FAILED,
157+
TfPolicyEvaluationStatus.ERRORED,
158+
TfPolicyEvaluationStatus.AWAITING_OVERRIDE,
159+
)
160+
]
161+
if non_compliant:
162+
raise SystemExit(f"Run {run_id} is not tf-policy compliant: {non_compliant}")
163+
```
164+
165+
## Creating a `kind=tfpolicy` policy set
166+
167+
tf-policy policy sets are primarily supported as **VCS-connected** policy
168+
sets — commit your `.policy.hcl` files to a repository and connect it via
169+
`vcs_repo` on `PolicySetCreateOptions`, the same pattern as Sentinel/OPA
170+
policy sets. That's the supported path for anything beyond local iteration.
171+
172+
For quick local testing without setting up a VCS/OAuth connection, the
173+
direct-upload path below also works and is what this scenario uses:
174+
175+
```python
176+
from pytfe.models import PolicyKind, PolicySetCreateOptions
177+
178+
policy_set = client.policy_sets.create(
179+
"my-organization",
180+
PolicySetCreateOptions(
181+
name="tfpolicy-guardrails",
182+
kind=PolicyKind.TFPOLICY,
183+
policy_tool_version="0.1.0",
184+
agent_enabled=True,
185+
overridable=True,
186+
),
187+
)
188+
189+
version = client.policy_set_versions.create(policy_set.id)
190+
client.policy_set_versions.upload(version, "./policies") # directory, not a tarball
191+
```
192+
193+
`client.policy_set_versions.upload()` takes the `PolicySetVersion` object
194+
itself (it reads the upload link off it), not a URL string — this differs
195+
from `client.configuration_versions.upload()`, which does take the upload URL
196+
directly. Easy to transpose the two if you're working with both in the same
197+
script.
198+
199+
If your `.policy.hcl` files live in a subdirectory of the uploaded archive
200+
rather than at its root, set `policies_path` on the policy set to point at
201+
that subdirectory:
202+
203+
```python
204+
from pytfe.models import PolicySetUpdateOptions
205+
206+
client.policy_sets.update(
207+
policy_set.id,
208+
PolicySetUpdateOptions(policies_path="policies"),
209+
)
210+
```
211+
212+
Without `policies_path` set, the engine looks for policy files at the
213+
archive root. A nested layout with `policies_path` left unset is accepted
214+
without error and silently evaluates zero policies — every evaluation
215+
"passes" with `result_count` at all zeros, indistinguishable from a
216+
genuinely compliant run until you notice nothing was actually checked.
217+
218+
## Cleanup
219+
220+
```python
221+
client.policy_sets.delete(policy_set.id)
222+
```
223+
224+
Evaluations themselves aren't deletable - they're immutable records tied to
225+
the run that produced them and are cleaned up when the run/workspace is.
226+
227+
## Wire-format notes
228+
229+
- `TfPolicyEnforcementLevel.MANDATORY_OVERRIDABLE` serializes as
230+
`"mandatory_overridable"` - underscore, unlike the hyphenated style used
231+
elsewhere in the JSON:API surface.
232+
- The `outcomes` array on `TfPolicySetOutcome`, and everything nested inside
233+
it (`PolicyOutcome`, `Diagnostic`, `OutcomeResource`, `TraversalValue`,
234+
`PassedResource`), is **snake_case on the wire** rather than dash-cased.
235+
The backend serializes that column verbatim from a stored value rather than
236+
passing it through the usual attribute-name transform, so the SDK models
237+
read it as-is — this is intentional, not a bug, if you're ever comparing
238+
raw JSON against the rest of the API's dash-case convention.
239+
- `override()`'s request body is a bare `{"comment": "..."}`, not a JSON:API
240+
`{"data": {"attributes": {...}}}` envelope - the SDK handles this for you,
241+
but it's worth knowing if you're debugging against raw HTTP logs.

examples/stack.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def _print_stack(item):
3030
print(f"- description: {item.description}")
3131
print(f"- created_at: {item.created_at}")
3232
print(f"- updated_at: {item.updated_at}")
33-
print(f"- speculation_enabled: {item.speculation_enabled}")
33+
print(f"- speculative_enabled: {item.speculative_enabled}")
3434
print(f"- project_id: {item.project.id if item.project else None}")
3535
print(f"- agent_pool_id: {item.agent_pool.id if item.agent_pool else None}")
3636

@@ -77,7 +77,7 @@ def main():
7777
parser.add_argument("--name", help="Stack name (required for create)")
7878
parser.add_argument("--description", help="Stack description")
7979
parser.add_argument(
80-
"--speculation-enabled",
80+
"--speculative-enabled",
8181
type=lambda v: str(v).lower() in ("1", "true", "yes", "y"),
8282
default=None,
8383
help="Enable speculation (true/false)",
@@ -130,7 +130,7 @@ def main():
130130
options = StackCreateOptions(
131131
name=args.name,
132132
description=args.description,
133-
speculation_enabled=args.speculation_enabled,
133+
speculative_enabled=args.speculative_enabled,
134134
vcs_repo=_build_vcs_repo_options(args),
135135
project=Project(id=args.project_id),
136136
agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None,
@@ -157,7 +157,7 @@ def main():
157157
[
158158
args.name,
159159
args.description,
160-
args.speculation_enabled is not None,
160+
args.speculative_enabled is not None,
161161
args.agent_pool_id,
162162
args.vcs_identifier,
163163
args.vcs_branch,
@@ -172,7 +172,7 @@ def main():
172172
options = StackUpdateOptions(
173173
name=args.name,
174174
description=args.description,
175-
speculation_enabled=args.speculation_enabled,
175+
speculative_enabled=args.speculative_enabled,
176176
vcs_repo=_build_vcs_repo_options(args),
177177
agent_pool=AgentPool(id=args.agent_pool_id) if args.agent_pool_id else None,
178178
project=Project(id=args.project_id) if args.project_id else None,

0 commit comments

Comments
 (0)