diff --git a/CHANGELOG.md b/CHANGELOG.md index e4aed710..57c99042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Unreleased # Released +# v1.3.0 + +## Enhancements + +### New resources +* Added `client.stack_deployments` — list the deployments that belong to a stack. `list(stack_id, options=None)` (`GET /stacks/{stack_id}/stack-deployments`) returns an `Iterator[StackDeployment]`, with optional pagination (`page_size`) and `?include=` (`latest_deployment_run`, `latest_deployment_run.stack_configuration`) via `StackDeploymentListOptions`. The `stack` relationship is hydrated as a typed field; the `latest-deployment-run` relation is reachable via the lossless raw accessors (`deployment.related("latest-deployment-run")`). New models: `StackDeployment`, `StackDeploymentListOptions`, `StackDeploymentIncludeOpt`. +* Added `client.stack_deployment_groups` — list, read, approve, and rerun deployment groups within a stack configuration. `list(stack_configuration_id)` (`GET /stack-configurations/{id}/stack-deployment-groups`), `read(group_id)` (`GET /stack-deployment-groups/{id}`), `read_by_name(stack_configuration_id, name)`, `approve_all_plans(group_id)` (`POST .../approve-all-plans`), `rerun(group_id, options)` (`POST .../rerun?deployments=...`). New models: `StackDeploymentGroup`, `DeploymentGroupStatus`, `StackDeploymentGroupListOptions`, `StackDeploymentGroupRerunOptions`. +* Added `client.stack_deployment_runs` — list, read, approve, and cancel individual deployment runs within a deployment group. `list(group_id)` (`GET /stack-deployment-groups/{id}/stack-deployment-runs`), `read(run_id)` (`GET /stack-deployment-runs/{id}`), `approve_all_plans(run_id)` (`POST .../approve-all-plans`), `cancel(run_id)` (`POST .../cancel`). New models: `StackDeploymentRun`, `DeploymentRunStatus`, `StackDeploymentRunListOptions`, `StackDeploymentRunReadOptions`, `StackDeploymentRunIncludeOpt`. +* Added `client.stack_deployment_steps` — list, read, advance, list diagnostics, and download artifacts for individual deployment steps within a deployment run. `list(run_id)` (`GET /stack-deployment-runs/{id}/stack-deployment-steps`), `read(step_id)` (`GET /stack-deployment-steps/{id}`), `advance(step_id)` (`POST .../advance`), `list_diagnostics(step_id)` (`GET .../stack-diagnostics`), `download_artifact(step_id, artifact_type)` (`GET .../artifacts?name=`) returns raw `bytes`. New models: `StackDeploymentStep`, `DeploymentStepStatus`, `StackDeploymentStepArtifactType`, `StackDeploymentStepIncludeOpt`, `StackDeploymentStepListOptions`, `StackDeploymentStepReadOptions`, `StackDiagnostic`, `StackDiagnosticListOptions`. +* Added `client.stack_states` — list, read, and download descriptions for stack states. `list(stack_id)` (`GET /stacks/{id}/stack-states`), `read(state_id)` (`GET /stack-states/{id}`), `download_description(state_id)` (`GET /stack-states/{id}/description`) returns raw `bytes`. New models: `StackState`, `StackStateListOptions`. New error: `InvalidStackStateIDError`. +* Added `client.stack_configuration_summaries` — list lightweight stack configuration summaries for a stack. `list(stack_id)` (`GET /stacks/{id}/stack-configuration-summaries`). New models: `StackConfigurationSummary`, `StackConfigurationSummaryListOptions`. +* Added `client.stack_deployment_group_summaries` — list rolled-up deployment group summaries for a stack configuration. `list(stack_configuration_id)` (`GET /stack-configurations/{id}/stack-deployment-group-summaries`). New models: `StackDeploymentGroupSummary`, `StackDeploymentGroupSummaryListOptions`, `StackDeploymentGroupStatusCounts`. +* Added `client.stack_diagnostics` — read and acknowledge stack diagnostics. `read(diagnostic_id)` (`GET /stack-diagnostics/{id}`), `acknowledge(diagnostic_id)` (`POST /stack-diagnostics/{id}/acknowledge`). New error: `InvalidStackDiagnosticIDError`. + # v1.2.0 ## Enhancements @@ -76,7 +90,6 @@ drive the SDK without hardcoding resource names or browsing the GitHub repo. * Fixed `organizations.read_entitlements` silently dropping most entitlement flags. The parser surfaced only 15 of the ~47 flags the API returns, so flags such as `hyok`, `assessments`, `stacks`, `terraform-actions`, and `change-requests` were discarded. `Entitlements` now exposes those as typed fields and retains every remaining flag (including the integer `*-limit` flags) under `model_extra` via `extra="allow"`. The change is additive — existing typed fields are unchanged. -# Released # v1.1.0 ## Features diff --git a/docs/api/index.md b/docs/api/index.md index b99f5d8d..670a073d 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -107,11 +107,29 @@ column. | `client.organization_tags` | `OrganizationTags` | `list`, `delete`, `add_workspaces` | [organization_tags.py](../../examples/organization_tags.py) | [Organization tags](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-tags) | | `client.comments` | `Comments` | `list`, `read`, `create` | [comment.py](../../examples/comment.py) | [Comments](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/comments) | | `client.explorer` | `Explorer` | query and saved-view helpers | [explorer.py](../../examples/explorer.py) | [Explorer](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/explorer) | -| `client.stacks` | `Stacks` | `list`, `read`, `create`, `update`, `delete`, `force_delete`, VCS fetch | [stack.py](../../examples/stack.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | -| `client.stack_configurations` | `StackConfigurations` | `list`, `read`, `create` | [stack_configuration.py](../../examples/stack_configuration.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks) | | `client.github_app_installations` | `GitHubAppInstallations` | `list`, `read` | [github_app_installations.py](../../examples/github_app_installations.py) | [GitHub App installations](https://developer.hashicorp.com/terraform/enterprise/api-docs/github-app-installations) | | `client.organization_token_ttl_policies` | `OrganizationTokenTTLPolicies` | `list`, `update`, `reset_to_defaults` | [org_token_ttl.py](../../examples/org_token_ttl.py) | [Org token TTL settings](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations/settings#api-tokens) | +## Stacks + +HCP Terraform Stacks coordinate multi-component, multi-environment Terraform +deployments. See [stacks.md](stacks.md) for full method details and +[stack-deployment.md](../scenarios/stack-deployment.md) for an end-to-end +scenario. + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.stacks` | `Stacks` | `list`, `read`, `create`, `update`, `delete`, `force_delete` | [stack.py](../../examples/stack.py) | [Stacks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stacks) | +| `client.stack_configurations` | `StackConfigurations` | `list`, `read`, `create` | [stack_configuration.py](../../examples/stack_configuration.py) | [Stack configurations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-configurations) | +| `client.stack_configuration_summaries` | `StackConfigurationSummaries` | `list` | [stack_configuration_summary.py](../../examples/stack_configuration_summary.py) | [Stack configurations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-configurations) | +| `client.stack_deployments` | `StackDeployments` | `list` | [stack_deployment.py](../../examples/stack_deployment.py) | [Stack deployments](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployments) | +| `client.stack_deployment_groups` | `StackDeploymentGroups` | `list`, `read`, `read_by_name`, `approve_all_plans`, `rerun` | [stack_deployment_group.py](../../examples/stack_deployment_group.py) | [Stack deployment groups](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-groups) | +| `client.stack_deployment_group_summaries` | `StackDeploymentGroupSummaries` | `list` | [stack_deployment_group_summary.py](../../examples/stack_deployment_group_summary.py) | [Stack deployment groups](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-groups) | +| `client.stack_deployment_runs` | `StackDeploymentRuns` | `list`, `read`, `approve_all_plans`, `cancel` | [stack_deployment_run.py](../../examples/stack_deployment_run.py) | [Stack deployment runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-runs) | +| `client.stack_deployment_steps` | `StackDeploymentSteps` | `list`, `read`, `advance`, `list_diagnostics`, `download_artifact` | [stack_deployment_step.py](../../examples/stack_deployment_step.py) | [Stack deployment steps](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-steps) | +| `client.stack_states` | `StackStates` | `list`, `read`, `download_description` | [stack_state.py](../../examples/stack_state.py) | [Stack states](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-states) | +| `client.stack_diagnostics` | `StackDiagnostics` | `read`, `acknowledge` | [stack_diagnostic.py](../../examples/stack_diagnostic.py) | [Stack diagnostics](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-diagnostics) | + ## TFE admin (site-admin only) These endpoints require TFE site-admin permission and return `404` on diff --git a/docs/api/stacks.md b/docs/api/stacks.md new file mode 100644 index 00000000..fa4e765e --- /dev/null +++ b/docs/api/stacks.md @@ -0,0 +1,361 @@ +# Stacks + +HCP Terraform Stacks let you manage multiple Terraform components as a single +unit, with coordinated deployments across multiple environments. The pytfe SDK +covers the full lifecycle: creating stacks, preparing configurations, +orchestrating deployment groups and runs, inspecting deployment steps, reading +stack states, and handling diagnostics. + +Upstream docs: + +- Stacks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stacks +- Stack configurations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-configurations +- Stack deployments: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployments +- Stack deployment groups: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-groups +- Stack deployment runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-runs +- Stack deployment steps: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-steps +- Stack states: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-states +- Stack diagnostics: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-diagnostics + +Examples: + +- [stack.py](../../examples/stack.py) +- [stack_configuration.py](../../examples/stack_configuration.py) +- [stack_deployment.py](../../examples/stack_deployment.py) +- [stack_deployment_group.py](../../examples/stack_deployment_group.py) +- [stack_deployment_run.py](../../examples/stack_deployment_run.py) +- [stack_deployment_step.py](../../examples/stack_deployment_step.py) +- [stack_state.py](../../examples/stack_state.py) +- [stack_configuration_summary.py](../../examples/stack_configuration_summary.py) +- [stack_deployment_group_summary.py](../../examples/stack_deployment_group_summary.py) +- [stack_diagnostic.py](../../examples/stack_diagnostic.py) + +See the end-to-end scenario at [stack-deployment.md](../scenarios/stack-deployment.md). + +--- + +## Stacks (`client.stacks`) + +| Method | Purpose | +|---|---| +| `client.stacks.create(options)` | Create a stack in a project. | +| `client.stacks.update(stack_id, options)` | Update a stack's name, description, or VCS settings. | +| `client.stacks.list(organization, options)` | Iterate stacks in an organization. | +| `client.stacks.read(stack_id)` | Read a single stack. | +| `client.stacks.delete(stack_id)` | Delete a stack. | +| `client.stacks.force_delete(stack_id)` | Force-delete a stack that cannot be deleted normally. | + +```python +from pytfe import TFEClient +from pytfe.models import Project, StackCreateOptions, StackListOptions, VCSRepo + +client = TFEClient() + +# Create +stack = client.stacks.create( + StackCreateOptions( + name="k8s-stack", + project=Project(id="prj-abc123"), + vcs_repo=VCSRepo( + identifier="my-org/k8s-stack", + branch="main", + oauth_token_id="ot-abc123", + ), + ) +) +print(stack.id, stack.name) + +# List +for stack in client.stacks.list("my-org", StackListOptions(page_size=20)): + print(stack.id, stack.name, stack.deployment_names) + +# Read / update / delete +stack = client.stacks.read("st-abc123") +client.stacks.delete("st-abc123") +``` + +--- + +## Stack configurations (`client.stack_configurations`) + +A stack configuration is a versioned snapshot of the stack's source, +created whenever a VCS commit triggers preparation. Its `status` progresses +from `pending` through `converging` to `converged` (or `errored` if +preparation fails). Check `client.stack_diagnostics` for details when a +configuration errors. + +| Method | Purpose | +|---|---| +| `client.stack_configurations.create(stack_id, options)` | Create (trigger preparation of) a new configuration. | +| `client.stack_configurations.list(stack_id, options=None)` | Iterate configurations for a stack, newest first. | +| `client.stack_configurations.read(configuration_id, options=None)` | Read a configuration, optionally with included relationships. | + +```python +from pytfe.models import ( + StackConfigurationCreateOptions, + StackConfigurationIncludeOps, + StackConfigurationReadOptions, + StackConfigurationSource, +) + +# Trigger preparation from the latest VCS commit +config = client.stack_configurations.create( + "st-abc123", + StackConfigurationCreateOptions(source=StackConfigurationSource.FETCH), +) +print(config.id, config.status) + +# Read with diagnostics included +config = client.stack_configurations.read( + "stc-abc123", + StackConfigurationReadOptions( + include=[StackConfigurationIncludeOps.STACK_DIAGNOSTICS] + ), +) + +# Iterate configurations for a stack +for config in client.stack_configurations.list("st-abc123"): + print(config.id, config.status, config.sequence_number) +``` + +--- + +## Stack configuration summaries (`client.stack_configuration_summaries`) + +Lightweight rollup of status and deployment counts per configuration — useful +for dashboards without fetching every configuration individually. Each summary +also carries `group_status_summary` and `run_status_summary` objects with +aggregated counts across all deployment groups and runs. + +| Method | Purpose | +|---|---| +| `client.stack_configuration_summaries.list(stack_id, options=None)` | Iterate configuration summaries for a stack, newest first. | + +```python +from pytfe.models import StackConfigurationSummaryListOptions + +for summary in client.stack_configuration_summaries.list("st-abc123"): + print(summary.id, f"seq={summary.sequence_number}", summary.status) + if summary.group_status_summary: + g = summary.group_status_summary + print(f" groups: succeeded={g.succeeded} failed={g.failed}") + if summary.run_status_summary: + r = summary.run_status_summary + print(f" runs: succeeded={r.succeeded} failed={r.failed}") +``` + +--- + +## Stack deployments (`client.stack_deployments`) + +A stack deployment represents one named environment (e.g. `dev`, `staging`, +`prod`) that receives configuration changes. Deployments are defined in the +stack's source and tracked here for status and history. + +| Method | Purpose | +|---|---| +| `client.stack_deployments.list(stack_id, options=None)` | Iterate deployments for a stack. | + +```python +for deployment in client.stack_deployments.list("st-abc123"): + print(deployment.id, deployment.name) +``` + +--- + +## Stack deployment groups (`client.stack_deployment_groups`) + +A deployment group coordinates the plan and apply runs for one deployment +within a configuration. Use `approve_all_plans` to advance pending plan steps, +or `rerun` to retry specific failed deployments. + +| Method | Purpose | +|---|---| +| `client.stack_deployment_groups.list(configuration_id, options=None)` | Iterate deployment groups for a configuration. | +| `client.stack_deployment_groups.read(group_id)` | Read a deployment group. | +| `client.stack_deployment_groups.read_by_name(configuration_id, name)` | Read a deployment group by its deployment name. | +| `client.stack_deployment_groups.approve_all_plans(group_id)` | Approve all pending plan steps in the group. | +| `client.stack_deployment_groups.rerun(group_id, options)` | Rerun specific failed deployments in the group. | + +```python +from pytfe.models import StackDeploymentGroupRerunOptions + +# List all groups for a configuration +for group in client.stack_deployment_groups.list("stc-abc123"): + print(group.id, group.status) + +# Read by deployment name +dev_group = client.stack_deployment_groups.read_by_name("stc-abc123", "dev") + +# Approve all pending plans +client.stack_deployment_groups.approve_all_plans("sdg-abc123") + +# Rerun failed deployments +client.stack_deployment_groups.rerun( + "sdg-abc123", + StackDeploymentGroupRerunOptions(deployments=["dev", "staging"]), +) +``` + +--- + +## Stack deployment group summaries (`client.stack_deployment_group_summaries`) + +Per-group rollup of run counts within a configuration — one record per +deployment group, with `status_counts` broken down by run status. + +| Method | Purpose | +|---|---| +| `client.stack_deployment_group_summaries.list(configuration_id, options=None)` | Iterate group summaries for a configuration. | + +```python +for summary in client.stack_deployment_group_summaries.list("stc-abc123"): + print(summary.name, summary.status) + if summary.status_counts: + c = summary.status_counts + print( + f" pending={c.pending} deploying={c.deploying} " + f"succeeded={c.succeeded} failed={c.failed}" + ) +``` + +--- + +## Stack deployment runs (`client.stack_deployment_runs`) + +A deployment run is the individual plan + apply execution within a deployment +group. Each run progresses through statuses such as `pre-deploying`, +`deploying`, `pending-operator`, `succeeded`, or `failed`. + +| Method | Purpose | +|---|---| +| `client.stack_deployment_runs.list(group_id, options=None)` | Iterate runs for a deployment group. | +| `client.stack_deployment_runs.read(run_id, options=None)` | Read a run, optionally with included relationships. | +| `client.stack_deployment_runs.approve_all_plans(run_id)` | Approve all pending plan steps in the run. | +| `client.stack_deployment_runs.cancel(run_id)` | Cancel an in-progress run. | + +```python +from pytfe.models import StackDeploymentRunIncludeOpt, StackDeploymentRunReadOptions + +# List runs in a deployment group +for run in client.stack_deployment_runs.list("sdg-abc123"): + print(run.id, run.status) + +# Read with relationships +run = client.stack_deployment_runs.read( + "sdr-abc123", + StackDeploymentRunReadOptions( + include=[StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP] + ), +) + +# Cancel +client.stack_deployment_runs.cancel("sdr-abc123") +``` + +--- + +## Stack deployment steps (`client.stack_deployment_steps`) + +Steps are the granular plan and apply operations within a run. A step in +`pending-operator` status requires an explicit `advance()` call before the +deployment can proceed — this is the operator approval gate. + +| Method | Purpose | +|---|---| +| `client.stack_deployment_steps.list(run_id, options=None)` | Iterate steps for a run. | +| `client.stack_deployment_steps.read(step_id, options=None)` | Read a step, optionally with included relationships. | +| `client.stack_deployment_steps.advance(step_id)` | Approve a `pending-operator` step to allow it to proceed. | +| `client.stack_deployment_steps.list_diagnostics(step_id, options=None)` | Iterate diagnostics attached to a step. | +| `client.stack_deployment_steps.download_artifact(step_id, artifact_type)` | Download a step artifact as raw bytes. | + +Artifact types: `PLAN_DESCRIPTION`, `APPLY_DESCRIPTION`, `PLAN_DEBUG_LOG`, +`APPLY_DEBUG_LOG`. + +```python +from pytfe.models import StackDeploymentStepArtifactType + +for step in client.stack_deployment_steps.list("sdr-abc123"): + print(step.id, step.operation_type, step.status) + +# Advance a step waiting for operator approval +client.stack_deployment_steps.advance("sds-abc123") + +# Download the plan description +plan_bytes = client.stack_deployment_steps.download_artifact( + "sds-abc123", + StackDeploymentStepArtifactType.PLAN_DESCRIPTION, +) +print(plan_bytes.decode()) + +# List diagnostics for a failed step +for diag in client.stack_deployment_steps.list_diagnostics("sds-abc123"): + print(diag.id, diag.severity, diag.summary) +``` + +--- + +## Stack states (`client.stack_states`) + +A stack state captures the Terraform state snapshot for one deployment at a +point in time. The `is_current` flag identifies the live state for each +deployment. Each state carries a `components` list describing which stack +components contributed to the snapshot. + +| Method | Purpose | +|---|---| +| `client.stack_states.list(stack_id, options=None)` | Iterate all state snapshots for a stack across all deployments. | +| `client.stack_states.read(state_id)` | Read a single state snapshot. | +| `client.stack_states.download_description(state_id)` | Download the raw state description bytes. | + +```python +from pytfe.models import StackStateListOptions + +# Current state per deployment +for state in client.stack_states.list("st-abc123"): + if state.is_current: + print( + state.id, + f"deployment={state.deployment}", + f"resources={state.resource_instance_count}", + ) + for comp in state.components: + print(f" component={comp.address}") + +# Download raw state description (treat as sensitive) +raw = client.stack_states.download_description("sts-abc123") +``` + +The description bytes are a JSON blob containing resource instance details. +Treat them as sensitive — they may contain provider credentials or other +secret material. + +--- + +## Stack diagnostics (`client.stack_diagnostics`) + +Diagnostics are error or warning records attached to a configuration or a +deployment step. They surface problems such as provider checksum mismatches, +deprecated filename extensions, or validation failures. Acknowledging a +diagnostic marks it as reviewed. + +Diagnostic IDs use the `std-` prefix. + +| Method | Purpose | +|---|---| +| `client.stack_diagnostics.read(diagnostic_id)` | Read a stack diagnostic. | +| `client.stack_diagnostics.acknowledge(diagnostic_id)` | Acknowledge a diagnostic (mark as reviewed). | + +```python +diag = client.stack_diagnostics.read("std-abc123") +print(diag.severity, diag.summary) +print(diag.detail) + +# diags is populated when the server rolls up multiple sub-diagnostics +if diag.diags: + for nested in diag.diags: + print(" ", nested.get("severity"), nested.get("summary")) + +if not diag.acknowledged: + client.stack_diagnostics.acknowledge("std-abc123") +``` diff --git a/docs/scenarios/stack-deployment.md b/docs/scenarios/stack-deployment.md new file mode 100644 index 00000000..dd6313b8 --- /dev/null +++ b/docs/scenarios/stack-deployment.md @@ -0,0 +1,237 @@ +# Scenario: Stack deployment lifecycle + +This scenario walks through the complete operational lifecycle of an HCP +Terraform Stack: from watching a configuration converge, through monitoring +deployment group progress, approving operator-gated plan steps, to reading the +final state and handling diagnostics. + +Upstream docs: + +- Stacks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stacks +- Stack configurations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-configurations +- Stack deployment groups: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-groups +- Stack deployment runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-runs +- Stack deployment steps: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-deployment-steps +- Stack states: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/stacks/stack-states + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +The token needs permission to read and manage the stack and its deployments. + +--- + +## 1. Read a stack and its latest configuration + +```python +import os +from pytfe import TFEClient + +client = TFEClient() +stack_id = "st-abc123" + +stack = client.stacks.read(stack_id) +print(stack.name, stack.deployment_names) + +# Get the most recent configuration (list returns newest first) +configs = list(client.stack_configurations.list(stack_id)) +latest_config = configs[0] if configs else None +if latest_config: + print(latest_config.id, latest_config.status, latest_config.sequence_number) +``` + +If the configuration `status` is `errored`, check diagnostics before +proceeding (see section 6). + +--- + +## 2. Watch configuration summaries for an overview + +Configuration summaries give a quick view of each configuration's health +without fetching every individual configuration or deployment group. + +```python +from pytfe.models import StackConfigurationSummaryListOptions + +for summary in client.stack_configuration_summaries.list(stack_id): + print(f"seq={summary.sequence_number} status={summary.status}") + if summary.group_status_summary: + g = summary.group_status_summary + print(f" groups: succeeded={g.succeeded} failed={g.failed}") + if summary.run_status_summary: + r = summary.run_status_summary + print(f" runs: succeeded={r.succeeded} failed={r.failed}") + # Stop after the most recent few + break +``` + +--- + +## 3. List deployment groups and their status + +Each deployment group coordinates one named environment (e.g. `dev`) within a +configuration. A `succeeded` group means all its runs finished cleanly. + +```python +config_id = latest_config.id + +for group in client.stack_deployment_groups.list(config_id): + print(group.id, group.name, group.status) +``` + +For a compact view using summaries: + +```python +for summary in client.stack_deployment_group_summaries.list(config_id): + c = summary.status_counts + if c: + print( + f"{summary.name}: succeeded={c.succeeded} failed={c.failed} " + f"pending={c.pending} deploying={c.deploying}" + ) +``` + +--- + +## 4. Inspect runs and advance operator-gated steps + +A deployment run goes through plan and apply steps. If a step reaches +`pending-operator`, the deployment is paused for approval. Call `advance()` to +allow the step to continue. + +```python +from pytfe.models import DeploymentStepStatus + +group = client.stack_deployment_groups.read_by_name(config_id, "dev") + +for run in client.stack_deployment_runs.list(group.id): + print(f"run {run.id} status={run.status}") + + for step in client.stack_deployment_steps.list(run.id): + print(f" step {step.id} op={step.operation_type} status={step.status}") + + if step.status == DeploymentStepStatus.PENDING_OPERATOR: + print(" → advancing operator-gated step") + client.stack_deployment_steps.advance(step.id) +``` + +To approve all pending plans in a group at once (skipping per-step iteration): + +```python +client.stack_deployment_groups.approve_all_plans(group.id) +``` + +--- + +## 5. Download plan and apply artifacts + +Plan and apply descriptions give a human-readable summary of proposed and +applied changes. Debug logs provide full Terraform output. + +```python +from pytfe.models import StackDeploymentStepArtifactType + +for step in client.stack_deployment_steps.list(run.id): + if step.operation_type == "plan": + plan_bytes = client.stack_deployment_steps.download_artifact( + step.id, StackDeploymentStepArtifactType.PLAN_DESCRIPTION + ) + print(plan_bytes.decode()) + elif step.operation_type == "apply": + apply_bytes = client.stack_deployment_steps.download_artifact( + step.id, StackDeploymentStepArtifactType.APPLY_DESCRIPTION + ) + print(apply_bytes.decode()) +``` + +--- + +## 6. Handle diagnostics on configuration errors + +When a configuration's `status` is `errored`, diagnostics explain what went +wrong. They are also attached to individual deployment steps via +`list_diagnostics`. + +```python +# Diagnostics from a configuration (fetched via its relationships URL) +# Use the read endpoint to get the diagnostic ID, then read it directly: +diag = client.stack_diagnostics.read("std-abc123") +print(diag.severity, diag.summary) +print(diag.detail) + +# Nested sub-diagnostics (present when the server rolls up multiple warnings) +if diag.diags: + for nested in diag.diags: + print(" ", nested.get("severity"), nested.get("summary")) + +# Acknowledge after review +if not diag.acknowledged: + client.stack_diagnostics.acknowledge(diag.id) +``` + +Diagnostics from a failed deployment step: + +```python +for diag in client.stack_deployment_steps.list_diagnostics(step.id): + print(diag.id, diag.severity, diag.summary) + if not diag.acknowledged: + client.stack_diagnostics.acknowledge(diag.id) +``` + +--- + +## 7. Read current stack state + +After a successful deployment, each environment has a current state snapshot +containing the resource instances that were applied. + +```python +for state in client.stack_states.list(stack_id): + if not state.is_current: + continue + print( + f"deployment={state.deployment} " + f"resources={state.resource_instance_count} " + f"gen={state.generation}" + ) + for comp in state.components: + print(f" component={comp.address} resources={comp.resource_instance_count}") +``` + +To download the raw state description for a specific environment: + +```python +# Find the current state for "dev" +dev_state = next( + (s for s in client.stack_states.list(stack_id) if s.is_current and s.deployment == "dev"), + None, +) + +if dev_state: + raw = client.stack_states.download_description(dev_state.id) + # raw is a JSON blob — treat as sensitive + print(f"downloaded {len(raw)} bytes for {dev_state.deployment}") +``` + +The description bytes may contain provider credentials or other sensitive +resource attributes. Do not log or commit them. + +--- + +## 8. Rerun failed deployments + +If specific deployments in a group failed, rerun them without touching the +ones that succeeded. + +```python +from pytfe.models import StackDeploymentGroupRerunOptions + +client.stack_deployment_groups.rerun( + group.id, + StackDeploymentGroupRerunOptions(deployments=["dev"]), +) +``` diff --git a/examples/stack_configuration.py b/examples/stack_configuration.py index d51ace60..1d9c96d6 100644 --- a/examples/stack_configuration.py +++ b/examples/stack_configuration.py @@ -10,6 +10,7 @@ from pytfe.models import ( StackConfigurationCreateOptions, StackConfigurationListOptions, + StackConfigurationSource, ) @@ -82,7 +83,7 @@ def main(): else: print(f"Total: {config_count} stack configurations") - # 2) Create a new stack configuration + # 2) Create a new stack configuration (manual archive upload) if args.create: _print_header("Creating a new stack configuration") create_opts = StackConfigurationCreateOptions( @@ -97,7 +98,19 @@ def main(): print(f" Sequence: {config.sequence_number}") print(f" Created: {config.created_at}") - # 3) Read a specific stack configuration + # 3) Fetch latest config from VCS (stack must have a vcs_repo wired) + if args.fetch_from_vcs: + _print_header("Fetching latest configuration from VCS") + config = client.stack_configurations.create( + stack_id=args.stack_id, + source=StackConfigurationSource.FETCH, + ) + print(f"Triggered VCS fetch: {config.id}") + print(f" Status: {config.status.value if config.status else None}") + print(f" Sequence: {config.sequence_number}") + print(f" Created: {config.created_at}") + + # 4) Read a specific stack configuration if args.read: if not args.id: print("--id is required for --read") diff --git a/examples/stack_configuration_summary.py b/examples/stack_configuration_summary.py new file mode 100644 index 00000000..5728c136 --- /dev/null +++ b/examples/stack_configuration_summary.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list stack configuration summaries for a stack. + +Usage:: + + export TFE_TOKEN= + + # List configuration summaries for a stack + python3 examples/stack_configuration_summary.py --stack-id st-abc123 + + # List with pagination + python3 examples/stack_configuration_summary.py --stack-id st-abc123 --page-size 10 +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import StackConfigurationSummaryListOptions + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="List stack configuration summaries") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument("--stack-id", required=True, help="Stack ID (e.g. st-abc123)") + parser.add_argument("--page-size", type=int, help="Max items per page") + args = parser.parse_args() + + if not args.token: + raise SystemExit("TFE_TOKEN is not set") + + client = TFEClient(config=TFEConfig(token=args.token, address=args.address)) + + _print_header(f"Stack Configuration Summaries for {args.stack_id}") + opts = ( + StackConfigurationSummaryListOptions(page_size=args.page_size) + if args.page_size + else None + ) + count = 0 + for summary in client.stack_configuration_summaries.list( + args.stack_id, options=opts + ): + count += 1 + print(f" {summary.id} seq={summary.sequence_number} status={summary.status}") + if summary.group_status_summary: + g = summary.group_status_summary + print( + f" groups: pending={g.pending} deploying={g.deploying} " + f"succeeded={g.succeeded} failed={g.failed} abandoned={g.abandoned}" + ) + if summary.run_status_summary: + r = summary.run_status_summary + print( + f" runs: succeeded={r.succeeded} failed={r.failed} " + f"deploying={r.deploying} pending={r.pending}" + ) + print(f"\nTotal: {count} configuration summary/summaries") + + +if __name__ == "__main__": + main() diff --git a/examples/stack_deployment.py b/examples/stack_deployment.py new file mode 100644 index 00000000..5d3f1333 --- /dev/null +++ b/examples/stack_deployment.py @@ -0,0 +1,84 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +""" +Terraform Cloud/Enterprise Stack Deployments Example + +Lists the deployments that belong to a Stack +(GET /stacks/:stack_id/stack-deployments). + +Prerequisites: + - Set TFE_TOKEN environment variable with your Terraform Cloud API token + - A Stack id (e.g. st-xxxxxxxx) + +Usage: + python examples/stack_deployment.py --stack-id st-xxxxxxxx + python examples/stack_deployment.py --stack-id st-xxxxxxxx --page-size 50 + python examples/stack_deployment.py --stack-id st-xxxxxxxx --include-latest-run +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + StackDeploymentIncludeOpt, + StackDeploymentListOptions, +) + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Stack Deployments demo for python-tfe SDK" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN", "")) + parser.add_argument("--stack-id", required=True, help="Stack ID (e.g. st-xxxxxxxx)") + parser.add_argument( + "--page-size", type=int, default=20, help="Page size for listing deployments" + ) + parser.add_argument( + "--include-latest-run", + action="store_true", + help="Request the latest_deployment_run related resource", + ) + args = parser.parse_args() + + if not args.token: + print("TFE_TOKEN is not set") + return 2 + + client = TFEClient(TFEConfig(address=args.address, token=args.token)) + + include = ( + [StackDeploymentIncludeOpt.LATEST_DEPLOYMENT_RUN] + if args.include_latest_run + else None + ) + options = StackDeploymentListOptions(page_size=args.page_size, include=include) + + _print_header(f"Listing deployments for stack {args.stack_id}") + for deployment in client.stack_deployments.list(args.stack_id, options): + print(f" - {deployment.id} {deployment.name}") + if deployment.stack: + print(f" stack: {deployment.stack.id}") + # The latest-deployment-run relation is not modelled as a typed field, + # but it is always reachable losslessly via the raw escape hatch. + for ref in deployment.related("latest-deployment-run"): + print(f" latest-deployment-run: {ref.get('id')}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/stack_deployment_group.py b/examples/stack_deployment_group.py new file mode 100644 index 00000000..70c00755 --- /dev/null +++ b/examples/stack_deployment_group.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list, read, approve, and rerun stack deployment groups. + +Usage:: + + export TFE_TOKEN= + + # List deployment groups in a stack configuration + python3 examples/stack_deployment_group.py \\ + --stack-config-id stc-abc123 + + # Read a specific group by ID + python3 examples/stack_deployment_group.py \\ + --stack-config-id stc-abc123 --read --group-id sdg-xyz789 + + # Read a group by deployment name + python3 examples/stack_deployment_group.py \\ + --stack-config-id stc-abc123 --read-by-name dev + + # Approve all plans in a group + python3 examples/stack_deployment_group.py \\ + --stack-config-id stc-abc123 --approve --group-id sdg-xyz789 + + # Rerun a failed deployment group (pass deployment names, not run IDs) + python3 examples/stack_deployment_group.py \\ + --stack-config-id stc-abc123 --rerun --group-id sdg-xyz789 \\ + --deployments dev prod +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + StackDeploymentGroupListOptions, + StackDeploymentGroupRerunOptions, +) + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage stack deployment groups") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument( + "--stack-config-id", required=True, help="Stack configuration ID (stc-...)" + ) + parser.add_argument("--group-id", help="Deployment group ID (sdg-...)") + parser.add_argument("--page-size", type=int, default=20) + parser.add_argument( + "--read", + action="store_true", + help="Read a specific group (requires --group-id)", + ) + parser.add_argument( + "--read-by-name", metavar="NAME", help="Read a group by deployment name" + ) + parser.add_argument( + "--approve", action="store_true", help="Approve all plans (requires --group-id)" + ) + parser.add_argument( + "--rerun", + action="store_true", + help="Rerun a failed group (requires --group-id and --deployments)", + ) + parser.add_argument( + "--deployments", + nargs="+", + metavar="NAME", + help="Deployment names to rerun, e.g. dev prod (from the 'deployment' field on runs)", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List deployment groups in the stack configuration + _print_header(f"Listing deployment groups for config: {args.stack_config_id}") + opts = StackDeploymentGroupListOptions(page_size=args.page_size) + count = 0 + for group in client.stack_deployment_groups.list( + args.stack_config_id, options=opts + ): + count += 1 + print(f"- {group.id}") + print(f" Name: {group.name}") + print(f" Status: {group.status.value if group.status else None}") + print(f" Created: {group.created_at}") + print(f" Updated: {group.updated_at}") + if group.stack_configuration: + print(f" Config: {group.stack_configuration.id}") + print() + print(f"Total: {count} deployment group(s)") + + # 2) Read a specific group by ID + if args.read: + if not args.group_id: + print("--group-id is required for --read") + else: + _print_header(f"Reading deployment group: {args.group_id}") + group = client.stack_deployment_groups.read(args.group_id) + print(f"ID: {group.id}") + print(f"Name: {group.name}") + print(f"Status: {group.status.value if group.status else None}") + print(f"Created: {group.created_at}") + print(f"Updated: {group.updated_at}") + + # 3) Read a group by deployment name + if args.read_by_name: + _print_header(f"Reading deployment group by name: {args.read_by_name!r}") + group = client.stack_deployment_groups.read_by_name( + args.stack_config_id, args.read_by_name + ) + print(f"ID: {group.id}") + print(f"Name: {group.name}") + print(f"Status: {group.status.value if group.status else None}") + + # 4) Approve all plans + if args.approve: + if not args.group_id: + print("--group-id is required for --approve") + else: + _print_header(f"Approving all plans for group: {args.group_id}") + client.stack_deployment_groups.approve_all_plans(args.group_id) + print(f"Approved {args.group_id}") + + # 5) Rerun a failed deployment group + if args.rerun: + if not args.group_id or not args.deployments: + print("--group-id and --deployments are required for --rerun") + else: + _print_header( + f"Rerunning {len(args.deployments)} deployment(s) in group: {args.group_id}" + ) + rerun_opts = StackDeploymentGroupRerunOptions(deployments=args.deployments) + client.stack_deployment_groups.rerun(args.group_id, rerun_opts) + print(f"Rerun triggered for: {', '.join(args.deployments)}") + + +if __name__ == "__main__": + main() diff --git a/examples/stack_deployment_group_summary.py b/examples/stack_deployment_group_summary.py new file mode 100644 index 00000000..9b597bd6 --- /dev/null +++ b/examples/stack_deployment_group_summary.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list stack deployment group summaries for a stack configuration. + +Usage:: + + export TFE_TOKEN= + + # List deployment group summaries for a configuration + python3 examples/stack_deployment_group_summary.py --configuration-id stc-abc123 + + # List with pagination + python3 examples/stack_deployment_group_summary.py \\ + --configuration-id stc-abc123 --page-size 10 +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import StackDeploymentGroupSummaryListOptions + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="List stack deployment group summaries" + ) + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument( + "--configuration-id", + required=True, + help="Stack configuration ID (e.g. stc-abc123)", + ) + parser.add_argument("--page-size", type=int, help="Max items per page") + args = parser.parse_args() + + if not args.token: + raise SystemExit("TFE_TOKEN is not set") + + client = TFEClient(config=TFEConfig(token=args.token, address=args.address)) + + _print_header(f"Deployment Group Summaries for {args.configuration_id}") + opts = ( + StackDeploymentGroupSummaryListOptions(page_size=args.page_size) + if args.page_size + else None + ) + count = 0 + for summary in client.stack_deployment_group_summaries.list( + args.configuration_id, options=opts + ): + count += 1 + counts = summary.status_counts + counts_str = "" + if counts: + counts_str = ( + f" [pending={counts.pending} deploying={counts.deploying} " + f"succeeded={counts.succeeded} failed={counts.failed}]" + ) + print( + f" {summary.id} name={summary.name} status={summary.status}{counts_str}" + ) + print(f"\nTotal: {count} deployment group summary/summaries") + + +if __name__ == "__main__": + main() diff --git a/examples/stack_deployment_run.py b/examples/stack_deployment_run.py new file mode 100644 index 00000000..86615684 --- /dev/null +++ b/examples/stack_deployment_run.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list, read, approve, and cancel stack deployment runs. + +Usage:: + + export TFE_TOKEN= + + # List runs in a deployment group + python3 examples/stack_deployment_run.py \\ + --group-id sdg-xyz789 + + # Read a single run + python3 examples/stack_deployment_run.py \\ + --group-id sdg-xyz789 --read --run-id sdr-abc123 + + # Approve all plans in a run that is pending-operator + python3 examples/stack_deployment_run.py \\ + --group-id sdg-xyz789 --approve --run-id sdr-abc123 + + # Cancel a run + python3 examples/stack_deployment_run.py \\ + --group-id sdg-xyz789 --cancel --run-id sdr-abc123 +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + StackDeploymentRunIncludeOpt, + StackDeploymentRunListOptions, + StackDeploymentRunReadOptions, +) + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage stack deployment runs") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument( + "--group-id", required=True, help="Deployment group ID (sdg-...)" + ) + parser.add_argument("--run-id", help="Deployment run ID (sdr-...)") + parser.add_argument("--page-size", type=int, default=20) + parser.add_argument( + "--include-group", + action="store_true", + help="Include stack-deployment-group relation", + ) + parser.add_argument( + "--read", action="store_true", help="Read a specific run (requires --run-id)" + ) + parser.add_argument( + "--approve", action="store_true", help="Approve all plans (requires --run-id)" + ) + parser.add_argument( + "--cancel", action="store_true", help="Cancel a run (requires --run-id)" + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List deployment runs in the group + _print_header(f"Listing deployment runs for group: {args.group_id}") + include = ( + [StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP] + if args.include_group + else None + ) + opts = StackDeploymentRunListOptions(page_size=args.page_size, include=include) + count = 0 + for run in client.stack_deployment_runs.list(args.group_id, options=opts): + count += 1 + print(f"- {run.id}") + print(f" Status: {run.status.value if run.status else None}") + print(f" Created: {run.created_at}") + print(f" Updated: {run.updated_at}") + if run.stack_deployment_group: + print(f" Group: {run.stack_deployment_group.id}") + print() + print(f"Total: {count} deployment run(s)") + + # 2) Read a specific run + if args.read: + if not args.run_id: + print("--run-id is required for --read") + else: + _print_header(f"Reading deployment run: {args.run_id}") + read_opts = StackDeploymentRunReadOptions( + include=[StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP] + if args.include_group + else None + ) + run = client.stack_deployment_runs.read(args.run_id, options=read_opts) + print(f"ID: {run.id}") + print(f"Status: {run.status.value if run.status else None}") + print(f"Created: {run.created_at}") + print(f"Updated: {run.updated_at}") + + # 3) Approve all plans + if args.approve: + if not args.run_id: + print("--run-id is required for --approve") + else: + _print_header(f"Approving all plans for run: {args.run_id}") + client.stack_deployment_runs.approve_all_plans(args.run_id) + print(f"Approved {args.run_id}") + + # 4) Cancel a run + if args.cancel: + if not args.run_id: + print("--run-id is required for --cancel") + else: + _print_header(f"Cancelling run: {args.run_id}") + client.stack_deployment_runs.cancel(args.run_id) + print(f"Cancelled {args.run_id}") + + +if __name__ == "__main__": + main() diff --git a/examples/stack_deployment_step.py b/examples/stack_deployment_step.py new file mode 100644 index 00000000..080fbee8 --- /dev/null +++ b/examples/stack_deployment_step.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list, read, advance, list diagnostics, and download artifacts for stack deployment steps. + +Usage:: + + export TFE_TOKEN= + + # List steps in a deployment run + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 + + # Read a single step + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 --read --step-id sds-xyz789 + + # Advance a step that is in the pending-operator state + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 --advance --step-id sds-xyz789 + + # List diagnostics for a step + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 --diagnostics --step-id sds-xyz789 + + # Download an artifact (writes to stdout or a file) + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 --artifact plan-description --step-id sds-xyz789 + + python3 examples/stack_deployment_step.py \\ + --run-id sdr-abc123 --artifact plan-description --step-id sds-xyz789 \\ + --output-file /tmp/plan.txt +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import ( + StackDeploymentStepArtifactType, + StackDeploymentStepIncludeOpt, + StackDeploymentStepListOptions, + StackDeploymentStepReadOptions, +) + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage stack deployment steps") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument("--run-id", required=True, help="Deployment run ID (sdr-...)") + parser.add_argument("--step-id", help="Deployment step ID (sds-...)") + parser.add_argument("--page-size", type=int, default=20) + parser.add_argument( + "--include-approval", + action="store_true", + help="Include stack-approval relation in list/read responses", + ) + parser.add_argument( + "--include-state", + action="store_true", + help="Include stack-state relation in list/read responses", + ) + parser.add_argument( + "--read", action="store_true", help="Read a specific step (requires --step-id)" + ) + parser.add_argument( + "--advance", + action="store_true", + help="Advance a step in pending-operator state (requires --step-id)", + ) + parser.add_argument( + "--diagnostics", + action="store_true", + help="List diagnostics for a step (requires --step-id)", + ) + parser.add_argument( + "--artifact", + choices=[ + "plan-description", + "apply-description", + "plan-debug-log", + "apply-debug-log", + ], + help="Download an artifact (requires --step-id)", + ) + parser.add_argument( + "--output-file", + help="Write downloaded artifact to this file instead of stdout", + ) + args = parser.parse_args() + + cfg = TFEConfig(address=args.address, token=args.token) + client = TFEClient(cfg) + + # 1) List deployment steps for the run + _print_header(f"Listing deployment steps for run: {args.run_id}") + includes = [] + if args.include_approval: + includes.append(StackDeploymentStepIncludeOpt.STACK_APPROVAL) + if args.include_state: + includes.append(StackDeploymentStepIncludeOpt.STACK_STATE) + opts = StackDeploymentStepListOptions( + page_size=args.page_size, include=includes or None + ) + count = 0 + for step in client.stack_deployment_steps.list(args.run_id, options=opts): + count += 1 + print(f"- {step.id}") + print(f" Status: {step.status.value if step.status else None}") + print(f" Operation type: {step.operation_type}") + print(f" Created: {step.created_at}") + print(f" Updated: {step.updated_at}") + if step.stack_deployment_run: + print(f" Run: {step.stack_deployment_run.id}") + print() + print(f"Total: {count} deployment step(s)") + + # 2) Read a specific step + if args.read: + if not args.step_id: + print("--step-id is required for --read") + else: + _print_header(f"Reading deployment step: {args.step_id}") + includes = [] + if args.include_approval: + includes.append(StackDeploymentStepIncludeOpt.STACK_APPROVAL) + if args.include_state: + includes.append(StackDeploymentStepIncludeOpt.STACK_STATE) + read_opts = StackDeploymentStepReadOptions(include=includes or None) + step = client.stack_deployment_steps.read(args.step_id, options=read_opts) + print(f"ID: {step.id}") + print(f"Status: {step.status.value if step.status else None}") + print(f"Operation type: {step.operation_type}") + print(f"Created: {step.created_at}") + print(f"Updated: {step.updated_at}") + + # 3) Advance a step + if args.advance: + if not args.step_id: + print("--step-id is required for --advance") + else: + _print_header(f"Advancing step: {args.step_id}") + client.stack_deployment_steps.advance(args.step_id) + print(f"Advanced {args.step_id}") + + # 4) List diagnostics + if args.diagnostics: + if not args.step_id: + print("--step-id is required for --diagnostics") + else: + _print_header(f"Diagnostics for step: {args.step_id}") + diag_count = 0 + for diag in client.stack_deployment_steps.list_diagnostics(args.step_id): + diag_count += 1 + print(f"- {diag.id}") + print(f" Severity: {diag.severity}") + print(f" Summary: {diag.summary}") + print(f" Detail: {diag.detail}") + print(f" Acknowledged: {diag.acknowledged}") + print() + print(f"Total: {diag_count} diagnostic(s)") + + # 5) Download an artifact + if args.artifact: + if not args.step_id: + print("--step-id is required for --artifact") + else: + artifact_type = StackDeploymentStepArtifactType(args.artifact) + _print_header( + f"Downloading artifact '{args.artifact}' for step: {args.step_id}" + ) + content = client.stack_deployment_steps.download_artifact( + args.step_id, artifact_type + ) + if args.output_file: + with open(args.output_file, "wb") as fh: + fh.write(content) + print(f"Artifact written to {args.output_file} ({len(content)} bytes)") + else: + print(content.decode(errors="replace")) + + +if __name__ == "__main__": + main() diff --git a/examples/stack_diagnostic.py b/examples/stack_diagnostic.py new file mode 100644 index 00000000..f510ee62 --- /dev/null +++ b/examples/stack_diagnostic.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: read and acknowledge stack diagnostics. + +Usage:: + + export TFE_TOKEN= + + # Read a specific diagnostic + python3 examples/stack_diagnostic.py --diagnostic-id stf-abc123 + + # Acknowledge a diagnostic + python3 examples/stack_diagnostic.py --diagnostic-id stf-abc123 --acknowledge +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage stack diagnostics") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument( + "--diagnostic-id", required=True, help="Stack diagnostic ID (e.g. stf-abc123)" + ) + parser.add_argument( + "--acknowledge", + action="store_true", + help="Acknowledge the diagnostic after reading it", + ) + args = parser.parse_args() + + if not args.token: + raise SystemExit("TFE_TOKEN is not set") + + client = TFEClient(config=TFEConfig(token=args.token, address=args.address)) + + _print_header(f"Stack Diagnostic: {args.diagnostic_id}") + diag = client.stack_diagnostics.read(args.diagnostic_id) + print(f" ID: {diag.id}") + print(f" Severity: {diag.severity}") + print(f" Summary: {diag.summary}") + print(f" Detail: {diag.detail}") + print(f" Acknowledged: {diag.acknowledged}") + print(f" Acknowledged At:{diag.acknowledged_at}") + print(f" Created At: {diag.created_at}") + + if args.acknowledge: + if diag.acknowledged: + print("\nDiagnostic is already acknowledged — nothing to do.") + else: + client.stack_diagnostics.acknowledge(args.diagnostic_id) + print("\nDiagnostic acknowledged successfully.") + + +if __name__ == "__main__": + main() diff --git a/examples/stack_state.py b/examples/stack_state.py new file mode 100644 index 00000000..2ef5b7cb --- /dev/null +++ b/examples/stack_state.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 +"""Example: list, read, and download descriptions for stack states. + +Usage:: + + export TFE_TOKEN= + + # List states for a stack + python3 examples/stack_state.py --stack-id st-abc123 + + # List with pagination + python3 examples/stack_state.py --stack-id st-abc123 --page-size 5 + + # Read a specific state + python3 examples/stack_state.py --stack-id st-abc123 --read --state-id ss-xyz789 + + # Download description for a state (prints to stdout) + python3 examples/stack_state.py --stack-id st-abc123 --description --state-id ss-xyz789 + + # Download description and save to a file + python3 examples/stack_state.py \\ + --stack-id st-abc123 --description --state-id ss-xyz789 \\ + --output-file /tmp/state-description.txt +""" + +from __future__ import annotations + +import argparse +import os + +from pytfe import TFEClient, TFEConfig +from pytfe.models import StackStateListOptions + + +def _print_header(title: str) -> None: + print("\n" + "=" * 80) + print(title) + print("=" * 80) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Manage stack states") + parser.add_argument( + "--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io") + ) + parser.add_argument("--token", default=os.getenv("TFE_TOKEN")) + parser.add_argument("--stack-id", required=True, help="Stack ID (e.g. st-abc123)") + parser.add_argument("--state-id", help="Stack state ID (e.g. ss-abc123)") + parser.add_argument("--page-size", type=int, help="Max items per page") + parser.add_argument( + "--read", + action="store_true", + help="Read a specific state (requires --state-id)", + ) + parser.add_argument( + "--description", + action="store_true", + help="Download the description for a state (requires --state-id)", + ) + parser.add_argument( + "--output-file", help="Write description bytes to this file instead of stdout" + ) + args = parser.parse_args() + + if not args.token: + raise SystemExit("TFE_TOKEN is not set") + + client = TFEClient(config=TFEConfig(token=args.token, address=args.address)) + + if args.read: + if not args.state_id: + raise SystemExit("--state-id is required with --read") + _print_header(f"Stack State: {args.state_id}") + state = client.stack_states.read(args.state_id) + print(f" ID: {state.id}") + print(f" Generation: {state.generation}") + print(f" Status: {state.status}") + print(f" Deployment: {state.deployment}") + print(f" Is Current: {state.is_current}") + print(f" Resource Instance Count: {state.resource_instance_count}") + return + + if args.description: + if not args.state_id: + raise SystemExit("--state-id is required with --description") + _print_header(f"State Description: {args.state_id}") + content = client.stack_states.download_description(args.state_id) + if args.output_file: + with open(args.output_file, "wb") as f: + f.write(content) + print(f" Written {len(content)} bytes to {args.output_file}") + else: + print(content.decode("utf-8", errors="replace")) + return + + # Default: list all states for the stack + _print_header(f"Stack States for {args.stack_id}") + opts = StackStateListOptions(page_size=args.page_size) if args.page_size else None + count = 0 + for state in client.stack_states.list(args.stack_id, options=opts): + count += 1 + current_marker = " (current)" if state.is_current else "" + print( + f" {state.id} gen={state.generation} " + f"status={state.status} deployment={state.deployment}" + f" resources={state.resource_instance_count}{current_marker}" + ) + print(f"\nTotal: {count} state(s)") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 1d7d182e..dbbc89f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytfe" -version = "1.2.0" +version = "1.3.0" description = "Official Python SDK for HashiCorp Terraform Cloud / Terraform Enterprise (TFE) API v2" readme = "README.md" license = { text = "MPL-2.0" } diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 14536448..dff09b5e 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -60,6 +60,14 @@ from .resources.ssh_keys import SSHKeys from .resources.stack import Stacks from .resources.stack_configuration import StackConfigurations +from .resources.stack_configuration_summaries import StackConfigurationSummaries +from .resources.stack_deployment import StackDeployments +from .resources.stack_deployment_group import StackDeploymentGroups +from .resources.stack_deployment_group_summaries import StackDeploymentGroupSummaries +from .resources.stack_deployment_run import StackDeploymentRuns +from .resources.stack_deployment_steps import StackDeploymentSteps +from .resources.stack_diagnostics import StackDiagnostics +from .resources.stack_states import StackStates from .resources.state_version_outputs import StateVersionOutputs from .resources.state_versions import StateVersions from .resources.subscription import Subscriptions @@ -206,6 +214,18 @@ def __init__(self, config: TFEConfig | None = None): # Stack resources self.stacks = Stacks(self._transport) self.stack_configurations = StackConfigurations(self._transport) + self.stack_deployments = StackDeployments(self._transport) + self.stack_deployment_groups = StackDeploymentGroups(self._transport) + self.stack_deployment_runs = StackDeploymentRuns(self._transport) + self.stack_deployment_steps = StackDeploymentSteps(self._transport) + self.stack_states = StackStates(self._transport) + self.stack_configuration_summaries = StackConfigurationSummaries( + self._transport + ) + self.stack_deployment_group_summaries = StackDeploymentGroupSummaries( + self._transport + ) + self.stack_diagnostics = StackDiagnostics(self._transport) # State and execution resources self.state_versions = StateVersions(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index b7546305..b5282003 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -786,6 +786,41 @@ def __init__(self, message: str = "invalid value for stack configuration ID"): super().__init__(message) +class InvalidStackDeploymentRunIDError(InvalidValues): + """Raised when an invalid stack deployment run ID is provided.""" + + def __init__(self, message: str = "invalid value for stack deployment run ID"): + super().__init__(message) + + +class InvalidStackDeploymentGroupIDError(InvalidValues): + """Raised when an invalid stack deployment group ID is provided.""" + + def __init__(self, message: str = "invalid value for stack deployment group ID"): + super().__init__(message) + + +class InvalidStackDeploymentStepIDError(InvalidValues): + """Raised when an invalid stack deployment step ID is provided.""" + + def __init__(self, message: str = "invalid value for stack deployment step ID"): + super().__init__(message) + + +class InvalidStackStateIDError(InvalidValues): + """Raised when an invalid stack state ID is provided.""" + + def __init__(self, message: str = "invalid value for stack state ID"): + super().__init__(message) + + +class InvalidStackDiagnosticIDError(InvalidValues): + """Raised when an invalid stack diagnostic ID is provided.""" + + def __init__(self, message: str = "invalid value for stack diagnostic ID"): + super().__init__(message) + + # Comment errors class InvalidCommentIDError(InvalidValues): """Raised when an invalid comment ID is provided.""" diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index 0fb9efc1..863471ba 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -543,6 +543,46 @@ StackConfigurationReadOptions, StackConfigurationSource, StackConfigurationStatus, + StackConfigurationSummary, + StackConfigurationSummaryGroupStatus, + StackConfigurationSummaryListOptions, + StackConfigurationSummaryRunStatus, +) +from .stack_deployment import ( + StackDeployment, + StackDeploymentIncludeOpt, + StackDeploymentListOptions, +) +from .stack_deployment_group import ( + DeploymentGroupStatus, + StackDeploymentGroup, + StackDeploymentGroupListOptions, + StackDeploymentGroupRerunOptions, + StackDeploymentGroupStatusCounts, + StackDeploymentGroupSummary, + StackDeploymentGroupSummaryListOptions, +) +from .stack_deployment_run import ( + DeploymentRunStatus, + StackDeploymentRun, + StackDeploymentRunIncludeOpt, + StackDeploymentRunListOptions, + StackDeploymentRunReadOptions, +) +from .stack_deployment_step import ( + DeploymentStepStatus, + StackDeploymentStep, + StackDeploymentStepArtifactType, + StackDeploymentStepIncludeOpt, + StackDeploymentStepListOptions, + StackDeploymentStepReadOptions, + StackDiagnostic, + StackDiagnosticListOptions, +) +from .stack_state import ( + StackState, + StackStateComponent, + StackStateListOptions, ) from .state_version import ( StateVersion, @@ -890,6 +930,41 @@ "StackConfigurationReadOptions", "StackConfigurationSource", "StackConfigurationStatus", + "StackConfigurationSummary", + "StackConfigurationSummaryGroupStatus", + "StackConfigurationSummaryListOptions", + "StackConfigurationSummaryRunStatus", + # Stack Deployment + "StackDeployment", + "StackDeploymentIncludeOpt", + "StackDeploymentListOptions", + # Stack Deployment Group + "DeploymentGroupStatus", + "StackDeploymentGroup", + "StackDeploymentGroupListOptions", + "StackDeploymentGroupRerunOptions", + "StackDeploymentGroupStatusCounts", + "StackDeploymentGroupSummary", + "StackDeploymentGroupSummaryListOptions", + # Stack Deployment Run + "DeploymentRunStatus", + "StackDeploymentRun", + "StackDeploymentRunIncludeOpt", + "StackDeploymentRunListOptions", + "StackDeploymentRunReadOptions", + # Stack Deployment Step + "DeploymentStepStatus", + "StackDeploymentStep", + "StackDeploymentStepArtifactType", + "StackDeploymentStepIncludeOpt", + "StackDeploymentStepListOptions", + "StackDeploymentStepReadOptions", + "StackDiagnostic", + "StackDiagnosticListOptions", + # Stack State + "StackState", + "StackStateComponent", + "StackStateListOptions", # Query runs "QueryRun", "QueryRunActions", diff --git a/src/pytfe/models/stack_configuration.py b/src/pytfe/models/stack_configuration.py index faf663c6..43ac03d7 100644 --- a/src/pytfe/models/stack_configuration.py +++ b/src/pytfe/models/stack_configuration.py @@ -103,3 +103,69 @@ class StackConfigurationReadOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) include: list[StackConfigurationIncludeOps] | None = None + + +class StackConfigurationSummaryGroupStatus(BaseModel): + """Rolled-up deployment group run-status counts within a configuration summary.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + pending: int = Field(default=0, alias="pending") + deploying: int = Field(default=0, alias="deploying") + succeeded: int = Field(default=0, alias="succeeded") + failed: int = Field(default=0, alias="failed") + abandoned: int = Field(default=0, alias="abandoned") + + +class StackConfigurationSummaryRunStatus(BaseModel): + """Rolled-up deployment run status counts within a configuration summary.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + pending: int = Field(default=0, alias="pending") + pre_deploying: int = Field(default=0, alias="pre-deploying") + pre_deploying_pending_operator: int = Field( + default=0, alias="pre-deploying-pending-operator" + ) + acquiring_lock: int = Field(default=0, alias="acquiring-lock") + pending_capacity: int = Field(default=0, alias="pending-capacity") + deploying: int = Field(default=0, alias="deploying") + deploying_pending_operator: int = Field( + default=0, alias="deploying-pending-operator" + ) + succeeded: int = Field(default=0, alias="succeeded") + failed: int = Field(default=0, alias="failed") + abandoned: int = Field(default=0, alias="abandoned") + + +class StackConfigurationSummary(TFEModel): + """StackConfigurationSummary represents a lightweight summary of a stack configuration. + + JSON:API type: ``stack-configuration-summaries``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + status: str | None = Field(default=None, alias="status") + sequence_number: int | None = Field(default=None, alias="sequence-number") + group_status_summary: StackConfigurationSummaryGroupStatus | None = Field( + default=None, alias="stack-deployment-group-status-summary" + ) + run_status_summary: StackConfigurationSummaryRunStatus | None = Field( + default=None, alias="stack-deployment-run-status-summary" + ) + + +class StackConfigurationSummaryListOptions(BaseModel): + """StackConfigurationSummaryListOptions represents the options for listing stack configuration summaries.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") diff --git a/src/pytfe/models/stack_deployment.py b/src/pytfe/models/stack_deployment.py new file mode 100644 index 00000000..67da03c7 --- /dev/null +++ b/src/pytfe/models/stack_deployment.py @@ -0,0 +1,46 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import TFEModel +from .stack import Stack + + +class StackDeploymentIncludeOpt(str, Enum): + """StackDeploymentIncludeOpt represents include options for stack deployment endpoints.""" + + LATEST_DEPLOYMENT_RUN = "latest_deployment_run" + LATEST_DEPLOYMENT_RUN_STACK_CONFIGURATION = ( + "latest_deployment_run.stack_configuration" + ) + + +class StackDeployment(TFEModel): + """StackDeployment represents a deployment that belongs to a stack. + + JSON:API type: ``stack-deployments``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + name: str | None = Field(default=None, alias="name") + + # Relations + stack: Stack | None = Field(default=None, alias="stack") + + +class StackDeploymentListOptions(BaseModel): + """StackDeploymentListOptions represents the options for listing stack deployments.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + include: list[StackDeploymentIncludeOpt] | None = None diff --git a/src/pytfe/models/stack_deployment_group.py b/src/pytfe/models/stack_deployment_group.py new file mode 100644 index 00000000..b26c3cac --- /dev/null +++ b/src/pytfe/models/stack_deployment_group.py @@ -0,0 +1,112 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import TFEModel +from .stack_configuration import StackConfiguration + + +class DeploymentGroupStatus(str, Enum): + """DeploymentGroupStatus represents the status of a stack deployment group.""" + + PENDING = "pending" + DEPLOYING = "deploying" + SUCCEEDED = "succeeded" + FAILED = "failed" + ABANDONED = "abandoned" + + +class StackDeploymentGroup(TFEModel): + """StackDeploymentGroup represents a group of deployment runs for a single deployment. + + JSON:API type: ``stack-deployment-groups``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + name: str | None = Field(default=None, alias="name") + status: DeploymentGroupStatus | None = Field(default=None, alias="status") + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + + # Relations + stack_configuration: StackConfiguration | None = Field( + default=None, alias="stack-configuration" + ) + + +class StackDeploymentGroupListOptions(BaseModel): + """StackDeploymentGroupListOptions represents the options for listing stack deployment groups.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + + +class StackDeploymentGroupRerunOptions(BaseModel): + """StackDeploymentGroupRerunOptions represents options for rerunning a failed deployment group. + + At least one deployment name must be specified. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + deployments: list[str] = Field(default_factory=list) + + +class StackDeploymentGroupStatusCounts(BaseModel): + """StackDeploymentGroupStatusCounts represents the run status counts within a deployment group summary.""" + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + pending: int = Field(default=0, alias="pending") + pre_deploying: int = Field(default=0, alias="pre-deploying") + # go-tfe uses "pending-operator" as the wire alias for this field + pre_deploying_pending_operator: int = Field(default=0, alias="pending-operator") + acquiring_lock: int = Field(default=0, alias="acquiring-lock") + deploying: int = Field(default=0, alias="deploying") + succeeded: int = Field(default=0, alias="succeeded") + failed: int = Field(default=0, alias="failed") + abandoned: int = Field(default=0, alias="abandoned") + + +class StackDeploymentGroupSummary(TFEModel): + """StackDeploymentGroupSummary represents a lightweight, rolled-up view of a deployment group. + + JSON:API type: ``stack-deployment-group-summaries``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + name: str | None = Field(default=None, alias="name") + status: str | None = Field(default=None, alias="status") + status_counts: StackDeploymentGroupStatusCounts | None = Field( + default=None, alias="status-counts" + ) + + # Relations + stack_deployment_group: StackDeploymentGroup | None = Field( + default=None, alias="stack-deployment-group" + ) + + +class StackDeploymentGroupSummaryListOptions(BaseModel): + """StackDeploymentGroupSummaryListOptions represents the options for listing stack deployment group summaries.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") diff --git a/src/pytfe/models/stack_deployment_run.py b/src/pytfe/models/stack_deployment_run.py new file mode 100644 index 00000000..bcfbbbc2 --- /dev/null +++ b/src/pytfe/models/stack_deployment_run.py @@ -0,0 +1,79 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import TFEModel +from .stack_deployment_group import StackDeploymentGroup + + +class DeploymentRunStatus(str, Enum): + """DeploymentRunStatus represents the lifecycle status of a stack deployment run.""" + + PENDING = "pending" + PRE_DEPLOYING = "pre-deploying" + PRE_DEPLOYING_PENDING_OPERATOR = "pre-deploying-pending-operator" + ACQUIRING_LOCK = "acquiring-lock" + DEPLOYING = "deploying" + DEPLOYING_PENDING_OPERATOR = "deploying-pending-operator" + SUCCEEDED = "succeeded" + FAILED = "failed" + ABANDONED = "abandoned" + + +class StackDeploymentRunIncludeOpt(str, Enum): + """StackDeploymentRunIncludeOpt represents include options for stack deployment run endpoints. + + ``LATEST_DEPLOYMENT_RUN_FOR_DEPLOYMENT`` is only valid on read (show) endpoints; + the remaining values are valid on both list and read. + """ + + STACK_DEPLOYMENT_GROUP = "stack_deployment_group" + STACK_APPROVAL = "stack_approval" + DESTROY_STACK_CONFIGURATION = "destroy_stack_configuration" + BLOCKED_BY_DEPLOYMENT_GROUP = "blocked_by_deployment_group" + LATEST_DEPLOYMENT_RUN_FOR_DEPLOYMENT = "latest_deployment_run_for_deployment" + + +class StackDeploymentRun(TFEModel): + """StackDeploymentRun represents a single deployment run within a deployment group. + + JSON:API type: ``stack-deployment-runs``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + deployment: str | None = Field(default=None, alias="deployment") + status: DeploymentRunStatus | None = Field(default=None, alias="status") + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + + # Relations + stack_deployment_group: StackDeploymentGroup | None = Field( + default=None, alias="stack-deployment-group" + ) + + +class StackDeploymentRunListOptions(BaseModel): + """StackDeploymentRunListOptions represents the options for listing stack deployment runs.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + include: list[StackDeploymentRunIncludeOpt] | None = None + + +class StackDeploymentRunReadOptions(BaseModel): + """StackDeploymentRunReadOptions represents the options for reading a stack deployment run.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[StackDeploymentRunIncludeOpt] | None = None diff --git a/src/pytfe/models/stack_deployment_step.py b/src/pytfe/models/stack_deployment_step.py new file mode 100644 index 00000000..142905f6 --- /dev/null +++ b/src/pytfe/models/stack_deployment_step.py @@ -0,0 +1,109 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import TFEModel +from .stack_deployment_run import StackDeploymentRun + + +class DeploymentStepStatus(str, Enum): + """DeploymentStepStatus represents the lifecycle status of a stack deployment step.""" + + BLOCKED = "blocked" + ABANDONED = "abandoned" + QUEUED = "queued" + RUNNING = "running" + PENDING_OPERATOR = "pending-operator" + COMPLETED = "completed" + FAILED = "failed" + + +class StackDeploymentStepArtifactType(str, Enum): + """StackDeploymentStepArtifactType represents the types of downloadable artifacts for a step.""" + + PLAN_DESCRIPTION = "plan-description" + APPLY_DESCRIPTION = "apply-description" + PLAN_DEBUG_LOG = "plan-debug-log" + APPLY_DEBUG_LOG = "apply-debug-log" + + +class StackDeploymentStepIncludeOpt(str, Enum): + """StackDeploymentStepIncludeOpt represents include options for stack deployment step endpoints.""" + + STACK_APPROVAL = "stack_approval" + STACK_APPROVAL_USER = "stack_approval.user" + STACK_STATE = "stack_state" + + +class StackDeploymentStep(TFEModel): + """StackDeploymentStep represents a single step within a stack deployment run. + + JSON:API type: ``stack-deployment-steps``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + status: DeploymentStepStatus | None = Field(default=None, alias="status") + operation_type: str | None = Field(default=None, alias="operation-type") + created_at: datetime | None = Field(default=None, alias="created-at") + updated_at: datetime | None = Field(default=None, alias="updated-at") + + # Relations + stack_deployment_run: StackDeploymentRun | None = Field( + default=None, alias="stack-deployment-run" + ) + + +class StackDeploymentStepListOptions(BaseModel): + """StackDeploymentStepListOptions represents the options for listing stack deployment steps.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") + include: list[StackDeploymentStepIncludeOpt] | None = None + + +class StackDeploymentStepReadOptions(BaseModel): + """StackDeploymentStepReadOptions represents the options for reading a stack deployment step.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[StackDeploymentStepIncludeOpt] | None = None + + +class StackDiagnostic(TFEModel): + """StackDiagnostic represents a diagnostic emitted during a stack deployment step. + + JSON:API type: ``stack-diagnostics``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + severity: str | None = Field(default=None, alias="severity") + summary: str | None = Field(default=None, alias="summary") + detail: str | None = Field(default=None, alias="detail") + diags: Any | None = Field(default=None, alias="diags") + acknowledged: bool | None = Field(default=None, alias="acknowledged") + acknowledged_at: datetime | None = Field(default=None, alias="acknowledged-at") + created_at: datetime | None = Field(default=None, alias="created-at") + + +class StackDiagnosticListOptions(BaseModel): + """StackDiagnosticListOptions represents the options for listing stack diagnostics.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") diff --git a/src/pytfe/models/stack_state.py b/src/pytfe/models/stack_state.py new file mode 100644 index 00000000..eca5e5ef --- /dev/null +++ b/src/pytfe/models/stack_state.py @@ -0,0 +1,67 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from ._base import TFEModel +from .stack import Stack +from .stack_deployment_run import StackDeploymentRun + + +class StackStateComponent(BaseModel): + """StackStateComponent represents a component entry within a stack state. + + This is distinct from :class:`StackComponent` (used in stack-configurations). + The state variant carries per-instance tracking fields rather than config fields. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + address: str | None = Field(default=None, alias="address") + component_address: str | None = Field(default=None, alias="component-address") + instance_correlator: str | None = Field(default=None, alias="instance-correlator") + component_correlator: str | None = Field(default=None, alias="component-correlator") + resource_instance_count: int | None = Field( + default=None, alias="resource-instance-count" + ) + + +class StackState(TFEModel): + """StackState represents a captured state for a stack deployment. + + JSON:API type: ``stack-states``. + """ + + model_config = ConfigDict( + populate_by_name=True, validate_by_name=True, extra="allow" + ) + + id: str + generation: int | None = Field(default=None, alias="generation") + status: str | None = Field(default=None, alias="status") + deployment: str | None = Field(default=None, alias="deployment") + components: list[StackStateComponent] = Field( + default_factory=list, alias="components" + ) + is_current: bool | None = Field(default=None, alias="is-current") + resource_instance_count: int | None = Field( + default=None, alias="resource-instance-count" + ) + + # Relations + stack: Stack | None = Field(default=None, alias="stack") + stack_deployment_run: StackDeploymentRun | None = Field( + default=None, alias="stack-deployment-run" + ) + + +class StackStateListOptions(BaseModel): + """StackStateListOptions represents the options for listing stack states.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + page_size: int | None = Field(default=None, alias="page[size]") diff --git a/src/pytfe/resources/stack_configuration_summaries.py b/src/pytfe/resources/stack_configuration_summaries.py new file mode 100644 index 00000000..3ffbd3af --- /dev/null +++ b/src/pytfe/resources/stack_configuration_summaries.py @@ -0,0 +1,61 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidStackIDError +from ..models.stack_configuration import ( + StackConfigurationSummary, + StackConfigurationSummaryListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackConfigurationSummaries(_Service): + """Service for listing stack configuration summaries.""" + + def list( + self, + stack_id: str, + options: StackConfigurationSummaryListOptions | None = None, + ) -> Iterator[StackConfigurationSummary]: + """List the configuration summaries for a stack. + + Args: + stack_id: The stack ID (e.g. ``"st-abc123"``). + options: Optional pagination, as a + :class:`StackConfigurationSummaryListOptions`. + + Returns: + A single-use ``Iterator[StackConfigurationSummary]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidStackIDError: If ``stack_id`` is empty or malformed. + TFEError: If the API request fails. + + Example: + >>> for summary in client.stack_configuration_summaries.list("st-abc123"): + ... print(summary.id, summary.sequence_number, summary.status) + """ + if not valid_string_id(stack_id): + raise InvalidStackIDError() + path = f"/api/v2/stacks/{stack_id}/stack-configuration-summaries" + params: dict[str, Any] = {} + if options and options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list(path=path, params=params): + yield self._summary_from(item) + + def _summary_from(self, data: dict[str, Any]) -> StackConfigurationSummary: + """Parse a StackConfigurationSummary from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + return attach_jsonapi( + StackConfigurationSummary.model_validate(attrs), data, None + ) diff --git a/src/pytfe/resources/stack_deployment.py b/src/pytfe/resources/stack_deployment.py new file mode 100644 index 00000000..aa0c60ed --- /dev/null +++ b/src/pytfe/resources/stack_deployment.py @@ -0,0 +1,75 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import InvalidStackIDError +from ..models.stack import Stack +from ..models.stack_deployment import ( + StackDeployment, + StackDeploymentListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackDeployments(_Service): + """Service for reading the deployments that belong to a stack.""" + + def list( + self, + stack_id: str, + options: StackDeploymentListOptions | None = None, + ) -> Iterator[StackDeployment]: + """List the deployments that belong to a stack. + + Args: + stack_id: The stack ID (e.g. ``"st-xxxxxxxx"``). + options: Optional pagination and includes, as a + :class:`StackDeploymentListOptions`. + + Returns: + A single-use ``Iterator[StackDeployment]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidStackIDError: If ``stack_id`` is empty or malformed. + TFEError: If the API request fails. + + Example: + >>> for deployment in client.stack_deployments.list("st-xyz789"): + ... print(deployment.id, deployment.name) + """ + if not valid_string_id(stack_id): + raise InvalidStackIDError() + path = f"/api/v2/stacks/{stack_id}/stack-deployments" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + for item in self._list(path=path, params=params): + yield self._stack_deployment_from(item) + + def _stack_deployment_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackDeployment: + """Parse a StackDeployment from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack": Stack}, + included=included, + ) + ) + return attach_jsonapi(StackDeployment.model_validate(attrs), data, included) diff --git a/src/pytfe/resources/stack_deployment_group.py b/src/pytfe/resources/stack_deployment_group.py new file mode 100644 index 00000000..9601b9b8 --- /dev/null +++ b/src/pytfe/resources/stack_deployment_group.py @@ -0,0 +1,206 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import ( + InvalidStackConfigurationIDError, + InvalidStackDeploymentGroupIDError, +) +from ..models.stack_configuration import StackConfiguration +from ..models.stack_deployment_group import ( + StackDeploymentGroup, + StackDeploymentGroupListOptions, + StackDeploymentGroupRerunOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackDeploymentGroups(_Service): + """Service for reading and managing deployment groups within a stack configuration.""" + + def list( + self, + stack_configuration_id: str, + options: StackDeploymentGroupListOptions | None = None, + ) -> Iterator[StackDeploymentGroup]: + """List the deployment groups for a stack configuration. + + Args: + stack_configuration_id: The stack configuration ID (e.g. ``"stc-abc123"``). + options: Optional pagination, as a :class:`StackDeploymentGroupListOptions`. + + Returns: + A single-use ``Iterator[StackDeploymentGroup]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidStackConfigurationIDError: If ``stack_configuration_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> for group in client.stack_deployment_groups.list("stc-abc123"): + ... print(group.id, group.name, group.status) + """ + if not valid_string_id(stack_configuration_id): + raise InvalidStackConfigurationIDError() + path = f"/api/v2/stack-configurations/{stack_configuration_id}/stack-deployment-groups" + params: dict[str, Any] = {} + if options and options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list(path=path, params=params): + yield self._stack_deployment_group_from(item) + + def read( + self, + stack_deployment_group_id: str, + ) -> StackDeploymentGroup: + """Read a stack deployment group by its ID. + + Args: + stack_deployment_group_id: The deployment group ID (e.g. ``"sdg-xyz789"``). + + Returns: + The :class:`StackDeploymentGroup`. + + Raises: + InvalidStackDeploymentGroupIDError: If ``stack_deployment_group_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> group = client.stack_deployment_groups.read("sdg-xyz789") + >>> print(group.status) + """ + if not valid_string_id(stack_deployment_group_id): + raise InvalidStackDeploymentGroupIDError() + path = f"/api/v2/stack-deployment-groups/{stack_deployment_group_id}" + r = self.t.request("GET", path=path) + payload = r.json() + data = payload.get("data", {}) + return self._stack_deployment_group_from(data, payload.get("included")) + + def read_by_name( + self, + stack_configuration_id: str, + name: str, + ) -> StackDeploymentGroup: + """Read a stack deployment group by its name within a stack configuration. + + Args: + stack_configuration_id: The stack configuration ID (e.g. ``"stc-abc123"``). + name: The deployment name (e.g. ``"dev"``). + + Returns: + The :class:`StackDeploymentGroup`. + + Raises: + InvalidStackConfigurationIDError: If ``stack_configuration_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> group = client.stack_deployment_groups.read_by_name("stc-abc123", "dev") + >>> print(group.id, group.status) + """ + if not valid_string_id(stack_configuration_id): + raise InvalidStackConfigurationIDError() + path = f"/api/v2/stack-configurations/{stack_configuration_id}/stack-deployment-groups/{name}" + r = self.t.request("GET", path=path) + payload = r.json() + data = payload.get("data", {}) + return self._stack_deployment_group_from(data, payload.get("included")) + + def approve_all_plans( + self, + stack_deployment_group_id: str, + ) -> None: + """Approve all pending plans in a stack deployment group. + + Args: + stack_deployment_group_id: The deployment group ID (e.g. ``"sdg-xyz789"``). + + Returns: + ``None`` on success (HTTP 200, no body). + + Raises: + InvalidStackDeploymentGroupIDError: If ``stack_deployment_group_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> client.stack_deployment_groups.approve_all_plans("sdg-xyz789") + """ + if not valid_string_id(stack_deployment_group_id): + raise InvalidStackDeploymentGroupIDError() + path = f"/api/v2/stack-deployment-groups/{stack_deployment_group_id}/approve-all-plans" + self.t.request("POST", path=path) + + def rerun( + self, + stack_deployment_group_id: str, + options: StackDeploymentGroupRerunOptions, + ) -> None: + """Rerun a failed deployment group by re-executing specific deployments within it. + + This endpoint is intended for deployment groups that have ended up in a + ``failed`` state. Pass the **deployment names** (the ``deployment`` attribute + on each run, e.g. ``"dev"``, ``"prod"``), not run IDs. + + Args: + stack_deployment_group_id: The deployment group ID (e.g. ``"sdg-xyz789"``). + options: Required rerun options containing the deployment names to re-execute, + as a :class:`StackDeploymentGroupRerunOptions`. Must include at least one + deployment name. + + Returns: + ``None`` on success (HTTP 204, no body). + + Raises: + InvalidStackDeploymentGroupIDError: If ``stack_deployment_group_id`` is empty + or malformed. + ValueError: If ``options.deployments`` is empty. + TFEError: If the API request fails. + + Example: + >>> from pytfe.models import StackDeploymentGroupRerunOptions + >>> client.stack_deployment_groups.rerun( + ... "sdg-xyz789", + ... StackDeploymentGroupRerunOptions(deployments=["dev", "prod"]), + ... ) + """ + if not valid_string_id(stack_deployment_group_id): + raise InvalidStackDeploymentGroupIDError() + if not options.deployments: + raise ValueError( + "options.deployments must contain at least one deployment name" + ) + path = f"/api/v2/stack-deployment-groups/{stack_deployment_group_id}/rerun" + params: dict[str, str] = {"deployments": ",".join(options.deployments)} + self.t.request("POST", path=path, params=params) + + def _stack_deployment_group_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackDeploymentGroup: + """Parse a StackDeploymentGroup from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack-configuration": StackConfiguration}, + included=included, + ) + ) + return attach_jsonapi( + StackDeploymentGroup.model_validate(attrs), data, included + ) diff --git a/src/pytfe/resources/stack_deployment_group_summaries.py b/src/pytfe/resources/stack_deployment_group_summaries.py new file mode 100644 index 00000000..68d49d1a --- /dev/null +++ b/src/pytfe/resources/stack_deployment_group_summaries.py @@ -0,0 +1,69 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import InvalidStackConfigurationIDError +from ..models.stack_deployment_group import ( + StackDeploymentGroup, + StackDeploymentGroupSummary, + StackDeploymentGroupSummaryListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackDeploymentGroupSummaries(_Service): + """Service for listing stack deployment group summaries.""" + + def list( + self, + stack_configuration_id: str, + options: StackDeploymentGroupSummaryListOptions | None = None, + ) -> Iterator[StackDeploymentGroupSummary]: + """List the deployment group summaries for a stack configuration. + + Args: + stack_configuration_id: The stack configuration ID (e.g. ``"stc-abc123"``). + options: Optional pagination, as a + :class:`StackDeploymentGroupSummaryListOptions`. + + Returns: + A single-use ``Iterator[StackDeploymentGroupSummary]``. Wrap with + ``list(...)`` to materialize the results or iterate more than once. + + Raises: + InvalidStackConfigurationIDError: If ``stack_configuration_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> for summary in client.stack_deployment_group_summaries.list("stc-abc123"): + ... print(summary.name, summary.status, summary.status_counts) + """ + if not valid_string_id(stack_configuration_id): + raise InvalidStackConfigurationIDError() + path = f"/api/v2/stack-configurations/{stack_configuration_id}/stack-deployment-group-summaries" + params: dict[str, Any] = {} + if options and options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list(path=path, params=params): + yield self._summary_from(item) + + def _summary_from(self, data: dict[str, Any]) -> StackDeploymentGroupSummary: + """Parse a StackDeploymentGroupSummary from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack-deployment-group": StackDeploymentGroup}, + ) + ) + return attach_jsonapi( + StackDeploymentGroupSummary.model_validate(attrs), data, None + ) diff --git a/src/pytfe/resources/stack_deployment_run.py b/src/pytfe/resources/stack_deployment_run.py new file mode 100644 index 00000000..02a2bb2f --- /dev/null +++ b/src/pytfe/resources/stack_deployment_run.py @@ -0,0 +1,169 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import ( + InvalidStackDeploymentGroupIDError, + InvalidStackDeploymentRunIDError, +) +from ..models.stack_deployment_group import StackDeploymentGroup +from ..models.stack_deployment_run import ( + StackDeploymentRun, + StackDeploymentRunListOptions, + StackDeploymentRunReadOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackDeploymentRuns(_Service): + """Service for reading and acting on deployment runs within a deployment group.""" + + def list( + self, + stack_deployment_group_id: str, + options: StackDeploymentRunListOptions | None = None, + ) -> Iterator[StackDeploymentRun]: + """List the deployment runs for a deployment group. + + Args: + stack_deployment_group_id: The deployment group ID (e.g. ``"sdg-xyz789"``). + options: Optional pagination and includes, as a + :class:`StackDeploymentRunListOptions`. + + Returns: + A single-use ``Iterator[StackDeploymentRun]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidStackDeploymentGroupIDError: If ``stack_deployment_group_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> for run in client.stack_deployment_runs.list("sdg-xyz789"): + ... print(run.id, run.status) + """ + if not valid_string_id(stack_deployment_group_id): + raise InvalidStackDeploymentGroupIDError() + path = f"/api/v2/stack-deployment-groups/{stack_deployment_group_id}/stack-deployment-runs" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + for item in self._list(path=path, params=params): + yield self._stack_deployment_run_from(item) + + def read( + self, + stack_deployment_run_id: str, + options: StackDeploymentRunReadOptions | None = None, + ) -> StackDeploymentRun: + """Read a stack deployment run by its ID. + + Args: + stack_deployment_run_id: The deployment run ID (e.g. ``"sdr-abc123"``). + options: Optional includes, as a :class:`StackDeploymentRunReadOptions`. + + Returns: + The :class:`StackDeploymentRun`. + + Raises: + InvalidStackDeploymentRunIDError: If ``stack_deployment_run_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> run = client.stack_deployment_runs.read("sdr-abc123") + >>> print(run.status) + """ + if not valid_string_id(stack_deployment_run_id): + raise InvalidStackDeploymentRunIDError() + path = f"/api/v2/stack-deployment-runs/{stack_deployment_run_id}" + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request("GET", path=path, params=params) + payload = r.json() + data = payload.get("data", {}) + return self._stack_deployment_run_from(data, payload.get("included")) + + def approve_all_plans( + self, + stack_deployment_run_id: str, + ) -> None: + """Approve all pending plans in a stack deployment run. + + This unblocks a run that is in the + ``pre-deploying-pending-operator`` or ``deploying-pending-operator`` state. + + Args: + stack_deployment_run_id: The deployment run ID (e.g. ``"sdr-abc123"``). + + Returns: + ``None`` on success (HTTP 200, no body). + + Raises: + InvalidStackDeploymentRunIDError: If ``stack_deployment_run_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> client.stack_deployment_runs.approve_all_plans("sdr-abc123") + """ + if not valid_string_id(stack_deployment_run_id): + raise InvalidStackDeploymentRunIDError() + path = ( + f"/api/v2/stack-deployment-runs/{stack_deployment_run_id}/approve-all-plans" + ) + self.t.request("POST", path=path) + + def cancel( + self, + stack_deployment_run_id: str, + ) -> None: + """Cancel a stack deployment run. + + Args: + stack_deployment_run_id: The deployment run ID (e.g. ``"sdr-abc123"``). + + Returns: + ``None`` on success (HTTP 200, no body). + + Raises: + InvalidStackDeploymentRunIDError: If ``stack_deployment_run_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> client.stack_deployment_runs.cancel("sdr-abc123") + """ + if not valid_string_id(stack_deployment_run_id): + raise InvalidStackDeploymentRunIDError() + path = f"/api/v2/stack-deployment-runs/{stack_deployment_run_id}/cancel" + self.t.request("POST", path=path) + + def _stack_deployment_run_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackDeploymentRun: + """Parse a StackDeploymentRun from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack-deployment-group": StackDeploymentGroup}, + included=included, + ) + ) + return attach_jsonapi(StackDeploymentRun.model_validate(attrs), data, included) diff --git a/src/pytfe/resources/stack_deployment_steps.py b/src/pytfe/resources/stack_deployment_steps.py new file mode 100644 index 00000000..71117aa1 --- /dev/null +++ b/src/pytfe/resources/stack_deployment_steps.py @@ -0,0 +1,218 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import ( + InvalidStackDeploymentRunIDError, + InvalidStackDeploymentStepIDError, +) +from ..models.stack_deployment_run import StackDeploymentRun +from ..models.stack_deployment_step import ( + StackDeploymentStep, + StackDeploymentStepArtifactType, + StackDeploymentStepListOptions, + StackDeploymentStepReadOptions, + StackDiagnostic, + StackDiagnosticListOptions, +) +from ..utils import valid_string_id +from ._base import _Service + + +class StackDeploymentSteps(_Service): + """Service for listing and acting on deployment steps within a deployment run.""" + + def list( + self, + stack_deployment_run_id: str, + options: StackDeploymentStepListOptions | None = None, + ) -> Iterator[StackDeploymentStep]: + """List the deployment steps for a stack deployment run. + + Args: + stack_deployment_run_id: The deployment run ID (e.g. ``"sdr-abc123"``). + options: Optional pagination and includes, as a + :class:`StackDeploymentStepListOptions`. + + Returns: + A single-use ``Iterator[StackDeploymentStep]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidStackDeploymentRunIDError: If ``stack_deployment_run_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> for step in client.stack_deployment_steps.list("sdr-abc123"): + ... print(step.id, step.status) + """ + if not valid_string_id(stack_deployment_run_id): + raise InvalidStackDeploymentRunIDError() + path = f"/api/v2/stack-deployment-runs/{stack_deployment_run_id}/stack-deployment-steps" + params: dict[str, Any] = {} + if options: + if options.page_size is not None: + params["page[size]"] = options.page_size + if options.include: + params["include"] = ",".join([i.value for i in options.include]) + for item in self._list(path=path, params=params): + yield self._stack_deployment_step_from(item) + + def read( + self, + stack_deployment_step_id: str, + options: StackDeploymentStepReadOptions | None = None, + ) -> StackDeploymentStep: + """Read a stack deployment step by its ID. + + Args: + stack_deployment_step_id: The deployment step ID (e.g. ``"sds-abc123"``). + options: Optional includes, as a :class:`StackDeploymentStepReadOptions`. + + Returns: + The :class:`StackDeploymentStep`. + + Raises: + InvalidStackDeploymentStepIDError: If ``stack_deployment_step_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> step = client.stack_deployment_steps.read("sds-abc123") + >>> print(step.status) + """ + if not valid_string_id(stack_deployment_step_id): + raise InvalidStackDeploymentStepIDError() + path = f"/api/v2/stack-deployment-steps/{stack_deployment_step_id}" + params: dict[str, str] = {} + if options and options.include: + params["include"] = ",".join([i.value for i in options.include]) + r = self.t.request("GET", path=path, params=params) + payload = r.json() + data = payload.get("data", {}) + return self._stack_deployment_step_from(data, payload.get("included")) + + def advance( + self, + stack_deployment_step_id: str, + ) -> None: + """Advance a stack deployment step that is in the ``pending-operator`` state. + + Args: + stack_deployment_step_id: The deployment step ID (e.g. ``"sds-abc123"``). + + Returns: + ``None`` on success (HTTP 200, no body). + + Raises: + InvalidStackDeploymentStepIDError: If ``stack_deployment_step_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> client.stack_deployment_steps.advance("sds-abc123") + """ + if not valid_string_id(stack_deployment_step_id): + raise InvalidStackDeploymentStepIDError() + path = f"/api/v2/stack-deployment-steps/{stack_deployment_step_id}/advance" + self.t.request("POST", path=path) + + def list_diagnostics( + self, + stack_deployment_step_id: str, + options: StackDiagnosticListOptions | None = None, + ) -> Iterator[StackDiagnostic]: + """List the diagnostics emitted for a stack deployment step. + + Args: + stack_deployment_step_id: The deployment step ID (e.g. ``"sds-abc123"``). + options: Optional pagination, as a :class:`StackDiagnosticListOptions`. + + Returns: + A single-use ``Iterator[StackDiagnostic]``. + + Raises: + InvalidStackDeploymentStepIDError: If ``stack_deployment_step_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> for diag in client.stack_deployment_steps.list_diagnostics("sds-abc123"): + ... print(diag.severity, diag.summary) + """ + if not valid_string_id(stack_deployment_step_id): + raise InvalidStackDeploymentStepIDError() + path = f"/api/v2/stack-deployment-steps/{stack_deployment_step_id}/stack-diagnostics" + params: dict[str, Any] = {} + if options and options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list(path=path, params=params): + yield self._stack_diagnostic_from(item) + + def download_artifact( + self, + stack_deployment_step_id: str, + artifact_type: StackDeploymentStepArtifactType, + ) -> bytes: + """Download an artifact for a stack deployment step. + + Follows the redirect to the archivist URL and returns the raw artifact bytes. + + Args: + stack_deployment_step_id: The deployment step ID (e.g. ``"sds-abc123"``). + artifact_type: The artifact to download, as a + :class:`StackDeploymentStepArtifactType`. + + Returns: + The raw artifact content as ``bytes``. + + Raises: + InvalidStackDeploymentStepIDError: If ``stack_deployment_step_id`` is empty + or malformed. + TFEError: If the API request fails. + + Example: + >>> content = client.stack_deployment_steps.download_artifact( + ... "sds-abc123", + ... StackDeploymentStepArtifactType.PLAN_DESCRIPTION, + ... ) + >>> print(content.decode()) + """ + if not valid_string_id(stack_deployment_step_id): + raise InvalidStackDeploymentStepIDError() + path = f"/api/v2/stack-deployment-steps/{stack_deployment_step_id}/artifacts" + resp = self.t.request("GET", path=path, params={"name": artifact_type.value}) + return resp.content + + def _stack_deployment_step_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackDeploymentStep: + """Parse a StackDeploymentStep from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + {"stack-deployment-run": StackDeploymentRun}, + included=included, + ) + ) + return attach_jsonapi(StackDeploymentStep.model_validate(attrs), data, included) + + def _stack_diagnostic_from( + self, + data: dict[str, Any], + ) -> StackDiagnostic: + """Parse a StackDiagnostic from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + return attach_jsonapi(StackDiagnostic.model_validate(attrs), data, None) diff --git a/src/pytfe/resources/stack_diagnostics.py b/src/pytfe/resources/stack_diagnostics.py new file mode 100644 index 00000000..1a4e26d0 --- /dev/null +++ b/src/pytfe/resources/stack_diagnostics.py @@ -0,0 +1,70 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from typing import Any + +from .._jsonapi import attach_jsonapi +from ..errors import InvalidStackDiagnosticIDError +from ..models.stack_deployment_step import StackDiagnostic +from ..utils import valid_string_id +from ._base import _Service + + +class StackDiagnostics(_Service): + """Service for reading and acknowledging stack diagnostics.""" + + def read(self, stack_diagnostic_id: str) -> StackDiagnostic: + """Read a stack diagnostic by its ID. + + Args: + stack_diagnostic_id: The stack diagnostic ID (e.g. ``"stf-abc123"``). + + Returns: + The :class:`StackDiagnostic`. + + Raises: + InvalidStackDiagnosticIDError: If ``stack_diagnostic_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> diag = client.stack_diagnostics.read("stf-abc123") + >>> print(diag.severity, diag.summary, diag.acknowledged) + """ + if not valid_string_id(stack_diagnostic_id): + raise InvalidStackDiagnosticIDError() + path = f"/api/v2/stack-diagnostics/{stack_diagnostic_id}" + r = self.t.request("GET", path=path) + payload = r.json() + data = payload.get("data", {}) + return self._diagnostic_from(data) + + def acknowledge(self, stack_diagnostic_id: str) -> None: + """Acknowledge a stack diagnostic, marking it as reviewed. + + Args: + stack_diagnostic_id: The stack diagnostic ID (e.g. ``"stf-abc123"``). + + Returns: + ``None`` on success. + + Raises: + InvalidStackDiagnosticIDError: If ``stack_diagnostic_id`` is empty or + malformed. + TFEError: If the API request fails. + + Example: + >>> client.stack_diagnostics.acknowledge("stf-abc123") + """ + if not valid_string_id(stack_diagnostic_id): + raise InvalidStackDiagnosticIDError() + path = f"/api/v2/stack-diagnostics/{stack_diagnostic_id}/acknowledge" + self.t.request("POST", path=path) + + def _diagnostic_from(self, data: dict[str, Any]) -> StackDiagnostic: + """Parse a StackDiagnostic from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + return attach_jsonapi(StackDiagnostic.model_validate(attrs), data, None) diff --git a/src/pytfe/resources/stack_states.py b/src/pytfe/resources/stack_states.py new file mode 100644 index 00000000..bab85f95 --- /dev/null +++ b/src/pytfe/resources/stack_states.py @@ -0,0 +1,122 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import builtins +from collections.abc import Iterator +from typing import Any + +from .._jsonapi import attach_jsonapi, parse_relationships +from ..errors import InvalidStackIDError, InvalidStackStateIDError +from ..models.stack import Stack +from ..models.stack_deployment_run import StackDeploymentRun +from ..models.stack_state import StackState, StackStateListOptions +from ..utils import valid_string_id +from ._base import _Service + + +class StackStates(_Service): + """Service for listing, reading, and downloading stack states.""" + + def list( + self, + stack_id: str, + options: StackStateListOptions | None = None, + ) -> Iterator[StackState]: + """List the states for a stack. + + Args: + stack_id: The stack ID (e.g. ``"st-abc123"``). + options: Optional pagination, as a :class:`StackStateListOptions`. + + Returns: + A single-use ``Iterator[StackState]``. Wrap with ``list(...)`` to + materialize the results or iterate more than once. + + Raises: + InvalidStackIDError: If ``stack_id`` is empty or malformed. + TFEError: If the API request fails. + + Example: + >>> for state in client.stack_states.list("st-abc123"): + ... print(state.id, state.deployment, state.is_current) + """ + if not valid_string_id(stack_id): + raise InvalidStackIDError() + path = f"/api/v2/stacks/{stack_id}/stack-states" + params: dict[str, Any] = {} + if options and options.page_size is not None: + params["page[size]"] = options.page_size + for item in self._list(path=path, params=params): + yield self._stack_state_from(item) + + def read(self, stack_state_id: str) -> StackState: + """Read a stack state by its ID. + + Args: + stack_state_id: The stack state ID (e.g. ``"ss-abc123"``). + + Returns: + The :class:`StackState`. + + Raises: + InvalidStackStateIDError: If ``stack_state_id`` is empty or malformed. + TFEError: If the API request fails. + + Example: + >>> state = client.stack_states.read("ss-abc123") + >>> print(state.is_current, state.generation) + """ + if not valid_string_id(stack_state_id): + raise InvalidStackStateIDError() + path = f"/api/v2/stack-states/{stack_state_id}" + r = self.t.request("GET", path=path) + payload = r.json() + data = payload.get("data", {}) + return self._stack_state_from(data, payload.get("included")) + + def download_description(self, stack_state_id: str) -> bytes: + """Download the state description for a stack state. + + Follows the redirect to the archivist URL and returns the raw bytes. + + Args: + stack_state_id: The stack state ID (e.g. ``"ss-abc123"``). + + Returns: + The raw description content as ``bytes``. + + Raises: + InvalidStackStateIDError: If ``stack_state_id`` is empty or malformed. + TFEError: If the API request fails. + + Example: + >>> content = client.stack_states.download_description("ss-abc123") + >>> print(content.decode()) + """ + if not valid_string_id(stack_state_id): + raise InvalidStackStateIDError() + path = f"/api/v2/stack-states/{stack_state_id}/description" + resp = self.t.request("GET", path=path) + return resp.content + + def _stack_state_from( + self, + data: dict[str, Any], + included: builtins.list[dict[str, Any]] | None = None, + ) -> StackState: + """Parse a StackState from API response data.""" + attrs = dict(data.get("attributes", {})) + attrs["id"] = data.get("id") + attrs.update( + parse_relationships( + data.get("relationships"), + { + "stack": Stack, + "stack-deployment-run": StackDeploymentRun, + }, + included=included, + ) + ) + return attach_jsonapi(StackState.model_validate(attrs), data, included) diff --git a/tests/units/test_stack_configuration_summary.py b/tests/units/test_stack_configuration_summary.py new file mode 100644 index 00000000..fd9c66d7 --- /dev/null +++ b/tests/units/test_stack_configuration_summary.py @@ -0,0 +1,95 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_configuration_summaries module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidStackIDError +from pytfe.models.stack_configuration import ( + StackConfigurationSummary, + StackConfigurationSummaryListOptions, +) +from pytfe.resources.stack_configuration_summaries import StackConfigurationSummaries + + +class TestStackConfigurationSummaries: + """Test the StackConfigurationSummaries service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackConfigurationSummaries service with mocked transport.""" + return StackConfigurationSummaries(mock_transport) + + @pytest.fixture + def summary_api_data(self): + """Typical API response item for a single stack configuration summary.""" + return { + "id": "stcs-abc123", + "type": "stack-configuration-summaries", + "attributes": { + "status": "converged", + "sequence-number": 5, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_configuration_summary_parse(self, summary_api_data): + """StackConfigurationSummary parses all attributes correctly.""" + attrs = dict(summary_api_data["attributes"]) + attrs["id"] = summary_api_data["id"] + summary = StackConfigurationSummary.model_validate(attrs) + assert summary.id == "stcs-abc123" + assert summary.status == "converged" + assert summary.sequence_number == 5 + + def test_stack_configuration_summary_list_options_serialization(self): + """StackConfigurationSummaryListOptions serializes page[size] correctly.""" + opts = StackConfigurationSummaryListOptions(page_size=15) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 15 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_stack_id_raises(self, service): + """list() with an empty stack ID raises InvalidStackIDError.""" + with pytest.raises(InvalidStackIDError): + list(service.list("")) + + def test_list_success(self, service, summary_api_data): + """list() yields StackConfigurationSummary objects from paginated results.""" + service._list = Mock(return_value=[summary_api_data]) + + results = list(service.list("st-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configuration-summaries", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackConfigurationSummary) + assert results[0].id == "stcs-abc123" + + def test_list_with_page_size(self, service, summary_api_data): + """list() passes page[size] param correctly.""" + service._list = Mock(return_value=[summary_api_data]) + opts = StackConfigurationSummaryListOptions(page_size=5) + list(service.list("st-xyz789", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-configuration-summaries", + params={"page[size]": 5}, + ) + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("st-xyz789")) == [] diff --git a/tests/units/test_stack_deployment.py b/tests/units/test_stack_deployment.py new file mode 100644 index 00000000..9116f01c --- /dev/null +++ b/tests/units/test_stack_deployment.py @@ -0,0 +1,149 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_deployment module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidStackIDError +from pytfe.models.stack import Stack +from pytfe.models.stack_deployment import ( + StackDeployment, + StackDeploymentIncludeOpt, + StackDeploymentListOptions, +) +from pytfe.resources.stack_deployment import StackDeployments + + +class TestStackDeployments: + """Test the StackDeployments service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDeployments service with mocked transport.""" + return StackDeployments(mock_transport) + + @pytest.fixture + def stack_deployment_api_data(self): + """Typical API response item for a single stack deployment.""" + return { + "id": "st-MWvJsvy1FCg3bnXY-std-simple", + "type": "stack-deployments", + "attributes": {"name": "simple"}, + "relationships": { + "stack": {"data": {"id": "st-MWvJsvy1FCg3bnXY", "type": "stacks"}}, + "latest-deployment-run": { + "data": { + "id": "sdr-vzub48Y4f7sFBk7J", + "type": "stack-deployment-runs", + } + }, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_deployment_parse(self, stack_deployment_api_data): + """StackDeployment parses id, name, and the stack relation.""" + attrs = dict(stack_deployment_api_data["attributes"]) + attrs["id"] = stack_deployment_api_data["id"] + deployment = StackDeployment.model_validate(attrs) + assert deployment.id == "st-MWvJsvy1FCg3bnXY-std-simple" + assert deployment.name == "simple" + + def test_list_options_serialization(self): + """StackDeploymentListOptions serializes page[size] and include.""" + opts = StackDeploymentListOptions( + page_size=50, + include=[StackDeploymentIncludeOpt.LATEST_DEPLOYMENT_RUN], + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 50 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_stack_id_raises(self, service): + """list() with an empty stack id raises InvalidStackIDError on iteration.""" + with pytest.raises(InvalidStackIDError): + list(service.list("")) + + def test_list_success(self, service, stack_deployment_api_data): + """list() yields StackDeployment objects from paginated results.""" + service._list = Mock(return_value=[stack_deployment_api_data]) + + opts = StackDeploymentListOptions(page_size=20) + results = list(service.list(stack_id="st-MWvJsvy1FCg3bnXY", options=opts)) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-MWvJsvy1FCg3bnXY/stack-deployments", + params={"page[size]": 20}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDeployment) + assert results[0].id == "st-MWvJsvy1FCg3bnXY-std-simple" + assert results[0].name == "simple" + + def test_list_hydrates_stack_relation(self, service, stack_deployment_api_data): + """list() parses the stack relationship into a typed Stack stub.""" + service._list = Mock(return_value=[stack_deployment_api_data]) + + results = list(service.list(stack_id="st-MWvJsvy1FCg3bnXY")) + + assert isinstance(results[0].stack, Stack) + assert results[0].stack.id == "st-MWvJsvy1FCg3bnXY" + + def test_list_latest_run_reachable_raw(self, service, stack_deployment_api_data): + """The unmodelled latest-deployment-run relation is reachable losslessly.""" + service._list = Mock(return_value=[stack_deployment_api_data]) + + results = list(service.list(stack_id="st-MWvJsvy1FCg3bnXY")) + + refs = results[0].related("latest-deployment-run") + assert refs[0]["id"] == "sdr-vzub48Y4f7sFBk7J" + # raw escape-hatch data never leaks into model_dump() + assert "relationships" not in results[0].model_dump() + + def test_list_with_include(self, service, stack_deployment_api_data): + """list() passes include param as a comma-separated string.""" + service._list = Mock(return_value=[stack_deployment_api_data]) + + opts = StackDeploymentListOptions( + include=[ + StackDeploymentIncludeOpt.LATEST_DEPLOYMENT_RUN, + StackDeploymentIncludeOpt.LATEST_DEPLOYMENT_RUN_STACK_CONFIGURATION, + ] + ) + list(service.list(stack_id="st-MWvJsvy1FCg3bnXY", options=opts)) + + _, kwargs = service._list.call_args + assert ( + kwargs["params"]["include"] + == "latest_deployment_run,latest_deployment_run.stack_configuration" + ) + + def test_list_empty(self, service): + """list() returns an empty iterator when no items are returned.""" + service._list = Mock(return_value=[]) + + results = list(service.list(stack_id="st-MWvJsvy1FCg3bnXY")) + assert results == [] + + def test_list_no_options(self, service, stack_deployment_api_data): + """list() works correctly when no options are given.""" + service._list = Mock(return_value=[stack_deployment_api_data]) + + results = list(service.list(stack_id="st-MWvJsvy1FCg3bnXY")) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-MWvJsvy1FCg3bnXY/stack-deployments", + params={}, + ) + assert len(results) == 1 diff --git a/tests/units/test_stack_deployment_group.py b/tests/units/test_stack_deployment_group.py new file mode 100644 index 00000000..93a76769 --- /dev/null +++ b/tests/units/test_stack_deployment_group.py @@ -0,0 +1,210 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_deployment_group module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidStackConfigurationIDError, + InvalidStackDeploymentGroupIDError, +) +from pytfe.models.stack_configuration import StackConfiguration +from pytfe.models.stack_deployment_group import ( + DeploymentGroupStatus, + StackDeploymentGroup, + StackDeploymentGroupListOptions, + StackDeploymentGroupRerunOptions, +) +from pytfe.resources.stack_deployment_group import StackDeploymentGroups + + +class TestStackDeploymentGroups: + """Test the StackDeploymentGroups service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDeploymentGroups service with mocked transport.""" + return StackDeploymentGroups(mock_transport) + + @pytest.fixture + def group_api_data(self): + """Typical API response item for a single stack deployment group.""" + return { + "id": "sdg-xyz789", + "type": "stack-deployment-groups", + "attributes": { + "name": "dev", + "status": "deploying", + "created-at": "2026-07-02T09:40:00.000Z", + "updated-at": "2026-07-02T09:41:00.000Z", + }, + "relationships": { + "stack-configuration": { + "data": {"id": "stc-abc123", "type": "stack-configurations"} + } + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_deployment_group_status_enum_values(self): + """DeploymentGroupStatus covers all wire values from go-tfe.""" + expected = {"pending", "deploying", "succeeded", "failed", "abandoned"} + assert {s.value for s in DeploymentGroupStatus} == expected + + def test_deployment_group_parse(self, group_api_data): + """StackDeploymentGroup parses id, name, and status from attributes.""" + attrs = dict(group_api_data["attributes"]) + attrs["id"] = group_api_data["id"] + group = StackDeploymentGroup.model_validate(attrs) + assert group.id == "sdg-xyz789" + assert group.name == "dev" + assert group.status == DeploymentGroupStatus.DEPLOYING + + def test_list_options_serialization(self): + """StackDeploymentGroupListOptions serializes page[size].""" + opts = StackDeploymentGroupListOptions(page_size=50) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 50 + + def test_rerun_options_serialization(self): + """StackDeploymentGroupRerunOptions stores deployment names.""" + opts = StackDeploymentGroupRerunOptions(deployments=["dev", "prod"]) + assert opts.deployments == ["dev", "prod"] + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_config_id_raises(self, service): + """list() with an empty config ID raises InvalidStackConfigurationIDError.""" + with pytest.raises(InvalidStackConfigurationIDError): + list(service.list("")) + + def test_list_success(self, service, group_api_data): + """list() yields StackDeploymentGroup objects from paginated results.""" + service._list = Mock(return_value=[group_api_data]) + + results = list(service.list("stc-abc123")) + + service._list.assert_called_once_with( + path="/api/v2/stack-configurations/stc-abc123/stack-deployment-groups", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDeploymentGroup) + assert results[0].id == "sdg-xyz789" + assert results[0].name == "dev" + + def test_list_with_page_size(self, service, group_api_data): + """list() passes page[size] param correctly.""" + service._list = Mock(return_value=[group_api_data]) + opts = StackDeploymentGroupListOptions(page_size=10) + list(service.list("stc-abc123", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stack-configurations/stc-abc123/stack-deployment-groups", + params={"page[size]": 10}, + ) + + def test_list_hydrates_config_relation(self, service, group_api_data): + """list() hydrates the stack-configuration relation as a typed stub.""" + service._list = Mock(return_value=[group_api_data]) + results = list(service.list("stc-abc123")) + assert isinstance(results[0].stack_configuration, StackConfiguration) + assert results[0].stack_configuration.id == "stc-abc123" + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("stc-abc123")) == [] + + # ── read() tests ───────────────────────────────────────────────────────── + + def test_read_invalid_id_raises(self, service): + """read() with an empty ID raises InvalidStackDeploymentGroupIDError.""" + with pytest.raises(InvalidStackDeploymentGroupIDError): + service.read("") + + def test_read_success(self, service, mock_transport, group_api_data): + """read() fetches a single deployment group by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"data": group_api_data} + mock_transport.request.return_value = mock_response + + group = service.read("sdg-xyz789") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-deployment-groups/sdg-xyz789" + ) + assert isinstance(group, StackDeploymentGroup) + assert group.id == "sdg-xyz789" + assert group.status == DeploymentGroupStatus.DEPLOYING + + # ── read_by_name() tests ────────────────────────────────────────────────── + + def test_read_by_name_invalid_config_id_raises(self, service): + """read_by_name() with an empty config ID raises InvalidStackConfigurationIDError.""" + with pytest.raises(InvalidStackConfigurationIDError): + service.read_by_name("", "dev") + + def test_read_by_name_success(self, service, mock_transport, group_api_data): + """read_by_name() fetches a deployment group by config ID and name.""" + mock_response = Mock() + mock_response.json.return_value = {"data": group_api_data} + mock_transport.request.return_value = mock_response + + group = service.read_by_name("stc-abc123", "dev") + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-configurations/stc-abc123/stack-deployment-groups/dev", + ) + assert group.name == "dev" + + # ── approve_all_plans() tests ───────────────────────────────────────────── + + def test_approve_all_plans_invalid_id_raises(self, service): + """approve_all_plans() with an empty ID raises InvalidStackDeploymentGroupIDError.""" + with pytest.raises(InvalidStackDeploymentGroupIDError): + service.approve_all_plans("") + + def test_approve_all_plans_calls_correct_endpoint(self, service, mock_transport): + """approve_all_plans() POSTs to the approve-all-plans action endpoint.""" + mock_transport.request.return_value = Mock() + service.approve_all_plans("sdg-xyz789") + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-deployment-groups/sdg-xyz789/approve-all-plans", + ) + + # ── rerun() tests ───────────────────────────────────────────────────────── + + def test_rerun_invalid_id_raises(self, service): + """rerun() with an empty ID raises InvalidStackDeploymentGroupIDError.""" + with pytest.raises(InvalidStackDeploymentGroupIDError): + service.rerun("", StackDeploymentGroupRerunOptions(deployments=["dev"])) + + def test_rerun_empty_deployments_raises(self, service): + """rerun() raises ValueError when options.deployments is empty.""" + with pytest.raises(ValueError, match="at least one"): + service.rerun( + "sdg-xyz789", StackDeploymentGroupRerunOptions(deployments=[]) + ) + + def test_rerun_calls_correct_endpoint(self, service, mock_transport): + """rerun() POSTs to the rerun endpoint with deployment names as a query param.""" + mock_transport.request.return_value = Mock() + opts = StackDeploymentGroupRerunOptions(deployments=["dev", "prod"]) + service.rerun("sdg-xyz789", opts) + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-deployment-groups/sdg-xyz789/rerun", + params={"deployments": "dev,prod"}, + ) diff --git a/tests/units/test_stack_deployment_group_summary.py b/tests/units/test_stack_deployment_group_summary.py new file mode 100644 index 00000000..9f580cce --- /dev/null +++ b/tests/units/test_stack_deployment_group_summary.py @@ -0,0 +1,146 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_deployment_group_summaries module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidStackConfigurationIDError +from pytfe.models.stack_deployment_group import ( + StackDeploymentGroup, + StackDeploymentGroupStatusCounts, + StackDeploymentGroupSummary, + StackDeploymentGroupSummaryListOptions, +) +from pytfe.resources.stack_deployment_group_summaries import ( + StackDeploymentGroupSummaries, +) + + +class TestStackDeploymentGroupSummaries: + """Test the StackDeploymentGroupSummaries service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDeploymentGroupSummaries service with mocked transport.""" + return StackDeploymentGroupSummaries(mock_transport) + + @pytest.fixture + def summary_api_data(self): + """Typical API response item for a single deployment group summary.""" + return { + "id": "sdgs-abc123", + "type": "stack-deployment-group-summaries", + "attributes": { + "name": "dev", + "status": "succeeded", + "status-counts": { + "pending": 0, + "pre-deploying": 0, + "pending-operator": 0, + "acquiring-lock": 0, + "deploying": 0, + "succeeded": 3, + "failed": 0, + "abandoned": 0, + }, + }, + "relationships": { + "stack-deployment-group": { + "data": {"id": "sdg-xyz789", "type": "stack-deployment-groups"} + } + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_deployment_group_status_counts_parse(self): + """StackDeploymentGroupStatusCounts parses all count fields.""" + counts_data = { + "pending": 1, + "pre-deploying": 2, + "pending-operator": 3, + "acquiring-lock": 4, + "deploying": 5, + "succeeded": 6, + "failed": 7, + "abandoned": 8, + } + counts = StackDeploymentGroupStatusCounts.model_validate(counts_data) + assert counts.pending == 1 + assert counts.pre_deploying == 2 + assert counts.pre_deploying_pending_operator == 3 + assert counts.acquiring_lock == 4 + assert counts.deploying == 5 + assert counts.succeeded == 6 + assert counts.failed == 7 + assert counts.abandoned == 8 + + def test_stack_deployment_group_summary_parse(self, summary_api_data): + """StackDeploymentGroupSummary parses name and status correctly.""" + attrs = dict(summary_api_data["attributes"]) + attrs["id"] = summary_api_data["id"] + summary = StackDeploymentGroupSummary.model_validate(attrs) + assert summary.id == "sdgs-abc123" + assert summary.name == "dev" + assert summary.status == "succeeded" + assert isinstance(summary.status_counts, StackDeploymentGroupStatusCounts) + assert summary.status_counts.succeeded == 3 + + def test_summary_list_options_serialization(self): + """StackDeploymentGroupSummaryListOptions serializes page[size] correctly.""" + opts = StackDeploymentGroupSummaryListOptions(page_size=25) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 25 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_configuration_id_raises(self, service): + """list() with an empty config ID raises InvalidStackConfigurationIDError.""" + with pytest.raises(InvalidStackConfigurationIDError): + list(service.list("")) + + def test_list_success(self, service, summary_api_data): + """list() yields StackDeploymentGroupSummary objects from paginated results.""" + service._list = Mock(return_value=[summary_api_data]) + + results = list(service.list("stc-abc123")) + + service._list.assert_called_once_with( + path="/api/v2/stack-configurations/stc-abc123/stack-deployment-group-summaries", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDeploymentGroupSummary) + assert results[0].id == "sdgs-abc123" + assert results[0].name == "dev" + + def test_list_with_page_size(self, service, summary_api_data): + """list() passes page[size] param correctly.""" + service._list = Mock(return_value=[summary_api_data]) + opts = StackDeploymentGroupSummaryListOptions(page_size=10) + list(service.list("stc-abc123", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stack-configurations/stc-abc123/stack-deployment-group-summaries", + params={"page[size]": 10}, + ) + + def test_list_hydrates_group_relation(self, service, summary_api_data): + """list() hydrates the stack-deployment-group relation as a typed stub.""" + service._list = Mock(return_value=[summary_api_data]) + results = list(service.list("stc-abc123")) + assert isinstance(results[0].stack_deployment_group, StackDeploymentGroup) + assert results[0].stack_deployment_group.id == "sdg-xyz789" + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("stc-abc123")) == [] diff --git a/tests/units/test_stack_deployment_run.py b/tests/units/test_stack_deployment_run.py new file mode 100644 index 00000000..dba4e6ed --- /dev/null +++ b/tests/units/test_stack_deployment_run.py @@ -0,0 +1,217 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_deployment_run module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidStackDeploymentGroupIDError, + InvalidStackDeploymentRunIDError, +) +from pytfe.models.stack_deployment_group import StackDeploymentGroup +from pytfe.models.stack_deployment_run import ( + DeploymentRunStatus, + StackDeploymentRun, + StackDeploymentRunIncludeOpt, + StackDeploymentRunListOptions, + StackDeploymentRunReadOptions, +) +from pytfe.resources.stack_deployment_run import StackDeploymentRuns + + +class TestStackDeploymentRuns: + """Test the StackDeploymentRuns service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDeploymentRuns service with mocked transport.""" + return StackDeploymentRuns(mock_transport) + + @pytest.fixture + def run_api_data(self): + """Typical API response item for a single stack deployment run.""" + return { + "id": "sdr-abc123", + "type": "stack-deployment-runs", + "attributes": { + "status": "pre-deploying-pending-operator", + "created-at": "2026-07-02T09:40:37.000Z", + "updated-at": "2026-07-02T09:40:38.000Z", + }, + "relationships": { + "stack-deployment-group": { + "data": {"id": "sdg-xyz789", "type": "stack-deployment-groups"} + } + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_deployment_run_status_enum_values(self): + """DeploymentRunStatus covers all wire values from go-tfe.""" + expected = { + "pending", + "pre-deploying", + "pre-deploying-pending-operator", + "acquiring-lock", + "deploying", + "deploying-pending-operator", + "succeeded", + "failed", + "abandoned", + } + assert {s.value for s in DeploymentRunStatus} == expected + + def test_deployment_run_include_opt_values(self): + """StackDeploymentRunIncludeOpt covers all valid include values from the API docs.""" + expected = { + "stack_deployment_group", + "stack_approval", + "destroy_stack_configuration", + "blocked_by_deployment_group", + "latest_deployment_run_for_deployment", + } + assert {o.value for o in StackDeploymentRunIncludeOpt} == expected + + def test_deployment_run_parse(self, run_api_data): + """StackDeploymentRun parses id and status from attributes.""" + attrs = dict(run_api_data["attributes"]) + attrs["id"] = run_api_data["id"] + run = StackDeploymentRun.model_validate(attrs) + assert run.id == "sdr-abc123" + assert run.status == DeploymentRunStatus.PRE_DEPLOYING_PENDING_OPERATOR + + def test_list_options_serialization(self): + """StackDeploymentRunListOptions serializes page[size] and include.""" + opts = StackDeploymentRunListOptions( + page_size=25, + include=[StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP], + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 25 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_group_id_raises(self, service): + """list() with an empty group ID raises InvalidStackDeploymentGroupIDError.""" + with pytest.raises(InvalidStackDeploymentGroupIDError): + list(service.list("")) + + def test_list_success(self, service, run_api_data): + """list() yields StackDeploymentRun objects from paginated results.""" + service._list = Mock(return_value=[run_api_data]) + + results = list(service.list("sdg-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-groups/sdg-xyz789/stack-deployment-runs", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDeploymentRun) + assert results[0].id == "sdr-abc123" + + def test_list_with_page_size_and_include(self, service, run_api_data): + """list() passes page[size] and include params correctly.""" + service._list = Mock(return_value=[run_api_data]) + opts = StackDeploymentRunListOptions( + page_size=10, + include=[StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP], + ) + list(service.list("sdg-xyz789", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-groups/sdg-xyz789/stack-deployment-runs", + params={"page[size]": 10, "include": "stack_deployment_group"}, + ) + + def test_list_hydrates_group_relation(self, service, run_api_data): + """list() hydrates the stack-deployment-group relation as a typed stub.""" + service._list = Mock(return_value=[run_api_data]) + results = list(service.list("sdg-xyz789")) + assert isinstance(results[0].stack_deployment_group, StackDeploymentGroup) + assert results[0].stack_deployment_group.id == "sdg-xyz789" + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("sdg-xyz789")) == [] + + # ── read() tests ───────────────────────────────────────────────────────── + + def test_read_invalid_id_raises(self, service): + """read() with an empty ID raises InvalidStackDeploymentRunIDError.""" + with pytest.raises(InvalidStackDeploymentRunIDError): + service.read("") + + def test_read_success(self, service, mock_transport, run_api_data): + """read() fetches a single deployment run by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"data": run_api_data} + mock_transport.request.return_value = mock_response + + run = service.read("sdr-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-deployment-runs/sdr-abc123", params={} + ) + assert isinstance(run, StackDeploymentRun) + assert run.id == "sdr-abc123" + assert run.status == DeploymentRunStatus.PRE_DEPLOYING_PENDING_OPERATOR + + def test_read_with_include(self, service, mock_transport, run_api_data): + """read() passes include= as a comma-separated query param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": run_api_data} + mock_transport.request.return_value = mock_response + + opts = StackDeploymentRunReadOptions( + include=[StackDeploymentRunIncludeOpt.STACK_DEPLOYMENT_GROUP] + ) + service.read("sdr-abc123", options=opts) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-deployment-runs/sdr-abc123", + params={"include": "stack_deployment_group"}, + ) + + # ── approve_all_plans() tests ───────────────────────────────────────────── + + def test_approve_all_plans_invalid_id_raises(self, service): + """approve_all_plans() with an empty ID raises InvalidStackDeploymentRunIDError.""" + with pytest.raises(InvalidStackDeploymentRunIDError): + service.approve_all_plans("") + + def test_approve_all_plans_calls_correct_endpoint(self, service, mock_transport): + """approve_all_plans() POSTs to the approve-all-plans action endpoint.""" + mock_transport.request.return_value = Mock() + service.approve_all_plans("sdr-abc123") + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-deployment-runs/sdr-abc123/approve-all-plans", + ) + + # ── cancel() tests ──────────────────────────────────────────────────────── + + def test_cancel_invalid_id_raises(self, service): + """cancel() with an empty ID raises InvalidStackDeploymentRunIDError.""" + with pytest.raises(InvalidStackDeploymentRunIDError): + service.cancel("") + + def test_cancel_calls_correct_endpoint(self, service, mock_transport): + """cancel() POSTs to the cancel action endpoint.""" + mock_transport.request.return_value = Mock() + service.cancel("sdr-abc123") + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-deployment-runs/sdr-abc123/cancel", + ) diff --git a/tests/units/test_stack_deployment_step.py b/tests/units/test_stack_deployment_step.py new file mode 100644 index 00000000..f7fab4de --- /dev/null +++ b/tests/units/test_stack_deployment_step.py @@ -0,0 +1,305 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_deployment_steps module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import ( + InvalidStackDeploymentRunIDError, + InvalidStackDeploymentStepIDError, +) +from pytfe.models.stack_deployment_run import StackDeploymentRun +from pytfe.models.stack_deployment_step import ( + DeploymentStepStatus, + StackDeploymentStep, + StackDeploymentStepArtifactType, + StackDeploymentStepIncludeOpt, + StackDeploymentStepListOptions, + StackDeploymentStepReadOptions, + StackDiagnostic, + StackDiagnosticListOptions, +) +from pytfe.resources.stack_deployment_steps import StackDeploymentSteps + + +class TestStackDeploymentSteps: + """Test the StackDeploymentSteps service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDeploymentSteps service with mocked transport.""" + return StackDeploymentSteps(mock_transport) + + @pytest.fixture + def step_api_data(self): + """Typical API response item for a single stack deployment step.""" + return { + "id": "sds-abc123", + "type": "stack-deployment-steps", + "attributes": { + "status": "pending-operator", + "operation-type": "plan", + "created-at": "2026-07-02T09:40:37.000Z", + "updated-at": "2026-07-02T09:40:38.000Z", + }, + "relationships": { + "stack-deployment-run": { + "data": {"id": "sdr-xyz789", "type": "stack-deployment-runs"} + } + }, + } + + @pytest.fixture + def diag_api_data(self): + """Typical API response item for a single stack diagnostic.""" + return { + "id": "std-diag001", + "type": "stack-diagnostics", + "attributes": { + "severity": "warning", + "summary": "Resource will be re-created", + "detail": "The resource will be destroyed and then re-created.", + "diags": None, + "acknowledged": False, + "acknowledged-at": None, + "created-at": "2026-07-02T09:40:37.000Z", + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_deployment_step_status_enum_values(self): + """DeploymentStepStatus covers all wire values from go-tfe.""" + expected = { + "blocked", + "abandoned", + "queued", + "running", + "pending-operator", + "completed", + "failed", + } + assert {s.value for s in DeploymentStepStatus} == expected + + def test_artifact_type_enum_values(self): + """StackDeploymentStepArtifactType covers all artifact types from go-tfe.""" + expected = { + "plan-description", + "apply-description", + "plan-debug-log", + "apply-debug-log", + } + assert {a.value for a in StackDeploymentStepArtifactType} == expected + + def test_step_parse(self, step_api_data): + """StackDeploymentStep parses id, status, and operation_type.""" + attrs = dict(step_api_data["attributes"]) + attrs["id"] = step_api_data["id"] + step = StackDeploymentStep.model_validate(attrs) + assert step.id == "sds-abc123" + assert step.status == DeploymentStepStatus.PENDING_OPERATOR + assert step.operation_type == "plan" + + def test_list_options_serialization(self): + """StackDeploymentStepListOptions serializes page[size] and include.""" + opts = StackDeploymentStepListOptions( + page_size=25, + include=[StackDeploymentStepIncludeOpt.STACK_APPROVAL], + ) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 25 + + def test_diagnostic_list_options_serialization(self): + """StackDiagnosticListOptions serializes page[size].""" + opts = StackDiagnosticListOptions(page_size=10) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 10 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_run_id_raises(self, service): + """list() with an empty run ID raises InvalidStackDeploymentRunIDError.""" + with pytest.raises(InvalidStackDeploymentRunIDError): + list(service.list("")) + + def test_list_success(self, service, step_api_data): + """list() yields StackDeploymentStep objects from paginated results.""" + service._list = Mock(return_value=[step_api_data]) + + results = list(service.list("sdr-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-runs/sdr-xyz789/stack-deployment-steps", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDeploymentStep) + assert results[0].id == "sds-abc123" + + def test_list_with_page_size_and_include(self, service, step_api_data): + """list() passes page[size] and include params correctly.""" + service._list = Mock(return_value=[step_api_data]) + opts = StackDeploymentStepListOptions( + page_size=10, + include=[StackDeploymentStepIncludeOpt.STACK_APPROVAL], + ) + list(service.list("sdr-xyz789", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-runs/sdr-xyz789/stack-deployment-steps", + params={"page[size]": 10, "include": "stack_approval"}, + ) + + def test_list_hydrates_run_relation(self, service, step_api_data): + """list() hydrates the stack-deployment-run relation as a typed stub.""" + service._list = Mock(return_value=[step_api_data]) + results = list(service.list("sdr-xyz789")) + assert isinstance(results[0].stack_deployment_run, StackDeploymentRun) + assert results[0].stack_deployment_run.id == "sdr-xyz789" + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("sdr-xyz789")) == [] + + # ── read() tests ───────────────────────────────────────────────────────── + + def test_read_invalid_id_raises(self, service): + """read() with an empty ID raises InvalidStackDeploymentStepIDError.""" + with pytest.raises(InvalidStackDeploymentStepIDError): + service.read("") + + def test_read_success(self, service, mock_transport, step_api_data): + """read() fetches a single deployment step by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"data": step_api_data} + mock_transport.request.return_value = mock_response + + step = service.read("sds-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-deployment-steps/sds-abc123", params={} + ) + assert isinstance(step, StackDeploymentStep) + assert step.id == "sds-abc123" + assert step.status == DeploymentStepStatus.PENDING_OPERATOR + + def test_read_with_include(self, service, mock_transport, step_api_data): + """read() passes include= as a comma-separated query param.""" + mock_response = Mock() + mock_response.json.return_value = {"data": step_api_data} + mock_transport.request.return_value = mock_response + + opts = StackDeploymentStepReadOptions( + include=[StackDeploymentStepIncludeOpt.STACK_APPROVAL] + ) + service.read("sds-abc123", options=opts) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-deployment-steps/sds-abc123", + params={"include": "stack_approval"}, + ) + + # ── advance() tests ─────────────────────────────────────────────────────── + + def test_advance_invalid_id_raises(self, service): + """advance() with an empty ID raises InvalidStackDeploymentStepIDError.""" + with pytest.raises(InvalidStackDeploymentStepIDError): + service.advance("") + + def test_advance_calls_correct_endpoint(self, service, mock_transport): + """advance() POSTs to the advance action endpoint.""" + mock_transport.request.return_value = Mock() + service.advance("sds-abc123") + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-deployment-steps/sds-abc123/advance", + ) + + # ── list_diagnostics() tests ────────────────────────────────────────────── + + def test_list_diagnostics_invalid_id_raises(self, service): + """list_diagnostics() with an empty ID raises InvalidStackDeploymentStepIDError.""" + with pytest.raises(InvalidStackDeploymentStepIDError): + list(service.list_diagnostics("")) + + def test_list_diagnostics_success(self, service, diag_api_data): + """list_diagnostics() yields StackDiagnostic objects.""" + service._list = Mock(return_value=[diag_api_data]) + + results = list(service.list_diagnostics("sds-abc123")) + + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-steps/sds-abc123/stack-diagnostics", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackDiagnostic) + assert results[0].id == "std-diag001" + assert results[0].severity == "warning" + + def test_list_diagnostics_with_page_size(self, service, diag_api_data): + """list_diagnostics() passes page[size] param.""" + service._list = Mock(return_value=[diag_api_data]) + opts = StackDiagnosticListOptions(page_size=5) + list(service.list_diagnostics("sds-abc123", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stack-deployment-steps/sds-abc123/stack-diagnostics", + params={"page[size]": 5}, + ) + + def test_list_diagnostics_empty(self, service): + """list_diagnostics() returns an empty iterator when no diagnostics.""" + service._list = Mock(return_value=[]) + assert list(service.list_diagnostics("sds-abc123")) == [] + + # ── download_artifact() tests ───────────────────────────────────────────── + + def test_download_artifact_invalid_id_raises(self, service): + """download_artifact() with an empty ID raises InvalidStackDeploymentStepIDError.""" + with pytest.raises(InvalidStackDeploymentStepIDError): + service.download_artifact( + "", StackDeploymentStepArtifactType.PLAN_DESCRIPTION + ) + + def test_download_artifact_returns_bytes(self, service, mock_transport): + """download_artifact() returns the raw response bytes.""" + mock_response = Mock() + mock_response.content = b"# Plan output\nchange: 1 to add" + mock_transport.request.return_value = mock_response + + result = service.download_artifact( + "sds-abc123", StackDeploymentStepArtifactType.PLAN_DESCRIPTION + ) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-deployment-steps/sds-abc123/artifacts", + params={"name": "plan-description"}, + ) + assert result == b"# Plan output\nchange: 1 to add" + + def test_download_artifact_apply_description(self, service, mock_transport): + """download_artifact() uses the correct artifact type name for apply-description.""" + mock_response = Mock() + mock_response.content = b"apply output" + mock_transport.request.return_value = mock_response + + service.download_artifact( + "sds-abc123", StackDeploymentStepArtifactType.APPLY_DESCRIPTION + ) + + mock_transport.request.assert_called_once_with( + "GET", + path="/api/v2/stack-deployment-steps/sds-abc123/artifacts", + params={"name": "apply-description"}, + ) diff --git a/tests/units/test_stack_diagnostic.py b/tests/units/test_stack_diagnostic.py new file mode 100644 index 00000000..46e9fe57 --- /dev/null +++ b/tests/units/test_stack_diagnostic.py @@ -0,0 +1,101 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_diagnostics module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidStackDiagnosticIDError +from pytfe.models.stack_deployment_step import StackDiagnostic +from pytfe.resources.stack_diagnostics import StackDiagnostics + + +class TestStackDiagnostics: + """Test the StackDiagnostics service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackDiagnostics service with mocked transport.""" + return StackDiagnostics(mock_transport) + + @pytest.fixture + def diagnostic_api_data(self): + """Typical API response data for a single stack diagnostic.""" + return { + "id": "std-abc123", + "type": "stack-diagnostics", + "attributes": { + "severity": "error", + "summary": "Invalid configuration", + "detail": "The stack configuration failed validation.", + "diags": None, + "acknowledged": False, + "acknowledged-at": None, + "created-at": "2026-07-03T10:00:00.000Z", + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_diagnostic_parse(self, diagnostic_api_data): + """StackDiagnostic parses all attributes correctly.""" + attrs = dict(diagnostic_api_data["attributes"]) + attrs["id"] = diagnostic_api_data["id"] + diag = StackDiagnostic.model_validate(attrs) + assert diag.id == "std-abc123" + assert diag.severity == "error" + assert diag.summary == "Invalid configuration" + assert diag.acknowledged is False + + # ── read() tests ───────────────────────────────────────────────────────── + + def test_read_invalid_id_raises(self, service): + """read() with an empty ID raises InvalidStackDiagnosticIDError.""" + with pytest.raises(InvalidStackDiagnosticIDError): + service.read("") + + def test_read_success(self, service, mock_transport, diagnostic_api_data): + """read() fetches a single stack diagnostic by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"data": diagnostic_api_data} + mock_transport.request.return_value = mock_response + + diag = service.read("std-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-diagnostics/std-abc123" + ) + assert isinstance(diag, StackDiagnostic) + assert diag.id == "std-abc123" + assert diag.severity == "error" + assert diag.acknowledged is False + + # ── acknowledge() tests ─────────────────────────────────────────────────── + + def test_acknowledge_invalid_id_raises(self, service): + """acknowledge() with an empty ID raises InvalidStackDiagnosticIDError.""" + with pytest.raises(InvalidStackDiagnosticIDError): + service.acknowledge("") + + def test_acknowledge_calls_correct_endpoint(self, service, mock_transport): + """acknowledge() POSTs to the acknowledge action endpoint.""" + mock_transport.request.return_value = Mock() + service.acknowledge("std-abc123") + mock_transport.request.assert_called_once_with( + "POST", + path="/api/v2/stack-diagnostics/std-abc123/acknowledge", + ) + + def test_acknowledge_returns_none(self, service, mock_transport): + """acknowledge() returns None on success.""" + mock_transport.request.return_value = Mock() + result = service.acknowledge("std-abc123") + assert result is None diff --git a/tests/units/test_stack_state.py b/tests/units/test_stack_state.py new file mode 100644 index 00000000..2e7fcc97 --- /dev/null +++ b/tests/units/test_stack_state.py @@ -0,0 +1,205 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the stack_states module.""" + +from unittest.mock import Mock + +import pytest + +from pytfe._http import HTTPTransport +from pytfe.errors import InvalidStackIDError, InvalidStackStateIDError +from pytfe.models.stack import Stack +from pytfe.models.stack_deployment_run import StackDeploymentRun +from pytfe.models.stack_state import ( + StackState, + StackStateComponent, + StackStateListOptions, +) +from pytfe.resources.stack_states import StackStates + + +class TestStackStates: + """Test the StackStates service class.""" + + @pytest.fixture + def mock_transport(self): + """Create a mock HTTPTransport.""" + return Mock(spec=HTTPTransport) + + @pytest.fixture + def service(self, mock_transport): + """Create a StackStates service with mocked transport.""" + return StackStates(mock_transport) + + @pytest.fixture + def state_api_data(self): + """Typical API response item for a single stack state.""" + return { + "id": "ss-abc123", + "type": "stack-states", + "attributes": { + "generation": 3, + "status": "current", + "deployment": "dev", + "components": [ + { + "address": "component.ns", + "component-address": "component.ns", + "instance-correlator": "abc123==", + "component-correlator": "xyz789==", + "resource-instance-count": 1, + } + ], + "is-current": True, + "resource-instance-count": 7, + }, + "relationships": { + "stack": {"data": {"id": "st-xyz789", "type": "stacks"}}, + "stack-deployment-run": { + "data": {"id": "sdr-run001", "type": "stack-deployment-runs"} + }, + }, + } + + # ── Model tests ────────────────────────────────────────────────────────── + + def test_stack_state_parse(self, state_api_data): + """StackState parses all attributes correctly.""" + attrs = dict(state_api_data["attributes"]) + attrs["id"] = state_api_data["id"] + state = StackState.model_validate(attrs) + assert state.id == "ss-abc123" + assert state.generation == 3 + assert state.status == "current" + assert state.deployment == "dev" + assert state.is_current is True + assert state.resource_instance_count == 7 + + def test_stack_state_component_parse(self): + """StackStateComponent parses the wire fields from a stack-state response.""" + raw = { + "address": "component.ns", + "component-address": "component.ns", + "instance-correlator": "abc123==", + "component-correlator": "xyz789==", + "resource-instance-count": 1, + } + comp = StackStateComponent.model_validate(raw) + assert comp.address == "component.ns" + assert comp.component_address == "component.ns" + assert comp.instance_correlator == "abc123==" + assert comp.component_correlator == "xyz789==" + assert comp.resource_instance_count == 1 + + def test_stack_state_list_options_serialization(self): + """StackStateListOptions serializes page[size] correctly.""" + opts = StackStateListOptions(page_size=20) + dumped = opts.model_dump(by_alias=True, exclude_none=True) + assert dumped["page[size]"] == 20 + + # ── list() tests ───────────────────────────────────────────────────────── + + def test_list_invalid_stack_id_raises(self, service): + """list() with an empty stack ID raises InvalidStackIDError.""" + with pytest.raises(InvalidStackIDError): + list(service.list("")) + + def test_list_success(self, service, state_api_data): + """list() yields StackState objects from paginated results.""" + service._list = Mock(return_value=[state_api_data]) + + results = list(service.list("st-xyz789")) + + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-states", + params={}, + ) + assert len(results) == 1 + assert isinstance(results[0], StackState) + assert results[0].id == "ss-abc123" + + def test_list_with_page_size(self, service, state_api_data): + """list() passes page[size] param correctly.""" + service._list = Mock(return_value=[state_api_data]) + opts = StackStateListOptions(page_size=10) + list(service.list("st-xyz789", options=opts)) + service._list.assert_called_once_with( + path="/api/v2/stacks/st-xyz789/stack-states", + params={"page[size]": 10}, + ) + + def test_list_hydrates_stack_relation(self, service, state_api_data): + """list() hydrates the stack relation as a typed stub.""" + service._list = Mock(return_value=[state_api_data]) + results = list(service.list("st-xyz789")) + assert isinstance(results[0].stack, Stack) + assert results[0].stack.id == "st-xyz789" + + def test_list_hydrates_run_relation(self, service, state_api_data): + """list() hydrates the stack-deployment-run relation as a typed stub.""" + service._list = Mock(return_value=[state_api_data]) + results = list(service.list("st-xyz789")) + assert isinstance(results[0].stack_deployment_run, StackDeploymentRun) + assert results[0].stack_deployment_run.id == "sdr-run001" + + def test_list_empty(self, service): + """list() returns an empty iterator when the API returns no items.""" + service._list = Mock(return_value=[]) + assert list(service.list("st-xyz789")) == [] + + # ── read() tests ───────────────────────────────────────────────────────── + + def test_read_invalid_id_raises(self, service): + """read() with an empty ID raises InvalidStackStateIDError.""" + with pytest.raises(InvalidStackStateIDError): + service.read("") + + def test_read_success(self, service, mock_transport, state_api_data): + """read() fetches a single stack state by ID.""" + mock_response = Mock() + mock_response.json.return_value = {"data": state_api_data} + mock_transport.request.return_value = mock_response + + state = service.read("ss-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-states/ss-abc123" + ) + assert isinstance(state, StackState) + assert state.id == "ss-abc123" + assert state.generation == 3 + assert state.is_current is True + + def test_read_hydrates_relations(self, service, mock_transport, state_api_data): + """read() hydrates both stack and run relations.""" + mock_response = Mock() + mock_response.json.return_value = {"data": state_api_data} + mock_transport.request.return_value = mock_response + + state = service.read("ss-abc123") + assert isinstance(state.stack, Stack) + assert state.stack.id == "st-xyz789" + assert isinstance(state.stack_deployment_run, StackDeploymentRun) + assert state.stack_deployment_run.id == "sdr-run001" + + # ── download_description() tests ───────────────────────────────────────── + + def test_download_description_invalid_id_raises(self, service): + """download_description() with an empty ID raises InvalidStackStateIDError.""" + with pytest.raises(InvalidStackStateIDError): + service.download_description("") + + def test_download_description_returns_bytes(self, service, mock_transport): + """download_description() returns the raw response bytes.""" + raw = b"# Stack state description\nresources: 7" + mock_response = Mock() + mock_response.content = raw + mock_transport.request.return_value = mock_response + + result = service.download_description("ss-abc123") + + mock_transport.request.assert_called_once_with( + "GET", path="/api/v2/stack-states/ss-abc123/description" + ) + assert result == raw