From 9fc67c1668bd7d4c8cbbd45e5b25c5089f520dac Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 16:59:43 +0530 Subject: [PATCH 1/5] Add more docs --- AGENTS.md | 1 + README.md | 27 +++- docs/api/index.md | 105 ++++++++++++++ docs/api/policies.md | 69 +++++++++ docs/api/run-tasks.md | 103 +++++++++++++ docs/api/runs-plans-applies.md | 105 ++++++++++++++ docs/api/state-versions.md | 97 +++++++++++++ docs/api/teams-and-access.md | 76 ++++++++++ docs/api/variables-and-variable-sets.md | 76 ++++++++++ docs/api/workspaces.md | 78 ++++++++++ docs/authentication.md | 145 +++++++++++++++++++ docs/errors.md | 98 +++++++++++++ docs/getting-started.md | 107 ++++++++++++++ docs/pagination.md | 95 ++++++++++++ docs/scenarios/api-driven-run.md | 137 ++++++++++++++++++ docs/scenarios/manage-workspace-variables.md | 125 ++++++++++++++++ docs/scenarios/policy-enforcement.md | 107 ++++++++++++++ docs/scenarios/run-task-integration.md | 101 +++++++++++++ docs/scenarios/state-management.md | 101 +++++++++++++ docs/scenarios/team-access-onboarding.md | 113 +++++++++++++++ docs/terraform-enterprise.md | 95 ++++++++++++ docs/troubleshooting.md | 123 ++++++++++++++++ 22 files changed, 2082 insertions(+), 2 deletions(-) create mode 100644 docs/api/index.md create mode 100644 docs/api/policies.md create mode 100644 docs/api/run-tasks.md create mode 100644 docs/api/runs-plans-applies.md create mode 100644 docs/api/state-versions.md create mode 100644 docs/api/teams-and-access.md create mode 100644 docs/api/variables-and-variable-sets.md create mode 100644 docs/api/workspaces.md create mode 100644 docs/authentication.md create mode 100644 docs/errors.md create mode 100644 docs/getting-started.md create mode 100644 docs/pagination.md create mode 100644 docs/scenarios/api-driven-run.md create mode 100644 docs/scenarios/manage-workspace-variables.md create mode 100644 docs/scenarios/policy-enforcement.md create mode 100644 docs/scenarios/run-task-integration.md create mode 100644 docs/scenarios/state-management.md create mode 100644 docs/scenarios/team-access-onboarding.md create mode 100644 docs/terraform-enterprise.md create mode 100644 docs/troubleshooting.md diff --git a/AGENTS.md b/AGENTS.md index f64aae2a..1b0caee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ src/pytfe/ config.py # TFEConfig — auth, timeout, retry, proxy settings _http.py # HTTPTransport — request, retry, redirects, auth _jsonapi.py # JSON:API envelope helpers + _logging.py. # Logging primitives for the pytfe SDK errors.py # Typed exception hierarchy (TFEError + ~80 subclasses) utils.py # Validation + small helpers models/ # Pydantic v2 models, one file per resource diff --git a/README.md b/README.md index 94728d15..7d6c2051 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,32 @@ For full details — environment variables, redaction guarantees, and how to add ## Documentation -- API reference and guides (SDK): **coming soon** +- User guides: + - [Getting started](./docs/getting-started.md) + - [Authentication](./docs/authentication.md) + - [Pagination](./docs/pagination.md) + - [API index](./docs/api/index.md) +- Focused API guides: + - [Workspaces](./docs/api/workspaces.md) + - [Runs, plans, and applies](./docs/api/runs-plans-applies.md) + - [State versions](./docs/api/state-versions.md) + - [Variables and variable sets](./docs/api/variables-and-variable-sets.md) + - [Teams and access](./docs/api/teams-and-access.md) + - [Policies](./docs/api/policies.md) + - [Run tasks](./docs/api/run-tasks.md) +- Scenario guides: + - [API-driven run](./docs/scenarios/api-driven-run.md) + - [Manage workspace variables](./docs/scenarios/manage-workspace-variables.md) + - [Team access onboarding](./docs/scenarios/team-access-onboarding.md) + - [State management](./docs/scenarios/state-management.md) + - [Policy enforcement](./docs/scenarios/policy-enforcement.md) + - [Run task integration](./docs/scenarios/run-task-integration.md) +- Operations guides: + - [Troubleshooting](./docs/troubleshooting.md) + - [Errors](./docs/errors.md) + - [Terraform Enterprise](./docs/terraform-enterprise.md) - Terraform Enterprise API: https://developer.hashicorp.com/terraform/enterprise/api-docs -- Internal reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md) +- Internal contributor reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md) ## Examples diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..f13ec141 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,105 @@ +# API index + +This page maps `TFEClient` attributes to pyTFE resource services, examples, and +upstream HCP Terraform or Terraform Enterprise API docs. It is intentionally a +high-signal map, not a duplicate of every method signature. + +For complete wire-level behavior, use the upstream API docs linked in the last +column. + +## Core organization and workspace resources + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.organizations` | `Organizations` | `list`, `read`, `create`, `update`, `delete`, capacity, entitlements, data retention | [org.py](../../examples/org.py) | [Organizations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organizations) | +| `client.projects` | `Projects` | `list`, `read`, `create`, `update`, `delete`, `move_workspaces`, tag bindings | [project.py](../../examples/project.py) | [Projects](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/projects) | +| `client.workspaces` | `Workspaces` | `list`, `read`, `create`, `update`, `delete`, lock/unlock, tags, remote state consumers, data retention | [workspace.py](../../examples/workspace.py) | [Workspaces](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces) | +| `client.workspace_resources` | `WorkspaceResourcesService` | `list` | [workspace_resources.py](../../examples/workspace_resources.py) | [Workspace resources](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-resources) | +| `client.ssh_keys` | `SSHKeys` | `list`, `read`, `create`, `update`, `delete` | [ssh_keys.py](../../examples/ssh_keys.py) | [SSH keys](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/ssh-keys) | +| `client.reserved_tag_key` | `ReservedTagKeys` | `list`, `create`, `update`, `delete` | [reserved_tag_key.py](../../examples/reserved_tag_key.py) | [Reserved tag keys](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/reserved-tag-keys) | + +## Runs, plans, applies, and state + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.configuration_versions` | `ConfigurationVersions` | `list`, `read`, `create`, `upload`, `download`, backing-data actions | [configuration_version.py](../../examples/configuration_version.py) | [Configuration versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions) | +| `client.runs` | `Runs` | `list`, `list_for_organization`, `read`, `create`, `apply`, `cancel`, `force_cancel`, `force_execute`, `discard` | [run.py](../../examples/run.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | +| `client.plans` | `Plans` | `read`, `read_for_run`, `logs`, `read_json_output`, `read_json_output_for_run`, `read_json_schema_for_run` | [plan.py](../../examples/plan.py) | [Plans](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans) | +| `client.applies` | `Applies` | `read`, `logs`, `errored_state` | [apply.py](../../examples/apply.py) | [Applies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies) | +| `client.run_events` | `RunEvents` | `list`, `read`, `read_with_options` | [run_events.py](../../examples/run_events.py) | [Runs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run) | +| `client.query_runs` | `QueryRuns` | `list`, `read`, `create`, `logs`, `cancel`, `force_cancel` | [query_run.py](../../examples/query_run.py) | [Query runs](https://developer.hashicorp.com/terraform/enterprise/api-docs/queries) | +| `client.state_versions` | `StateVersions` | `list`, `read`, `read_current`, `create`, `upload`, `download`, `rollback`, backing-data actions | [state_versions.py](../../examples/state_versions.py) | [State versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions) | +| `client.state_version_outputs` | `StateVersionOutputs` | `read`, `read_current` | [state_versions.py](../../examples/state_versions.py) | [State version outputs](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs) | + +## Variables and variable sets + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.variables` | `Variables` | `list`, `list_all`, `read`, `create`, `update`, `delete` | [variables.py](../../examples/variables.py) | [Workspace variables](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables) | +| `client.variable_sets` | `VariableSets` | `list`, `list_for_workspace`, `list_for_project`, `read`, `create`, `update`, `delete`, apply/remove relationships | [variable_sets.py](../../examples/variable_sets.py) | [Variable sets](https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets) | +| `client.variable_set_variables` | `VariableSetVariables` | `list`, `read`, `create`, `update`, `delete` | [variable_sets.py](../../examples/variable_sets.py) | [Variable sets](https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets) | + +## Teams, users, and access + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.users` | `Users` | `read`, `read_current`, `update_current` | [user.py](../../examples/user.py) | [Users](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/users) | +| `client.teams` | `Teams` | `list`, `read`, `create`, `update`, `delete`, membership helpers | [team.py](../../examples/team.py) | [Teams](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams) | +| `client.team_workspace_accesses` | `TeamWorkspaceAccesses` | `list`, `read`, `add`, `update`, `remove` | [team_workspace_access.py](../../examples/team_workspace_access.py) | [Team access](https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access) | +| `client.team_project_accesses` | `TeamProjectAccesses` | `list`, `read`, `add`, `update`, `remove` | [team_project_access.py](../../examples/team_project_access.py) | [Project team access](https://developer.hashicorp.com/terraform/enterprise/api-docs/project-team-access) | +| `client.team_tokens` | `TeamTokens` | `list`, `read`, `create`, `delete` | [team_token.py](../../examples/team_token.py) | [Team tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens) | +| `client.organization_memberships` | `OrganizationMemberships` | `list`, `read`, `create`, `delete` | [organization_membership.py](../../examples/organization_membership.py) | [Organization memberships](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-memberships) | +| `client.organization_tokens` | `OrganizationTokens` | `read`, `create`, `delete` | [organization_token.py](../../examples/organization_token.py) | [Organization tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-tokens) | + +## Policies and policy results + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.policies` | `Policies` | `list`, `read`, `create`, `update`, `delete`, `upload`, `download` | [policy.py](../../examples/policy.py) | [Policies](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies) | +| `client.policy_sets` | `PolicySets` | `list`, `read`, `create`, `update`, `delete`, add/remove policies, projects, workspaces, exclusions | [policy_set.py](../../examples/policy_set.py) | [Policy sets](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_parameters` | `PolicySetParameters` | `list`, `read`, `create`, `update`, `delete` | [policy_set_parameter.py](../../examples/policy_set_parameter.py) | [Policy sets](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_versions` | `PolicySetVersions` | `create`, `read`, `upload` | [policy_set.py](../../examples/policy_set.py) | [Policy set versions](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets) | +| `client.policy_set_outcomes` | `PolicySetOutcomes` | `list`, `read` | [policy_set.py](../../examples/policy_set.py) | [Policy evaluations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations) | +| `client.policy_checks` | `PolicyChecks` | `list`, `read`, `override`, `logs` | [policy_check.py](../../examples/policy_check.py) | [Policy checks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks) | +| `client.policy_evaluations` | `PolicyEvaluations` | `list` | [policy_evaluation.py](../../examples/policy_evaluation.py) | [Policy evaluations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations) | + +## Run tasks + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.run_tasks` | `RunTasks` | `list`, `read`, `create`, `update`, `delete` | [run_task.py](../../examples/run_task.py) | [Run tasks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks) | +| `client.workspace_run_tasks` | `WorkspaceRunTasks` | `list`, `read`, `create`, `update`, `delete` | [workspace_run_task.py](../../examples/workspace_run_task.py) | [Run tasks](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks) | +| `client.run_task_integrations` | `RunTaskIntegrations` | `callback` | [run_task_integration.py](../../examples/run_task_integration.py) | [Run task integration](https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration) | +| `client.task_stages` | `TaskStages` | `list`, `read`, `override` | [task_stage_example.py](../../examples/task_stage_example.py) | [Run task stages and results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results) | +| `client.task_results` | `TaskResults` | `read` | [task_result.py](../../examples/task_result.py) | [Run task stages and results](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results) | + +## Agents, registry, integrations, and other resources + +| Client attribute | Resource class | Common methods | Example | Upstream API docs | +|---|---|---|---|---| +| `client.agent_pools` | `AgentPools` | `list`, `read`, `create`, `update`, `delete`, assign/remove workspaces/projects | [agent_pool.py](../../examples/agent_pool.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | +| `client.agents` | `Agents` | `list`, `read`, `delete` | [agent.py](../../examples/agent.py) | [Agents](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents) | +| `client.agent_tokens` | `AgentTokens` | `list`, `read`, `create`, `delete` | [agent.py](../../examples/agent.py) | [Agent tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens) | +| `client.registry_modules` | `RegistryModules` | `list`, `read`, `create`, `update`, `delete`, version and upload helpers | [registry_module.py](../../examples/registry_module.py) | [Registry modules](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules) | +| `client.registry_providers` | `RegistryProviders` | `list`, `read`, `create`, `delete` | [registry_provider.py](../../examples/registry_provider.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.registry_provider_versions` | `RegistryProviderVersions` | `list`, `read`, `create`, `delete` | [registry_provider_version.py](../../examples/registry_provider_version.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.registry_provider_platforms` | `RegistryProviderPlatforms` | `list`, `read`, `create`, `delete` | [registry_provider_platform.py](../../examples/registry_provider_platform.py) | [Registry providers](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/providers) | +| `client.oauth_clients` | `OAuthClients` | `list`, `read`, `create`, `update`, `delete`, project relationships | [oauth_client.py](../../examples/oauth_client.py) | [OAuth clients](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/oauth-clients) | +| `client.oauth_tokens` | `OAuthTokens` | `list`, `read`, `update`, `delete` | [oauth_token.py](../../examples/oauth_token.py) | [OAuth tokens](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/oauth-tokens) | +| `client.notification_configurations` | `NotificationConfigurations` | `list`, `read`, `create`, `update`, `delete`, `verify` | [notification_configuration.py](../../examples/notification_configuration.py) | [Notification configurations](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/notification-configurations) | +| `client.organization_audit_configurations` | `OrganizationAuditConfigurations` | `read`, `test`, `update` | [organization_audit_configuration.py](../../examples/organization_audit_configuration.py) | [Audit trail](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/audit-trails) | +| `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) | + +## Focused guides + +- [workspaces.md](workspaces.md) +- [runs-plans-applies.md](runs-plans-applies.md) +- [state-versions.md](state-versions.md) +- [variables-and-variable-sets.md](variables-and-variable-sets.md) +- [teams-and-access.md](teams-and-access.md) +- [policies.md](policies.md) +- [run-tasks.md](run-tasks.md) diff --git a/docs/api/policies.md b/docs/api/policies.md new file mode 100644 index 00000000..7a0f93ac --- /dev/null +++ b/docs/api/policies.md @@ -0,0 +1,69 @@ +# Policies + +pyTFE supports policy libraries, policy sets, policy set parameters, policy set +versions, policy checks, policy evaluations, and policy set outcomes. + +Upstream docs: + +- Policies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies +- Policy sets: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets +- Policy checks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks +- Policy evaluations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations + +Examples: + +- [policy.py](../../examples/policy.py) +- [policy_set.py](../../examples/policy_set.py) +- [policy_check.py](../../examples/policy_check.py) +- [policy_evaluation.py](../../examples/policy_evaluation.py) + +## Policies + +| Method | Purpose | +|---|---| +| `client.policies.list(organization, options=None)` | Iterate policies. | +| `client.policies.read(policy_id)` | Read a policy. | +| `client.policies.create(organization, options)` | Create a policy. | +| `client.policies.update(policy_id, options)` | Update a policy. | +| `client.policies.delete(policy_id)` | Delete a policy. | +| `client.policies.upload(policy_id, content)` | Upload policy content. | +| `client.policies.download(policy_id)` | Download policy content. | + +## Policy sets + +| Method | Purpose | +|---|---| +| `client.policy_sets.list(organization, options=None)` | Iterate policy sets. | +| `client.policy_sets.read(policy_set_id)` | Read a policy set. | +| `client.policy_sets.read_with_options(policy_set_id, options)` | Read with includes. | +| `client.policy_sets.create(organization, options)` | Create a policy set. | +| `client.policy_sets.update(policy_set_id, options)` | Update a policy set. | +| `client.policy_sets.delete(policy_set_id)` | Delete a policy set. | +| `client.policy_sets.add_policies(...)` / `remove_policies(...)` | Attach or remove policies. | +| `client.policy_sets.add_workspaces(...)` / `remove_workspaces(...)` | Attach or remove workspaces. | +| `client.policy_sets.add_projects(...)` / `remove_projects(...)` | Attach or remove projects. | +| `client.policy_sets.add_workspace_exclusions(...)` / `remove_workspace_exclusions(...)` | Manage workspace exclusions. | +| `client.policy_sets.add_project_exclusions(...)` / `remove_project_exclusions(...)` | Manage project exclusions. | + +## Policy checks + +Policy checks are attached to runs: + +```python +for check in client.policy_checks.list("run-abc123"): + print(check.id, check.status) +``` + +Override a policy check only when your token has permission: + +```python +client.policy_checks.override("polchk-abc123") +``` + +## Policy set parameters and versions + +- `client.policy_set_parameters` manages parameter values for policy sets. +- `client.policy_set_versions` creates and uploads policy set versions. +- `client.policy_set_outcomes` reads outcome data. +- `client.policy_evaluations` lists policy evaluations. + diff --git a/docs/api/run-tasks.md b/docs/api/run-tasks.md new file mode 100644 index 00000000..bc473c73 --- /dev/null +++ b/docs/api/run-tasks.md @@ -0,0 +1,103 @@ +# Run tasks + +Run tasks integrate external systems into the run lifecycle. pyTFE supports +organization run tasks, workspace run task attachments, run task webhook +callbacks, task stages, and task results. + +Upstream docs: + +- Run tasks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks +- Run task stages and results: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results +- Run task integration: https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration + +Examples: + +- [run_task.py](../../examples/run_task.py) +- [workspace_run_task.py](../../examples/workspace_run_task.py) +- [run_task_integration.py](../../examples/run_task_integration.py) +- [task_stage_example.py](../../examples/task_stage_example.py) +- [task_result.py](../../examples/task_result.py) + +## Organization run tasks + +| Method | Purpose | +|---|---| +| `client.run_tasks.list(organization, options=None)` | Iterate run tasks in an organization. | +| `client.run_tasks.read(task_id)` | Read a run task. | +| `client.run_tasks.read_with_options(task_id, options)` | Read with included relationships. | +| `client.run_tasks.create(organization, options)` | Create a run task. | +| `client.run_tasks.update(task_id, options)` | Update a run task. | +| `client.run_tasks.delete(task_id)` | Delete a run task. | + +```python +from pytfe.models import RunTaskCreateOptions + +task = client.run_tasks.create( + "my-organization", + RunTaskCreateOptions( + name="security-check", + url="https://example.com/tfc/run-task", + category="task", + enabled=True, + ), +) + +print(task.id) +``` + +## Workspace run task attachments + +Attach an organization run task to a workspace with +`client.workspace_run_tasks`: + +```python +from pytfe.models import ( + RunTask, + Stage, + TaskEnforcementLevel, + WorkspaceRunTaskCreateOptions, +) + +attachment = client.workspace_run_tasks.create( + "ws-abc123", + WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=RunTask(id="task-abc123"), + stages=[Stage.PRE_PLAN], + ), +) + +print(attachment.id) +``` + +Stage enum values must match the API wire values. Do not convert documented +stage names to a different spelling. + +## Integration callbacks + +Run task callback handling is exposed through `client.run_task_integrations`. +The callback URL and token come from a run task webhook payload: + +```python +from pytfe.models import TaskResultCallbackRequestOptions, TaskResultStatus + + +client.run_task_integrations.callback( + callback_url, + access_token, + TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="Checks passed", + ), +) +``` + +## Task stages and results + +Use `client.task_stages` and `client.task_results` for stage-level inspection +and overrides: + +```python +stage = client.task_stages.read("ts-abc123") +result = client.task_results.read("taskrs-abc123") +``` diff --git a/docs/api/runs-plans-applies.md b/docs/api/runs-plans-applies.md new file mode 100644 index 00000000..704748a6 --- /dev/null +++ b/docs/api/runs-plans-applies.md @@ -0,0 +1,105 @@ +# Runs, plans, and applies + +Runs represent the lifecycle of a Terraform operation. A run can have a plan, +an apply, policy checks, task stages, comments, events, and a configuration +version. + +Upstream docs: + +- Runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run +- Plans: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies +- Configuration versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions + +Examples: + +- [run.py](../../examples/run.py) +- [plan.py](../../examples/plan.py) +- [apply.py](../../examples/apply.py) +- [configuration_version.py](../../examples/configuration_version.py) + +## Common run methods + +| Method | Purpose | +|---|---| +| `client.runs.list(workspace_id, options=None)` | Iterate runs for a workspace. | +| `client.runs.list_for_organization(organization, options=None)` | Iterate runs across an organization. | +| `client.runs.read(run_id)` | Read a run. | +| `client.runs.read_with_options(run_id, options)` | Read with included relationships. | +| `client.runs.create(options)` | Queue a run. | +| `client.runs.apply(run_id, options=None)` | Confirm/apply a run. | +| `client.runs.cancel(run_id, options=None)` | Cancel a run. | +| `client.runs.force_cancel(run_id, options=None)` | Force-cancel a run. | +| `client.runs.discard(run_id, options=None)` | Discard a run. | + +## List workspace runs + +```python +from pytfe import TFEClient +from pytfe.models import RunListOptions + +client = TFEClient() + +options = RunListOptions(page_size=50, status="planned") + +for run in client.runs.list("ws-abc123", options): + print(run.id, run.status) +``` + +## Read a run with relationships + +```python +from pytfe.models import RunIncludeOpt, RunReadOptions + +run = client.runs.read_with_options( + "run-abc123", + RunReadOptions(include=[RunIncludeOpt.RUN_WORKSPACE, RunIncludeOpt.RUN_PLAN]), +) + +print(run.workspace.id if run.workspace else None) +print(run.plan.id if run.plan else None) +``` + +## Queue a run + +```python +from pytfe.models import RunCreateOptions, Workspace + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id="ws-abc123"), + message="Queued by pyTFE", + ) +) + +print(run.id) +``` + +## Plans and JSON output + +```python +plan = client.plans.read_for_run("run-abc123") +json_output = client.plans.read_json_output_for_run("run-abc123") + +print(plan.id) +print(json_output.get("format_version")) +``` + +Plan JSON output and schema endpoints may redirect to signed blob URLs. pyTFE +handles those redirects internally. + +## Applies and errored state + +```python +apply = client.applies.read("apply-abc123") +logs = client.applies.logs(apply.id) + +try: + errored_state = client.applies.errored_state(apply.id) +except Exception: + errored_state = None +``` + +`errored_state` is only available for applies that failed during state upload. +The API returns `404` when there is no recoverable errored state. + diff --git a/docs/api/state-versions.md b/docs/api/state-versions.md new file mode 100644 index 00000000..f829cd40 --- /dev/null +++ b/docs/api/state-versions.md @@ -0,0 +1,97 @@ +# State versions + +State versions represent Terraform state snapshots stored by HCP Terraform or +Terraform Enterprise. Use this API carefully: state can contain sensitive +values. + +Upstream docs: + +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- State version outputs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs + +Example: [state_versions.py](../../examples/state_versions.py) + +## Common methods + +| Method | Purpose | +|---|---| +| `client.state_versions.list(options=None)` | Iterate state versions with optional organization/workspace filters. | +| `client.state_versions.read(state_version_id)` | Read a state version. | +| `client.state_versions.read_current(workspace_id)` | Read the current state version for a workspace. | +| `client.state_versions.create(workspace, options, organization=None)` | Create a state-version record. | +| `client.state_versions.upload(...)` | Create a state version and upload raw state bytes. | +| `client.state_versions.download(state_version_id)` | Download raw state bytes. | +| `client.state_versions.download_current(workspace_id)` | Download current raw state bytes. | +| `client.state_versions.list_outputs(state_version_id, options=None)` | Iterate outputs for a state version. | +| `client.state_versions.rollback(workspace_id, state_version_id)` | Roll a workspace back to an earlier state version. | +| `client.state_version_outputs.read(output_id)` | Read a single output. | +| `client.state_version_outputs.read_current(workspace_id, options=None)` | Iterate current outputs for a workspace. | + +## List state versions + +```python +from pytfe import TFEClient +from pytfe.models import StateVersionListOptions + +client = TFEClient() + +options = StateVersionListOptions( + organization="my-organization", + workspace="example-workspace", + page_size=50, +) + +for state_version in client.state_versions.list(options): + print(state_version.id, state_version.serial, state_version.status) +``` + +## Read or download current state + +```python +current = client.state_versions.read_current("ws-abc123") +raw_state = client.state_versions.download_current("ws-abc123") + +print(current.id) +print(len(raw_state)) +``` + +Downloaded state bytes should be treated as sensitive. + +## Upload state + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions + +raw_state = b"{... terraform state json ...}" + +state_version = client.state_versions.upload( + "ws-abc123", + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=42, + md5=hashlib.md5(raw_state).hexdigest(), + ), +) + +print(state_version.id, state_version.status) +``` + +`upload` follows the API's hosted upload URL workflow and returns a refreshed +state-version object. Depending on server timing, the returned state version may +still be processing. + +## Roll back a workspace + +```python +rolled_back = client.state_versions.rollback( + "ws-abc123", + "sv-previous123", +) + +print(rolled_back.id) +``` + +The workspace must be locked by the caller before rollback; otherwise the API +returns a conflict. diff --git a/docs/api/teams-and-access.md b/docs/api/teams-and-access.md new file mode 100644 index 00000000..b0909fc8 --- /dev/null +++ b/docs/api/teams-and-access.md @@ -0,0 +1,76 @@ +# Teams and access + +Team and access APIs control who can view, plan, apply, manage variables, and +administer workspaces or projects. + +Upstream docs: + +- Teams: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams +- Team tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens +- Team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access +- Project team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/project-team-access +- Organization memberships: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/organization-memberships + +Examples: + +- [team.py](../../examples/team.py) +- [team_workspace_access.py](../../examples/team_workspace_access.py) +- [team_project_access.py](../../examples/team_project_access.py) +- [team_token.py](../../examples/team_token.py) + +## Teams + +| Method | Purpose | +|---|---| +| `client.teams.list(organization, options=None)` | Iterate teams in an organization. | +| `client.teams.read(team_id)` | Read a team. | +| `client.teams.create(organization, options)` | Create a team. | +| `client.teams.update(team_id, options)` | Update a team. | +| `client.teams.delete(team_id)` | Delete a team. | +| `client.teams.add_users(team_id, usernames)` | Add users by username. | +| `client.teams.remove_users(team_id, usernames)` | Remove users by username. | +| `client.teams.add_organization_memberships(team_id, ids)` | Add users by organization membership ID. | +| `client.teams.remove_organization_memberships(team_id, ids)` | Remove users by organization membership ID. | +| `client.teams.list_users(team_id)` | List users included in a team. | +| `client.teams.list_organization_memberships(team_id)` | List team memberships. | + +## Workspace access + +```python +from pytfe.models import ( + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, +) + +grant = client.team_workspace_accesses.add( + TeamWorkspaceAccessAddOptions( + team_id="team-abc123", + workspace_id="ws-abc123", + access=TeamWorkspaceAccessType.WRITE, + ) +) + +print(grant.id) +``` + +Use `TeamWorkspaceAccessType.CUSTOM` with the custom permission fields when you +need to model fine-grained access. + +## Project access + +`client.team_project_accesses` manages access grants between teams and projects. +Use project access for broad permissions across all workspaces in a project. +Use workspace access for exceptions or smaller scopes. + +## Team tokens + +Team tokens are useful for automation owned by a team: + +```python +token = client.team_tokens.create("team-abc123") +print(token.token) +``` + +Store returned token values in a secret manager. Token values are sensitive and +may only be returned at creation time. + diff --git a/docs/api/variables-and-variable-sets.md b/docs/api/variables-and-variable-sets.md new file mode 100644 index 00000000..b68df60a --- /dev/null +++ b/docs/api/variables-and-variable-sets.md @@ -0,0 +1,76 @@ +# Variables and variable sets + +pyTFE has separate services for workspace variables, variable sets, and +variables inside variable sets. + +Upstream docs: + +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- Variable sets: https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets + +Examples: + +- [variables.py](../../examples/variables.py) +- [variable_sets.py](../../examples/variable_sets.py) + +## Workspace variables + +| Method | Purpose | +|---|---| +| `client.variables.list(workspace_id, options=None)` | Iterate variables directly attached to a workspace. | +| `client.variables.list_all(workspace_id, options=None)` | Iterate direct and inherited variables. | +| `client.variables.read(workspace_id, variable_id)` | Read a variable. | +| `client.variables.create(workspace_id, options)` | Create a variable. | +| `client.variables.update(workspace_id, variable_id, options)` | Update a variable. | +| `client.variables.delete(workspace_id, variable_id)` | Delete a variable. | + +```python +from pytfe.models import CategoryType, VariableCreateOptions + +variable = client.variables.create( + "ws-abc123", + VariableCreateOptions( + key="TF_VAR_region", + value="us-east-1", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +print(variable.id) +``` + +Use `list_all` when you need variables inherited from variable sets: + +```python +for variable in client.variables.list_all("ws-abc123"): + print(variable.key) +``` + +## Variable sets + +| Method | Purpose | +|---|---| +| `client.variable_sets.list(organization, options=None)` | Iterate variable sets in an organization. | +| `client.variable_sets.list_for_workspace(workspace_id, options=None)` | Iterate variable sets attached to a workspace. | +| `client.variable_sets.list_for_project(project_id, options=None)` | Iterate variable sets attached to a project. | +| `client.variable_sets.read(varset_id, options=None)` | Read a variable set. | +| `client.variable_sets.create(organization, options)` | Create a variable set. | +| `client.variable_sets.update(varset_id, options)` | Update a variable set. | +| `client.variable_sets.delete(varset_id)` | Delete a variable set. | +| `client.variable_sets.apply_to_workspaces(...)` | Attach a variable set to workspaces. | +| `client.variable_sets.apply_to_projects(...)` | Attach a variable set to projects. | + +## Variables inside variable sets + +| Method | Purpose | +|---|---| +| `client.variable_set_variables.list(varset_id, options=None)` | Iterate variables in a variable set. | +| `client.variable_set_variables.read(varset_id, variable_id)` | Read a variable-set variable. | +| `client.variable_set_variables.create(varset_id, options)` | Create a variable-set variable. | +| `client.variable_set_variables.update(varset_id, variable_id, options)` | Update a variable-set variable. | +| `client.variable_set_variables.delete(varset_id, variable_id)` | Delete a variable-set variable. | + +Prefer variable sets for shared values across many workspaces or projects. +Prefer workspace variables for workspace-specific values. + diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md new file mode 100644 index 00000000..ed49cf46 --- /dev/null +++ b/docs/api/workspaces.md @@ -0,0 +1,78 @@ +# Workspaces + +Workspaces are the center of most pyTFE workflows. Use `client.workspaces` for +workspace settings and relationships, then combine it with runs, variables, +state versions, teams, and policies as needed. + +Upstream docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces + +Example: [workspace.py](../../examples/workspace.py) + +## Common methods + +| Method | Purpose | +|---|---| +| `client.workspaces.list(organization, options=None)` | Iterate workspaces in an organization. | +| `client.workspaces.read(organization, name)` | Read by organization/name. | +| `client.workspaces.read_by_id(workspace_id)` | Read by workspace ID. | +| `client.workspaces.create(organization, options)` | Create a workspace. | +| `client.workspaces.update(organization, name, options)` | Update by organization/name. | +| `client.workspaces.update_by_id(workspace_id, options)` | Update by workspace ID. | +| `client.workspaces.delete(...)` / `delete_by_id(...)` | Delete a workspace. | +| `client.workspaces.safe_delete(...)` / `safe_delete_by_id(...)` | Delete with the API safe-delete path. | +| `client.workspaces.lock(...)`, `unlock(...)`, `force_unlock(...)` | Manage workspace locks. | +| `client.workspaces.assign_ssh_key(...)`, `unassign_ssh_key(...)` | Manage workspace SSH key assignment. | +| `client.workspaces.list_remote_state_consumers(...)` and related methods | Manage remote state consumers. | +| `client.workspaces.list_tags(...)`, `add_tags(...)`, `remove_tags(...)` | Manage workspace tags. | +| `client.workspaces.list_tag_bindings(...)` and related methods | Manage tag bindings. | + +## List and filter + +```python +from pytfe import TFEClient +from pytfe.models import WorkspaceListOptions + +client = TFEClient() + +options = WorkspaceListOptions(page_size=50, search="prod") + +for workspace in client.workspaces.list("my-organization", options): + print(workspace.id, workspace.name) +``` + +`list` returns an iterator. Use `list(client.workspaces.list(...))` if you need +a materialized Python list. + +## Create a workspace + +```python +from pytfe import TFEClient +from pytfe.models import WorkspaceCreateOptions + +client = TFEClient() + +workspace = client.workspaces.create( + "my-organization", + WorkspaceCreateOptions(name="example-workspace"), +) + +print(workspace.id) +``` + +## Read by name or ID + +```python +workspace = client.workspaces.read("my-organization", "example-workspace") +same_workspace = client.workspaces.read_by_id(workspace.id) +``` + +Prefer ID-based methods in automation when you already have the workspace ID. +They avoid ambiguity when names change. + +## Related resources + +- Runs: [runs-plans-applies.md](runs-plans-applies.md) +- State: [state-versions.md](state-versions.md) +- Variables: [variables-and-variable-sets.md](variables-and-variable-sets.md) +- Teams and access: [teams-and-access.md](teams-and-access.md) + diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..e9100f0f --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,145 @@ +# Authentication + +pyTFE authenticates to HCP Terraform and Terraform Enterprise with an API token. +The SDK sends the token as a bearer token on API requests. + +HashiCorp's API overview documents bearer-token authentication, and HashiCorp's +API token guide explains user, team, group, and organization token behavior: + +- HCP Terraform API overview: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- API token guide: https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens + +## Default environment-based configuration + +`TFEClient()` with no arguments calls `TFEConfig.from_env()`, which reads the +same defaults as `TFEConfig()`. + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +Supported SDK configuration environment variables: + +| Environment variable | `TFEConfig` field | Default | Notes | +|---|---|---|---| +| `TFE_TOKEN` | `token` | `""` | API token used for bearer authentication. Most real calls require this to be set. | +| `TFE_ADDRESS` | `address` | `https://app.terraform.io` | Base URL for HCP Terraform or Terraform Enterprise. Do not include `/api/v2`. | +| `TFE_TIMEOUT` | `timeout` | `30` | Request timeout in seconds. Parsed as a float. | +| `TFE_VERIFY_TLS` | `verify_tls` | `true` | Set to `0`, `false`, or `no` to disable TLS verification. Use this only for controlled local testing. | +| `TFE_MAX_RETRIES` | `max_retries` | `5` | Maximum retry attempts for transient transport/server failures. Parsed as an integer. | +| `SSL_CERT_FILE` | `ca_bundle` | unset | Path to a custom CA bundle, useful for Terraform Enterprise installations using an internal CA. | + +Example: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +export TFE_TIMEOUT="60" +export TFE_MAX_RETRIES="5" +``` + +Then: + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +## Explicit configuration + +Explicit `TFEConfig(...)` values override environment fallback for the fields +you set: + +```python +from pytfe import TFEClient, TFEConfig + +config = TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + timeout=60.0, + verify_tls=True, + max_retries=3, + ca_bundle="/etc/ssl/certs/internal-ca.pem", + user_agent_suffix="my-automation/1.0", +) + +client = TFEClient(config) +``` + +Use explicit configuration when a process needs multiple clients, when tests +need isolated config, or when credentials come from a secret manager instead of +process environment variables. + +## Token type guidance + +Choose the narrowest token type that can perform the workflow: + +| Token type | Typical use | +|---|---| +| User token | Interactive or user-owned automation. Most flexible because permissions follow the user. | +| Team token | Workspace automation owned by a team. Good for routine run, state, and workspace workflows where team permissions are already scoped. | +| Group token | HCP Europe equivalent for group-based access. | +| Organization token | Organization setup and administration, such as creating workspaces and teams. Avoid using it as a general-purpose automation token. | + +Some endpoints cannot be used with organization tokens. HashiCorp marks those +endpoints in the upstream API docs. For example, API-driven runs, configuration +version uploads, and state-version writes often need a user, team, or group +token with workspace permissions. + +Never commit tokens to source control. Prefer environment variables, CI secret +stores, or a secret manager. + +## HCP Terraform vs Terraform Enterprise addresses + +| Platform | Address value | +|---|---| +| HCP Terraform | `https://app.terraform.io` | +| HCP Terraform Europe | Use the organization URL, commonly `https://app.eu.terraform.io` | +| Terraform Enterprise | Your installation base URL, for example `https://tfe.example.com` | + +The SDK appends `/api/v2/...` paths internally. + +## TLS and custom CAs + +For Terraform Enterprise with an internal CA: + +```bash +export SSL_CERT_FILE="/path/to/internal-ca-bundle.pem" +``` + +Or explicitly: + +```python +from pytfe import TFEConfig + +config = TFEConfig(ca_bundle="/path/to/internal-ca-bundle.pem") +``` + +Disabling verification is supported for local testing: + +```bash +export TFE_VERIFY_TLS=false +``` + +Do not disable TLS verification for production automation. + +## `TFE_ORG` and `TFE_ORGANIZATION` + +`TFE_ORG` and `TFE_ORGANIZATION` are not SDK configuration fields. Some example +scripts read them as convenient defaults for an organization name: + +```bash +export TFE_ORG="my-organization" +python examples/workspace.py +``` + +Application code should pass organization names to resource methods directly: + +```python +for workspace in client.workspaces.list("my-organization"): + print(workspace.name) +``` + diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 00000000..76452fbd --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,98 @@ +# Errors + +pyTFE raises Python exceptions for local validation failures, transport errors, +and API errors returned by HCP Terraform or Terraform Enterprise. + +## Base exception + +All SDK-owned API/transport errors inherit from `pytfe.errors.TFEError`: + +```python +from pytfe.errors import TFEError + +try: + workspace = client.workspaces.read_by_id("ws-abc123") +except TFEError as exc: + print(exc) + print(exc.status) + print(exc.errors) +``` + +`TFEError` exposes: + +- `status`: HTTP status code when available. +- `errors`: parsed JSON:API error objects when available. + +## Common typed errors + +| Error | Typical cause | +|---|---| +| `AuthError` | Unauthorized or forbidden request. | +| `NotFound` | Resource not found or not visible to the token. | +| `RateLimited` | Server asked the client to slow down. Includes `retry_after` when available. | +| `ValidationError` | API validation failure. | +| `ServerError` | Server-side or transport failure. | +| `UnsupportedInCloud` | Endpoint only supported in Terraform Enterprise. | +| `UnsupportedInEnterprise` | Endpoint only supported in HCP Terraform. | + +Resource-specific errors, such as `InvalidWorkspaceIDError` or +`InvalidRunIDError`, also live in `pytfe.errors`. + +## Local validation errors + +Some existing public methods raise `ValueError` for invalid local input. This is +kept for backward compatibility. Newer APIs generally prefer typed `TFEError` +subclasses, but callers should be prepared for both in older resource surfaces. + +```python +from pytfe.errors import TFEError + +try: + runs = list(client.runs.list("")) +except TFEError as exc: + handle_sdk_error(exc) +except ValueError as exc: + handle_local_validation_error(exc) +``` + +## Downstream tools and Ansible modules + +Downstream tools should catch narrow errors when they can produce a useful +message, then catch `TFEError` for general API failures: + +```python +from pytfe.errors import AuthError, NotFound, TFEError + +try: + workspace = client.workspaces.read_by_id(workspace_id) +except AuthError as exc: + module.fail_json(msg=f"authentication failed: {exc}") +except NotFound as exc: + module.fail_json(msg=f"workspace not found or not visible: {exc}") +except TFEError as exc: + module.fail_json( + msg=str(exc), + status=exc.status, + errors=exc.errors, + ) +except ValueError as exc: + module.fail_json(msg=f"invalid module input: {exc}") +``` + +Avoid string-matching error messages when a typed exception, HTTP status, or +JSON:API error pointer is available. + +## Inspecting API error details + +When the server returns JSON:API errors, `exc.errors` may contain structured +entries: + +```python +except TFEError as exc: + for error in exc.errors: + print(error) +``` + +Use these details for logs and diagnostics, but keep user-facing messages short +and avoid printing secrets. + diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..cab08515 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,107 @@ +# Getting started + +pyTFE is a Python client for the HCP Terraform and Terraform Enterprise API v2. +The client exposes one `TFEClient` object with resource services such as +`client.workspaces`, `client.runs`, and `client.state_versions`. + +## Install + +```bash +pip install pytfe +``` + +For local development from this repository: + +```bash +pip install -e .[dev] +``` + +## Configure credentials + +The quickest setup is environment variables: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +`TFE_ADDRESS` defaults to `https://app.terraform.io`, so HCP Terraform users +usually only need `TFE_TOKEN`. Terraform Enterprise users should set +`TFE_ADDRESS` to the base URL of their self-hosted instance, for example +`https://tfe.example.com`. Do not include `/api/v2`; pyTFE adds API paths. + +For HCP Terraform Europe, use the address shown for your organization, commonly +`https://app.eu.terraform.io`. + +## Create a client + +With environment variables: + +```python +from pytfe import TFEClient + +client = TFEClient() +``` + +With explicit configuration: + +```python +from pytfe import TFEClient, TFEConfig + +config = TFEConfig( + address="https://app.terraform.io", + token="your-api-token", + timeout=30.0, +) +client = TFEClient(config) +``` + +Explicit `TFEConfig(...)` values are useful in applications that manage more +than one HCP Terraform or Terraform Enterprise instance. + +## First API call + +List the organizations visible to the token: + +```python +from pytfe import TFEClient + +client = TFEClient() + +for organization in client.organizations.list(): + print(organization.name) +``` + +List workspaces in an organization: + +```python +from pytfe import TFEClient + +client = TFEClient() + +for workspace in client.workspaces.list("my-organization"): + print(workspace.id, workspace.name) +``` + +All methods named `list` or `list_*` return iterators. Use `list(...)` when you +need a concrete Python list: + +```python +workspaces = list(client.workspaces.list("my-organization")) +print(f"found {len(workspaces)} workspaces") +``` + +See [pagination.md](pagination.md) for details. + +## Common next steps + +- [authentication.md](authentication.md) documents supported environment + variables, token types, TLS settings, and explicit `TFEConfig` fields. +- [pagination.md](pagination.md) explains list iterators and page-size options. +- [api/index.md](api/index.md) maps `TFEClient` attributes to pyTFE resources, + examples, and upstream HCP Terraform API docs. +- [scenarios/api-driven-run.md](scenarios/api-driven-run.md) walks through a + full API-driven run from configuration upload to final status. +- [troubleshooting.md](troubleshooting.md) covers auth, permissions, + pagination, TLS, retries, and debug logging. +- [../examples](../examples) contains runnable scripts for common workflows. diff --git a/docs/pagination.md b/docs/pagination.md new file mode 100644 index 00000000..370d1c9b --- /dev/null +++ b/docs/pagination.md @@ -0,0 +1,95 @@ +# Pagination + +HCP Terraform list endpoints are paginated. pyTFE hides the page loop behind +Python iterators so callers can stream results naturally. + +## The rule + +Every public resource method named `list` or `list_*` returns an iterator: + +```python +for workspace in client.workspaces.list("my-organization"): + print(workspace.name) +``` + +The SDK fetches more pages as the iterator advances. + +## Materialize when you need a Python list + +Use `list(...)` when you need indexing, `len(...)`, sorting, or multiple passes: + +```python +workspaces = list(client.workspaces.list("my-organization")) + +print(len(workspaces)) +print(workspaces[0].name) +``` + +## Iterators are single-use + +Once an iterator has been consumed, iterating it again returns no items: + +```python +workspace_iter = client.workspaces.list("my-organization") + +first_pass = list(workspace_iter) +second_pass = list(workspace_iter) # [] +``` + +Create a new iterator or materialize the results first. + +## Iterators are always truthy + +Do not use `if client.workspaces.list(...):` to check whether results exist. +Python iterator objects are truthy even if the API would return zero items. + +Use: + +```python +workspaces = list(client.workspaces.list("my-organization")) +if workspaces: + print("found workspaces") +``` + +## Page-size options + +Many resources have a `*ListOptions` model with `page_size`, filters, search +fields, or include options. The SDK still returns an iterator; `page_size` only +controls how many items each underlying API request asks for. + +```python +from pytfe.models import WorkspaceListOptions + +options = WorkspaceListOptions(page_size=50, search="prod") + +for workspace in client.workspaces.list("my-organization", options): + print(workspace.name) +``` + +Runs support both page size and filters: + +```python +from pytfe.models import RunListOptions + +options = RunListOptions(page_size=50, status="planned") + +for run in client.runs.list("ws-abc123", options): + print(run.id, run.status) +``` + +## Common gotchas + +- `list` / `list_*` methods are lazy. If an invalid-id check is inside a + generator method, the exception is raised when you iterate, not when you + create the iterator. +- Some relationship endpoints are not paginated by the server, but pyTFE still + exposes them as iterators for a consistent public API. +- A small number of older methods intentionally return concrete lists for + backward compatibility. Prefer the iterator rule for new code, and check the + method's return type if you are unsure. +- `page[number]` is managed internally by pyTFE's pagination helper. In normal + usage, set filters and `page_size`, then iterate. + +For contributor implementation rules, see the internal reference +[ITERATORS.md](ITERATORS.md). + diff --git a/docs/scenarios/api-driven-run.md b/docs/scenarios/api-driven-run.md new file mode 100644 index 00000000..a03ed9d5 --- /dev/null +++ b/docs/scenarios/api-driven-run.md @@ -0,0 +1,137 @@ +# Scenario: API-driven run + +This scenario shows the common API-driven workflow: + +1. Create or read a workspace. +2. Create a configuration version. +3. Upload Terraform configuration. +4. Queue a run using that configuration version. +5. Wait for the plan. +6. Read plan JSON output. +7. Apply the run. +8. Read the final run status. + +Upstream docs: + +- Configuration versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/configuration-versions +- Runs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run +- Plans: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/plans +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies + +## Prerequisites + +Set authentication and choose an organization: + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +export TFE_ORG="my-organization" +``` + +The token needs permission to create or update the workspace, create +configuration versions, queue runs, and apply runs. + +## End-to-end example + +```python +import os +import time +from pathlib import Path + +from pytfe import TFEClient +from pytfe.models import ( + ConfigurationVersion, + ConfigurationVersionCreateOptions, + RunApplyOptions, + RunCreateOptions, + Workspace, + WorkspaceCreateOptions, +) +from pytfe.errors import TFEError + + +client = TFEClient() +organization = os.environ["TFE_ORG"] +workspace_name = "pytfe-api-driven-example" +terraform_dir = Path("./terraform") + + +def read_or_create_workspace() -> Workspace: + try: + return client.workspaces.read(organization, workspace_name) + except TFEError: + return client.workspaces.create( + organization, + WorkspaceCreateOptions(name=workspace_name), + ) + + +workspace = read_or_create_workspace() + +config_version = client.configuration_versions.create( + workspace.id, + ConfigurationVersionCreateOptions(auto_queue_runs=False), +) + +if not config_version.upload_url: + raise RuntimeError("configuration version did not include an upload URL") + +client.configuration_versions.upload(config_version.upload_url, str(terraform_dir)) + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace.id), + configuration_version=ConfigurationVersion(id=config_version.id), + message="Queued by pyTFE", + ) +) + +terminal_statuses = {"applied", "errored", "canceled", "discarded"} +plan_ready_statuses = { + "planned", + "planned_and_finished", + "planned_and_saved", + "policy_checked", + "policy_soft_failed", + "cost_estimated", +} + +while True: + run = client.runs.read(run.id) + status = run.status.value if run.status else "" + print("run status:", status) + + if status in plan_ready_statuses or status in terminal_statuses: + break + + time.sleep(5) + +plan_json = client.plans.read_json_output_for_run(run.id) +print("plan format:", plan_json.get("format_version")) + +if (run.status.value if run.status else "") not in terminal_statuses: + client.runs.apply(run.id, RunApplyOptions(comment="Applied by pyTFE")) + +while True: + run = client.runs.read(run.id) + status = run.status.value if run.status else "" + print("run status:", status) + + if status in terminal_statuses: + break + + time.sleep(5) + +print("final status:", run.status) +``` + +## Notes + +- `ConfigurationVersionCreateOptions(auto_queue_runs=False)` keeps the example + explicit: the code uploads configuration first, then queues a run. +- `configuration_versions.upload(upload_url, path)` packages the directory into + a tar gzip archive and uploads it to the hosted upload URL. +- Plan JSON endpoints may redirect to signed blob URLs. pyTFE follows those + redirects internally. +- Always add cleanup if this runs in CI or repeated integration tests. + diff --git a/docs/scenarios/manage-workspace-variables.md b/docs/scenarios/manage-workspace-variables.md new file mode 100644 index 00000000..4d8c1700 --- /dev/null +++ b/docs/scenarios/manage-workspace-variables.md @@ -0,0 +1,125 @@ +# Scenario: Manage workspace variables + +HCP Terraform has two related variable concepts: + +- Workspace variables belong directly to one workspace. +- Variable sets are reusable collections that can apply to many workspaces or + projects. + +Use workspace variables for workspace-specific values. Use variable sets for +shared values such as cloud regions, common Terraform inputs, or provider +credentials reused across many workspaces. + +Upstream docs: + +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- Variable sets: https://developer.hashicorp.com/terraform/enterprise/api-docs/variable-sets + +## Workspace variables + +```python +from pytfe import TFEClient +from pytfe.models import CategoryType, VariableCreateOptions, VariableUpdateOptions + + +client = TFEClient() +workspace_id = "ws-abc123" + +region = client.variables.create( + workspace_id, + VariableCreateOptions( + key="TF_VAR_region", + value="us-east-1", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +updated = client.variables.update( + workspace_id, + region.id, + VariableUpdateOptions(value="us-west-2"), +) + +for variable in client.variables.list(workspace_id): + print(variable.id, variable.key, variable.category, variable.sensitive) + +client.variables.delete(workspace_id, updated.id) +``` + +Sensitive variable values may not be returned by the API after creation. Store +the source value in your secret manager; do not rely on reading it back. + +## Inherited variables + +`client.variables.list(...)` returns variables directly attached to a workspace. +Use `list_all(...)` when you also need variables inherited from variable sets: + +```python +for variable in client.variables.list_all("ws-abc123"): + print(variable.key) +``` + +## Variable sets + +```python +from pytfe.models import ( + CategoryType, + VariableSetApplyToWorkspacesOptions, + VariableSetCreateOptions, + VariableSetVariableCreateOptions, + Workspace, +) + + +varset = client.variable_sets.create( + "my-organization", + VariableSetCreateOptions( + name="shared-cloud-settings", + description="Shared cloud settings", + global_=False, + ), +) + +client.variable_set_variables.create( + varset.id, + VariableSetVariableCreateOptions( + key="TF_VAR_owner", + value="platform-team", + category=CategoryType.TERRAFORM, + sensitive=False, + ), +) + +client.variable_sets.apply_to_workspaces( + varset.id, + VariableSetApplyToWorkspacesOptions( + workspaces=[Workspace(id="ws-abc123")], + ), +) +``` + +## Update and cleanup + +```python +from pytfe.models import VariableSetVariableUpdateOptions + + +for variable in client.variable_set_variables.list(varset.id): + if variable.key == "TF_VAR_owner": + client.variable_set_variables.update( + varset.id, + variable.id, + VariableSetVariableUpdateOptions(value="infra-team"), + ) + +client.variable_sets.delete(varset.id) +``` + +## Operational tips + +- Treat sensitive variables as write-only. +- Prefer variable sets for shared values to avoid drift between workspaces. +- Use workspace variables for exceptions and workspace-local values. +- Be deliberate with global variable sets because they apply broadly. + diff --git a/docs/scenarios/policy-enforcement.md b/docs/scenarios/policy-enforcement.md new file mode 100644 index 00000000..db9cd029 --- /dev/null +++ b/docs/scenarios/policy-enforcement.md @@ -0,0 +1,107 @@ +# Scenario: Policy enforcement + +This scenario shows a basic policy workflow: create a policy, create a policy +set, attach it to a workspace or project, inspect policy checks, and override +when permitted. + +Upstream docs: + +- Policies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policies +- Policy sets: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-sets +- Policy checks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-checks +- Policy evaluations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/policy-evaluations + +## Create a policy + +```python +from pytfe import TFEClient +from pytfe.models import ( + EnforcementLevel, + Policy, + PolicyCreateOptions, + PolicyKind, + PolicySetAddPoliciesOptions, + PolicySetAddWorkspacesOptions, + PolicySetCreateOptions, + PolicySetRemovePoliciesOptions, + Workspace, +) + + +client = TFEClient() +organization = "my-organization" + +policy = client.policies.create( + organization, + PolicyCreateOptions( + name="require-tags", + kind=PolicyKind.OPA, + query="data.terraform.main.deny", + enforcement_level=EnforcementLevel.ENFORCEMENT_ADVISORY, + description="Example policy managed by pyTFE", + ), +) + +client.policies.upload(policy.id, b'package terraform.main\n\ndeny := []\n') +``` + +## Create a policy set and attach resources + +```python +policy_set = client.policy_sets.create( + organization, + PolicySetCreateOptions( + name="platform-guardrails", + description="Platform policy set", + kind=PolicyKind.OPA, + Global=False, + ), +) + +client.policy_sets.add_policies( + policy_set.id, + PolicySetAddPoliciesOptions(policies=[Policy(id=policy.id)]), +) + +client.policy_sets.add_workspaces( + policy_set.id, + PolicySetAddWorkspacesOptions(workspaces=[Workspace(id="ws-abc123")]), +) +``` + +Use project relationships when every workspace in a project should share the +same policy set. + +## Inspect checks on a run + +```python +for check in client.policy_checks.list("run-abc123"): + print(check.id, check.status) +``` + +Read logs for a check: + +```python +logs = client.policy_checks.logs("polchk-abc123") +print(logs) +``` + +## Override when allowed + +```python +client.policy_checks.override("polchk-abc123") +``` + +Overrides require server-side permission and policy configuration that allows +overrides. + +## Cleanup + +```python +client.policy_sets.remove_policies( + policy_set.id, + PolicySetRemovePoliciesOptions(policies=[Policy(id=policy.id)]), +) +client.policy_sets.delete(policy_set.id) +client.policies.delete(policy.id) +``` diff --git a/docs/scenarios/run-task-integration.md b/docs/scenarios/run-task-integration.md new file mode 100644 index 00000000..b3fee85b --- /dev/null +++ b/docs/scenarios/run-task-integration.md @@ -0,0 +1,101 @@ +# Scenario: Run task integration + +Run tasks let HCP Terraform call an external service during the run lifecycle. +pyTFE supports managing run tasks, attaching them to workspaces, sending +callback responses, and reading task stages/results. + +Upstream docs: + +- Run tasks: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-tasks +- Run task integration: https://developer.hashicorp.com/terraform/enterprise/api-docs/run-tasks/run-tasks-integration +- Run task stages and results: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-tasks/run-task-stages-and-results + +## Create a run task + +```python +from pytfe import TFEClient +from pytfe.models import ( + RunTask, + RunTaskCreateOptions, + Stage, + TaskEnforcementLevel, + TaskResultCallbackRequestOptions, + TaskResultStatus, + WorkspaceRunTaskCreateOptions, +) + + +client = TFEClient() + +task = client.run_tasks.create( + "my-organization", + RunTaskCreateOptions( + name="external-security-check", + description="Example external check", + url="https://example.com/tfc/run-task", + category="task", + hmac_key="shared-secret", + enabled=True, + ), +) +``` + +## Attach the task to a workspace + +```python +workspace_task = client.workspace_run_tasks.create( + "ws-abc123", + WorkspaceRunTaskCreateOptions( + enforcement_level=TaskEnforcementLevel.MANDATORY, + run_task=RunTask(id=task.id), + stages=[Stage.PRE_PLAN], + ), +) + +print(workspace_task.id) +``` + +Stage enum values should match the API contract. Check upstream docs and go-tfe +when adding or changing stage handling. + +## Send a callback response + +When HCP Terraform triggers the run task, it sends your service a request body +that includes a callback URL and callback access token. Use those values for the +callback; do not use the SDK client's normal `TFE_TOKEN`. + +```python +client.run_task_integrations.callback( + callback_url, + callback_access_token, + TaskResultCallbackRequestOptions( + status=TaskResultStatus.passed, + message="External check passed", + url="https://example.com/results/123", + ), +) +``` + +## Inspect task stages and results + +```python +for stage in client.task_stages.list("run-abc123"): + print(stage.id, stage.stage, stage.status) + +stage = client.task_stages.read("ts-abc123") +result = client.task_results.read("taskrs-abc123") +``` + +If a stage is awaiting override and your token has permission: + +```python +client.task_stages.override("ts-abc123", "Approved by platform team") +``` + +## Security notes + +- Verify webhook signatures in your run task service. +- Store the HMAC key in a secret manager. +- Use the callback access token only for the callback request. +- Do not log webhook payloads if they may contain sensitive plan data. + diff --git a/docs/scenarios/state-management.md b/docs/scenarios/state-management.md new file mode 100644 index 00000000..f4ee12bf --- /dev/null +++ b/docs/scenarios/state-management.md @@ -0,0 +1,101 @@ +# Scenario: State management + +Terraform state can contain provider credentials, resource attributes, outputs, +and other sensitive values. Treat any downloaded state bytes as secret material. +Do not log state, commit state to source control, or store state in CI artifacts. + +Upstream docs: + +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- State version outputs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-version-outputs + +## Read current state metadata + +```python +from pytfe import TFEClient + + +client = TFEClient() +workspace_id = "ws-abc123" + +current = client.state_versions.read_current(workspace_id) +print(current.id, current.serial, current.status) +``` + +## Download current state bytes + +```python +raw_state = client.state_versions.download_current(workspace_id) +print(f"downloaded {len(raw_state)} bytes") +``` + +The returned bytes are the raw state file. Keep them in memory when possible. +If you must write them to disk, use restricted permissions and remove the file +after use. + +## List outputs + +```python +for output in client.state_versions.list_outputs(current.id): + print(output.name, output.sensitive) +``` + +For current workspace outputs: + +```python +for output in client.state_version_outputs.read_current(workspace_id): + print(output.name, output.sensitive) +``` + +Do not print sensitive output values. + +## Upload state + +Uploading state is an advanced operation. Prefer normal Terraform runs when +possible. + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +raw_state = b"{... raw terraform state json ...}" + +new_state = client.state_versions.upload( + workspace_id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=43, + md5=hashlib.md5(raw_state).hexdigest(), + ), +) + +print(new_state.id, new_state.status) +``` + +Use a serial number newer than the current state. Depending on server timing, +the returned state version may still be processing. + +## Roll back a workspace + +Rollback duplicates an older state version and makes the copy current. The +workspace must be locked by the caller first. + +```python +client.workspaces.lock( + workspace_id, + WorkspaceLockOptions(reason="Rollback state with pyTFE"), +) + +try: + rolled_back = client.state_versions.rollback( + workspace_id, + "sv-previous123", + ) + print(rolled_back.id) +finally: + client.workspaces.unlock(workspace_id) +``` + +Use rollback only with an explicit operational reason and a recovery plan. diff --git a/docs/scenarios/team-access-onboarding.md b/docs/scenarios/team-access-onboarding.md new file mode 100644 index 00000000..7766d0fc --- /dev/null +++ b/docs/scenarios/team-access-onboarding.md @@ -0,0 +1,113 @@ +# Scenario: Team access onboarding + +This scenario shows how to create a team, add members, grant workspace access, +and create a team token for automation. + +Upstream docs: + +- Teams: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/teams +- Team access: https://developer.hashicorp.com/terraform/enterprise/api-docs/team-access +- Team tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/team-tokens + +## Create a team + +```python +from datetime import datetime, timezone + +from pytfe import TFEClient +from pytfe.models import ( + TeamCreateOptions, + TeamTokenCreateOptions, + TeamWorkspaceAccessAddOptions, + TeamWorkspaceAccessType, +) + + +client = TFEClient() + +team = client.teams.create( + "my-organization", + TeamCreateOptions( + name="platform-automation", + visibility="organization", + ), +) + +print(team.id) +``` + +## Add users or organization memberships + +If you know usernames: + +```python +client.teams.add_users(team.id, ["alice", "bob"]) +``` + +If you manage users by organization membership ID: + +```python +client.teams.add_organization_memberships( + team.id, + ["ou-abc123", "ou-def456"], +) +``` + +You can inspect membership later: + +```python +users = list(client.teams.list_users(team.id)) +memberships = list(client.teams.list_organization_memberships(team.id)) +``` + +## Grant workspace access + +```python +grant = client.team_workspace_accesses.add( + TeamWorkspaceAccessAddOptions( + team_id=team.id, + workspace_id="ws-abc123", + access=TeamWorkspaceAccessType.WRITE, + ) +) + +print(grant.id) +``` + +Use project access when the team needs the same access across a project: + +```python +# See examples/team_project_access.py for a full project access example. +for access in client.team_project_accesses.list("team-abc123"): + print(access.id) +``` + +## Create a team token + +```python +token = client.team_tokens.create_with_options( + team.id, + TeamTokenCreateOptions( + description="automation", + expired_at=datetime(2026, 12, 31, tzinfo=timezone.utc), + ), +) + +print(token.id) +print(token.token) +``` + +The token value is sensitive. Store it in a secret manager immediately. Do not +print it in production logs. + +## Cleanup + +```python +client.team_workspace_accesses.remove("twsa-abc123") +client.team_tokens.delete_by_id("at-abc123") +client.teams.remove_users(team.id, ["alice", "bob"]) +client.teams.delete(team.id) +``` + +Use the IDs returned by your create calls for cleanup. + diff --git a/docs/terraform-enterprise.md b/docs/terraform-enterprise.md new file mode 100644 index 00000000..6b3c1065 --- /dev/null +++ b/docs/terraform-enterprise.md @@ -0,0 +1,95 @@ +# Terraform Enterprise + +pyTFE supports both HCP Terraform and Terraform Enterprise. Most SDK calls use +the same API paths, but Terraform Enterprise installations often need extra +connection and compatibility setup. + +## Address + +Set `TFE_ADDRESS` to the base URL of your Terraform Enterprise installation: + +```bash +export TFE_ADDRESS="https://tfe.example.com" +export TFE_TOKEN="your-api-token" +``` + +Do not include `/api/v2`; pyTFE adds API paths internally. + +Equivalent explicit configuration: + +```python +from pytfe import TFEClient, TFEConfig + +client = TFEClient( + TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + ) +) +``` + +## TLS and private CAs + +Use `SSL_CERT_FILE` for private or internal certificate authorities: + +```bash +export SSL_CERT_FILE="/etc/ssl/certs/tfe-ca-bundle.pem" +``` + +Or: + +```python +from pytfe import TFEConfig + +config = TFEConfig( + address="https://tfe.example.com", + token="your-api-token", + ca_bundle="/etc/ssl/certs/tfe-ca-bundle.pem", +) +``` + +Disable TLS verification only for controlled local testing: + +```bash +export TFE_VERIFY_TLS=false +``` + +## Private network access + +Terraform Enterprise instances are often only reachable from a private network. +If pyTFE works locally but fails in CI, check: + +- CI runner network access to the Terraform Enterprise hostname. +- DNS resolution from the runner. +- Corporate proxy rules. +- Firewall rules and allow lists. +- TLS interception and CA trust. + +## Older server feature gaps + +Some pyTFE methods wrap newer API endpoints. Older Terraform Enterprise +versions may not support every feature exposed by the SDK. + +Common signs: + +- `404` for an endpoint that exists in current HCP Terraform docs. +- `422` validation errors for newer request attributes. +- Missing response fields or relationships. +- Hosted upload/download URLs not returned by older endpoints. + +When adding new automation, test it against the oldest Terraform Enterprise +version you support. + +## Enterprise-only and Cloud-only endpoints + +Some endpoints are only available in Terraform Enterprise. Others are only +available in HCP Terraform. pyTFE exposes typed errors such as +`UnsupportedInEnterprise` and `UnsupportedInCloud` where the SDK can identify +that distinction. + +For behavior questions, compare: + +- Terraform Enterprise API docs: https://developer.hashicorp.com/terraform/enterprise/api-docs +- HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- go-tfe: https://github.com/hashicorp/go-tfe + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..04794492 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,123 @@ +# Troubleshooting + +This page covers common pyTFE issues and the first checks to run. + +## Turn on transport logs + +```bash +PYTFE_LOG=debug python your_script.py +``` + +`PYTFE_LOG=debug` prints request/response traces through the `pytfe.transport` +logger. Tokens and common secret keys are redacted before logging. + +For retry-only visibility: + +```bash +PYTFE_LOG=info python your_script.py +``` + +See [LOGGING.md](LOGGING.md) for the full logging reference. + +## Authentication failures + +Symptoms: + +- `AuthError` +- HTTP `401` +- HTTP `403` + +Checks: + +- Confirm `TFE_TOKEN` is set in the process running the code. +- Confirm `TFE_ADDRESS` points to the right HCP Terraform or Terraform + Enterprise instance. +- Confirm the token type is accepted by the endpoint. Some endpoints require a + user, team, or group token rather than an organization token. +- Confirm the token has permission on the target organization, project, or + workspace. + +## `404` can mean missing resource or missing permission + +HCP Terraform may return `404` when the resource does not exist or when the +token cannot see it. Check: + +- Does the ID belong to the same organization or instance? +- Is the resource deleted or archived? +- Does the token have permission to read the parent workspace/project/org? +- Are you using a workspace name where the SDK method expects a workspace ID? + +## Pagination surprises + +All `list` and `list_*` methods return iterators: + +```python +items = client.workspaces.list("my-org") +print(items) # iterator object +print(list(items)) # actual results +``` + +Iterators are single-use. If you need to traverse results twice, materialize +them once: + +```python +workspaces = list(client.workspaces.list("my-org")) +``` + +See [pagination.md](pagination.md). + +## Terraform Enterprise with self-signed or private CAs + +If TLS verification fails for Terraform Enterprise, prefer a CA bundle: + +```bash +export SSL_CERT_FILE="/path/to/internal-ca-bundle.pem" +``` + +Or: + +```python +from pytfe import TFEConfig + +config = TFEConfig(ca_bundle="/path/to/internal-ca-bundle.pem") +``` + +Avoid `TFE_VERIFY_TLS=false` outside local testing. + +## Retries and transient failures + +pyTFE retries transient failures in the transport layer. Configure the maximum +retry count with: + +```bash +export TFE_MAX_RETRIES=5 +``` + +For noisy CI environments, enabling retry logs can help: + +```bash +PYTFE_LOG=info python your_script.py +``` + +## Signed upload and download URLs + +Some endpoints return hosted upload/download URLs for configuration versions, +state versions, plan JSON, or errored state. Prefer the high-level pyTFE helper +methods such as: + +- `client.configuration_versions.upload(...)` +- `client.state_versions.upload(...)` +- `client.state_versions.download(...)` +- `client.plans.read_json_output_for_run(...)` +- `client.applies.errored_state(...)` + +These methods handle the non-standard response shapes for you. + +## Getting help from the upstream API docs + +When an endpoint behaves unexpectedly, compare your code with: + +- HCP Terraform API docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs +- Terraform Enterprise API docs: https://developer.hashicorp.com/terraform/enterprise/api-docs +- go-tfe implementation: https://github.com/hashicorp/go-tfe + From bcfd550ebbafb331b511b575fe61b7b0abe17703 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 18:33:44 +0530 Subject: [PATCH 2/5] fix few doc --- README.md | 3 + docs/TESTS.md | 19 +- docs/api/workspaces.md | 28 ++- docs/scenarios/agent-pool-setup.md | 170 ++++++++++++++++ docs/scenarios/api-driven-run.md | 2 +- docs/scenarios/errored-state-recovery.md | 167 +++++++++++++++ docs/scenarios/notification-configurations.md | 192 ++++++++++++++++++ docs/scenarios/team-access-onboarding.md | 9 +- 8 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 docs/scenarios/agent-pool-setup.md create mode 100644 docs/scenarios/errored-state-recovery.md create mode 100644 docs/scenarios/notification-configurations.md diff --git a/README.md b/README.md index 7d6c2051..303119dc 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,9 @@ For full details — environment variables, redaction guarantees, and how to add - [Manage workspace variables](./docs/scenarios/manage-workspace-variables.md) - [Team access onboarding](./docs/scenarios/team-access-onboarding.md) - [State management](./docs/scenarios/state-management.md) + - [Errored state recovery](./docs/scenarios/errored-state-recovery.md) + - [Agent pool setup](./docs/scenarios/agent-pool-setup.md) + - [Notification configurations](./docs/scenarios/notification-configurations.md) - [Policy enforcement](./docs/scenarios/policy-enforcement.md) - [Run task integration](./docs/scenarios/run-task-integration.md) - Operations guides: diff --git a/docs/TESTS.md b/docs/TESTS.md index 9ddc2af0..ea886f7d 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -136,8 +136,8 @@ def test_create_workspace(self, client): client._transport.request = MagicMock(return_value=mock_response) # Execute the operation - options = WorkspaceCreateOptions(name="new-workspace", organization="test-org") - workspace = client.workspaces.create(options) + options = WorkspaceCreateOptions(name="new-workspace") + workspace = client.workspaces.create("test-org", options) # Assertions assert workspace.id == "ws-new" @@ -156,15 +156,20 @@ Always test validation and error handling: ```python def test_create_workspace_invalid_org(self, client): - """Test creating workspace with invalid organization.""" + """Test creating workspace with an empty organization name.""" + options = WorkspaceCreateOptions(name="test") with pytest.raises(InvalidOrgError): - options = WorkspaceCreateOptions(name="test", organization="") - client.workspaces.create(options) + client.workspaces.create("", options) def test_read_workspace_invalid_id(self, client): - """Test reading workspace with invalid ID.""" + """Test read_by_id with an empty workspace ID.""" with pytest.raises(InvalidWorkspaceIDError): - client.workspaces.read(workspace_id="") + client.workspaces.read_by_id("") + +def test_read_workspace_invalid_name(self, client): + """Test read with an empty workspace name.""" + with pytest.raises(InvalidWorkspaceValueError): + client.workspaces.read("", organization="valid-org") ``` ### 4. Test Pagination diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index ed49cf46..eb1bae7f 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -13,15 +13,17 @@ Example: [workspace.py](../../examples/workspace.py) | Method | Purpose | |---|---| | `client.workspaces.list(organization, options=None)` | Iterate workspaces in an organization. | -| `client.workspaces.read(organization, name)` | Read by organization/name. | +| `client.workspaces.read(name, *, organization)` | Read by name. `organization` is keyword-only. | | `client.workspaces.read_by_id(workspace_id)` | Read by workspace ID. | | `client.workspaces.create(organization, options)` | Create a workspace. | -| `client.workspaces.update(organization, name, options)` | Update by organization/name. | +| `client.workspaces.update(name, options, *, organization)` | Update by name. `organization` is keyword-only. | | `client.workspaces.update_by_id(workspace_id, options)` | Update by workspace ID. | -| `client.workspaces.delete(...)` / `delete_by_id(...)` | Delete a workspace. | -| `client.workspaces.safe_delete(...)` / `safe_delete_by_id(...)` | Delete with the API safe-delete path. | +| `client.workspaces.delete(name, *, organization)` / `delete_by_id(workspace_id)` | Delete a workspace. | +| `client.workspaces.safe_delete(name, *, organization)` / `safe_delete_by_id(workspace_id)` | Delete with the API safe-delete path. | | `client.workspaces.lock(...)`, `unlock(...)`, `force_unlock(...)` | Manage workspace locks. | | `client.workspaces.assign_ssh_key(...)`, `unassign_ssh_key(...)` | Manage workspace SSH key assignment. | +| `client.workspaces.current_assessment_result(workspace_id)` | Read the latest health assessment, or `None` if assessments are disabled. | +| `client.workspaces.list_applicable_varsets(workspace_id)` | Iterate variable sets that apply to a workspace (direct, inherited, and global). | | `client.workspaces.list_remote_state_consumers(...)` and related methods | Manage remote state consumers. | | `client.workspaces.list_tags(...)`, `add_tags(...)`, `remove_tags(...)` | Manage workspace tags. | | `client.workspaces.list_tag_bindings(...)` and related methods | Manage tag bindings. | @@ -62,10 +64,26 @@ print(workspace.id) ## Read by name or ID ```python -workspace = client.workspaces.read("my-organization", "example-workspace") +workspace = client.workspaces.read("example-workspace", organization="my-organization") same_workspace = client.workspaces.read_by_id(workspace.id) ``` +`organization` is a keyword-only argument on `read`, `update`, `delete`, and +`safe_delete`. The workspace name is positional; the organization name must be +passed by keyword. The same applies when updating or deleting by name: + +```python +from pytfe.models import WorkspaceUpdateOptions + +client.workspaces.update( + "example-workspace", + WorkspaceUpdateOptions(description="Updated by pyTFE"), + organization="my-organization", +) + +client.workspaces.delete("example-workspace", organization="my-organization") +``` + Prefer ID-based methods in automation when you already have the workspace ID. They avoid ambiguity when names change. diff --git a/docs/scenarios/agent-pool-setup.md b/docs/scenarios/agent-pool-setup.md new file mode 100644 index 00000000..a4fd34f8 --- /dev/null +++ b/docs/scenarios/agent-pool-setup.md @@ -0,0 +1,170 @@ +# Scenario: Agent pool setup + +HCP Terraform agents let Terraform runs reach private networks that the hosted +runners cannot. A working agent setup needs four pieces: + +1. An agent pool in the organization. +2. An agent authentication token for each running agent process. +3. Workspaces (or projects) configured to use the pool. +4. One or more agent processes started with the token, pointing at the API + address. + +This scenario covers the SDK side: creating the pool, generating a token, and +attaching workspaces. Starting the agent process itself is done outside Python +with the `tfc-agent` binary. + +Upstream docs: + +- Agents: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agents +- Agent tokens: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/agent-tokens +- Agent pool concepts: https://developer.hashicorp.com/terraform/cloud-docs/agents + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +The token needs `manage-agent-pools` permission on the organization, and +workspace write access on any workspaces you intend to attach. + +## Step 1: Create the agent pool + +```python +from pytfe import TFEClient +from pytfe.models import AgentPoolCreateOptions + + +client = TFEClient() +organization = "my-organization" + +pool = client.agent_pools.create( + organization, + AgentPoolCreateOptions( + name="private-network-pool", + organization_scoped=False, + allowed_workspace_ids=["ws-abc123"], + ), +) + +print(pool.id, pool.name) +``` + +Set `organization_scoped=True` to allow every workspace in the organization to +use the pool. Set it to `False` and pass `allowed_workspace_ids` (and/or +`allowed_project_ids`) to scope the pool explicitly. Scoped pools are safer for +shared organizations because they prevent unrelated workspaces from picking up +the agent. + +## Step 2: Create an agent token + +Each running agent process needs its own token: + +```python +from pytfe.models import AgentTokenCreateOptions + +token = client.agent_tokens.create( + pool.id, + AgentTokenCreateOptions(description="agent-host-1"), +) + +print(token.id) +print(token.token) +``` + +The token value is returned only at creation time. Store it in a secret manager +immediately and reference it from the agent host's environment. + +For multiple agents, create one token per host so revocation is granular: + +```python +for host in ["agent-host-1", "agent-host-2", "agent-host-3"]: + t = client.agent_tokens.create( + pool.id, + AgentTokenCreateOptions(description=host), + ) + save_to_secret_manager(host, t.token) +``` + +## Step 3: Attach workspaces to the pool + +A workspace uses an agent pool when its `execution_mode` is `agent` and its +`agent_pool_id` references the pool: + +```python +from pytfe.models import ExecutionMode, WorkspaceUpdateOptions + +client.workspaces.update_by_id( + "ws-abc123", + WorkspaceUpdateOptions( + execution_mode=ExecutionMode.AGENT, + agent_pool_id=pool.id, + ), +) +``` + +For a scoped pool, you can also widen or narrow the allowed list later: + +```python +from pytfe.models import AgentPoolAssignToWorkspacesOptions + +client.agent_pools.assign_to_workspaces( + pool.id, + AgentPoolAssignToWorkspacesOptions( + workspace_ids=["ws-abc123", "ws-def456"], + ), +) +``` + +`assign_to_workspaces` replaces the allowed-workspaces list in full; it does +not append. Always pass the complete intended list. + +## Step 4: Start the agent process + +Outside Python, on the host that has the network path to your private +infrastructure: + +```bash +export TFC_AGENT_TOKEN="" +export TFC_AGENT_NAME="agent-host-1" +export TFC_ADDRESS="https://app.terraform.io" + +tfc-agent +``` + +Confirm the agent registered: + +```python +for agent in client.agents.list(pool.id): + print(agent.id, agent.name, agent.status) +``` + +A healthy agent reports `status="idle"` or `status="busy"`. + +## Cleanup + +Revoke tokens when a host is decommissioned. Delete the pool only after no +workspaces or projects reference it. + +```python +client.agent_tokens.delete(token.id) + +# Detach the workspace before deleting the pool. +client.workspaces.update_by_id( + "ws-abc123", + WorkspaceUpdateOptions(execution_mode=ExecutionMode.REMOTE), +) +client.agent_pools.delete(pool.id) +``` + +## Operational notes + +- Treat agent tokens like SSH keys: one per host, rotated, stored in a secret + manager. +- Prefer scoped pools over organization-scoped pools when only some workspaces + need private-network access. +- Agent processes hold long-lived connections. Restart them after rotating + tokens or upgrading the agent binary. +- An agent pool with no running agents will leave runs queued indefinitely. + Monitor `client.agents.list(pool.id)` from your observability stack. diff --git a/docs/scenarios/api-driven-run.md b/docs/scenarios/api-driven-run.md index a03ed9d5..6d12a4e2 100644 --- a/docs/scenarios/api-driven-run.md +++ b/docs/scenarios/api-driven-run.md @@ -58,7 +58,7 @@ terraform_dir = Path("./terraform") def read_or_create_workspace() -> Workspace: try: - return client.workspaces.read(organization, workspace_name) + return client.workspaces.read(workspace_name, organization=organization) except TFEError: return client.workspaces.create( organization, diff --git a/docs/scenarios/errored-state-recovery.md b/docs/scenarios/errored-state-recovery.md new file mode 100644 index 00000000..2c93d065 --- /dev/null +++ b/docs/scenarios/errored-state-recovery.md @@ -0,0 +1,167 @@ +# Scenario: Recover from an errored apply + +When an apply fails after Terraform has already mutated real infrastructure but +before the new state file is uploaded, HCP Terraform stores the in-flight state +on the apply record. The workspace's current state still points at the old +version, so re-running Terraform without recovery will either replay destructive +changes or report drift it cannot reconcile. + +This scenario walks through the recovery path: + +1. Detect that an apply finished in `errored` and has recoverable errored state. +2. Download the errored state bytes. +3. Inspect or repair the state locally. +4. Upload the repaired state as the workspace's new current state version. + +Upstream docs: + +- Applies: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/applies +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions +- Manipulating Terraform state: https://developer.hashicorp.com/terraform/cli/state + +## Prerequisites + +- A workspace with an apply in `errored` status. +- A token with workspace write access and permission to upload state versions. +- The workspace must be locked by the same caller before uploading state. + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +## Step 1: Download the errored state + +```python +from pytfe import TFEClient +from pytfe.errors import NotFound + + +client = TFEClient() +apply_id = "apply-abc123" + +try: + errored_state = client.applies.errored_state(apply_id) +except NotFound: + errored_state = None + +if errored_state is None: + raise SystemExit("apply has no recoverable errored state") + +print(f"downloaded {len(errored_state)} bytes") +``` + +`applies.errored_state` returns the raw bytes of the state file Terraform was +about to upload when the apply failed. `NotFound` means the apply either +succeeded, failed before any state was produced, or has already been recovered. + +Treat the returned bytes as secret. Do not log them or commit them to source +control. + +## Step 2: Inspect or repair the state locally + +Write the state to a temporary file with restricted permissions and use the +Terraform CLI to inspect or surgically edit it: + +```python +import os +import tempfile + +with tempfile.NamedTemporaryFile( + prefix="errored-", + suffix=".tfstate", + delete=False, +) as fh: + os.chmod(fh.name, 0o600) + fh.write(errored_state) + statefile_path = fh.name + +print(f"wrote state to {statefile_path}") +``` + +Typical local commands: + +```bash +terraform show -json "$statefile_path" | jq '.values.root_module.resources[].address' +terraform state list -state="$statefile_path" +terraform state rm -state="$statefile_path" 'aws_instance.removed_by_mistake' +``` + +Always make a backup copy before editing. State surgery is irreversible. + +## Step 3: Upload the repaired state + +The workspace must be locked by the caller before uploading state. Read the +current serial first and use a strictly greater serial for the new version. + +```python +import hashlib +from pathlib import Path + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +workspace_id = "ws-abc123" + +repaired = Path(statefile_path).read_bytes() + +current = client.state_versions.read_current(workspace_id) +new_serial = (current.serial or 0) + 1 + +client.workspaces.lock( + workspace_id, + WorkspaceLockOptions(reason="Recover errored apply state via pyTFE"), +) + +try: + new_state = client.state_versions.upload( + workspace_id, + raw_state=repaired, + options=StateVersionCreateOptions( + serial=new_serial, + md5=hashlib.md5(repaired).hexdigest(), + ), + ) + print("uploaded", new_state.id, new_state.status) +finally: + client.workspaces.unlock(workspace_id) + os.unlink(statefile_path) +``` + +`state_versions.upload` follows the API's hosted upload-URL workflow: create the +state-version record, `PUT` the raw bytes to the signed Archivist URL, then +read the version back. Depending on server timing the returned version may +still be processing; poll `read` if you need to wait for `finalized`. + +## Step 4: Confirm the next run sees the repaired state + +After unlocking, queue a no-op plan to confirm Terraform sees the recovered +state: + +```python +from pytfe.models import RunCreateOptions, Workspace + +run = client.runs.create( + RunCreateOptions( + workspace=Workspace(id=workspace_id), + message="Verify errored-state recovery", + is_destroy=False, + ) +) +print("verification run:", run.id) +``` + +A plan that shows zero changes confirms recovery succeeded. A plan with +unexpected creates or destroys means the repaired state still diverges from +reality; do not apply until the divergence is understood. + +## Operational notes + +- Always lock the workspace before uploading state. The API returns `409` if + the workspace is unlocked or locked by a different caller. +- Pick a `serial` strictly greater than the current state's serial. Reusing or + decreasing the serial is rejected. +- Keep the downloaded bytes out of logs, CI artifacts, and long-lived disk. + Remove the temporary file in a `finally` block. +- Recovery is a manual operational action. Pair it with an incident note and a + follow-up to investigate why the apply failed mid-upload. diff --git a/docs/scenarios/notification-configurations.md b/docs/scenarios/notification-configurations.md new file mode 100644 index 00000000..506c4acd --- /dev/null +++ b/docs/scenarios/notification-configurations.md @@ -0,0 +1,192 @@ +# Scenario: Notification configurations + +Notification configurations send run-lifecycle events from a workspace (or +team) to an external destination. HCP Terraform supports four destination +types: + +- `email` — sends to a list of organization users. +- `slack` — posts to an incoming-webhook URL. +- `microsoft-teams` — posts to a Microsoft Teams incoming webhook. +- `generic` — POSTs a JSON payload to a URL you control, signed with an HMAC + token. + +This scenario shows how to create each type, verify delivery, and update or +delete configurations later. + +Upstream docs: + +- Notification configurations: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/notification-configurations +- Notification payload reference: https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications + +Example: [notification_configuration.py](../../examples/notification_configuration.py) + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" +export TFE_ADDRESS="https://app.terraform.io" +``` + +The token needs write access on the workspace (or team) that owns the +configuration. + +## Create a Slack notification + +```python +from pytfe import TFEClient +from pytfe.models import ( + NotificationConfigurationCreateOptions, + NotificationDestinationType, + NotificationTriggerType, +) + + +client = TFEClient() +workspace_id = "ws-abc123" + +slack = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="slack-run-events", + destination_type=NotificationDestinationType.SLACK, + enabled=True, + url="https://hooks.slack.com/services/T000/B000/XXXX", + triggers=[ + NotificationTriggerType.NEEDS_ATTENTION, + NotificationTriggerType.ERRORED, + NotificationTriggerType.COMPLETED, + ], + ), +) + +print(slack.id) +``` + +Slack and Microsoft Teams configurations need a `url`. The SDK validates this +locally and raises `ValidationError` if the URL is missing for a destination +type that requires it. + +## Create a Microsoft Teams notification + +```python +teams = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="teams-run-events", + destination_type=NotificationDestinationType.MICROSOFT_TEAMS, + enabled=True, + url="https://outlook.office.com/webhook/...", + triggers=[ + NotificationTriggerType.ERRORED, + NotificationTriggerType.NEEDS_ATTENTION, + ], + ), +) +``` + +## Create a generic webhook notification + +`generic` posts a JSON payload to your own service. Use the `token` field to +share an HMAC signing secret; HCP Terraform sends `X-TFE-Notification-Signature` +on each delivery so your service can verify authenticity. + +```python +import secrets + +hmac_secret = secrets.token_urlsafe(32) + +webhook = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="generic-webhook", + destination_type=NotificationDestinationType.GENERIC, + enabled=True, + url="https://example.com/tfe-notifications", + token=hmac_secret, + triggers=[NotificationTriggerType.COMPLETED], + ), +) + +# Persist hmac_secret in your secret manager — it is not returned again. +``` + +Store the HMAC secret in a secret manager. The API does not return the token +value on subsequent reads. + +## Create an email notification + +Email notifications go to organization users, identified either by email +address or by user ID: + +```python +email = client.notification_configurations.create( + workspace_id, + NotificationConfigurationCreateOptions( + name="ops-email", + destination_type=NotificationDestinationType.EMAIL, + enabled=True, + triggers=[NotificationTriggerType.ERRORED], + email_addresses=["oncall@example.com"], + ), +) +``` + +`email` configurations do not use `url`. They use `email_addresses` and/or +`email_users` (user objects with an `id`). + +## Verify the configuration + +`verify()` asks HCP Terraform to deliver a test payload to the configured +destination and records the response on the configuration: + +```python +verified = client.notification_configurations.verify(webhook.id) + +for delivery in verified.delivery_responses: + print(delivery.code, delivery.successful, delivery.sent_at) +``` + +A `successful` value of `"true"` confirms the destination accepted the test +payload. A `code` outside the 2xx range or `successful="false"` indicates the +destination URL is unreachable, returns a non-2xx status, or rejects the +payload format. + +Verification works for `slack`, `microsoft-teams`, and `generic`. Email +verification is implicit when the user receives the test message. + +## Update an existing configuration + +```python +from pytfe.models import NotificationConfigurationUpdateOptions + +client.notification_configurations.update( + slack.id, + NotificationConfigurationUpdateOptions( + enabled=False, + ), +) +``` + +Disable a configuration with `enabled=False` instead of deleting it when you +want to keep its history of deliveries. + +## List and delete + +```python +for config in client.notification_configurations.list(workspace_id): + print(config.id, config.name, config.destination_type, config.enabled) + +client.notification_configurations.delete(webhook.id) +``` + +## Operational notes + +- Treat the generic `token` like an HMAC signing key. Rotate it by creating a + new configuration with a new token, switching consumers to it, then deleting + the old configuration. +- Verify every new generic webhook before relying on it in production. The API + will silently drop deliveries to a misconfigured URL. +- Scope triggers narrowly. `NEEDS_ATTENTION` plus `ERRORED` covers most + on-call needs without paging on every successful apply. +- Slack/Teams incoming-webhook URLs grant posting rights to their channel. + Store them in a secret manager and rotate them when team membership changes. diff --git a/docs/scenarios/team-access-onboarding.md b/docs/scenarios/team-access-onboarding.md index 7766d0fc..bd61783d 100644 --- a/docs/scenarios/team-access-onboarding.md +++ b/docs/scenarios/team-access-onboarding.md @@ -77,11 +77,18 @@ print(grant.id) Use project access when the team needs the same access across a project: ```python +from pytfe.models.team_project_access import TeamProjectAccessListOptions + # See examples/team_project_access.py for a full project access example. -for access in client.team_project_accesses.list("team-abc123"): +for access in client.team_project_accesses.list( + TeamProjectAccessListOptions(Project_id="prj-abc123") +): print(access.id) ``` +`team_project_accesses.list` filters by project, not by team. Pass the project +ID and iterate the returned grants to see which teams have access. + ## Create a team token ```python From 4aabd4f3dcdcc3924a2d59981ff11ebe3b6511c9 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 21:09:51 +0530 Subject: [PATCH 3/5] More docs --- README.md | 42 +-- docs/ITERATORS.md | 18 +- docs/RESOURCE.md | 54 ++-- docs/errors.md | 3 +- docs/scenarios/manage-workspace-variables.md | 11 +- .../scenarios/migrate-workspaces-and-state.md | 284 ++++++++++++++++++ 6 files changed, 339 insertions(+), 73 deletions(-) create mode 100644 docs/scenarios/migrate-workspaces-and-state.md diff --git a/README.md b/README.md index 303119dc..0bd386ed 100644 --- a/README.md +++ b/README.md @@ -157,35 +157,19 @@ For full details — environment variables, redaction guarantees, and how to add ## Documentation -- User guides: - - [Getting started](./docs/getting-started.md) - - [Authentication](./docs/authentication.md) - - [Pagination](./docs/pagination.md) - - [API index](./docs/api/index.md) -- Focused API guides: - - [Workspaces](./docs/api/workspaces.md) - - [Runs, plans, and applies](./docs/api/runs-plans-applies.md) - - [State versions](./docs/api/state-versions.md) - - [Variables and variable sets](./docs/api/variables-and-variable-sets.md) - - [Teams and access](./docs/api/teams-and-access.md) - - [Policies](./docs/api/policies.md) - - [Run tasks](./docs/api/run-tasks.md) -- Scenario guides: - - [API-driven run](./docs/scenarios/api-driven-run.md) - - [Manage workspace variables](./docs/scenarios/manage-workspace-variables.md) - - [Team access onboarding](./docs/scenarios/team-access-onboarding.md) - - [State management](./docs/scenarios/state-management.md) - - [Errored state recovery](./docs/scenarios/errored-state-recovery.md) - - [Agent pool setup](./docs/scenarios/agent-pool-setup.md) - - [Notification configurations](./docs/scenarios/notification-configurations.md) - - [Policy enforcement](./docs/scenarios/policy-enforcement.md) - - [Run task integration](./docs/scenarios/run-task-integration.md) -- Operations guides: - - [Troubleshooting](./docs/troubleshooting.md) - - [Errors](./docs/errors.md) - - [Terraform Enterprise](./docs/terraform-enterprise.md) -- Terraform Enterprise API: https://developer.hashicorp.com/terraform/enterprise/api-docs -- Internal contributor reference: [`docs/ITERATORS.md`](./docs/ITERATORS.md), [`docs/MODELS.md`](./docs/MODELS.md), [`docs/RESOURCE.md`](./docs/RESOURCE.md), [`docs/LOGGING.md`](./docs/LOGGING.md) +Start with [Getting started](./docs/getting-started.md), then use the +[API index](./docs/api/index.md) to find resource-specific guides, examples, +and upstream HCP Terraform API docs. + +| Need | Start here | +|---|---| +| Configure the SDK | [Authentication](./docs/authentication.md), [Pagination](./docs/pagination.md), [Logging](./docs/LOGGING.md) | +| API guides | [API index](./docs/api/index.md), [Workspaces](./docs/api/workspaces.md), [Runs/plans/applies](./docs/api/runs-plans-applies.md), [State versions](./docs/api/state-versions.md) | +| Scenario guides | [API-driven run](./docs/scenarios/api-driven-run.md), [State management](./docs/scenarios/state-management.md), [Migrate workspaces and state](./docs/scenarios/migrate-workspaces-and-state.md), [Team access onboarding](./docs/scenarios/team-access-onboarding.md) | +| Operations guides | [Troubleshooting](./docs/troubleshooting.md), [Errors](./docs/errors.md), [Terraform Enterprise](./docs/terraform-enterprise.md) | +| Contribute to the SDK | [CONTRIBUTING](./docs/CONTRIBUTING.md), [ITERATORS](./docs/ITERATORS.md), [MODELS](./docs/MODELS.md), [RESOURCE](./docs/RESOURCE.md) | + +Upstream API reference: https://developer.hashicorp.com/terraform/cloud-docs/api-docs ## Examples diff --git a/docs/ITERATORS.md b/docs/ITERATORS.md index 46634a24..e49c16ed 100644 --- a/docs/ITERATORS.md +++ b/docs/ITERATORS.md @@ -72,15 +72,21 @@ with pytest.raises(InvalidOrgError): If you genuinely need eager validation (raised from the call expression itself, not the first `for` loop), use the wrapper pattern: ```python -def list(self, organization: str, ...) -> Iterator[Workspace]: +def list( + self, + organization: str, + options: WorkspaceListOptions | None = None, +) -> Iterator[Workspace]: if not valid_string_id(organization): raise InvalidOrgError() # eager - params = ... - path = ... + params = options.model_dump(by_alias=True, exclude_none=True, mode="json") if options else {} + path = f"/api/v2/organizations/{organization}/workspaces" + def _gen() -> Iterator[Workspace]: for item in self._list(path, params=params): yield self._workspace_from(item) + return _gen() ``` @@ -131,15 +137,15 @@ Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Us ```python # ❌ Returns Iterable instead of Iterator — looks similar, isn't. -def list(...) -> Iterable[Workspace]: ... +def list(self) -> Iterable[Workspace]: ... # ❌ Returns Pager / LazyList / custom wrapper. -def list(...) -> WorkspaceList: ... +def list(self) -> WorkspaceList: ... # ❌ Returns concrete list. The type is a public contract; consumers will # rely on len(), indexing, and isinstance(result, list). See "Known # exceptions" below for the one method where this is documented. -def list_widgets(...) -> list[Widget]: ... +def list_widgets(self) -> list[Widget]: ... ``` ## Known exceptions (and why) diff --git a/docs/RESOURCE.md b/docs/RESOURCE.md index b3be73cd..3ffb34b6 100644 --- a/docs/RESOURCE.md +++ b/docs/RESOURCE.md @@ -37,11 +37,11 @@ from ._base import _Service class Widgets(_Service): """Service for managing widgets.""" - def list(...) -> Iterator[Widget]: ... - def read(...) -> Widget: ... - def create(...) -> Widget: ... - def update(...) -> Widget: ... - def delete(...) -> None: ... + def list(self) -> Iterator[Widget]: ... + def read(self, widget_id: str) -> Widget: ... + def create(self, organization: str, options: WidgetCreateOptions) -> Widget: ... + def update(self, widget_id: str, options: WidgetUpdateOptions) -> Widget: ... + def delete(self, widget_id: str) -> None: ... def _widget_from(self, data: dict[str, Any]) -> Widget: ... ``` @@ -188,32 +188,23 @@ If the model has relationships, pull them from `data["relationships"]` and eithe 1. **Embed an id-stub** using `Model.model_construct(id=...)` — use this when the model defines the relation as `OtherModel | None`. `model_construct` skips validation, which is correct for partial `{id, type}` data: - ```python - relationships = data.get("relationships", {}) - run_data = relationships.get("run", {}).get("data") - if run_data: - attributes["run"] = Run.model_construct(id=run_data["id"]) - ``` +```python +relationships = data.get("relationships", {}) +run_data = relationships.get("run", {}).get("data") +if run_data: + attributes["run"] = Run.model_construct(id=run_data["id"]) +``` 2. **Flatten to `*_id`** when the model exposes a flat `team_id: str | None` field: - ```python - team_data = (relationships.get("team") or {}).get("data") or {} - if team_data.get("id"): - attributes["team-id"] = team_data["id"] - ``` +```python +team_data = (relationships.get("team") or {}).get("data") or {} +if team_data.get("id"): + attributes["team-id"] = team_data["id"] +``` Always defensively coalesce with `or {}` — relationships may be missing from sparse responses. -## Presigned URLs and redirects - -The TFE bearer token must not be forwarded to Archivist, S3, or other presigned blob hosts. Signed upload/download URLs already carry their own credentials. - -- Direct signed URL: `self.t.request("GET", url, include_auth=False)` -- API endpoint that returns a redirect: call the API path with `allow_redirects=False`, read the `Location` header, then fetch that URL with `include_auth=False` -- Add a unit test that asserts the blob URL call uses `include_auth=False` - -This applies to state upload/download, plan JSON output/schema, apply errored state, and any future blob-backed endpoint. ## Pagination — use `self._list`, don't roll your own @@ -249,12 +240,12 @@ You usually don't need to catch these — let them propagate to the caller. Catc - You want to translate to a more specific error (`except TFEError as e: if "rate-limit" in str(e): raise ...`) - The "error" is actually an expected outcome — like a `NotFound` meaning "no current assessment yet": - ```python - try: - r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result") - except NotFound: - return None - ``` +```python +try: + r = self.t.request("GET", f"/api/v2/workspaces/{ws_id}/current-assessment-result") +except NotFound: + return None +``` ## Wiring into the client @@ -460,7 +451,6 @@ raise InvalidWidgetIDError() # preferred for new APIs - [ ] `list*` returns `Iterator[X]` via `self._list(...)` (see [ITERATORS.md](ITERATORS.md)) - [ ] Response parsing helper `_widget_from(data)` translates JSON:API → Pydantic - [ ] Non-standard response shapes (`204`, `null`, bare resources, raw bytes, redirects) are verified against docs/go-tfe/spec and covered by tests -- [ ] Presigned upload/download/blob URLs are fetched with `include_auth=False` - [ ] Classes with `def list(...)` avoid later bare `list[...]` annotations - [ ] Models added per [MODELS.md](MODELS.md), wired in `models/__init__.py` - [ ] Resource wired into `client.py` (import + `self.widgets = Widgets(...)`) diff --git a/docs/errors.md b/docs/errors.md index 76452fbd..1b472e62 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -88,6 +88,8 @@ When the server returns JSON:API errors, `exc.errors` may contain structured entries: ```python +try: + make_request() except TFEError as exc: for error in exc.errors: print(error) @@ -95,4 +97,3 @@ except TFEError as exc: Use these details for logs and diagnostics, but keep user-facing messages short and avoid printing secrets. - diff --git a/docs/scenarios/manage-workspace-variables.md b/docs/scenarios/manage-workspace-variables.md index 4d8c1700..fe2779d9 100644 --- a/docs/scenarios/manage-workspace-variables.md +++ b/docs/scenarios/manage-workspace-variables.md @@ -74,10 +74,12 @@ from pytfe.models import ( varset = client.variable_sets.create( "my-organization", - VariableSetCreateOptions( - name="shared-cloud-settings", - description="Shared cloud settings", - global_=False, + VariableSetCreateOptions.model_validate( + { + "name": "shared-cloud-settings", + "description": "Shared cloud settings", + "global": False, + } ), ) @@ -122,4 +124,3 @@ client.variable_sets.delete(varset.id) - Prefer variable sets for shared values to avoid drift between workspaces. - Use workspace variables for exceptions and workspace-local values. - Be deliberate with global variable sets because they apply broadly. - diff --git a/docs/scenarios/migrate-workspaces-and-state.md b/docs/scenarios/migrate-workspaces-and-state.md new file mode 100644 index 00000000..0ab578e8 --- /dev/null +++ b/docs/scenarios/migrate-workspaces-and-state.md @@ -0,0 +1,284 @@ +# Scenario: Migrate workspaces and state between instances + +Upstream docs: + +- Migration overview: https://developer.hashicorp.com/terraform/cloud-docs/migrate +- Workspaces: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspaces +- Workspace variables: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/workspace-variables +- State versions: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/state-versions + +## What pyTFE covers + +| Migration area | pyTFE coverage | Notes | +|---|---|---| +| Source and target clients | `TFEClient(TFEConfig(...))` | Use explicit configs so source and target tokens do not mix. | +| Workspace inventory and creation | `workspaces.list`, `read`, `create`, `update` | Copy only settings you understand. Keep VCS and agent settings deliberate. | +| Current state migration | `state_versions.read_current`, `download_current`, `upload` | Lock the target workspace before upload. Prefer new target workspaces. | +| Local state-file import | `workspaces.create`, `workspaces.lock`, `state_versions.upload`, `workspaces.unlock` | Useful when migrating many Terraform OSS state files. | +| Workspace variables | `variables.list`, `variables.create` | Sensitive values may not be readable. Supply them from a secret map. | +| Variable sets | `variable_sets`, `variable_set_variables` | Same sensitive-value caveat as workspace variables. | +| Teams and access | `teams`, `team_workspace_accesses`, `team_project_accesses` | Usernames, org membership IDs, and target team IDs need planning. | +| Policies and policy sets | `policies`, `policy_sets`, `policy_set_parameters`, `policy_set_versions` | Sensitive policy-set parameters must be rehydrated from a secure source. | +| SSH keys | `ssh_keys` | Public metadata can be listed, but private key material must be re-added. | +| Configuration versions | `configuration_versions.download`, `upload` | API availability and source retention vary. Keep local config archives when possible. | +| VCS connections | `oauth_clients`, `oauth_tokens`, workspace `vcs_repo` fields | Target VCS connections usually need a manual source-token to target-token map. GitHub App connections may need manual setup. | +| TFE admin settings | Partial or unsupported | SSO, cost estimation, some admin settings, and older TFE-only APIs are outside this scenario. | + +## Prepare source and target clients + +Use separate environment variables for the two sides: + +```bash +export TFE_SOURCE_ADDRESS="https://tfe-source.example.com" +export TFE_SOURCE_TOKEN="source-user-token" +export TFE_SOURCE_ORG="source-org" + +export TFE_TARGET_ADDRESS="https://app.terraform.io" +export TFE_TARGET_TOKEN="target-user-token" +export TFE_TARGET_ORG="target-org" +``` + +Then create explicit clients: + +```python +import os + +from pytfe import TFEClient, TFEConfig + + +source = TFEClient( + TFEConfig( + address=os.environ["TFE_SOURCE_ADDRESS"], + token=os.environ["TFE_SOURCE_TOKEN"], + ) +) +target = TFEClient( + TFEConfig( + address=os.environ["TFE_TARGET_ADDRESS"], + token=os.environ["TFE_TARGET_TOKEN"], + ) +) + +source_org = os.environ["TFE_SOURCE_ORG"] +target_org = os.environ["TFE_TARGET_ORG"] +``` + +Use user or team tokens with enough permission to read source data and create +target workspaces, variables, and state versions. + +## Create a matching target workspace + +Start with a conservative subset of workspace settings. Do not blindly copy +agent pools, VCS OAuth token IDs, SSH key IDs, or project IDs across instances; +those IDs are instance-local and usually need a target-side mapping. + +```python +from pytfe.errors import NotFound +from pytfe.models import Workspace, WorkspaceCreateOptions + + +def read_or_create_target_workspace(source_workspace: Workspace) -> Workspace: + assert source_workspace.name is not None + + try: + return target.workspaces.read(source_workspace.name, organization=target_org) + except NotFound: + pass + + options = WorkspaceCreateOptions( + name=source_workspace.name, + description=source_workspace.description, + terraform_version=source_workspace.terraform_version, + working_directory=source_workspace.working_directory, + auto_apply=source_workspace.auto_apply, + file_triggers_enabled=source_workspace.file_triggers_enabled, + global_remote_state=source_workspace.global_remote_state, + queue_all_runs=source_workspace.queue_all_runs, + speculative_enabled=source_workspace.speculative_enabled, + trigger_prefixes=source_workspace.trigger_prefixes or None, + trigger_patterns=source_workspace.trigger_patterns or None, + ) + return target.workspaces.create(target_org, options) +``` + +If the source workspace uses VCS, agents, SSH keys, or project placement, +create the target-side resources first and pass the mapped target IDs in a +separate migration pass. + +## Copy workspace variables + +Non-sensitive variables can be copied from the source API response. Sensitive +variables usually cannot be read back, so pass their values in from a secret +manager or an operator-reviewed JSON file. + +```python +from pytfe.models import VariableCreateOptions + + +def copy_workspace_variables( + source_workspace_id: str, + target_workspace_id: str, + *, + sensitive_values: dict[str, str], +) -> list[str]: + missing_sensitive: list[str] = [] + + for variable in source.variables.list(source_workspace_id): + if variable.sensitive: + value = sensitive_values.get(variable.key or "") + if value is None: + missing_sensitive.append(variable.key or "") + continue + else: + value = variable.value + + target.variables.create( + target_workspace_id, + VariableCreateOptions( + key=variable.key, + value=value, + description=variable.description, + category=variable.category, + hcl=variable.hcl, + sensitive=variable.sensitive, + ), + ) + + return missing_sensitive +``` + +Do not print sensitive values. If `missing_sensitive` is not empty, pause the +migration and fill the secret map before running a real plan. + +## Migrate current state from a source workspace + +This copies only the current state version. That is the safest default for a +trimmed migration. Historical state-version migration is possible with +`state_versions.list(...)`, but it is slower, noisier, and usually not needed +for a functional cutover. + +```python +import hashlib + +from pytfe.models import StateVersionCreateOptions, WorkspaceLockOptions + + +def migrate_current_state(source_workspace_id: str, target_workspace_id: str) -> str: + source_current = source.state_versions.read_current(source_workspace_id) + raw_state = source.state_versions.download_current(source_workspace_id) + + target.workspaces.lock( + target_workspace_id, + WorkspaceLockOptions(reason="Migrate current state with pyTFE"), + ) + try: + migrated = target.state_versions.upload( + target_workspace_id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=source_current.serial or 1, + md5=hashlib.md5(raw_state).hexdigest(), + ), + ) + finally: + target.workspaces.unlock(target_workspace_id) + + return migrated.id +``` + +Use this against a target workspace that has never run Terraform. If the target +already has state, choose a serial strictly greater than the target current +serial and confirm that replacing the state is intentional. + +## Import many local state files + +The state-only workflow from local `terraform.tfstate` files uses the same +workspace-create, lock, upload, and unlock sequence. + +```python +from pathlib import Path + + +def upload_local_state_file( + workspace_name: str, + state_path: Path, + *, + serial: int = 1, +) -> str: + try: + workspace = target.workspaces.read(workspace_name, organization=target_org) + except NotFound: + workspace = target.workspaces.create( + target_org, + WorkspaceCreateOptions(name=workspace_name), + ) + + raw_state = state_path.read_bytes() + + target.workspaces.lock( + workspace.id, + WorkspaceLockOptions(reason=f"Import {state_path.name} with pyTFE"), + ) + try: + state_version = target.state_versions.upload( + workspace.id, + raw_state=raw_state, + options=StateVersionCreateOptions( + serial=serial, + md5=hashlib.md5(raw_state).hexdigest(), + ), + ) + finally: + target.workspaces.unlock(workspace.id) + + return state_version.id +``` + +For bulk migrations, keep a manifest that maps each state file to the intended +target workspace name. Run a dry-run pass that creates no resources and prints +the planned mappings before uploading anything. + +## End-to-end skeleton + +```python +sensitive_values_by_workspace = { + # "source-workspace-name": {"TF_VAR_password": "..."} +} + +for source_workspace in source.workspaces.list(source_org): + if not source_workspace.name: + continue + + target_workspace = read_or_create_target_workspace(source_workspace) + + missing = copy_workspace_variables( + source_workspace.id, + target_workspace.id, + sensitive_values=sensitive_values_by_workspace.get(source_workspace.name, {}), + ) + if missing: + print(f"skipped sensitive values for {source_workspace.name}: {missing}") + + migrated_state_id = migrate_current_state( + source_workspace.id, + target_workspace.id, + ) + print(source_workspace.name, "state migrated as", migrated_state_id) +``` + +After migration, queue a speculative or no-op plan in each target workspace +before enabling normal automation. + +## Operational checklist + +- Stop source-side Terraform operations before copying state. +- Prefer new target workspaces that have never performed a run. +- Keep state bytes, variable values, SSH keys, and configuration archives out + of logs and CI artifacts. +- Build explicit ID maps for VCS OAuth tokens, SSH keys, projects, teams, and + agent pools. Do not reuse source IDs in the target instance. +- Rehydrate sensitive variables, sensitive policy-set parameters, SSH private + keys, and configuration archives from a secure operator-provided source. +- Validate the target workspace with a plan before applying. +- Keep the source organization read-only until the target validation is + complete and rollback expectations are documented. From a8fc2e0ffb247ec1425df2d0e717175603117eff Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 21:37:27 +0530 Subject: [PATCH 4/5] Fix variable_set models and renamed global to global_ --- docs/scenarios/manage-workspace-variables.md | 10 +++--- examples/variable_sets.py | 36 ++++++++------------ src/pytfe/models/variable_set.py | 8 ++++- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/docs/scenarios/manage-workspace-variables.md b/docs/scenarios/manage-workspace-variables.md index fe2779d9..ea39e802 100644 --- a/docs/scenarios/manage-workspace-variables.md +++ b/docs/scenarios/manage-workspace-variables.md @@ -74,12 +74,10 @@ from pytfe.models import ( varset = client.variable_sets.create( "my-organization", - VariableSetCreateOptions.model_validate( - { - "name": "shared-cloud-settings", - "description": "Shared cloud settings", - "global": False, - } + VariableSetCreateOptions( + name="shared-cloud-settings", + description="Shared cloud settings", + global_=False, ), ) diff --git a/examples/variable_sets.py b/examples/variable_sets.py index 258319a2..2fb980cf 100644 --- a/examples/variable_sets.py +++ b/examples/variable_sets.py @@ -76,13 +76,11 @@ def variable_set_example(): # 2. Create a new variable set print("2. Creating a new variable set...") - create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-example-varset", - "description": "Example variable set created with Python SDK", - "global": False, # Not global, will apply to specific workspaces/projects - "priority": True, # High priority - } + create_options = VariableSetCreateOptions( + name="python-sdk-example-varset", + description="Example variable set created with Python SDK", + global_=False, # Not global, will apply to specific workspaces/projects + priority=True, # High priority ) new_variable_set = client.variable_sets.create(org_name, create_options) @@ -359,13 +357,11 @@ def global_variable_set_example(): # Create a global variable set print("Creating a global variable set...") - global_create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-global-varset", - "description": "Global variable set for common settings", - "global": True, # Make it global - "priority": False, - } + global_create_options = VariableSetCreateOptions( + name="python-sdk-global-varset", + description="Global variable set for common settings", + global_=True, # Make it global + priority=False, ) global_varset = client.variable_sets.create(org_name, global_create_options) @@ -451,13 +447,11 @@ def project_scoped_variable_set_example(): print("Creating a project-scoped variable set...") parent = Parent(project=Project(id=target_project.id)) - project_create_options = VariableSetCreateOptions.model_validate( - { - "name": "python-sdk-project-varset", - "description": f"Project-specific variables for {target_project.name}", - "global": False, # Not global - "parent": parent.model_dump(), # Scope to specific project - } + project_create_options = VariableSetCreateOptions( + name="python-sdk-project-varset", + description=f"Project-specific variables for {target_project.name}", + global_=False, # Not global + parent=parent, # Scope to specific project ) project_varset = client.variable_sets.create(org_name, project_create_options) diff --git a/src/pytfe/models/variable_set.py b/src/pytfe/models/variable_set.py index 02c413f4..32256836 100644 --- a/src/pytfe/models/variable_set.py +++ b/src/pytfe/models/variable_set.py @@ -6,7 +6,7 @@ from datetime import datetime from enum import Enum -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .organization import Organization from .project import Project @@ -33,6 +33,8 @@ class Parent(BaseModel): class VariableSet(BaseModel): """Represents a Terraform Enterprise variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + id: str | None = None name: str | None = None description: str | None = None @@ -80,6 +82,8 @@ class VariableSetListOptions(BaseModel): class VariableSetCreateOptions(BaseModel): """Options for creating a variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str description: str | None = None global_: bool = Field(alias="global") @@ -96,6 +100,8 @@ class VariableSetReadOptions(BaseModel): class VariableSetUpdateOptions(BaseModel): """Options for updating a variable set.""" + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + name: str | None = None description: str | None = None global_: bool | None = Field(alias="global", default=None) From b8c1a148619fd0248f9581190e07d3854571cb97 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Tue, 26 May 2026 22:02:12 +0530 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc5d914..8f3d333e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ ## Bug Fixes * Fixed task result relationships to map into typed SDK models instead of raw JSON by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) * Fixed task stage relationship mapping in the task result resource by @TanyaSingh369-svg [#156](https://github.com/hashicorp/python-tfe/pull/156) +* Updated variable set models to support ``global_`` inputs. Since ``global`` is a Python reserved word, callers previously had to use ``model_validate`` as a workaround; existing ``global`` alias usage continues to work unchanged. # v0.1.5