Skip to content

Commit d3f5401

Browse files
Merge pull request #110 from hashicorp/bug/agentpoolworkspaces
bug/agentpoolworkspaces - Agent Pools using non-existent endpoints and failing to add allowed workspaces. (99)
2 parents 316b52a + 361b94e commit d3f5401

4 files changed

Lines changed: 291 additions & 33 deletions

File tree

examples/agent_pool.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
This example demonstrates:
44
1. Agent Pool CRUD operations (Create, Read, Update, Delete)
55
2. Agent token creation and management
6-
3. Using the organization SDK client
6+
3. Workspace assignment using assign_to_workspaces and remove_from_workspaces
77
4. Proper error handling
88
99
Make sure to set the following environment variables:
1010
- TFE_TOKEN: Your Terraform Cloud/Enterprise API token
1111
- TFE_ADDRESS: Your Terraform Cloud/Enterprise URL (optional, defaults to https://app.terraform.io)
1212
- TFE_ORG: Your organization name
13+
- TFE_WORKSPACE_ID: A workspace ID for testing workspace assignment (optional)
1314
1415
Usage:
1516
export TFE_TOKEN="your-token-here"
@@ -24,8 +25,10 @@
2425
from pytfe.errors import NotFound
2526
from pytfe.models import (
2627
AgentPoolAllowedWorkspacePolicy,
28+
AgentPoolAssignToWorkspacesOptions,
2729
AgentPoolCreateOptions,
2830
AgentPoolListOptions,
31+
AgentPoolRemoveFromWorkspacesOptions,
2932
AgentPoolUpdateOptions,
3033
AgentTokenCreateOptions,
3134
)
@@ -37,6 +40,9 @@ def main():
3740
token = os.environ.get("TFE_TOKEN")
3841
org = os.environ.get("TFE_ORG")
3942
address = os.environ.get("TFE_ADDRESS", "https://app.terraform.io")
43+
workspace_id = os.environ.get(
44+
"TFE_WORKSPACE_ID"
45+
) # optional, for workspace assignment
4046

4147
if not token:
4248
print("TFE_TOKEN environment variable is required")
@@ -96,7 +102,27 @@ def main():
96102
updated_pool = client.agent_pools.update(new_pool.id, update_options)
97103
print(f"Updated agent pool name to: {updated_pool.name}")
98104

99-
# Example 5: Create an agent token
105+
# Example 5: Workspace assignment
106+
# assign_to_workspaces sends PATCH /agent-pools/:id with relationships.allowed-workspaces
107+
# remove_from_workspaces sends PATCH /agent-pools/:id with relationships.excluded-workspaces
108+
if workspace_id:
109+
print("\n Assigning workspace to agent pool...")
110+
updated_pool = client.agent_pools.assign_to_workspaces(
111+
new_pool.id,
112+
AgentPoolAssignToWorkspacesOptions(workspace_ids=[workspace_id]),
113+
)
114+
print(f" Assigned workspace {workspace_id} to pool {updated_pool.name}")
115+
116+
print("\n Removing workspace from agent pool...")
117+
updated_pool = client.agent_pools.remove_from_workspaces(
118+
new_pool.id,
119+
AgentPoolRemoveFromWorkspacesOptions(workspace_ids=[workspace_id]),
120+
)
121+
print(f" Removed workspace {workspace_id} from pool {updated_pool.name}")
122+
else:
123+
print("\n Skipping workspace assignment (set TFE_WORKSPACE_ID to test)")
124+
125+
# Example 6: Create an agent token
100126
print("\n Creating agent token...")
101127
token_options = AgentTokenCreateOptions(
102128
description="SDK example token" # Optional description
@@ -107,7 +133,7 @@ def main():
107133
if agent_token.token:
108134
print(f" Token (first 10 chars): {agent_token.token[:10]}...")
109135

110-
# Example 6: List agent tokens
136+
# Example 7: List agent tokens
111137
print("\n Listing agent tokens...")
112138
tokens = client.agent_tokens.list(new_pool.id)
113139

@@ -117,7 +143,7 @@ def main():
117143
for token in token_list:
118144
print(f" - {token.description or 'No description'} (ID: {token.id})")
119145

120-
# Example 7: Clean up - delete the token and pool
146+
# Example 8: Clean up - delete the token and pool
121147
print("\n Cleaning up...")
122148
client.agent_tokens.delete(agent_token.id)
123149
print("Deleted agent token")

src/pytfe/models/agent.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ class AgentPoolCreateOptions(BaseModel):
8282
organization_scoped: bool | None = None
8383
# Optional: Allowed workspace policy
8484
allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None
85+
# Optional: IDs of workspaces allowed to use this pool (sent as relationships.allowed-workspaces)
86+
allowed_workspace_ids: list[str] = Field(default_factory=list)
87+
# Optional: IDs of workspaces excluded from this pool (sent as relationships.excluded-workspaces)
88+
excluded_workspace_ids: list[str] = Field(default_factory=list)
8589

8690

8791
class AgentPoolUpdateOptions(BaseModel):
@@ -93,6 +97,10 @@ class AgentPoolUpdateOptions(BaseModel):
9397
organization_scoped: bool | None = None
9498
# Optional: Allowed workspace policy
9599
allowed_workspace_policy: AgentPoolAllowedWorkspacePolicy | None = None
100+
# Optional: Full replacement list of workspace IDs allowed to use this pool
101+
allowed_workspace_ids: list[str] = Field(default_factory=list)
102+
# Optional: Full replacement list of workspace IDs excluded from this pool
103+
excluded_workspace_ids: list[str] = Field(default_factory=list)
96104

97105

98106
class AgentPoolReadOptions(BaseModel):

src/pytfe/resources/agent_pools.py

Lines changed: 140 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,27 @@ def create(self, organization: str, options: AgentPoolCreateOptions) -> AgentPoo
203203
options.allowed_workspace_policy.value
204204
)
205205

206-
payload = {"data": {"type": "agent-pools", "attributes": attributes}}
206+
relationships: dict[str, Any] = {}
207+
if options.allowed_workspace_ids:
208+
relationships["allowed-workspaces"] = {
209+
"data": [
210+
{"type": "workspaces", "id": ws_id}
211+
for ws_id in options.allowed_workspace_ids
212+
]
213+
}
214+
if options.excluded_workspace_ids:
215+
relationships["excluded-workspaces"] = {
216+
"data": [
217+
{"type": "workspaces", "id": ws_id}
218+
for ws_id in options.excluded_workspace_ids
219+
]
220+
}
221+
222+
payload: dict[str, Any] = {
223+
"data": {"type": "agent-pools", "attributes": attributes}
224+
}
225+
if relationships:
226+
payload["data"]["relationships"] = relationships
207227

208228
response = self.t.request("POST", path, json_body=payload)
209229
data = response.json()["data"]
@@ -320,13 +340,31 @@ def update(self, agent_pool_id: str, options: AgentPoolUpdateOptions) -> AgentPo
320340
options.allowed_workspace_policy.value
321341
)
322342

