From 68ff3bcd45ebd4bfeb7566bade6cacbfc913f82b Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 15:09:20 +0530 Subject: [PATCH 1/3] Add no-code module support and bump user-agent --- CHANGELOG.md | 6 + README.md | 2 +- docs/api/index.md | 2 + docs/api/no-code-provisioning.md | 274 ++++++++++ docs/scenarios/no-code-provisioning.md | 253 +++++++++ examples/no_code_provisioning.py | 177 +++++++ src/pytfe/_jsonapi.py | 2 +- src/pytfe/client.py | 2 + src/pytfe/errors.py | 22 + src/pytfe/models/__init__.py | 25 + src/pytfe/models/no_code_module.py | 182 +++++++ src/pytfe/resources/no_code_module.py | 463 +++++++++++++++++ tests/units/test_no_code_module.py | 689 +++++++++++++++++++++++++ 13 files changed, 2097 insertions(+), 2 deletions(-) create mode 100644 docs/api/no-code-provisioning.md create mode 100644 docs/scenarios/no-code-provisioning.md create mode 100644 examples/no_code_provisioning.py create mode 100644 src/pytfe/models/no_code_module.py create mode 100644 src/pytfe/resources/no_code_module.py create mode 100644 tests/units/test_no_code_module.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3d333e..8cd690da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,11 @@ * Updated State Version resource with new rollback method by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) * Updated Workspaces resource with additional current_assessment_result and list_applicable_varsets methods by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +### No-Code Provisioning +* Added ``client.no_code_modules`` resource with ``create``, ``read``, ``update``, ``delete``, ``read_variables``, ``create_workspace``, ``upgrade_workspace``, ``read_workspace_upgrade``, and ``confirm_workspace_upgrade`` methods covering the full HCP Terraform no-code provisioning workflow. +* Added ``NoCodeModule``, ``NoCodeModuleCreateOptions``, ``NoCodeModuleUpdateOptions``, ``NoCodeModuleReadOptions``, ``NoCodeModuleIncludeOpt``, ``NoCodeVariableOption``, ``NoCodeWorkspaceCreateOptions``, ``NoCodeWorkspaceUpgradeOptions``, ``NoCodeWorkspaceVariable``, ``RegistryModuleVariable``, and ``WorkspaceUpgrade`` models. +* Added ``InvalidNoCodeModuleIDError``, ``InvalidWorkspaceUpgradeIDError``, and ``RequiredRegistryModuleIDError`` typed exceptions. + ### SDK Logging * Added pytfe._logging module with structured stdlib-based logging framework by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) * Added setup_logging() function to configure the pytfe logger namespace with optional level and format control by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) @@ -98,6 +103,7 @@ * 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. +* Fixed the workspace JSON:API parser to populate the singular ``agent_pool`` field instead of writing to a non-existent ``agent_pools`` key. The relationship was previously parsed off the wire but silently dropped because the model field is singular; ``workspace.agent_pool`` now returns the related ``AgentPool`` stub as documented. # v0.1.5 diff --git a/README.md b/README.md index 0bd386ed..072071b2 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and upstream HCP Terraform API docs. |---|---| | 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) | +| 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), [No-code provisioning](./docs/scenarios/no-code-provisioning.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) | diff --git a/docs/api/index.md b/docs/api/index.md index f13ec141..f61ea95e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -81,6 +81,7 @@ column. | `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.no_code_modules` | `NoCodeModules` | `create`, `read`, `update`, `delete`, `read_variables`, `create_workspace`, `upgrade_workspace`, `read_workspace_upgrade`, `confirm_workspace_upgrade` | [no_code_provisioning.py](../../examples/no_code_provisioning.py) | [No-code provisioning](https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning) | | `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) | @@ -103,3 +104,4 @@ column. - [teams-and-access.md](teams-and-access.md) - [policies.md](policies.md) - [run-tasks.md](run-tasks.md) +- [no-code-provisioning.md](no-code-provisioning.md) diff --git a/docs/api/no-code-provisioning.md b/docs/api/no-code-provisioning.md new file mode 100644 index 00000000..a851cd19 --- /dev/null +++ b/docs/api/no-code-provisioning.md @@ -0,0 +1,274 @@ +# No-code provisioning + +No-code provisioning lets users create workspaces from a curated registry +module without writing Terraform code. The workflow has three actors: + +- A platform admin enables a registry module for no-code use and sets allowed + variable values. +- An end user creates a workspace from that module, supplying only variable + values. +- Either party can later upgrade an existing no-code workspace to a newer + module version. + +`client.no_code_modules` covers all four pieces: module CRUD, variable +introspection, workspace creation, and workspace upgrade lifecycle. + +Upstream docs: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning + +Example: [no_code_provisioning.py](../../examples/no_code_provisioning.py) + +## Token requirements + +Every write endpoint (`create`, `update`, `delete`, `create_workspace`, +`upgrade_workspace`, `confirm_workspace_upgrade`) requires a **user or team +token**. Organization tokens are not accepted by the API. Read endpoints +(`read`, `read_variables`, `read_workspace_upgrade`) do not have this +restriction. + +## Common methods + +| Method | Purpose | +|---|---| +| `client.no_code_modules.create(organization, options)` | Enable no-code provisioning for a registry module. | +| `client.no_code_modules.read(no_code_module_id, options=None)` | Read a no-code module; pass `NoCodeModuleReadOptions(include=[VARIABLE_OPTIONS])` to materialise allowed-values. | +| `client.no_code_modules.update(no_code_module_id, options)` | Update enabled flag, version pin, or variable options. | +| `client.no_code_modules.delete(no_code_module_id)` | Disable no-code provisioning for the module. | +| `client.no_code_modules.read_variables(no_code_module_id, version)` | Iterate variables declared by a specific module version, for form-building. | +| `client.no_code_modules.create_workspace(no_code_module_id, options)` | Create a workspace from the no-code module. | +| `client.no_code_modules.upgrade_workspace(no_code_module_id, workspace_id, options=None)` | Start an upgrade; returns a `WorkspaceUpgrade` to poll. | +| `client.no_code_modules.read_workspace_upgrade(no_code_module_id, workspace_id, upgrade_id)` | Poll upgrade status. | +| `client.no_code_modules.confirm_workspace_upgrade(no_code_module_id, workspace_id, upgrade_id)` | Confirm and apply the upgrade plan. | + +## Enable a registry module for no-code use + +```python +from pytfe import TFEClient +from pytfe.models import ( + NoCodeModuleCreateOptions, + NoCodeVariableOption, +) + + +client = TFEClient() + +no_code_module = client.no_code_modules.create( + "my-organization", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + enabled=True, + version_pin="1.4.0", + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + NoCodeVariableOption( + variable_name="instance_size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), +) + +print(no_code_module.id) +``` + +`version_pin` defaults to the latest version when omitted. Each +`NoCodeVariableOption` constrains end users to one of the listed values for +that variable. + +## Read a module with its variable options + +`variable_options` are returned by reference only unless you ask for them with +the `include` query: + +```python +from pytfe.models import NoCodeModuleIncludeOpt, NoCodeModuleReadOptions + +module = client.no_code_modules.read( + "nocode-abc123", + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), +) + +for option in module.variable_options: + print(option.variable_name, option.options) +``` + +Without the include, `module.variable_options` contains stubs with `id` only. + +## Update variable options + +To update an existing option, pass its `id` in the entry; entries without an +`id` are added as new options. To remove an option, omit it from the list — +update calls replace the entire `variable-options` set. + +The HCP API requires every PATCH on a no-code module to include the +`registry-module` relationship in the body, or the server returns `404`. The +SDK handles this transparently: if you don't supply `registry_module_id` +on `NoCodeModuleUpdateOptions`, the resource issues an extra GET to pick up +the current module's relationship. Pass `registry_module_id` explicitly to +skip the round trip: + +```python +NoCodeModuleUpdateOptions( + registry_module_id="mod-abc123", + enabled=False, +) +``` + +```python +from pytfe.models import NoCodeModuleUpdateOptions, NoCodeVariableOption + +updated = client.no_code_modules.update( + "nocode-abc123", + NoCodeModuleUpdateOptions( + version_pin="1.5.0", + variable_options=[ + NoCodeVariableOption( + id="vo-existing123", + variable_name="region", + variable_type="string", + options=["us-east-1", "us-east-2", "us-west-2"], + ), + NoCodeVariableOption( + variable_name="environment", + variable_type="string", + options=["dev", "staging", "prod"], + ), + ], + ), +) +``` + +## Introspect variables for a module version + +When building a UI that lets users pick variable values, use +`read_variables` to discover what the module accepts: + +```python +for var in client.no_code_modules.read_variables("nocode-abc123", "1.4.0"): + print(var.name, var.type, var.required, var.options) +``` + +The returned `RegistryModuleVariable` objects include `name`, `type`, +`description`, `default`, `required`, `sensitive`, and `options`. + +## Create a workspace from a no-code module + +```python +from pytfe.models import ( + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +workspace = client.no_code_modules.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="customer-acme-us-east-1", + project_id="prj-abc123", + description="Production environment for ACME (us-east-1)", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="environment", + value="prod", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +print(workspace.id, workspace.execution_mode) +``` + +The returned `Workspace` is parsed the same way as `client.workspaces.read`, +so relationships (`project`, `agent_pool`, `vars`) are available when the +server includes them. + +For agent execution, set both `execution_mode` and `agent_pool_id`: + +```python +from pytfe.models import ExecutionMode + +workspace = client.no_code_modules.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="private-network-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), +) +``` + +The SDK raises `RequiredAgentPoolIDError` if `execution_mode=AGENT` is set +without an `agent_pool_id`. + +## Upgrade a no-code workspace + +Upgrades are a three-step lifecycle: initiate → poll → confirm. + +```python +import time + +from pytfe.models import ( + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +upgrade = client.no_code_modules.upgrade_workspace( + "nocode-abc123", + "ws-abc123", + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-west-2", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +terminal_plan_states = {"planned_and_finished", "errored", "canceled"} +while True: + status = client.no_code_modules.read_workspace_upgrade( + "nocode-abc123", "ws-abc123", upgrade.id + ) + print(status.status, status.plan_url) + if status.status in terminal_plan_states: + break + time.sleep(5) + +if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + "nocode-abc123", "ws-abc123", upgrade.id + ) +``` + +`confirm_workspace_upgrade` returns `None` and signals success via HTTP +status; the API responds with a plain-text body (`"Workspace update +completed"`) which is intentionally not surfaced. + +## Operational notes + +- **Variable options are wire-replaced, not merged.** Every `update` call + with a `variable_options` list replaces the whole set. To keep an existing + option, include it (with its `id`) in the list. +- **`version_pin` controls what the no-code workspace gets.** Update it to + roll out a new module version; downstream workspaces still need explicit + `upgrade_workspace` calls to adopt it. +- **Pin a version explicitly in production.** Defaulting to "latest" means a + registry module publish can change behaviour for every consumer. +- **Treat the workspace returned by `create_workspace` like any other.** + Once created, use `client.workspaces`, `client.runs`, `client.state_versions` + to manage it. diff --git a/docs/scenarios/no-code-provisioning.md b/docs/scenarios/no-code-provisioning.md new file mode 100644 index 00000000..f30c4a83 --- /dev/null +++ b/docs/scenarios/no-code-provisioning.md @@ -0,0 +1,253 @@ +# Scenario: No-code provisioning + +This scenario walks through the full no-code provisioning lifecycle from a +platform team's perspective: + +1. Enable a private registry module for no-code use. +2. Constrain end users to specific allowed values for module variables. +3. Create a workspace from the no-code module on behalf of an end user. +4. Roll the workspace forward to a new module version (upgrade). + +Upstream docs: + +- No-code provisioning: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning +- Private registry modules: https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/modules + +## Prerequisites + +```bash +export TFE_TOKEN="your-api-token" # user or team token (not org) +export TFE_ADDRESS="https://app.terraform.io" +export TFE_ORG="my-organization" +``` + +You also need: + +- A private registry module already published in the organization. +- A project ID for the workspaces you'll create. +- The token must have permission to manage no-code modules and to create + workspaces in the target project. + +Organization tokens are **not** accepted by the no-code write endpoints. Use +a user or team token. See [authentication.md](../authentication.md). + +## Step 1: Enable no-code provisioning on a registry module + +```python +import os + +from pytfe import TFEClient +from pytfe.models import ( + NoCodeModuleCreateOptions, + NoCodeVariableOption, +) + + +client = TFEClient() +organization = os.environ["TFE_ORG"] +registry_module_id = "mod-abc123" + +no_code_module = client.no_code_modules.create( + organization, + NoCodeModuleCreateOptions( + registry_module_id=registry_module_id, + enabled=True, + version_pin="1.4.0", + ), +) + +print("no-code module id:", no_code_module.id) +``` + +`version_pin` defaults to the latest published version if omitted. Pinning +explicitly is safer in production: a registry publish would otherwise change +behaviour for every consumer with no review step. + +## Step 2: Discover the module's variables + +When you don't yet know what variables the module exposes, ask the API: + +```python +for var in client.no_code_modules.read_variables(no_code_module.id, "1.4.0"): + print(f"{var.name} ({var.type})", "required" if var.required else "optional") + if var.description: + print(" ", var.description) +``` + +`read_variables` returns one `RegistryModuleVariable` per declared variable +with `name`, `type`, `description`, `default`, `required`, `sensitive`, and +any `options` listed by the module author. + +## Step 3: Constrain allowed values + +For variables where end users should only pick from a curated set, attach +`NoCodeVariableOption` entries: + +```python +from pytfe.models import NoCodeModuleUpdateOptions, NoCodeVariableOption + +client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions( + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + NoCodeVariableOption( + variable_name="instance_size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), +) +``` + +`update` replaces the entire `variable_options` list every time. To keep +existing options, include them (with their `id` set) in the next update. + +## Step 4: Create a workspace from the module + +This is what an end user (or a platform automation acting on their behalf) +runs. The workspace gets its Terraform code from the pinned module version +and its variable values from the inline `vars`. + +```python +from pytfe.models import ( + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name="customer-acme-us-east-1", + project_id="prj-abc123", + description="Production environment for ACME (us-east-1)", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="instance_size", + value="medium", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +print("workspace id:", workspace.id) +``` + +The returned `Workspace` is the same shape `client.workspaces.read` returns, +so you can chain it with the standard workspace, run, and state APIs. + +### Agent execution mode + +Workspaces that need to reach a private network use agents: + +```python +from pytfe.models import ExecutionMode + +workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name="private-network-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), +) +``` + +The SDK raises `RequiredAgentPoolIDError` locally if `execution_mode=AGENT` +is set without an `agent_pool_id`. + +## Step 5: Upgrade a workspace to a new module version + +Bump the no-code module's `version_pin`, then upgrade each consuming +workspace. Upgrades are a three-step lifecycle: initiate → poll → confirm. + +```python +import time + +from pytfe.models import ( + NoCodeModuleUpdateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + CategoryType, +) + +# Bump the module version. +client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions(version_pin="1.5.0"), +) + +# Initiate the upgrade for one workspace, optionally changing variables. +upgrade = client.no_code_modules.upgrade_workspace( + no_code_module.id, + workspace.id, + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="instance_size", + value="large", + category=CategoryType.TERRAFORM, + ), + ], + ), +) + +terminal_plan_states = {"planned_and_finished", "errored", "canceled"} +while True: + status = client.no_code_modules.read_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print("upgrade status:", status.status) + if status.status in terminal_plan_states: + break + time.sleep(5) + +if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print("upgrade applied") +``` + +`confirm_workspace_upgrade` returns `None`; success is signalled by HTTP +status. The plan URL on `status.plan_url` opens the upgrade plan in the +HCP Terraform UI for review before confirming. + +## Cleanup + +To disable no-code provisioning for the module (without deleting the +underlying registry module): + +```python +client.no_code_modules.delete(no_code_module.id) +``` + +Workspaces already created from the module remain; they just no longer +receive new upgrades through the no-code flow. Delete the workspaces +separately through `client.workspaces.delete(...)` if appropriate. + +## Operational notes + +- Use a **team token** for automation. User tokens work but couple the + automation to a single person. +- Pin `version_pin` explicitly. Defaulting to "latest" lets a publish change + every workspace created from the module thereafter. +- Treat `variable_options` updates as set replacements — every update writes + the full list. +- Audit who creates workspaces from no-code modules. Combine team workspace + access (see [Team access onboarding](team-access-onboarding.md)) with + no-code provisioning to keep ownership clean. diff --git a/examples/no_code_provisioning.py b/examples/no_code_provisioning.py new file mode 100644 index 00000000..e1946df7 --- /dev/null +++ b/examples/no_code_provisioning.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Example: HCP Terraform no-code provisioning. + +Walks through the four phases of the no-code lifecycle: + +1. Enable a registry module for no-code use, with allowed variable values. +2. Read variables declared by the module version (used for form-building). +3. Create a workspace from the no-code module on behalf of an end user. +4. Upgrade the workspace to a newer module version. + +Environment variables: + + TFE_TOKEN user or team token (not an org token) + TFE_ADDRESS HCP Terraform / Terraform Enterprise URL + TFE_ORG organization name + TFE_REGISTRY_MODULE_ID registry module ID to enable + TFE_PROJECT_ID project to create workspaces in + TFE_MODULE_VERSION initial module version (default: latest) + TFE_NEXT_MODULE_VERSION upgrade target version (optional; skips upgrade if unset) + +The script creates one workspace and (optionally) upgrades it. It does NOT +delete the workspace or the no-code module by default — uncomment the +cleanup block at the end if you want it to. +""" + +from __future__ import annotations + +import os +import sys +import time + +from pytfe.client import TFEClient +from pytfe.errors import TFEError +from pytfe.models import ( + CategoryType, + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, +) + +TERMINAL_UPGRADE_STATES = {"planned_and_finished", "errored", "canceled"} + + +def main() -> int: + organization = os.environ["TFE_ORG"] + registry_module_id = os.environ["TFE_REGISTRY_MODULE_ID"] + project_id = os.environ["TFE_PROJECT_ID"] + module_version = os.environ.get("TFE_MODULE_VERSION") + next_version = os.environ.get("TFE_NEXT_MODULE_VERSION") + + client = TFEClient() + + print("=== pyTFE No-Code Provisioning Example ===\n") + + # 1. Enable no-code provisioning on a registry module with allowed values. + print(f"1. Enabling no-code on registry module {registry_module_id}...") + no_code_module = client.no_code_modules.create( + organization, + NoCodeModuleCreateOptions( + registry_module_id=registry_module_id, + enabled=True, + version_pin=module_version, + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2", "eu-west-1"], + ), + ], + ), + ) + print(f" no-code module id: {no_code_module.id}") + print(f" version pin: {no_code_module.version_pin}") + + # Re-read with include to confirm variable options round-trip. + refreshed = client.no_code_modules.read( + no_code_module.id, + NoCodeModuleReadOptions( + include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS] + ), + ) + for option in refreshed.variable_options: + print( + " variable option:", + option.variable_name, + "->", + option.options, + ) + + # 2. Introspect variables for the pinned version. + if module_version: + print(f"\n2. Reading variables for version {module_version}...") + try: + for var in client.no_code_modules.read_variables( + no_code_module.id, module_version + ): + req = "required" if var.required else "optional" + print(f" - {var.name} ({var.type}) [{req}]") + except TFEError as exc: + print(f" could not read variables: {exc}") + else: + print("\n2. Skipping variable introspection — TFE_MODULE_VERSION not set.") + + # 3. Create a workspace from the no-code module. + print("\n3. Creating a workspace from the no-code module...") + workspace_name = f"pytfe-no-code-{int(time.time())}" + workspace = client.no_code_modules.create_workspace( + no_code_module.id, + NoCodeWorkspaceCreateOptions( + name=workspace_name, + project_id=project_id, + description="Created by pyTFE no-code example", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + ], + ), + ) + print(f" workspace id: {workspace.id}") + print(f" workspace name: {workspace.name}") + print(f" execution mode: {workspace.execution_mode}") + + # 4. Optional: upgrade to a newer version. + if next_version: + print(f"\n4. Upgrading workspace to module version {next_version}...") + client.no_code_modules.update( + no_code_module.id, + NoCodeModuleUpdateOptions(version_pin=next_version), + ) + upgrade = client.no_code_modules.upgrade_workspace( + no_code_module.id, + workspace.id, + NoCodeWorkspaceUpgradeOptions(), + ) + print(f" upgrade id: {upgrade.id}") + + while True: + status = client.no_code_modules.read_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print(f" upgrade status: {status.status}") + if status.status in TERMINAL_UPGRADE_STATES: + break + time.sleep(5) + + if status.status == "planned_and_finished": + client.no_code_modules.confirm_workspace_upgrade( + no_code_module.id, workspace.id, upgrade.id + ) + print(" upgrade applied.") + else: + print(f" upgrade did not complete cleanly (status={status.status}).") + else: + print("\n4. Skipping upgrade — TFE_NEXT_MODULE_VERSION not set.") + + print("\nDone.") + print( + "Workspace and no-code module were NOT deleted. " + "Delete them via client.workspaces.delete_by_id(...) " + "and client.no_code_modules.delete(...) when finished." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pytfe/_jsonapi.py b/src/pytfe/_jsonapi.py index 7fafff9c..6f031452 100644 --- a/src/pytfe/_jsonapi.py +++ b/src/pytfe/_jsonapi.py @@ -7,7 +7,7 @@ def build_headers(user_agent_suffix: str | None = None) -> dict[str, str]: - ua = "pytfe/0.1" + ua = "pytfe/1.0" if user_agent_suffix: ua = f"{ua} {user_agent_suffix}" return { diff --git a/src/pytfe/client.py b/src/pytfe/client.py index 2655c1fc..63548716 100644 --- a/src/pytfe/client.py +++ b/src/pytfe/client.py @@ -11,6 +11,7 @@ from .resources.comment import Comments from .resources.configuration_version import ConfigurationVersions from .resources.explorer import Explorer +from .resources.no_code_module import NoCodeModules from .resources.notification_configuration import NotificationConfigurations from .resources.oauth_client import OAuthClients from .resources.oauth_token import OAuthTokens @@ -108,6 +109,7 @@ def __init__(self, config: TFEConfig | None = None): self.workspace_resources = WorkspaceResourcesService(self._transport) self.workspace_run_tasks = WorkspaceRunTasks(self._transport) self.registry_modules = RegistryModules(self._transport) + self.no_code_modules = NoCodeModules(self._transport) self.registry_providers = RegistryProviders(self._transport) self.registry_provider_versions = RegistryProviderVersions(self._transport) self.registry_provider_platforms = RegistryProviderPlatforms(self._transport) diff --git a/src/pytfe/errors.py b/src/pytfe/errors.py index dbf4ba38..dc870dae 100644 --- a/src/pytfe/errors.py +++ b/src/pytfe/errors.py @@ -717,3 +717,25 @@ class RequiredProjectError(RequiredFieldMissing): def __init__(self, message: str = "project is required"): super().__init__(message) + + +# No-code module errors +class InvalidNoCodeModuleIDError(InvalidValues): + """Raised when an invalid no-code module ID is provided.""" + + def __init__(self, message: str = "invalid value for no-code module ID"): + super().__init__(message) + + +class InvalidWorkspaceUpgradeIDError(InvalidValues): + """Raised when an invalid workspace upgrade ID is provided.""" + + def __init__(self, message: str = "invalid value for workspace upgrade ID"): + super().__init__(message) + + +class RequiredRegistryModuleIDError(RequiredFieldMissing): + """Raised when a registry module ID is required but missing.""" + + def __init__(self, message: str = "registry module ID is required"): + super().__init__(message) diff --git a/src/pytfe/models/__init__.py b/src/pytfe/models/__init__.py index c3c6f56b..6ca910dd 100644 --- a/src/pytfe/models/__init__.py +++ b/src/pytfe/models/__init__.py @@ -76,6 +76,19 @@ ) # ── Notification Configurations ─────────────────────────────────────────────── +from .no_code_module import ( + NoCodeModule, + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + RegistryModuleVariable, + WorkspaceUpgrade, +) from .notification_configuration import ( DeliveryResponse, NotificationConfiguration, @@ -523,6 +536,18 @@ # ── Public surface ──────────────────────────────────────────────────────────── __all__ = [ + # No-code provisioning + "NoCodeModule", + "NoCodeModuleCreateOptions", + "NoCodeModuleIncludeOpt", + "NoCodeModuleReadOptions", + "NoCodeModuleUpdateOptions", + "NoCodeVariableOption", + "NoCodeWorkspaceCreateOptions", + "NoCodeWorkspaceUpgradeOptions", + "NoCodeWorkspaceVariable", + "RegistryModuleVariable", + "WorkspaceUpgrade", # Notification configurations "DeliveryResponse", "NotificationConfiguration", diff --git a/src/pytfe/models/no_code_module.py b/src/pytfe/models/no_code_module.py new file mode 100644 index 00000000..c82a12a6 --- /dev/null +++ b/src/pytfe/models/no_code_module.py @@ -0,0 +1,182 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from .organization import Organization +from .registry_module import RegistryModule +from .variable import CategoryType +from .workspace import ExecutionMode, Workspace + + +class NoCodeModuleIncludeOpt(str, Enum): + """Include options for no-code module read.""" + + VARIABLE_OPTIONS = "variable_options" + + +class NoCodeVariableOption(BaseModel): + """An allowed-values constraint on a single variable in a no-code module. + + Returned as part of a no-code module's ``variable-options`` relationship. + The same shape is used both when reading a module (with ``include`` set) + and when constructing create/update options. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + variable_name: str | None = Field(default=None, alias="variable-name") + variable_type: str | None = Field(default=None, alias="variable-type") + options: list[str] = Field(default_factory=list) + + +class NoCodeModule(BaseModel): + """Represents a no-code module — a registry module that has been enabled + for the no-code provisioning workflow. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + + # Relationships + organization: Organization | None = None + registry_module: RegistryModule | None = Field( + default=None, alias="registry-module" + ) + variable_options: list[NoCodeVariableOption] = Field( + default_factory=list, alias="variable-options" + ) + + +class NoCodeModuleCreateOptions(BaseModel): + """Options for enabling no-code provisioning on a registry module.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + registry_module_id: str = Field(..., description="ID of the registry module to enable") + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + variable_options: list[NoCodeVariableOption] = Field( + default_factory=list, alias="variable-options" + ) + + +class NoCodeModuleUpdateOptions(BaseModel): + """Options for updating no-code provisioning settings. + + ``variable_options`` entries with an ``id`` set update existing options; + entries without an ``id`` add new options. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + registry_module_id: str | None = None + enabled: bool | None = None + version_pin: str | None = Field(default=None, alias="version-pin") + variable_options: list[NoCodeVariableOption] | None = Field( + default=None, alias="variable-options" + ) + + +class NoCodeModuleReadOptions(BaseModel): + """Options for reading a no-code module with optional includes.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + include: list[NoCodeModuleIncludeOpt] | None = None + + +class NoCodeWorkspaceVariable(BaseModel): + """A workspace variable supplied inline during no-code workspace creation + or upgrade. Mirrors the fields accepted under the ``vars`` relationship. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + key: str + value: str | None = None + description: str | None = None + category: CategoryType | None = None + hcl: bool | None = None + sensitive: bool | None = None + + +class NoCodeWorkspaceCreateOptions(BaseModel): + """Options for creating a workspace from a no-code module.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + name: str = Field(..., description="Workspace name") + project_id: str = Field(..., description="ID of the project to create the workspace in") + description: str | None = None + agent_pool_id: str | None = Field(default=None, alias="agent-pool-id") + auto_apply: bool | None = None + execution_mode: ExecutionMode | None = Field(default=None, alias="execution-mode") + source_name: str | None = Field(default=None, alias="source-name") + source_url: str | None = Field(default=None, alias="source-url") + terraform_version: str | None = Field(default=None, alias="terraform-version") + vars: list[NoCodeWorkspaceVariable] = Field(default_factory=list) + + +class NoCodeWorkspaceUpgradeOptions(BaseModel): + """Options for initiating a no-code workspace upgrade.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + vars: list[NoCodeWorkspaceVariable] = Field(default_factory=list) + + +class WorkspaceUpgrade(BaseModel): + """The result of initiating or polling a no-code workspace upgrade.""" + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + status: str | None = None + plan_url: str | None = Field(default=None, alias="plan-url") + message: str | None = None + + # Relationships + workspace: Workspace | None = None + + +class RegistryModuleVariable(BaseModel): + """A variable declared by a specific version of a registry module. + + Returned by ``client.no_code_modules.read_variables`` for use in driving + UIs that build no-code workspace creation forms. + """ + + model_config = ConfigDict(populate_by_name=True, validate_by_name=True) + + id: str | None = None + name: str | None = None + type: str | None = None + description: str | None = None + default: str | None = None + required: bool | None = None + sensitive: bool | None = None + options: list[str] = Field(default_factory=list) + + +__all__ = [ + "NoCodeModule", + "NoCodeModuleCreateOptions", + "NoCodeModuleIncludeOpt", + "NoCodeModuleReadOptions", + "NoCodeModuleUpdateOptions", + "NoCodeVariableOption", + "NoCodeWorkspaceCreateOptions", + "NoCodeWorkspaceUpgradeOptions", + "NoCodeWorkspaceVariable", + "RegistryModuleVariable", + "WorkspaceUpgrade", +] diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py new file mode 100644 index 00000000..d0d9e278 --- /dev/null +++ b/src/pytfe/resources/no_code_module.py @@ -0,0 +1,463 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ..errors import ( + InvalidNoCodeModuleIDError, + InvalidOrgError, + InvalidVersionError, + InvalidWorkspaceIDError, + InvalidWorkspaceUpgradeIDError, + RequiredAgentPoolIDError, + RequiredNameError, + RequiredProjectError, + RequiredRegistryModuleIDError, +) +from ..models.no_code_module import ( + NoCodeModule, + NoCodeModuleCreateOptions, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, + RegistryModuleVariable, + WorkspaceUpgrade, +) +from ..models.organization import Organization +from ..models.registry_module import RegistryModule +from ..models.workspace import ExecutionMode, Workspace +from ..utils import valid_string, valid_string_id +from ._base import _Service + +_NO_CODE_MODULE_TYPE = "no-code-modules" +_REGISTRY_MODULE_TYPE = "registry-module" +_VARIABLE_OPTIONS_TYPE = "variable-options" +_WORKSPACE_TYPE = "workspaces" +_VARS_TYPE = "vars" + + +def _variable_option_payload(opt: NoCodeVariableOption) -> dict[str, Any]: + """Serialize a NoCodeVariableOption to its JSON:API relationship-data shape. + + The API accepts both new entries (no ``id``) and updates to existing + entries (``id`` set). Only the wire-aliased keys are emitted. + """ + attrs: dict[str, Any] = {} + if opt.variable_name is not None: + attrs["variable-name"] = opt.variable_name + if opt.variable_type is not None: + attrs["variable-type"] = opt.variable_type + if opt.options: + attrs["options"] = list(opt.options) + entry: dict[str, Any] = {"type": _VARIABLE_OPTIONS_TYPE, "attributes": attrs} + if opt.id: + entry["id"] = opt.id + return entry + + +def _inline_var_payload(var: NoCodeWorkspaceVariable) -> dict[str, Any]: + attrs: dict[str, Any] = {"key": var.key} + if var.value is not None: + attrs["value"] = var.value + if var.description is not None: + attrs["description"] = var.description + if var.category is not None: + attrs["category"] = var.category.value + if var.hcl is not None: + attrs["hcl"] = var.hcl + if var.sensitive is not None: + attrs["sensitive"] = var.sensitive + return {"type": _VARS_TYPE, "attributes": attrs} + + +def _variable_option_from(data: dict[str, Any]) -> NoCodeVariableOption: + attrs = data.get("attributes") or {} + return NoCodeVariableOption.model_validate( + { + "id": data.get("id"), + "variable-name": attrs.get("variable-name"), + "variable-type": attrs.get("variable-type"), + "options": attrs.get("options") or [], + } + ) + + +def _no_code_module_from( + data: dict[str, Any], included: list[dict[str, Any]] | None = None +) -> NoCodeModule: + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + + module = NoCodeModule.model_validate( + { + "id": data.get("id"), + "enabled": attrs.get("enabled"), + "version-pin": attrs.get("version-pin"), + } + ) + + org_data = (relationships.get("organization") or {}).get("data") + if org_data and org_data.get("id"): + module.organization = Organization.model_construct(id=org_data["id"]) + + rm_data = (relationships.get("registry-module") or {}).get("data") + if rm_data and rm_data.get("id"): + module.registry_module = RegistryModule.model_construct(id=rm_data["id"]) + + vo_rel = (relationships.get("variable-options") or {}).get("data") or [] + if vo_rel and included: + index = {(item.get("type"), item.get("id")): item for item in included} + resolved: list[NoCodeVariableOption] = [] + for ref in vo_rel: + key = (ref.get("type"), ref.get("id")) + full = index.get(key) + if full is not None: + resolved.append(_variable_option_from(full)) + else: + resolved.append( + NoCodeVariableOption.model_validate({"id": ref.get("id")}) + ) + module.variable_options = resolved + elif vo_rel: + module.variable_options = [ + NoCodeVariableOption.model_validate({"id": ref.get("id")}) + for ref in vo_rel + if ref.get("id") + ] + + return module + + +def _workspace_upgrade_from(data: dict[str, Any]) -> WorkspaceUpgrade: + attrs = data.get("attributes") or {} + relationships = data.get("relationships") or {} + + upgrade = WorkspaceUpgrade.model_validate( + { + "id": data.get("id"), + "status": attrs.get("status"), + "plan-url": attrs.get("plan-url"), + "message": attrs.get("message"), + } + ) + + ws_data = (relationships.get("workspace") or {}).get("data") + if ws_data and ws_data.get("id"): + upgrade.workspace = Workspace.model_construct(id=ws_data["id"]) + + return upgrade + + +class NoCodeModules(_Service): + """No-code provisioning: enable a registry module for self-service + workspace creation, manage allowed variable values, and drive workspace + create/upgrade flows. + + Upstream docs: + https://developer.hashicorp.com/terraform/cloud-docs/api-docs/no-code-provisioning + + All write endpoints require a user or team token. Organization tokens are + not supported by the API. + """ + + # ---- No-code module CRUD ---- + + def create( + self, organization: str, options: NoCodeModuleCreateOptions + ) -> NoCodeModule: + """Enable no-code provisioning on a registry module.""" + if not valid_string_id(organization): + raise InvalidOrgError() + if not valid_string_id(options.registry_module_id): + raise RequiredRegistryModuleIDError() + + body = self._build_module_payload(options) + r = self.t.request( + "POST", + f"/api/v2/organizations/{organization}/no-code-modules", + json_body=body, + ) + return _no_code_module_from(r.json()["data"]) + + def read( + self, + no_code_module_id: str, + options: NoCodeModuleReadOptions | None = None, + ) -> NoCodeModule: + """Read a no-code module by ID, optionally including variable options.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + params: dict[str, Any] = {} + if options and options.include: + params["include"] = ",".join(i.value for i in options.include) + + r = self.t.request( + "GET", + f"/api/v2/no-code-modules/{no_code_module_id}", + params=params or None, + ) + body = r.json() + return _no_code_module_from(body["data"], body.get("included")) + + def update( + self, no_code_module_id: str, options: NoCodeModuleUpdateOptions + ) -> NoCodeModule: + """Update no-code provisioning settings. + + The HCP API requires every PATCH on a no-code module to include the + ``registry-module`` relationship in the request body — without it, + the endpoint returns 404 even though the module exists. If the + caller didn't supply ``registry_module_id`` in ``options``, we + read the current module to pick up its existing relationship so + callers don't have to remember this quirk. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + if not options.registry_module_id: + current = self.read(no_code_module_id) + if current.registry_module and current.registry_module.id: + options = options.model_copy( + update={"registry_module_id": current.registry_module.id} + ) + + body = self._build_module_payload(options) + r = self.t.request( + "PATCH", + f"/api/v2/no-code-modules/{no_code_module_id}", + json_body=body, + ) + return _no_code_module_from(r.json()["data"]) + + def delete(self, no_code_module_id: str) -> None: + """Disable no-code provisioning for a registry module.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + + self.t.request("DELETE", f"/api/v2/no-code-modules/{no_code_module_id}") + + def read_variables( + self, no_code_module_id: str, version: str + ) -> Iterator[RegistryModuleVariable]: + """Iterate the variables declared by a specific version of a no-code + module. Useful for driving a form that lets users supply ``vars`` when + creating a workspace. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string(version): + raise InvalidVersionError() + + path = ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/versions/{version}/module-variables" + ) + for item in self._list(path): + attrs = item.get("attributes") or {} + yield RegistryModuleVariable.model_validate( + { + "id": item.get("id"), + "name": attrs.get("name"), + "type": attrs.get("type"), + "description": attrs.get("description"), + "default": attrs.get("default"), + "required": attrs.get("required"), + "sensitive": attrs.get("sensitive"), + "options": attrs.get("options") or [], + } + ) + + # ---- Workspace lifecycle ---- + + def create_workspace( + self, no_code_module_id: str, options: NoCodeWorkspaceCreateOptions + ) -> Workspace: + """Create a workspace from a no-code module. + + The returned Workspace is populated by the workspaces parser, so + relationships (project, agent_pool, vars) are available when the + server includes them. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string(options.name): + raise RequiredNameError() + if not valid_string_id(options.project_id): + raise RequiredProjectError() + if ( + options.execution_mode == ExecutionMode.AGENT + and not valid_string_id(options.agent_pool_id) + ): + raise RequiredAgentPoolIDError() + + body = self._build_workspace_payload(options) + r = self.t.request( + "POST", + f"/api/v2/no-code-modules/{no_code_module_id}/workspaces", + json_body=body, + ) + # Reuse the workspace parser so all relationship handling stays in + # one place. Imported lazily to avoid a circular import. + from .workspaces import _ws_from + + return _ws_from(r.json()["data"]) + + def upgrade_workspace( + self, + no_code_module_id: str, + workspace_id: str, + options: NoCodeWorkspaceUpgradeOptions | None = None, + ) -> WorkspaceUpgrade: + """Initiate a no-code workspace upgrade. Returns the upgrade record; + poll with ``read_workspace_upgrade`` until ``status`` is + ``planned_and_finished`` (or terminal), then call + ``confirm_workspace_upgrade``. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + + attrs: dict[str, Any] = {} + body: dict[str, Any] = {"data": {"type": _WORKSPACE_TYPE, "attributes": attrs}} + if options and options.vars: + body["data"]["relationships"] = { + "vars": {"data": [_inline_var_payload(v) for v in options.vars]} + } + + r = self.t.request( + "POST", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade" + ), + json_body=body, + ) + return _workspace_upgrade_from(r.json()["data"]) + + def read_workspace_upgrade( + self, + no_code_module_id: str, + workspace_id: str, + upgrade_id: str, + ) -> WorkspaceUpgrade: + """Read the current status of a no-code workspace upgrade.""" + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(upgrade_id): + raise InvalidWorkspaceUpgradeIDError() + + r = self.t.request( + "GET", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade/{upgrade_id}" + ), + ) + return _workspace_upgrade_from(r.json()["data"]) + + def confirm_workspace_upgrade( + self, + no_code_module_id: str, + workspace_id: str, + upgrade_id: str, + ) -> None: + """Confirm and apply a no-code workspace upgrade plan. + + The API returns a plain-text body (``"Workspace update completed"``) + rather than a JSON:API envelope; we intentionally return ``None`` and + rely on the HTTP status for success/failure semantics, matching the + SDK's pattern for action endpoints. + """ + if not valid_string_id(no_code_module_id): + raise InvalidNoCodeModuleIDError() + if not valid_string_id(workspace_id): + raise InvalidWorkspaceIDError() + if not valid_string_id(upgrade_id): + raise InvalidWorkspaceUpgradeIDError() + + self.t.request( + "POST", + ( + f"/api/v2/no-code-modules/{no_code_module_id}" + f"/workspaces/{workspace_id}/upgrade/{upgrade_id}" + ), + ) + + # ---- Payload builders ---- + + def _build_module_payload( + self, + options: NoCodeModuleCreateOptions | NoCodeModuleUpdateOptions, + ) -> dict[str, Any]: + attrs: dict[str, Any] = {} + if options.enabled is not None: + attrs["enabled"] = options.enabled + if options.version_pin is not None: + attrs["version-pin"] = options.version_pin + + body: dict[str, Any] = { + "data": {"type": _NO_CODE_MODULE_TYPE, "attributes": attrs} + } + + relationships: dict[str, Any] = {} + if options.registry_module_id: + relationships["registry-module"] = { + "data": { + "type": _REGISTRY_MODULE_TYPE, + "id": options.registry_module_id, + } + } + + var_opts = options.variable_options + if var_opts: + relationships["variable-options"] = { + "data": [_variable_option_payload(v) for v in var_opts] + } + + if relationships: + body["data"]["relationships"] = relationships + + return body + + def _build_workspace_payload( + self, options: NoCodeWorkspaceCreateOptions + ) -> dict[str, Any]: + attrs: dict[str, Any] = {"name": options.name} + if options.description is not None: + attrs["description"] = options.description + if options.agent_pool_id is not None: + attrs["agent-pool-id"] = options.agent_pool_id + if options.auto_apply is not None: + attrs["auto_apply"] = options.auto_apply + if options.execution_mode is not None: + attrs["execution-mode"] = options.execution_mode.value + if options.source_name is not None: + attrs["source-name"] = options.source_name + if options.source_url is not None: + attrs["source-url"] = options.source_url + if options.terraform_version is not None: + attrs["terraform-version"] = options.terraform_version + + body: dict[str, Any] = { + "data": {"type": _WORKSPACE_TYPE, "attributes": attrs} + } + + relationships: dict[str, Any] = { + "project": {"data": {"type": "projects", "id": options.project_id}}, + } + if options.vars: + relationships["vars"] = { + "data": [_inline_var_payload(v) for v in options.vars] + } + body["data"]["relationships"] = relationships + return body diff --git a/tests/units/test_no_code_module.py b/tests/units/test_no_code_module.py new file mode 100644 index 00000000..14a6ae44 --- /dev/null +++ b/tests/units/test_no_code_module.py @@ -0,0 +1,689 @@ +# Copyright IBM Corp. 2025, 2026 +# SPDX-License-Identifier: MPL-2.0 + +"""Unit tests for the no-code provisioning resource.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest + +from pytfe.errors import ( + InvalidNoCodeModuleIDError, + InvalidOrgError, + InvalidVersionError, + InvalidWorkspaceIDError, + InvalidWorkspaceUpgradeIDError, + RequiredAgentPoolIDError, + RequiredNameError, + RequiredProjectError, + RequiredRegistryModuleIDError, +) +from pytfe.models.no_code_module import ( + NoCodeModuleCreateOptions, + NoCodeModuleIncludeOpt, + NoCodeModuleReadOptions, + NoCodeModuleUpdateOptions, + NoCodeVariableOption, + NoCodeWorkspaceCreateOptions, + NoCodeWorkspaceUpgradeOptions, + NoCodeWorkspaceVariable, +) +from pytfe.models.variable import CategoryType +from pytfe.models.workspace import ExecutionMode +from pytfe.resources.no_code_module import NoCodeModules + + +def _resp(json_body: Any) -> Mock: + r = Mock() + r.json.return_value = json_body + return r + + +def _no_code_module_body( + *, + nc_id: str = "nocode-abc123", + enabled: bool = True, + version_pin: str | None = "1.0.0", + org_id: str = "my-org", + registry_module_id: str = "mod-abc123", + variable_option_refs: list[dict[str, str]] | None = None, + included: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + relationships: dict[str, Any] = { + "organization": {"data": {"type": "organizations", "id": org_id}}, + "registry-module": { + "data": {"type": "registry-modules", "id": registry_module_id} + }, + } + if variable_option_refs is not None: + relationships["variable-options"] = {"data": variable_option_refs} + + body: dict[str, Any] = { + "data": { + "id": nc_id, + "type": "no-code-modules", + "attributes": {"enabled": enabled, "version-pin": version_pin}, + "relationships": relationships, + } + } + if included is not None: + body["included"] = included + return body + + +def _workspace_body(*, ws_id: str = "ws-abc123") -> dict[str, Any]: + return { + "data": { + "id": ws_id, + "type": "workspaces", + "attributes": { + "name": "no-code-ws", + "execution-mode": "remote", + }, + "relationships": { + "project": {"data": {"type": "projects", "id": "prj-abc123"}}, + }, + } + } + + +def _upgrade_body( + *, + upgrade_id: str = "wsu-abc123", + status: str = "planned", + ws_id: str = "ws-abc123", +) -> dict[str, Any]: + return { + "data": { + "id": upgrade_id, + "type": "workspace-upgrade", + "attributes": { + "status": status, + "plan-url": "https://app.terraform.io/plan/abc", + }, + "relationships": { + "workspace": {"data": {"type": "workspaces", "id": ws_id}}, + }, + } + } + + +class TestNoCodeModuleCreate: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_create_minimum_payload(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + result = self.service.create( + "my-org", + NoCodeModuleCreateOptions(registry_module_id="mod-abc123"), + ) + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + + assert method == "POST" + assert path == "/api/v2/organizations/my-org/no-code-modules" + assert body["data"]["type"] == "no-code-modules" + assert body["data"]["attributes"] == {} + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-abc123", + } + assert "variable-options" not in body["data"]["relationships"] + assert result.id == "nocode-abc123" + assert result.registry_module is not None + assert result.registry_module.id == "mod-abc123" + + def test_create_with_enabled_and_version_pin(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.create( + "my-org", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + enabled=True, + version_pin="2.4.0", + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + assert body["data"]["attributes"] == { + "enabled": True, + "version-pin": "2.4.0", + } + + def test_create_with_variable_options(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.create( + "my-org", + NoCodeModuleCreateOptions( + registry_module_id="mod-abc123", + variable_options=[ + NoCodeVariableOption( + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2"], + ) + ], + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + var_opts = body["data"]["relationships"]["variable-options"]["data"] + assert len(var_opts) == 1 + assert var_opts[0] == { + "type": "variable-options", + "attributes": { + "variable-name": "region", + "variable-type": "string", + "options": ["us-east-1", "us-west-2"], + }, + } + # No id on a new option + assert "id" not in var_opts[0] + + def test_create_invalid_org_raises(self) -> None: + with pytest.raises(InvalidOrgError): + self.service.create( + "", + NoCodeModuleCreateOptions(registry_module_id="mod-abc123"), + ) + + def test_create_missing_registry_module_id_raises(self) -> None: + with pytest.raises(RequiredRegistryModuleIDError): + self.service.create( + "my-org", + NoCodeModuleCreateOptions(registry_module_id=""), + ) + + +class TestNoCodeModuleRead: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_without_include(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + result = self.service.read("nocode-abc123") + + method, path = self.transport.request.call_args.args + kwargs = self.transport.request.call_args.kwargs + assert method == "GET" + assert path == "/api/v2/no-code-modules/nocode-abc123" + assert kwargs.get("params") is None + assert result.id == "nocode-abc123" + assert result.enabled is True + + def test_read_with_include_emits_query_param(self) -> None: + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.read( + "nocode-abc123", + NoCodeModuleReadOptions( + include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS] + ), + ) + + params = self.transport.request.call_args.kwargs["params"] + assert params == {"include": "variable_options"} + + def test_read_resolves_included_variable_options(self) -> None: + body = _no_code_module_body( + variable_option_refs=[ + {"type": "variable-options", "id": "vo-1"}, + {"type": "variable-options", "id": "vo-2"}, + ], + included=[ + { + "type": "variable-options", + "id": "vo-1", + "attributes": { + "variable-name": "region", + "variable-type": "string", + "options": ["us-east-1"], + }, + }, + # Only one of the two is in `included` — the other should + # fall back to an id-only stub. + ], + ) + self.transport.request.return_value = _resp(body) + + result = self.service.read("nocode-abc123") + + assert len(result.variable_options) == 2 + first = result.variable_options[0] + assert first.id == "vo-1" + assert first.variable_name == "region" + assert first.options == ["us-east-1"] + second = result.variable_options[1] + assert second.id == "vo-2" + assert second.variable_name is None + + def test_read_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.read("") + + +class TestNoCodeModuleUpdate: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_update_variable_options_with_and_without_ids(self) -> None: + # Caller supplies registry_module_id explicitly, so no auto-read. + self.transport.request.return_value = _resp(_no_code_module_body()) + + self.service.update( + "nocode-abc123", + NoCodeModuleUpdateOptions( + registry_module_id="mod-abc123", + enabled=False, + variable_options=[ + NoCodeVariableOption( + id="vo-existing", + variable_name="region", + variable_type="string", + options=["us-east-1", "us-west-2"], + ), + NoCodeVariableOption( + variable_name="size", + variable_type="string", + options=["small", "medium", "large"], + ), + ], + ), + ) + + # One PATCH (no preceding GET because the caller provided + # registry_module_id explicitly). + assert self.transport.request.call_count == 1 + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "PATCH" + assert path == "/api/v2/no-code-modules/nocode-abc123" + assert body["data"]["attributes"] == {"enabled": False} + + # API requires the registry-module relationship on every PATCH. + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-abc123", + } + + var_opts = body["data"]["relationships"]["variable-options"]["data"] + assert len(var_opts) == 2 + assert var_opts[0]["id"] == "vo-existing" + assert "id" not in var_opts[1] + + def test_update_without_registry_module_id_auto_reads(self) -> None: + # When the caller omits registry_module_id, the resource fetches the + # current module to satisfy the API's PATCH requirement. + read_response = _resp(_no_code_module_body(registry_module_id="mod-xyz789")) + patch_response = _resp(_no_code_module_body(registry_module_id="mod-xyz789")) + self.transport.request.side_effect = [read_response, patch_response] + + self.service.update( + "nocode-abc123", + NoCodeModuleUpdateOptions(enabled=True), + ) + + # Two requests: first GET (auto-read), then PATCH. + assert self.transport.request.call_count == 2 + first_method, first_path = self.transport.request.call_args_list[0].args + second_method, second_path = self.transport.request.call_args_list[1].args + assert first_method == "GET" + assert first_path == "/api/v2/no-code-modules/nocode-abc123" + assert second_method == "PATCH" + assert second_path == "/api/v2/no-code-modules/nocode-abc123" + + # PATCH body picked up the existing registry-module relationship. + body = self.transport.request.call_args_list[1].kwargs["json_body"] + assert body["data"]["relationships"]["registry-module"]["data"] == { + "type": "registry-module", + "id": "mod-xyz789", + } + + def test_update_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.update( + "", + NoCodeModuleUpdateOptions(enabled=True), + ) + + +class TestNoCodeModuleDelete: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_delete_calls_delete_path(self) -> None: + self.transport.request.return_value = _resp({}) + + self.service.delete("nocode-abc123") + + method, path = self.transport.request.call_args.args + assert method == "DELETE" + assert path == "/api/v2/no-code-modules/nocode-abc123" + + def test_delete_invalid_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.delete("") + + +class TestNoCodeModuleReadVariables: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_variables_yields_typed_records(self) -> None: + page = { + "data": [ + { + "id": "modvar-1", + "type": "module-variables", + "attributes": { + "name": "region", + "type": "string", + "description": "AWS region", + "default": "us-east-1", + "required": False, + "sensitive": False, + "options": ["us-east-1", "us-west-2"], + }, + } + ], + "meta": {"pagination": {"current-page": 1, "total-pages": 1}}, + } + self.transport.request.return_value = _resp(page) + + result = list(self.service.read_variables("nocode-abc123", "1.0.0")) + + path = self.transport.request.call_args.args[1] + assert ( + path + == "/api/v2/no-code-modules/nocode-abc123/versions/1.0.0/module-variables" + ) + assert len(result) == 1 + v = result[0] + assert v.id == "modvar-1" + assert v.name == "region" + assert v.options == ["us-east-1", "us-west-2"] + + def test_read_variables_invalid_id(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + list(self.service.read_variables("", "1.0.0")) + + def test_read_variables_invalid_version(self) -> None: + with pytest.raises(InvalidVersionError): + list(self.service.read_variables("nocode-abc123", "")) + + +class TestNoCodeCreateWorkspace: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_minimum_payload(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + ws = self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", project_id="prj-abc123" + ), + ) + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert path == "/api/v2/no-code-modules/nocode-abc123/workspaces" + assert body["data"]["type"] == "workspaces" + assert body["data"]["attributes"] == {"name": "no-code-ws"} + assert body["data"]["relationships"]["project"]["data"] == { + "type": "projects", + "id": "prj-abc123", + } + assert "vars" not in body["data"]["relationships"] + assert ws.id == "ws-abc123" + + def test_payload_with_inline_vars(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + description="from no-code module", + terraform_version="1.7.0", + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-east-1", + category=CategoryType.TERRAFORM, + ), + NoCodeWorkspaceVariable( + key="API_KEY", + value="redacted", + category=CategoryType.ENV, + sensitive=True, + ), + ], + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + attrs = body["data"]["attributes"] + assert attrs["name"] == "no-code-ws" + assert attrs["description"] == "from no-code module" + assert attrs["terraform-version"] == "1.7.0" + + var_data = body["data"]["relationships"]["vars"]["data"] + assert len(var_data) == 2 + assert var_data[0] == { + "type": "vars", + "attributes": { + "key": "region", + "value": "us-east-1", + "category": "terraform", + }, + } + assert var_data[1]["attributes"]["sensitive"] is True + assert var_data[1]["attributes"]["category"] == "env" + + def test_agent_execution_mode_requires_agent_pool_id(self) -> None: + with pytest.raises(RequiredAgentPoolIDError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + ), + ) + + def test_agent_execution_mode_with_agent_pool_id_succeeds(self) -> None: + self.transport.request.return_value = _resp(_workspace_body()) + + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", + project_id="prj-abc123", + execution_mode=ExecutionMode.AGENT, + agent_pool_id="apool-abc123", + ), + ) + + attrs = self.transport.request.call_args.kwargs["json_body"]["data"][ + "attributes" + ] + assert attrs["execution-mode"] == "agent" + assert attrs["agent-pool-id"] == "apool-abc123" + + def test_missing_name_raises(self) -> None: + with pytest.raises(RequiredNameError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions(name="", project_id="prj-abc123"), + ) + + def test_missing_project_id_raises(self) -> None: + with pytest.raises(RequiredProjectError): + self.service.create_workspace( + "nocode-abc123", + NoCodeWorkspaceCreateOptions(name="no-code-ws", project_id=""), + ) + + def test_invalid_module_id_raises(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.create_workspace( + "", + NoCodeWorkspaceCreateOptions( + name="no-code-ws", project_id="prj-abc123" + ), + ) + + +class TestNoCodeUpgradeWorkspace: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_upgrade_without_vars(self) -> None: + self.transport.request.return_value = _resp(_upgrade_body()) + + result = self.service.upgrade_workspace("nocode-abc123", "ws-abc123") + + method, path = self.transport.request.call_args.args + body = self.transport.request.call_args.kwargs["json_body"] + assert method == "POST" + assert ( + path + == "/api/v2/no-code-modules/nocode-abc123/workspaces/ws-abc123/upgrade" + ) + assert body == {"data": {"type": "workspaces", "attributes": {}}} + assert result.id == "wsu-abc123" + assert result.status == "planned" + assert result.plan_url == "https://app.terraform.io/plan/abc" + assert result.workspace is not None + assert result.workspace.id == "ws-abc123" + + def test_upgrade_with_vars(self) -> None: + self.transport.request.return_value = _resp(_upgrade_body()) + + self.service.upgrade_workspace( + "nocode-abc123", + "ws-abc123", + NoCodeWorkspaceUpgradeOptions( + vars=[ + NoCodeWorkspaceVariable( + key="region", + value="us-west-2", + category=CategoryType.TERRAFORM, + ), + ] + ), + ) + + body = self.transport.request.call_args.kwargs["json_body"] + var_data = body["data"]["relationships"]["vars"]["data"] + assert var_data[0]["attributes"]["value"] == "us-west-2" + + def test_invalid_module_id(self) -> None: + with pytest.raises(InvalidNoCodeModuleIDError): + self.service.upgrade_workspace("", "ws-abc123") + + def test_invalid_workspace_id(self) -> None: + with pytest.raises(InvalidWorkspaceIDError): + self.service.upgrade_workspace("nocode-abc123", "") + + +class TestNoCodeReadAndConfirmUpgrade: + def setup_method(self) -> None: + self.transport = Mock() + self.service = NoCodeModules(self.transport) + + def test_read_upgrade(self) -> None: + self.transport.request.return_value = _resp( + _upgrade_body(status="planned_and_finished") + ) + + result = self.service.read_workspace_upgrade( + "nocode-abc123", "ws-abc123", "wsu-abc123" + ) + + method, path = self.transport.request.call_args.args + assert method == "GET" + assert path == ( + "/api/v2/no-code-modules/nocode-abc123/workspaces/" + "ws-abc123/upgrade/wsu-abc123" + ) + assert result.status == "planned_and_finished" + + def test_confirm_upgrade_returns_none_and_ignores_plain_text(self) -> None: + # The API returns a plain-text body. The resource should not try to + # parse it; status code alone signals success. + r = Mock() + r.json.side_effect = ValueError("not JSON") + r.text = "Workspace update completed" + self.transport.request.return_value = r + + result = self.service.confirm_workspace_upgrade( + "nocode-abc123", "ws-abc123", "wsu-abc123" + ) + + method, path = self.transport.request.call_args.args + assert method == "POST" + assert path == ( + "/api/v2/no-code-modules/nocode-abc123/workspaces/" + "ws-abc123/upgrade/wsu-abc123" + ) + assert result is None + + def test_read_upgrade_invalid_upgrade_id(self) -> None: + with pytest.raises(InvalidWorkspaceUpgradeIDError): + self.service.read_workspace_upgrade( + "nocode-abc123", "ws-abc123", "" + ) + + def test_confirm_upgrade_invalid_workspace_id(self) -> None: + with pytest.raises(InvalidWorkspaceIDError): + self.service.confirm_workspace_upgrade( + "nocode-abc123", "", "wsu-abc123" + ) + + +class TestWorkspaceAgentPoolParserFix: + """Regression test for the workspace parser bug fixed alongside this + feature. Prior to the fix, the parser wrote ``attr["agent_pools"]`` + (plural) while the Workspace model declares ``agent_pool`` (singular), + so the relationship was always silently dropped. + """ + + def test_agent_pool_relationship_populated_on_parsed_workspace(self) -> None: + from pytfe.resources.workspaces import _ws_from + + data = { + "id": "ws-agent", + "type": "workspaces", + "attributes": {"name": "agent-ws", "execution-mode": "agent"}, + "relationships": { + "agent-pool": { + "data": {"type": "agent-pools", "id": "apool-abc123"}, + }, + }, + } + + ws = _ws_from(data) + + assert ws.agent_pool is not None + assert ws.agent_pool.id == "apool-abc123" From 8fa51a8902e45391313e6b609a5e87dce69b7160 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 15:22:32 +0530 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd690da..1a7c26f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ ### Team Workspace Access * Added Team Workspace Access resource list, read, add, update and remove methods along with models and examples for managing team access to workspaces by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) +### No-Code Provisioning +* Added no_code_modules resource with create, read, update, delete, read_variables, create_workspace, upgrade_workspace, read_workspace_upgrade, and confirm_workspace_upgrade methods. + ## Enhancements ### Terraform Actions @@ -82,11 +85,6 @@ * Updated State Version resource with new rollback method by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) * Updated Workspaces resource with additional current_assessment_result and list_applicable_varsets methods by @iam404 [#168](https://github.com/hashicorp/python-tfe/pull/168) -### No-Code Provisioning -* Added ``client.no_code_modules`` resource with ``create``, ``read``, ``update``, ``delete``, ``read_variables``, ``create_workspace``, ``upgrade_workspace``, ``read_workspace_upgrade``, and ``confirm_workspace_upgrade`` methods covering the full HCP Terraform no-code provisioning workflow. -* Added ``NoCodeModule``, ``NoCodeModuleCreateOptions``, ``NoCodeModuleUpdateOptions``, ``NoCodeModuleReadOptions``, ``NoCodeModuleIncludeOpt``, ``NoCodeVariableOption``, ``NoCodeWorkspaceCreateOptions``, ``NoCodeWorkspaceUpgradeOptions``, ``NoCodeWorkspaceVariable``, ``RegistryModuleVariable``, and ``WorkspaceUpgrade`` models. -* Added ``InvalidNoCodeModuleIDError``, ``InvalidWorkspaceUpgradeIDError``, and ``RequiredRegistryModuleIDError`` typed exceptions. - ### SDK Logging * Added pytfe._logging module with structured stdlib-based logging framework by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) * Added setup_logging() function to configure the pytfe logger namespace with optional level and format control by @iam404 [#171](https://github.com/hashicorp/python-tfe/pull/171) @@ -102,8 +100,8 @@ ## 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. -* Fixed the workspace JSON:API parser to populate the singular ``agent_pool`` field instead of writing to a non-existent ``agent_pools`` key. The relationship was previously parsed off the wire but silently dropped because the model field is singular; ``workspace.agent_pool`` now returns the related ``AgentPool`` stub as documented. +* 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. +* Fixed the workspace JSON:API parser to populate the singular agent_pool field instead of writing to a non-existent agent_pools key. The relationship was previously parsed off the wire but silently dropped because the model field is singular; workspace.agent_pool now returns the related AgentPool stub as documented. # v0.1.5 From ec378f1d123fc8446d1d3e54c1d20df27c01a7f6 Mon Sep 17 00:00:00 2001 From: Prabuddha Chakraborty Date: Wed, 27 May 2026 15:25:17 +0530 Subject: [PATCH 3/3] fix lint --- examples/no_code_provisioning.py | 4 +--- src/pytfe/models/no_code_module.py | 8 ++++++-- src/pytfe/resources/no_code_module.py | 9 +++------ tests/units/test_no_code_module.py | 19 +++++-------------- 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/examples/no_code_provisioning.py b/examples/no_code_provisioning.py index e1946df7..958a6bb8 100644 --- a/examples/no_code_provisioning.py +++ b/examples/no_code_provisioning.py @@ -83,9 +83,7 @@ def main() -> int: # Re-read with include to confirm variable options round-trip. refreshed = client.no_code_modules.read( no_code_module.id, - NoCodeModuleReadOptions( - include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS] - ), + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), ) for option in refreshed.variable_options: print( diff --git a/src/pytfe/models/no_code_module.py b/src/pytfe/models/no_code_module.py index c82a12a6..c2f5ea6c 100644 --- a/src/pytfe/models/no_code_module.py +++ b/src/pytfe/models/no_code_module.py @@ -61,7 +61,9 @@ class NoCodeModuleCreateOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) - registry_module_id: str = Field(..., description="ID of the registry module to enable") + registry_module_id: str = Field( + ..., description="ID of the registry module to enable" + ) enabled: bool | None = None version_pin: str | None = Field(default=None, alias="version-pin") variable_options: list[NoCodeVariableOption] = Field( @@ -115,7 +117,9 @@ class NoCodeWorkspaceCreateOptions(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_by_name=True) name: str = Field(..., description="Workspace name") - project_id: str = Field(..., description="ID of the project to create the workspace in") + project_id: str = Field( + ..., description="ID of the project to create the workspace in" + ) description: str | None = None agent_pool_id: str | None = Field(default=None, alias="agent-pool-id") auto_apply: bool | None = None diff --git a/src/pytfe/resources/no_code_module.py b/src/pytfe/resources/no_code_module.py index d0d9e278..e285b9e2 100644 --- a/src/pytfe/resources/no_code_module.py +++ b/src/pytfe/resources/no_code_module.py @@ -291,9 +291,8 @@ def create_workspace( raise RequiredNameError() if not valid_string_id(options.project_id): raise RequiredProjectError() - if ( - options.execution_mode == ExecutionMode.AGENT - and not valid_string_id(options.agent_pool_id) + if options.execution_mode == ExecutionMode.AGENT and not valid_string_id( + options.agent_pool_id ): raise RequiredAgentPoolIDError() @@ -448,9 +447,7 @@ def _build_workspace_payload( if options.terraform_version is not None: attrs["terraform-version"] = options.terraform_version - body: dict[str, Any] = { - "data": {"type": _WORKSPACE_TYPE, "attributes": attrs} - } + body: dict[str, Any] = {"data": {"type": _WORKSPACE_TYPE, "attributes": attrs}} relationships: dict[str, Any] = { "project": {"data": {"type": "projects", "id": options.project_id}}, diff --git a/tests/units/test_no_code_module.py b/tests/units/test_no_code_module.py index 14a6ae44..b6255e09 100644 --- a/tests/units/test_no_code_module.py +++ b/tests/units/test_no_code_module.py @@ -227,9 +227,7 @@ def test_read_with_include_emits_query_param(self) -> None: self.service.read( "nocode-abc123", - NoCodeModuleReadOptions( - include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS] - ), + NoCodeModuleReadOptions(include=[NoCodeModuleIncludeOpt.VARIABLE_OPTIONS]), ) params = self.transport.request.call_args.kwargs["params"] @@ -436,9 +434,7 @@ def test_minimum_payload(self) -> None: ws = self.service.create_workspace( "nocode-abc123", - NoCodeWorkspaceCreateOptions( - name="no-code-ws", project_id="prj-abc123" - ), + NoCodeWorkspaceCreateOptions(name="no-code-ws", project_id="prj-abc123"), ) method, path = self.transport.request.call_args.args @@ -567,8 +563,7 @@ def test_upgrade_without_vars(self) -> None: body = self.transport.request.call_args.kwargs["json_body"] assert method == "POST" assert ( - path - == "/api/v2/no-code-modules/nocode-abc123/workspaces/ws-abc123/upgrade" + path == "/api/v2/no-code-modules/nocode-abc123/workspaces/ws-abc123/upgrade" ) assert body == {"data": {"type": "workspaces", "attributes": {}}} assert result.id == "wsu-abc123" @@ -651,15 +646,11 @@ def test_confirm_upgrade_returns_none_and_ignores_plain_text(self) -> None: def test_read_upgrade_invalid_upgrade_id(self) -> None: with pytest.raises(InvalidWorkspaceUpgradeIDError): - self.service.read_workspace_upgrade( - "nocode-abc123", "ws-abc123", "" - ) + self.service.read_workspace_upgrade("nocode-abc123", "ws-abc123", "") def test_confirm_upgrade_invalid_workspace_id(self) -> None: with pytest.raises(InvalidWorkspaceIDError): - self.service.confirm_workspace_upgrade( - "nocode-abc123", "", "wsu-abc123" - ) + self.service.confirm_workspace_upgrade("nocode-abc123", "", "wsu-abc123") class TestWorkspaceAgentPoolParserFix: