Skip to content

Commit de33103

Browse files
committed
add assesment result
1 parent 6d80e00 commit de33103

9 files changed

Lines changed: 386 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
2+
23
# v1.2.0
4+
35
## Enhancements
46

57
### Relationships
@@ -18,6 +20,7 @@ Related data is now a complete, first-class part of every response. Before, `?in
1820
* Added `client.cost_estimates` — read run cost estimates. `read(id)` returns a `CostEstimate`; `logs(id)` returns the estimate's log output text. `CostEstimate`, `CostEstimateStatus`, and `CostEstimateStatusTimestamps` are now exported from `pytfe.models`. New error: `InvalidCostEstimateIDError`.
1921
* Added IP allowlists (the JSON:API `cidr-range-lists` / `cidr-ranges` resources) as `client.cidr_range_lists` and `client.cidr_ranges`. `cidr_range_lists` supports `list`, `create`, `read`, `update`, `delete`, plus `list_cidr_ranges`, `add_cidr_range`, and `add_agent_pools` / `remove_agent_pools`; `cidr_ranges` supports `read`, `update`, `delete`. New models: `CIDRRangeList`, `CIDRRange`, `EnforcementScope`, and their create/update/list options. New errors: `InvalidCIDRRangeListIDError`, `InvalidCIDRRangeIDError`, `RequiredCIDRBlockError`.
2022
* Added `client.registry` — a client for the **public Terraform Registry** module API (`registry.terraform.io`). This is a new, unauthenticated surface on a different host (the SDK never sends the bearer token to the registry); `base_url` is configurable for other registries implementing the module registry protocol. Methods: `list_modules`, `search_modules`, `list_latest_for_all_providers`, `latest_for_provider`, `get_module`, `list_versions`, `download_url`, `latest_download_url`, and `downloads_summary`. New models are exported under the `PublicRegistry*` prefix (e.g. `PublicRegistryModule`, `PublicRegistryModuleVersions`, `PublicRegistryModuleDownloadsSummary`). New errors: `InvalidModuleNamespaceError`, `InvalidModuleNameError`, `InvalidModuleProviderError`, `InvalidModuleVersionError`.
23+
* Added `client.assessment_results` — read workspace health assessment (drift detection / continuous validation) results. `read(id)` returns an `AssessmentResult`; `json_output(id)` and `json_schema(id)` return the underlying JSON plan / provider schema (following the blob redirect, `None` on 204); `log_output(id)` returns the Terraform JSON log as text. `AssessmentResult` is now a `TFEModel`, so its `workspace`/`source` relationships are reachable via `.relationships` / `.related(...)`. New error: `InvalidAssessmentResultIDError`.
2124

2225
## Bug Fixes
2326

@@ -29,8 +32,6 @@ Related data is now a complete, first-class part of every response. Before, `?in
2932
### Cost estimates
3033
* Fixed `CostEstimate` failing to parse real API responses: `status-timestamps` now treats every timestamp as optional (the API only returns the ones that have occurred) and adds the missing `pending-at`, and `error-message` now accepts `null`. Previously an included `cost-estimate` with a null error or partial timestamps would silently collapse to an id-only stub.
3134

32-
### Transport
33-
* Fixed the shared HTTP client retaining `Set-Cookie` session cookies across requests. The `/api/meta/ip-ranges` endpoint returns an `_atlas_session_data` cookie; once stored, that browser session silently overrode bearer-token auth on every subsequent request, causing spurious `401`/`404` errors. The transport now never persists cookies (this SDK authenticates only with the bearer token). Without this fix, any call to `client.ip_ranges.read()` broke all later authenticated calls on the same client.
3435

3536
# Released
3637
# v1.1.0

docs/api-coverage.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ resource list is reconciled against the public
88

99
**Legend:** ✅ Covered  ·  🟡 Partial  ·  ❌ Not yet implemented
1010

11-
pytfe implements **67 resource namespaces**. The resources still missing or
11+
pytfe implements **68 resource namespaces**. The resources still missing or
1212
partially covered are listed at the bottom of this page.
1313