323-
payload = {
343+
relationships: dict[str, Any] = {}
344+
if options.allowed_workspace_ids:
345+
relationships["allowed-workspaces"] = {
346+
"data": [
347+
{"type": "workspaces", "id": ws_id}
348+
for ws_id in options.allowed_workspace_ids
349+
]
350+
}
351+
if options.excluded_workspace_ids:
352+
relationships["excluded-workspaces"] = {
353+
"data": [
354+
{"type": "workspaces", "id": ws_id}
355+
for ws_id in options.excluded_workspace_ids
356+
]
357+
}
358+
359+
payload: dict[str, Any] = {
324360
"data": {
325361
"type": "agent-pools",
326362
"id": agent_pool_id,
327363
"attributes": attributes,
328364
}
329365
}
366+
if relationships:
367+
payload["data"]["relationships"] = relationships
330368

331369
response = self.t.request("PATCH", path, json_body=payload)
332370
data = response.json()["data"]
@@ -371,13 +409,20 @@ def delete(self, agent_pool_id: str) -> None:
371409

372410
def assign_to_workspaces(
373411
self, agent_pool_id: str, options: AgentPoolAssignToWorkspacesOptions
374-
) -> None:
375-
"""Assign an agent pool to workspaces.
412+
) -> AgentPool:
413+
"""Assign an agent pool to workspaces by updating the allowed-workspaces
414+
relationship via PATCH /agent-pools/:id.
415+
416+
The provided workspace IDs become the new complete list of allowed
417+
workspaces for this pool (full replacement, not append).
376418
377419
Args:
378420
agent_pool_id: Agent pool ID
379421
options: Assignment options containing workspace IDs
380422
423+
Returns:
424+
Updated AgentPool object
425+
381426
Raises:
382427
ValueError: If parameters are invalid
383428
TFEError: If API request fails
@@ -388,26 +433,67 @@ def assign_to_workspaces(
388433
if not options.workspace_ids:
389434
raise ValueError("At least one workspace ID is required")
390435

391-
path = f"/api/v2/agent-pools/{agent_pool_id}/relationships/workspaces"
392-
393-
# Create data payload with workspace references
394-
workspace_data = []
395436
for workspace_id in options.workspace_ids:
396437
if not valid_string_id(workspace_id):
397438
raise ValueError(f"Invalid workspace ID: {workspace_id}")
398-
workspace_data.append({"type": "workspaces", "id": workspace_id})
399439

400-
payload = {"data": workspace_data}
401-
self.t.request("POST", path, json_body=payload)
440+
path = f"/api/v2/agent-pools/{agent_pool_id}"
441+
payload: dict[str, Any] = {
442+
"data": {
443+
"type": "agent-pools",
444+
"id": agent_pool_id,
445+
"attributes": {},
446+
"relationships": {
447+
"allowed-workspaces": {
448+
"data": [
449+
{"type": "workspaces", "id": ws_id}
450+
for ws_id in options.workspace_ids
451+
]
452+
}
453+
},
454+
}
455+
}
456+
response = self.t.request("PATCH", path, json_body=payload)
457+
data = response.json()["data"]
458+
459+
# Extract agent pool data from response
460+
attr = data.get("attributes", {}) or {}
461+
agent_pool_data = {
462+
"id": _safe_str(data.get("id")),
463+
"name": _safe_str(attr.get("name")),
464+
"created_at": attr.get("created-at"),
465+
"organization_scoped": attr.get("organization-scoped"),
466+
"allowed_workspace_policy": attr.get("allowed-workspace-policy"),
467+
"agent_count": attr.get("agent-count", 0),
468+
}
469+
470+
return AgentPool(
471+
id=_safe_str(agent_pool_data["id"]) or "",
472+
name=_safe_str(agent_pool_data["name"]),
473+
created_at=cast(Any, agent_pool_data["created_at"]),
474+
organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]),
475+
allowed_workspace_policy=_safe_workspace_policy(
476+
agent_pool_data["allowed_workspace_policy"]
477+
),
478+
agent_count=_safe_int(agent_pool_data["agent_count"]),
479+
)
402480

