Skip to content

Commit b89dea6

Browse files
Add workspace run task feature
- Add WorkspaceRunTask models and resources - Add WorkspaceRunTasks service with CRUD operations - Update RunTask model to include workspace_run_tasks relationship - Fix Stage enum to use underscore format (pre_plan, post_plan, etc.) - Add comprehensive unit tests (20 tests) - Add example CLI for workspace run task operations - Update client to include workspace_run_tasks service
1 parent e6305f3 commit b89dea6

File tree

10 files changed

+1289
-17
lines changed

10 files changed

+1289
-17
lines changed

examples/workspace_run_task.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
"""
2+
Terraform Cloud/Enterprise Workspace Run Task Management Example
3+
4+
This example demonstrates comprehensive workspace run task operations using the python-tfe SDK.
5+
It provides a command-line interface for managing workspace run tasks with various operations
6+
including attach/create, read, update, delete, and listing attached tasks.
7+
8+
Prerequisites:
9+
- Set TFE_TOKEN environment variable with your Terraform Cloud API token
10+
- Ensure you have access to the target organization and workspaces
11+
- Run tasks must exist in the organization before attaching to workspaces
12+
13+
Basic Usage:
14+
python examples/workspace_run_task.py --help
15+
16+
Core Operations:
17+
18+
1. List Workspace Run Tasks (default operation):
19+
python examples/workspace_run_task.py --workspace-id ws-abc123
20+
python examples/workspace_run_task.py --workspace-id ws-abc123 --page-size 20
21+
22+
2. Attach Run Task to Workspace (Create):
23+
python examples/workspace_run_task.py --workspace-id ws-abc123 --run-task-id task-def456 --create --enforcement-level mandatory --stages pre-plan post-plan
24+
25+
3. Read Workspace Run Task Details:
26+
python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789
27+
28+
4. Update Workspace Run Task:
29+
python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789 --update --enforcement-level advisory --stages pre-plan
30+
31+
5. Delete Workspace Run Task:
32+
python examples/workspace_run_task.py --workspace-id ws-abc123 --workspace-task-id wstask-xyz789 --delete
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import argparse
38+
import os
39+
40+
from pytfe import TFEClient, TFEConfig
41+
from pytfe.models import (
42+
RunTask,
43+
Stage,
44+
TaskEnforcementLevel,
45+
WorkspaceRunTaskCreateOptions,
46+
WorkspaceRunTaskListOptions,
47+
WorkspaceRunTaskUpdateOptions,
48+
)
49+
50+
# Ensure models are fully rebuilt to resolve forward references
51+
WorkspaceRunTaskUpdateOptions.model_rebuild()
52+
WorkspaceRunTaskCreateOptions.model_rebuild()
53+
54+
55+
def _print_header(title: str) -> None:
56+
"""Print a formatted header for operations."""
57+
print("\n" + "=" * 80)
58+
print(title)
59+
print("=" * 80)
60+
61+
62+
def main():
63+
parser = argparse.ArgumentParser(
64+
description="Workspace Run Task demo for python-tfe SDK"
65+
)
66+
parser.add_argument(
67+
"--address", default=os.getenv("TFE_ADDRESS", "https://app.terraform.io")
68+
)
69+
parser.add_argument("--token", default=os.getenv("TFE_TOKEN", ""))
70+
parser.add_argument("--workspace-id", required=True, help="Workspace ID")
71+
parser.add_argument(
72+
"--run-task-id", help="Run Task ID to attach (for create operation)"
73+
)
74+
parser.add_argument(
75+
"--workspace-task-id", help="Workspace Run Task ID for read/update/delete"
76+
)
77+
parser.add_argument(
78+
"--create", action="store_true", help="Create/attach a workspace run task"
79+
)
80+
parser.add_argument(
81+
"--update", action="store_true", help="Update a workspace run task"
82+
)
83+
parser.add_argument(
84+
"--delete", action="store_true", help="Delete a workspace run task"
85+
)
86+
parser.add_argument(
87+
"--enforcement-level",
88+
choices=["advisory", "mandatory"],
89+
help="Enforcement level for create/update",
90+
)
91+
parser.add_argument(
92+
"--stages",
93+
nargs="+",
94+
choices=["pre-plan", "post-plan", "pre-apply", "post-apply"],
95+
help="Stages to run the task in (for create/update)",
96+
)
97+
parser.add_argument(
98+
"--stage",
99+
choices=["pre-plan", "post-plan", "pre-apply", "post-apply"],
100+
help="Deprecated: Single stage to run the task in (use --stages instead)",
101+
)
102+
parser.add_argument("--page", type=int, default=1, help="Page number for listing")
103+
parser.add_argument(
104+
"--page-size", type=int, default=10, help="Page size for listing"
105+
)
106+
args = parser.parse_args()
107+
108+
cfg = TFEConfig(address=args.address, token=args.token)
109+
client = TFEClient(cfg)
110+
111+
# Create a new workspace run task (attach run task to workspace)
112+
if args.create:
113+
if not args.run_task_id:
114+
print("Error: --run-task-id is required for creating a workspace run task")
115+
return
116+
117+
if not args.enforcement_level:
118+
print("Error: --enforcement-level is required for creating")
119+
return
120+
121+
_print_header("Creating Workspace Run Task")
122+
123+
# Convert enforcement level string to enum
124+
enforcement_level = (
125+
TaskEnforcementLevel.MANDATORY
126+
if args.enforcement_level == "mandatory"
127+
else TaskEnforcementLevel.ADVISORY
128+
)
129+
130+
# Convert stages to enum
131+
stages = None
132+
if args.stages:
133+
stages = []
134+
for stage_str in args.stages:
135+
if stage_str == "pre-plan":
136+
stages.append(Stage.PRE_PLAN)
137+
elif stage_str == "post-plan":
138+
stages.append(Stage.POST_PLAN)
139+
elif stage_str == "pre-apply":
140+
stages.append(Stage.PRE_APPLY)
141+
elif stage_str == "post-apply":
142+
stages.append(Stage.POST_APPLY)
143+
144+
# Deprecated stage support
145+
stage = None
146+
if args.stage:
147+
if args.stage == "pre-plan":
148+
stage = Stage.PRE_PLAN
149+
elif args.stage == "post-plan":
150+
stage = Stage.POST_PLAN
151+
elif args.stage == "pre-apply":
152+
stage = Stage.PRE_APPLY
153+
elif args.stage == "post-apply":
154+
stage = Stage.POST_APPLY
155+
156+
# Create RunTask object with just ID (minimal required)
157+
run_task = RunTask(
158+
id=args.run_task_id,
159+
name="", # Name not needed for attachment
160+
url="", # URL not needed for attachment
161+
category="task",
162+
enabled=True,
163+
)
164+
165+
options = WorkspaceRunTaskCreateOptions(
166+
enforcement_level=enforcement_level,
167+
run_task=run_task,
168+
stages=stages,
169+
stage=stage,
170+
)
171+
172+
try:
173+
workspace_task = client.workspace_run_tasks.create(
174+
args.workspace_id, options
175+
)
176+
print("✓ Successfully attached run task to workspace")
177+
print(f" Workspace Task ID: {workspace_task.id}")
178+
print(f" Enforcement Level: {workspace_task.enforcement_level.value}")
179+
print(f" Stage: {workspace_task.stage.value}")
180+
if workspace_task.stages:
181+
print(f" Stages: {[s.value for s in workspace_task.stages]}")
182+
except Exception as e:
183+
print(f"✗ Failed to create workspace run task: {e}")
184+
185+
# Update an existing workspace run task
186+
elif args.update:
187+
if not args.workspace_task_id:
188+
print("Error: --workspace-task-id is required for updating")
189+
return
190+
191+
_print_header("Updating Workspace Run Task")
192+
193+
# Build update options
194+
enforcement_level = None
195+
if args.enforcement_level:
196+
enforcement_level = (
197+
TaskEnforcementLevel.MANDATORY
198+
if args.enforcement_level == "mandatory"
199+
else TaskEnforcementLevel.ADVISORY
200+
)
201+
202+
# Update stages if provided
203+
stages = None
204+
if args.stages:
205+
stages = []
206+
for stage_str in args.stages:
207+
if stage_str == "pre-plan":
208+
stages.append(Stage.PRE_PLAN)
209+
elif stage_str == "post-plan":
210+
stages.append(Stage.POST_PLAN)
211+
elif stage_str == "pre-apply":
212+
stages.append(Stage.PRE_APPLY)
213+
elif stage_str == "post-apply":
214+
stages.append(Stage.POST_APPLY)
215+
216+
options = WorkspaceRunTaskUpdateOptions(
217+
enforcement_level=enforcement_level, stages=stages
218+
)
219+
220+
# Update stage if provided (deprecated)
221+
if args.stage:
222+
if args.stage == "pre-plan":
223+
options.stage = Stage.PRE_PLAN
224+
elif args.stage == "post-plan":
225+
options.stage = Stage.POST_PLAN
226+
elif args.stage == "pre-apply":
227+
options.stage = Stage.PRE_APPLY
228+
elif args.stage == "post-apply":
229+
options.stage = Stage.POST_APPLY
230+
231+
try:
232+
workspace_task = client.workspace_run_tasks.update(
233+
args.workspace_id, args.workspace_task_id, options
234+
)
235+
print("✓ Successfully updated workspace run task")
236+
print(f" Workspace Task ID: {workspace_task.id}")
237+
print(f" Enforcement Level: {workspace_task.enforcement_level.value}")
238+
print(f" Stage: {workspace_task.stage.value}")
239+
if workspace_task.stages:
240+
print(f" Stages: {[s.value for s in workspace_task.stages]}")
241+
except Exception as e:
242+
print(f"✗ Failed to update workspace run task: {e}")
243+
244+
# Delete a workspace run task
245+
elif args.delete:
246+
if not args.workspace_task_id:
247+
print("Error: --workspace-task-id is required for deleting")
248+
return
249+
250+
_print_header("Deleting Workspace Run Task")
251+
252+
try:
253+
client.workspace_run_tasks.delete(args.workspace_id, args.workspace_task_id)
254+
print(
255+
f"✓ Successfully deleted workspace run task: {args.workspace_task_id}"
256+
)
257+
except Exception as e:
258+
print(f"✗ Failed to delete workspace run task: {e}")
259+
260+
# Read a specific workspace run task
261+
elif args.workspace_task_id:
262+
_print_header("Reading Workspace Run Task")
263+
264+
try:
265+
workspace_task = client.workspace_run_tasks.read(
266+
args.workspace_id, args.workspace_task_id
267+
)
268+
print("✓ Workspace Run Task Details:")
269+
print(f" ID: {workspace_task.id}")
270+
print(f" Enforcement Level: {workspace_task.enforcement_level.value}")
271+
print(f" Stage (deprecated): {workspace_task.stage.value}")
272+
if workspace_task.stages:
273+
print(f" Stages: {[s.value for s in workspace_task.stages]}")
274+
if workspace_task.run_task:
275+
print(f" Run Task ID: {workspace_task.run_task.id}")
276+
if workspace_task.workspace:
277+
print(f" Workspace ID: {workspace_task.workspace.id}")
278+
except Exception as e:
279+
print(f"✗ Failed to read workspace run task: {e}")
280+
281+
# List all workspace run tasks (default operation)
282+
else:
283+
_print_header(f"Listing Workspace Run Tasks for Workspace: {args.workspace_id}")
284+
285+
options = WorkspaceRunTaskListOptions(
286+
page_number=args.page,
287+
page_size=args.page_size,
288+
)
289+
290+
try:
291+
count = 0
292+
for workspace_task in client.workspace_run_tasks.list(
293+
args.workspace_id, options
294+
):
295+
count += 1
296+
print(f"\n{count}. Workspace Run Task ID: {workspace_task.id}")
297+
print(f" Enforcement Level: {workspace_task.enforcement_level.value}")
298+
print(f" Stage: {workspace_task.stage.value}")
299+
if workspace_task.stages:
300+
print(f" Stages: {[s.value for s in workspace_task.stages]}")
301+
if workspace_task.run_task:
302+
print(f" Run Task ID: {workspace_task.run_task.id}")
303+
304+
if count == 0:
305+
print("No workspace run tasks found for this workspace.")
306+
else:
307+
print(f"\n✓ Total workspace run tasks listed: {count}")
308+
except Exception as e:
309+
print(f"✗ Failed to list workspace run tasks: {e}")
310+
311+
312+
if __name__ == "__main__":
313+
main()

src/pytfe/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .resources.variable import Variables
3636
from .resources.variable_sets import VariableSets, VariableSetVariables
3737
from .resources.workspace_resources import WorkspaceResourcesService
38+
from .resources.workspace_run_task import WorkspaceRunTasks
3839
from .resources.workspaces import Workspaces
3940

4041

@@ -83,6 +84,7 @@ def __init__(self, config: TFEConfig | None = None):
8384
self.state_versions = StateVersions(self._transport)
8485
self.state_version_outputs = StateVersionOutputs(self._transport)
8586
self.run_tasks = RunTasks(self._transport)
87+
self.workspace_run_tasks = WorkspaceRunTasks(self._transport)
8688
self.run_triggers = RunTriggers(self._transport)
8789
self.runs = Runs(self._transport)
8890
self.query_runs = QueryRuns(self._transport)

src/pytfe/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,13 @@ def __init__(self, message: str = 'category must be "task"'):
312312
super().__init__(message)
313313

314314

315+
class InvalidWorkspaceRunTaskIDError(InvalidValues):
316+
"""Raised when an invalid workspace run task ID is provided."""
317+
318+
def __init__(self, message: str = "invalid value for workspace run task ID"):
319+
super().__init__(message)
320+
321+
315322
# Run Trigger errors
316323
class RequiredRunTriggerListOpsError(RequiredFieldMissing):
317324
"""Raised when required run trigger list options are missing."""

src/pytfe/models/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,15 @@
362362
WorkspaceResourceListOptions,
363363
)
364364

365+
# ── Workspace Run Tasks ──────────────────────────────────────────────────────
366+
from .workspace_run_task import (
367+
WorkspaceRunTask,
368+
WorkspaceRunTaskCreateOptions,
369+
WorkspaceRunTaskList,
370+
WorkspaceRunTaskListOptions,
371+
WorkspaceRunTaskUpdateOptions,
372+
)
373+
365374
# ── Public surface ────────────────────────────────────────────────────────────
366375
__all__ = [
367376
# OAuth
@@ -588,6 +597,12 @@
588597
"SourceableChoice",
589598
"RunTriggerFilterOp",
590599
"RunTriggerIncludeOp",
600+
# Workspace Run Tasks
601+
"WorkspaceRunTask",
602+
"WorkspaceRunTaskCreateOptions",
603+
"WorkspaceRunTaskList",
604+
"WorkspaceRunTaskListOptions",
605+
"WorkspaceRunTaskUpdateOptions",
591606
# Policy Checks
592607
"PolicyCheck",
593608
"PolicyCheckIncludeOpt",

0 commit comments

Comments
 (0)