1414
## Covered resources
@@ -44,6 +44,7 @@ partially covered are listed at the bottom of this page.
4444
| | Plan exports | `client.plan_exports` ||
4545
| | Applies | `client.applies` ||
4646
| | Cost estimates | `client.cost_estimates` ||
47+
| | Assessment results | `client.assessment_results` ||
4748
| | Comments | `client.comments` ||
4849
| | Query runs | `client.query_runs` ||
4950
| | State versions | `client.state_versions` ||
@@ -99,7 +100,6 @@ Public HCP Terraform API resources that do not yet have a pytfe client namespace
99100

100101
| Resource | Notes |
101102
|---|---|
102-
| Assessment results | Health-assessment reads. Model exists (`models/assessment_result.py`); surfaced indirectly via `workspace.current_assessment_result`. |
103103
| Audit trails tokens | Auth tokens for the audit-trail streaming API. |
104104
| Change requests ||
105105
| Feature sets | Organization feature sets. |

docs/api/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ column.
2626
| `client.runs` | `Runs` | `list`, `list_for_organization`, `read`, `create`, `apply`, `cancel`, `force_cancel`, `force_execute`, `discard` | [run.py](../../examples/run.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) |
2727
| `client.plans` | `Plans` | `read`, `read_for_run`, `logs`, `read_json_output`, `read_json_output_for_run`, `read_json_schema_for_run` | [plan.py](../../examples/plan.py) | [Plans](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans) |
2828
| `client.applies` | `Applies` | `read`, `logs`, `errored_state` | [apply.py](../../examples/apply.py) | [Applies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies) |
29+
| `client.assessment_results` | `AssessmentResults` | `read`, `json_output`, `json_schema`, `log_output` | [assessment_result.py](../../examples/assessment_result.py) | [Assessment results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/assessment-results) |
2930
| `client.run_events` | `RunEvents` | `list`, `read`, `read_with_options` | [run_events.py](../../examples/run_events.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) |
3031
| `client.query_runs` | `QueryRuns` | `list`, `read`, `create`, `logs`, `cancel`, `force_cancel` | [query_run.py](../../examples/query_run.py) | [Query runs](https://developer.hashicorp.com/terraform/enterprise/api-docs/queries) |
3132
| `client.state_versions` | `StateVersions` | `list`, `read`, `read_current`, `create`, `upload`, `download`, `rollback`, backing-data actions | [state_versions.py](../../examples/state_versions.py) | [State versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions) |

examples/assessment_result.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright IBM Corp. 2025, 2026
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
"""Assessment results (workspace health / drift detection) demo.
5+
6+
Find an assessment result via a workspace's current assessment, then read its
7+
summary and (optionally) the underlying JSON plan / log output.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import os
14+
15+
from pytfe import TFEClient, TFEConfig
16+
17+
18+
def _print_header(title: str) -> None:
19+
print("\n" + "=" * 80)
20+
print(title)
21+
print("=" * 80)
22+
23+
24+
def main() -> int:
25+
parser = argparse.ArgumentParser(
26+
description="Assessment results demo for python-tfe SDK"
27+
)
28+
parser.add_argument(
29+
"--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io")
30+
)
31+
parser.add_argument("--token", default=os.getenv("TFE_TOKEN", ""))
32+
parser.add_argument(
33+
"--id", help="Assessment result ID to read (e.g. asmtres-xxxxx)"
34+
)
35+
parser.add_argument(
36+
"--workspace-id",
37+
help="Workspace ID to discover the current assessment result from",
38+
)
39+
parser.add_argument(
40+
"--json-output", action="store_true", help="Also fetch the JSON plan output"
41+
)
42+
parser.add_argument(
43+
"--log-output", action="store_true", help="Also fetch the JSON log output"
44+
)
45+
args = parser.parse_args()
46+
47+
if not args.token:
48+
print("TFE_TOKEN is not set")
49+
return 2
50+
51+
client = TFEClient(TFEConfig(address=args.address, token=args.token))
52+
53+
ar_id = args.id
54+
if not ar_id and args.workspace_id:
55+
_print_header(f"Current assessment for workspace {args.workspace_id}")
56+
current = client.workspaces.current_assessment_result(args.workspace_id)
57+
if not current:
58+
print("Workspace has no assessment result (health assessments disabled?).")
59+
return 0
60+
ar_id = current.id
61+
print(f"Found assessment result: {ar_id}")
62+
63+
if not ar_id:
64+
print("Provide --id or --workspace-id")
65+
return 2
66+
67+
_print_header(f"Assessment result: {ar_id}")
68+
ar = client.assessment_results.read(ar_id)
69+
print(f" drifted: {ar.drifted}")
70+
print(f" succeeded: {ar.succeeded}")
71+
print(f" created_at: {ar.created_at}")
72+
if ar.error_message:
73+
print(f" error: {ar.error_message}")
74+
75+
# The output endpoints require a user or team token with workspace admin access.
76+
if args.json_output:
77+
_print_header("JSON plan output")
78+
out = client.assessment_results.json_output(ar_id)
79+
print(f" format_version: {out.get('format_version') if out else None}")
80+
81+
if args.log_output:
82+
_print_header("JSON log output (first 500 chars)")
83+
print(client.assessment_results.log_output(ar_id)[:500] or "(no log output)")
84+
85+
return 0
86+
87+
88+
if __name__ == "__main__":
89+
raise SystemExit(main())

src/pytfe/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .resources.agent_pools import AgentPools
1010
from .resources.agents import Agents, AgentTokens
1111
from .resources.apply import Applies
12+
from .resources.assessment_result import AssessmentResults
1213
from .resources.cidr_range_list import CIDRRangeLists, CIDRRanges
1314
from .resources.comment import Comments
1415
from .resources.configuration_version import ConfigurationVersions
@@ -116,6 +117,8 @@ def __init__(self, config: TFEConfig | None = None):
116117
self.plans = Plans(self._transport)
117118
self.plan_exports = PlanExports(self._transport)
118119
self.cost_estimates = CostEstimates(self._transport)
120+
# Workspace health assessments (drift detection / continuous validation)
121+
self.assessment_results = AssessmentResults(self._transport)
119122
# Meta endpoint: HCP Terraform / TFE outbound IP ranges
120123
self.ip_ranges = IPRanges(self._transport)
121124
# IP allowlists (JSON:API cidr-range-lists / cidr-ranges)

src/pytfe/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,14 @@ def __init__(self, message: str = "invalid value for cost estimate ID"):
458458
super().__init__(message)
459459

460460

461+
# Assessment result errors
462+
class InvalidAssessmentResultIDError(InvalidValues):
463+
"""Raised when an invalid assessment result ID is provided."""
464+
465+
def __init__(self, message: str = "invalid value for assessment result ID"):
466+
super().__init__(message)
467+
468+
461469
# IP allowlist (CIDR range list) errors
462470
class InvalidCIDRRangeListIDError(InvalidValues):
463471
"""Raised when an invalid CIDR range list (IP allowlist) ID is provided."""

src/pytfe/models/assessment_result.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
from datetime import datetime
77

8-
from pydantic import BaseModel, ConfigDict, Field
8+
from pydantic import ConfigDict, Field
99

10+
from ._base import TFEModel
1011

11-
class AssessmentResult(BaseModel):
12+
13+
class AssessmentResult(TFEModel):
1214
"""Result of a workspace health assessment (drift detection)."""
1315

1416
model_config = ConfigDict(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Copyright IBM Corp. 2025, 2026
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
"""Read health assessment (drift detection / continuous validation) results.
5+
6+
``GET /api/v2/assessment-results/:id`` returns the assessment summary; the
7+
``/json-output``, ``/json-schema`` and ``/log-output`` companion endpoints
8+
return the underlying plan JSON, provider schema, and Terraform JSON log.
9+
10+
Those output endpoints do not adhere to JSON:API and (per the API docs) require
11+
a **user or team token with admin access to the workspace** — organization
12+
tokens cannot read them.
13+
14+
API reference:
15+
https://developer.hashicorp.com/terraform/cloud-docs/api-docs/assessment-results
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any
21+
22+
import httpx
23+
24+
from .._jsonapi import attach_jsonapi
25+
from ..errors import InvalidAssessmentResultIDError, TFEError
26+
from ..models.assessment_result import AssessmentResult
27+
from ..utils import valid_string_id
28+
from ._base import _Service
29+
30+
31+
def _assessment_result_from(
32+
data: dict[str, Any], included: list[dict[str, Any]] | None = None
33+
) -> AssessmentResult:
34+
"""Parse a JSON:API assessment-results resource into an AssessmentResult."""
35+
attrs = dict(data.get("attributes") or {})
36+
attrs["id"] = data.get("id")
37+
return attach_jsonapi(AssessmentResult.model_validate(attrs), data, included)
38+
39+
40+
class AssessmentResults(_Service):
41+
"""Service for reading workspace health assessment results."""
42+
43+
def read(self, assessment_result_id: str) -> AssessmentResult:
44+
"""Read an assessment result by its ID."""
45+
if not valid_string_id(assessment_result_id):
46+
raise InvalidAssessmentResultIDError()
47+
r = self.t.request(
48+
"GET", f"/api/v2/assessment-results/{assessment_result_id}"
49+
)
50+
body = r.json()
51+
data = (body or {}).get("data") or {} if isinstance(body, dict) else {}
52+
included = body.get("included") if isinstance(body, dict) else None
53+
return _assessment_result_from(data, included)
54+
55+
def json_output(self, assessment_result_id: str) -> dict[str, Any] | None:
56+
"""Return the JSON plan output for an assessment result.
57+
58+
Only available once the assessment has succeeded and produced JSON
59+
output. Returns ``None`` when output is not yet ready (HTTP 204); the
60+
transport raises (e.g. ``NotFound``) when the assessment produced no
61+
output, such as when it did not succeed. Requires a user/team token with
62+
workspace admin access.
63+
"""
64+
if not valid_string_id(assessment_result_id):
65+
raise InvalidAssessmentResultIDError()
66+
resp = self._follow_blob(
67+
f"/api/v2/assessment-results/{assessment_result_id}/json-output"
68+
)
69+
return self._as_json(resp)
70+
71+
def json_schema(self, assessment_result_id: str) -> dict[str, Any] | None:
72+
"""Return the JSON provider schema for an assessment result.
73+
74+
Returns ``None`` when the schema is not yet ready (HTTP 204); the
75+
transport raises when the assessment produced no schema (e.g. it did not
76+
succeed). Requires a user/team token with workspace admin access.
77+
"""
78+
if not valid_string_id(assessment_result_id):
79+
raise InvalidAssessmentResultIDError()
80+
resp = self._follow_blob(
81+
f"/api/v2/assessment-results/{assessment_result_id}/json-schema"
82+
)
83+
return self._as_json(resp)
84+
85+
def log_output(self, assessment_result_id: str) -> str:
86+
"""Return the Terraform JSON log output for an assessment result as text.
87+
88+
Returns an empty string when there is no log output yet (HTTP 204).
89+
Requires a user/team token with workspace admin access.
90+
"""
91+
if not valid_string_id(assessment_result_id):
92+
raise InvalidAssessmentResultIDError()
93+
resp = self._follow_blob(
94+
f"/api/v2/assessment-results/{assessment_result_id}/log-output"
95+
)
96+
return resp.text if resp is not None else ""
97+
98+
def _follow_blob(self, path: str) -> httpx.Response | None:
99+
"""Fetch a non-JSON:API output endpoint, following a blob redirect.
100+
101+
These endpoints may 307-redirect to a HashiCorp object-storage URL
102+
(Archivist), which requires the API bearer; we re-issue the request to
103+
the ``Location`` with auth (matching the plan ``json-output`` flow).
104+
Returns ``None`` when the API responds ``204 No Content``.
105+
"""
106+
resp = self.t.request("GET", path, allow_redirects=False)
107+
if resp.status_code == 204:
108+
return None
109+
if resp.status_code in (301, 302, 303, 307, 308):
110+
location = resp.headers.get("Location") or resp.headers.get("location")
111+
if not location:
112+
raise TFEError(
113+
"assessment-results output redirect did not include a Location header"
114+
)
115+
return self.t.request("GET", location)
116+
return resp
117+
118+
@staticmethod
119+
def _as_json(resp: httpx.Response | None) -> dict[str, Any] | None:
120+
if resp is None:
121+
return None
122+
try:
123+
data = resp.json()
124+
except Exception:
125+
return None
126+
if data is None:
127+
return None
128+
return data if isinstance(data, dict) else {"data": data}

0 commit comments

Comments
 (0)