403481
def remove_from_workspaces(
404482
self, agent_pool_id: str, options: AgentPoolRemoveFromWorkspacesOptions
405-
) -> None:
406-
"""Remove an agent pool from workspaces.
483+
) -> AgentPool:
484+
"""Exclude workspaces from an agent pool by updating the excluded-workspaces
485+
relationship via PATCH /agent-pools/:id.
486+
487+
Use this for organization-scoped pools where most workspaces are allowed
488+
but you want to block specific ones. The provided list becomes the new
489+
complete excluded-workspaces list (full replacement, not append).
407490
408491
Args:
409492
agent_pool_id: Agent pool ID
410-
options: Removal options containing workspace IDs
493+
options: Removal options containing workspace IDs to exclude
494+
495+
Returns:
496+
Updated AgentPool object
411497
412498
Raises:
413499
ValueError: If parameters are invalid
@@ -419,14 +505,47 @@ def remove_from_workspaces(
419505
if not options.workspace_ids:
420506
raise ValueError("At least one workspace ID is required")
421507

422-
path = f"/api/v2/agent-pools/{agent_pool_id}/relationships/workspaces"
423-
424-
# Create data payload with workspace references
425-
workspace_data = []
426508
for workspace_id in options.workspace_ids:
427509
if not valid_string_id(workspace_id):
428510
raise ValueError(f"Invalid workspace ID: {workspace_id}")
429-
workspace_data.append({"type": "workspaces", "id": workspace_id})
430511

431-
payload = {"data": workspace_data}
432-
self.t.request("DELETE", path, json_body=payload)
512+
path = f"/api/v2/agent-pools/{agent_pool_id}"
513+
payload: dict[str, Any] = {
514+
"data": {
515+
"type": "agent-pools",
516+
"id": agent_pool_id,
517+
"attributes": {},
518+
"relationships": {
519+
"excluded-workspaces": {
520+
"data": [
521+
{"type": "workspaces", "id": ws_id}
522+
for ws_id in options.workspace_ids
523+
]
524+
}
525+
},
526+
}
527+
}
528+
response = self.t.request("PATCH", path, json_body=payload)
529+
data = response.json()["data"]
530+
531+
# Extract agent pool data from response
532+
attr = data.get("attributes", {}) or {}
533+
agent_pool_data = {
534+
"id": _safe_str(data.get("id")),
535+
"name": _safe_str(attr.get("name")),
536+
"created_at": attr.get("created-at"),
537+
"organization_scoped": attr.get("organization-scoped"),
538+
"allowed_workspace_policy": attr.get("allowed-workspace-policy"),
539+
"agent_count": attr.get("agent-count", 0),
540+
}
541+
542+
return AgentPool(
543+
id=_safe_str(agent_pool_data["id"]) or "",
544+
name=_safe_str(agent_pool_data["name"]),
545+
created_at=cast(Any, agent_pool_data["created_at"]),
546+
organization_scoped=_safe_bool(agent_pool_data["organization_scoped"]),
547+
allowed_workspace_policy=_safe_workspace_policy(
548+
agent_pool_data["allowed_workspace_policy"]
549+
),
550+
agent_count=_safe_int(agent_pool_data["agent_count"]),
551+
)

0 commit comments

Comments
 (0)