From cf1a7d7f2deab066129fb417ce16db65ce6454ee Mon Sep 17 00:00:00 2001 From: Etourneau Gwenn Date: Fri, 18 Jul 2025 13:38:00 +0900 Subject: [PATCH] Added AllowedWorkspace to scope the workspace from the Agent --- .../FEATURES-622-20250717-145101.yaml | 5 + api/v1alpha2/agentpool_types.go | 5 + api/v1alpha2/zz_generated.deepcopy.go | 9 ++ .../crds/app.terraform.io_agentpools.yaml | 7 ++ .../bases/app.terraform.io_agentpools.yaml | 7 ++ docs/api-reference.md | 1 + internal/controller/agentpool_controller.go | 33 ++++++- .../controller/agentpool_controller_test.go | 97 +++++++++++++++++++ .../controller/workspace_controller_test.go | 15 +++ 9 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .changes/unreleased/FEATURES-622-20250717-145101.yaml diff --git a/.changes/unreleased/FEATURES-622-20250717-145101.yaml b/.changes/unreleased/FEATURES-622-20250717-145101.yaml new file mode 100644 index 00000000..ba93a37a --- /dev/null +++ b/.changes/unreleased/FEATURES-622-20250717-145101.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: Added a new field `allowedWorkspaces` to `AgentPool` to allow to set scope workspace at the Agent Pools level +time: 2025-07-17T14:51:01.914242+09:00 +custom: + PR: "622" diff --git a/api/v1alpha2/agentpool_types.go b/api/v1alpha2/agentpool_types.go index 2842c909..4bff49ee 100644 --- a/api/v1alpha2/agentpool_types.go +++ b/api/v1alpha2/agentpool_types.go @@ -145,6 +145,11 @@ type AgentPoolSpec struct { //+optional AgentDeploymentAutoscaling *AgentDeploymentAutoscaling `json:"autoscaling,omitempty"` + //AllowedWorkspaces is a list of HCP Terraform Workspaces ID which + // are scoped to this agent pool. + //+optional + AllowedWorkspaces *[]string `json:"allowedWorkspaces,omitempty"` + // The Deletion Policy specifies the behavior of the custom resource and its associated agent pool when the custom resource is deleted. // - `retain`: When you delete the custom resource, the operator will remove only the custom resource. // The HCP Terraform agent pool will be retained. The managed tokens will remain active on the HCP Terraform side; however, the corresponding secrets and managed agents will be removed. diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index d8fccce7..37520033 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -228,6 +228,15 @@ func (in *AgentPoolSpec) DeepCopyInto(out *AgentPoolSpec) { *out = new(AgentDeploymentAutoscaling) (*in).DeepCopyInto(*out) } + if in.AllowedWorkspaces != nil { + in, out := &in.AllowedWorkspaces, &out.AllowedWorkspaces + *out = new([]string) + if **in != nil { + in, out := *in, *out + *out = make([]string, len(*in)) + copy(*out, *in) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPoolSpec. diff --git a/charts/hcp-terraform-operator/crds/app.terraform.io_agentpools.yaml b/charts/hcp-terraform-operator/crds/app.terraform.io_agentpools.yaml index cfe40726..a65b93f4 100644 --- a/charts/hcp-terraform-operator/crds/app.terraform.io_agentpools.yaml +++ b/charts/hcp-terraform-operator/crds/app.terraform.io_agentpools.yaml @@ -8109,6 +8109,13 @@ spec: type: object minItems: 1 type: array + allowedWorkspaces: + description: |- + AllowedWorkspaces is a list of HCP Terraform Workspaces ID which + are scoped to this agent pool. + items: + type: string + type: array autoscaling: description: Agent deployment settings properties: diff --git a/config/crd/bases/app.terraform.io_agentpools.yaml b/config/crd/bases/app.terraform.io_agentpools.yaml index 050fc0a3..f0776e7a 100644 --- a/config/crd/bases/app.terraform.io_agentpools.yaml +++ b/config/crd/bases/app.terraform.io_agentpools.yaml @@ -8106,6 +8106,13 @@ spec: type: object minItems: 1 type: array + allowedWorkspaces: + description: |- + AllowedWorkspaces is a list of HCP Terraform Workspaces ID which + are scoped to this agent pool. + items: + type: string + type: array autoscaling: description: Agent deployment settings properties: diff --git a/docs/api-reference.md b/docs/api-reference.md index b9d6bec4..9c35858e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -135,6 +135,7 @@ _Appears in:_ | `agentTokens` _[AgentToken](#agenttoken) array_ | List of the agent tokens to generate. | | `agentDeployment` _[AgentDeployment](#agentdeployment)_ | Agent deployment settings | | `autoscaling` _[AgentDeploymentAutoscaling](#agentdeploymentautoscaling)_ | Agent deployment settings | +| `allowedWorkspaces` _string_ | AllowedWorkspaces is a list of HCP Terraform Workspaces ID which
are scoped to this agent pool. | | `deletionPolicy` _[AgentPoolDeletionPolicy](#agentpooldeletionpolicy)_ | The Deletion Policy specifies the behavior of the custom resource and its associated agent pool when the custom resource is deleted.
- `retain`: When you delete the custom resource, the operator will remove only the custom resource.
The HCP Terraform agent pool will be retained. The managed tokens will remain active on the HCP Terraform side; however, the corresponding secrets and managed agents will be removed.
- `destroy`: The operator will attempt to remove the managed HCP Terraform agent pool.
On success, the managed agents and the corresponding secret with tokens will be removed along with the custom resource.
On failure, the managed agents will be scaled down to 0, and the managed tokens, along with the corresponding secret, will be removed. The operator will continue attempting to remove the agent pool until it succeeds.
Default: `retain`. | diff --git a/internal/controller/agentpool_controller.go b/internal/controller/agentpool_controller.go index 8ff35bca..b3703cdc 100644 --- a/internal/controller/agentpool_controller.go +++ b/internal/controller/agentpool_controller.go @@ -185,8 +185,21 @@ func (r *AgentPoolReconciler) updateStatus(ctx context.Context, ap *agentPoolIns func (r *AgentPoolReconciler) createAgentPool(ctx context.Context, ap *agentPoolInstance) (*tfc.AgentPool, error) { options := tfc.AgentPoolCreateOptions{ - Name: &ap.instance.Spec.Name, + Name: &ap.instance.Spec.Name, + OrganizationScoped: tfc.Bool(true), } + + if ap.instance.Spec.AllowedWorkspaces != nil { + options.AllowedWorkspaces = []*tfc.Workspace{} + if len(*ap.instance.Spec.AllowedWorkspaces) > 0 { + options.OrganizationScoped = tfc.Bool(false) + for _, workspace := range *ap.instance.Spec.AllowedWorkspaces { + ap.log.Info("Reconcile Agent Pool", "msg", fmt.Sprintf("creating allowed workspace %s to the agent pool", workspace)) + options.AllowedWorkspaces = append(options.AllowedWorkspaces, &tfc.Workspace{ID: workspace}) + } + } + } + agentPool, err := ap.tfClient.Client.AgentPools.Create(ctx, ap.instance.Spec.Organization, options) if err != nil { return nil, err @@ -208,6 +221,24 @@ func (r *AgentPoolReconciler) updateAgentPool(ctx context.Context, ap *agentPool if agentPool.Name != spec.Name { options.Name = tfc.String(spec.Name) } + optionsAllow := tfc.AgentPoolAllowedWorkspacesUpdateOptions{ + AllowedWorkspaces: []*tfc.Workspace{}, + } + if spec.AllowedWorkspaces != nil { + if len(*ap.instance.Spec.AllowedWorkspaces) > 0 { + options.OrganizationScoped = tfc.Bool(false) + for _, workspace := range *ap.instance.Spec.AllowedWorkspaces { + ap.log.Info("Reconcile Agent Pool", "msg", fmt.Sprintf("Updating allowed workspace %s to the agent pool", workspace)) + optionsAllow.AllowedWorkspaces = append(optionsAllow.AllowedWorkspaces, &tfc.Workspace{ID: workspace}) + } + _, err := ap.tfClient.Client.AgentPools.UpdateAllowedWorkspaces(ctx, ap.instance.Status.AgentPoolID, optionsAllow) + if err != nil { + return nil, err + } + } + } else { + options.OrganizationScoped = tfc.Bool(true) + } return ap.tfClient.Client.AgentPools.Update(ctx, ap.instance.Status.AgentPoolID, options) } diff --git a/internal/controller/agentpool_controller_test.go b/internal/controller/agentpool_controller_test.go index 25c37582..ff6d8353 100644 --- a/internal/controller/agentpool_controller_test.go +++ b/internal/controller/agentpool_controller_test.go @@ -331,7 +331,104 @@ var _ = Describe("Agent Pool controller", Ordered, func() { // VALIDATE AGENT DEPLOYMENT ATTRIBUTES validateAgentPoolDeployment(ctx, instance) }) + It("can create an agent pool with allowed workspaces", func() { + // CREATE A NEW AGENT POOL + createTestAgentPool(instance) + // VALIDATE SPEC AGAINST STATUS + validateAgentPoolTestStatus(ctx, instance) + // VALIDATE AGENT TOKENS + validateAgentPoolTestTokens(ctx, instance) + + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + Expect(instance.Spec.AgentDeployment).To(BeNil()) + + workspaceInstance := testWorkspace("test-workspace-pool", "default", instance.Spec.Name) + createWorkspace(workspaceInstance) + + // ADD AllowedWorkspaces + instance.Spec.AllowedWorkspaces = &[]string{workspaceInstance.Status.WorkspaceID} + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + + agentPoolTestGenerationsMatch(instance) + + Expect(instance.Spec.AllowedWorkspaces).ToNot(BeNil()) + Expect(*instance.Spec.AllowedWorkspaces).To(HaveLen(1)) + Expect(instance.Spec.AllowedWorkspaces).To(Equal(&[]string{workspaceInstance.Status.WorkspaceID})) + + // VALIDATE AGENT DEPLOYMENT ATTRIBUTES + deleteWorkspace(workspaceInstance) + }) + It("can update an agent pool allowed workspaces", func() { + // CREATE A NEW AGENT POOL + createTestAgentPool(instance) + + // CREATE 2 WORKSPACE + workspaceInstance := testWorkspace("test-workspace-dl-pool", "default", instance.Spec.Name) + workspaceInstance2 := testWorkspace("test-workspace-dl-pool2", "default", instance.Spec.Name) + workspaceInstance2.Spec.AgentPool = nil + workspaceInstance2.Spec.ExecutionMode = "remote" + createWorkspace(workspaceInstance) + createWorkspace(workspaceInstance2) + + // VALIDATE SPEC AGAINST STATUS + validateAgentPoolTestStatus(ctx, instance) + + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + Expect(instance.Spec.AgentDeployment).To(BeNil()) + instance.Spec.AllowedWorkspaces = &[]string{workspaceInstance.Status.WorkspaceID} + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + agentPoolTestGenerationsMatch(instance) + // ADD AllowedWorkspaces + instance.Spec.AllowedWorkspaces = &[]string{ + workspaceInstance.Status.WorkspaceID, + workspaceInstance2.Status.WorkspaceID, + } + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + agentPoolTestGenerationsMatch(instance) + + Expect(instance.Spec.AllowedWorkspaces).ToNot(BeNil()) + Expect(*instance.Spec.AllowedWorkspaces).To(HaveLen(2)) + Expect(instance.Spec.AllowedWorkspaces).To(Equal(&[]string{ + workspaceInstance.Status.WorkspaceID, + workspaceInstance2.Status.WorkspaceID, + })) + + deleteWorkspace(workspaceInstance) + deleteWorkspace(workspaceInstance2) + }) + It("can remove from an agent pool allowed workspaces", func() { + // CREATE A NEW AGENT POOL + createTestAgentPool(instance) + // CREATE A WORKSPACE + workspaceInstance := testWorkspace("test-workspace-dl-pool", "default", instance.Spec.Name) + createWorkspace(workspaceInstance) + + // VALIDATE SPEC AGAINST STATUS + validateAgentPoolTestStatus(ctx, instance) + + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + Expect(instance.Spec.AgentDeployment).To(BeNil()) + + instance.Spec.AllowedWorkspaces = &[]string{workspaceInstance.Status.WorkspaceID} + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + agentPoolTestGenerationsMatch(instance) + workspaceInstance.Spec.AgentPool = nil + workspaceInstance.Spec.ExecutionMode = "remote" + updateWorkspace(workspaceInstance) + // ADD AllowedWorkspaces + instance.Spec.AllowedWorkspaces = nil + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + agentPoolTestGenerationsMatch(instance) + + Expect(instance.Spec.AllowedWorkspaces).To(BeNil()) + deleteWorkspace(workspaceInstance) + }) It("can delete agent deployments", func() { // CREATE A NEW AGENT POOL createTestAgentPool(instance) diff --git a/internal/controller/workspace_controller_test.go b/internal/controller/workspace_controller_test.go index bdf0b40e..c0976470 100644 --- a/internal/controller/workspace_controller_test.go +++ b/internal/controller/workspace_controller_test.go @@ -261,6 +261,21 @@ func createWorkspace(instance *appv1alpha2.Workspace) { Expect(instance.Status.WorkspaceID).Should(HavePrefix("ws-")) } +func updateWorkspace(instance *appv1alpha2.Workspace) { + namespacedName := getNamespacedName(instance) + + // Create a new Kubernetes workspace object + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + // Wait until the controller finishes the reconciliation + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ObservedGeneration == instance.Generation + }).Should(BeTrue()) + + // The Kubernetes workspace object should have Status.WorkspaceID with the valid workspace ID + Expect(instance.Status.WorkspaceID).Should(HavePrefix("ws-")) +} + func deleteWorkspace(instance *appv1alpha2.Workspace) { namespacedName := getNamespacedName(instance)