diff --git a/api/v1alpha2/workspace_types.go b/api/v1alpha2/workspace_types.go index f536b51f..17ca1c99 100644 --- a/api/v1alpha2/workspace_types.go +++ b/api/v1alpha2/workspace_types.go @@ -61,6 +61,39 @@ type RemoteStateSharing struct { Workspaces []*ConsumerWorkspace `json:"workspaces,omitempty"` } +// Run tasks allow Terraform Cloud to interact with external systems at specific points in the Terraform Cloud run lifecycle. +// Only one of the fields `ID` or `Name` is allowed. +// At least one of the fields `ID` or `Name` is mandatory. +// More information: +// - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks +type WorkspaceRunTask struct { + // Run Task ID. + // + //+kubebuilder:validation:Pattern="^task-[a-zA-Z0-9]+$" + //+optional + ID string `json:"id,omitempty"` + // Run Task Name. + // + //+kubebuilder:validation:MinLength=1 + //+optional + Name string `json:"name,omitempty"` + // Run Task Type. Must be "task". + // + //+kubebuilder:validation:Pattern="^(workspace-tasks)$" + //+kubebuilder:default:=workspace-tasks + Type string `json:"type"` + // Run Task Enforcement Level. + // + //+kubebuilder:validation:Pattern="^(advisory|mandatory)$" + //+kubebuilder:default:=advisory + EnforcementLevel string `json:"enforcementLevel"` + // Run Task Stage. + //+kubebuilder:validation:Pattern="^(pre_apply|pre_plan|post_plan)$" + //+kubebuilder:default:=post_plan + //+optional + Stage string `json:"stage,omitempty"` +} + // RunTrigger allows you to connect this workspace to one or more source workspaces. // These connections allow runs to queue automatically in this workspace on successful apply of runs in any of the source workspaces. // Only one of the fields `ID` or `Name` is allowed. @@ -290,6 +323,13 @@ type WorkspaceSpec struct { //+kubebuilder:default=remote //+optional ExecutionMode string `json:"executionMode,omitempty"` + // Run tasks allow Terraform Cloud to interact with external systems at specific points in the Terraform Cloud run lifecycle. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks + // + //+kubebuilder:validation:MinItems=1 + //+optional + RunTasks []WorkspaceRunTask `json:"runTasks,omitempty"` // Workspace tags are used to help identify and group together workspaces. // //+kubebuilder:validation:MinItems=1 diff --git a/api/v1alpha2/workspace_validation.go b/api/v1alpha2/workspace_validation.go index 3a013a2c..2a0d3a80 100644 --- a/api/v1alpha2/workspace_validation.go +++ b/api/v1alpha2/workspace_validation.go @@ -16,7 +16,8 @@ func (w *Workspace) ValidateSpec() error { allErrs = append(allErrs, w.validateSpecAgentPool()...) allErrs = append(allErrs, w.validateSpecRemoteStateSharing()...) - allErrs = append(allErrs, w.validateSpecRunTrigger()...) + allErrs = append(allErrs, w.validateSpecRunTasks()...) + allErrs = append(allErrs, w.validateSpecRunTriggers()...) allErrs = append(allErrs, w.validateSpecSSHKey()...) if len(allErrs) == 0 { @@ -134,7 +135,49 @@ func (w *Workspace) validateSpecRemoteStateSharingWorkspaces() field.ErrorList { return allErrs } -func (w *Workspace) validateSpecRunTrigger() field.ErrorList { +func (w *Workspace) validateSpecRunTasks() field.ErrorList { + allErrs := field.ErrorList{} + + rti := make(map[string]int) + rtn := make(map[string]int) + + for i, rt := range w.Spec.RunTasks { + f := field.NewPath("spec").Child(fmt.Sprintf("runTasks[%d]", i)) + if rt.ID == "" && rt.Name == "" { + allErrs = append(allErrs, field.Invalid( + f, + "", + "one of the field ID or Name must be set"), + ) + } + + if rt.ID != "" && rt.Name != "" { + allErrs = append(allErrs, field.Invalid( + f, + "", + "only one of the field ID or Name is allowed"), + ) + } + + if rt.ID != "" { + if _, ok := rti[rt.ID]; ok { + allErrs = append(allErrs, field.Duplicate(f.Child("ID"), rt.ID)) + } + rti[rt.ID] = i + } + + if rt.Name != "" { + if _, ok := rtn[rt.Name]; ok { + allErrs = append(allErrs, field.Duplicate(f.Child("Name"), rt.Name)) + } + rtn[rt.Name] = i + } + } + + return allErrs +} + +func (w *Workspace) validateSpecRunTriggers() field.ErrorList { allErrs := field.ErrorList{} rti := make(map[string]int) @@ -167,7 +210,7 @@ func (w *Workspace) validateSpecRunTrigger() field.ErrorList { if rt.Name != "" { if _, ok := rtn[rt.Name]; ok { - allErrs = append(allErrs, field.Duplicate(f.Child("ID"), rt.Name)) + allErrs = append(allErrs, field.Duplicate(f.Child("Name"), rt.Name)) } rtn[rt.Name] = i } diff --git a/api/v1alpha2/workspace_validation_test.go b/api/v1alpha2/workspace_validation_test.go index 55583db7..3861a125 100644 --- a/api/v1alpha2/workspace_validation_test.go +++ b/api/v1alpha2/workspace_validation_test.go @@ -210,7 +210,105 @@ func TestValidateWorkspaceSpecRemoteStateSharing(t *testing.T) { } } -func TestValidateWorkspaceSpecRunTrigger(t *testing.T) { +func TestValidateWorkspaceSpecRunTasks(t *testing.T) { + successCases := map[string]Workspace{ + "HasOnlyID": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + ID: "this", + }, + }, + }, + }, + "HasOnlyName": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + Name: "this", + }, + }, + }, + }, + "HasOneWithIDandOneWithName": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + ID: "this", + }, + { + Name: "this", + }, + }, + }, + }, + } + + for n, c := range successCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecRunTasks(); len(errs) != 0 { + t.Errorf("Unexpected validation errors: %v", errs) + } + }) + } + + errorCases := map[string]Workspace{ + "HasIDandName": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + ID: "this", + Name: "this", + }, + }, + }, + }, + "HasEmptyIDandName": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + Name: "", + ID: "", + }, + }, + }, + }, + "HasDuplicateID": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + ID: "this", + }, + { + ID: "this", + }, + }, + }, + }, + "HasDuplicateName": { + Spec: WorkspaceSpec{ + RunTasks: []WorkspaceRunTask{ + { + Name: "this", + }, + { + Name: "this", + }, + }, + }, + }, + } + + for n, c := range errorCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecRunTasks(); len(errs) == 0 { + t.Error("Unexpected failure, at least one error is expected") + } + }) + } +} + +func TestValidateWorkspaceSpecRunTriggers(t *testing.T) { successCases := map[string]Workspace{ "HasOnlyID": { Spec: WorkspaceSpec{ @@ -246,7 +344,7 @@ func TestValidateWorkspaceSpecRunTrigger(t *testing.T) { for n, c := range successCases { t.Run(n, func(t *testing.T) { - if errs := c.validateSpecRunTrigger(); len(errs) != 0 { + if errs := c.validateSpecRunTriggers(); len(errs) != 0 { t.Errorf("Unexpected validation errors: %v", errs) } }) @@ -301,7 +399,7 @@ func TestValidateWorkspaceSpecRunTrigger(t *testing.T) { for n, c := range errorCases { t.Run(n, func(t *testing.T) { - if errs := c.validateSpecRunTrigger(); len(errs) == 0 { + if errs := c.validateSpecRunTriggers(); len(errs) == 0 { t.Error("Unexpected failure, at least one error is expected") } }) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1dbf1838..4359f377 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -649,6 +649,21 @@ func (in *WorkspaceList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceRunTask) DeepCopyInto(out *WorkspaceRunTask) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceRunTask. +func (in *WorkspaceRunTask) DeepCopy() *WorkspaceRunTask { + if in == nil { + return nil + } + out := new(WorkspaceRunTask) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { *out = *in @@ -658,6 +673,11 @@ func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { *out = new(WorkspaceAgentPool) **out = **in } + if in.RunTasks != nil { + in, out := &in.RunTasks, &out.RunTasks + *out = make([]WorkspaceRunTask, len(*in)) + copy(*out, *in) + } if in.Tags != nil { in, out := &in.Tags, &out.Tags *out = make([]string, len(*in)) diff --git a/config/crd/bases/app.terraform.io_workspaces.yaml b/config/crd/bases/app.terraform.io_workspaces.yaml index edc9f68b..41d788a1 100644 --- a/config/crd/bases/app.terraform.io_workspaces.yaml +++ b/config/crd/bases/app.terraform.io_workspaces.yaml @@ -196,6 +196,46 @@ spec: minItems: 1 type: array type: object + runTasks: + description: 'Run tasks allow Terraform Cloud to interact with external + systems at specific points in the Terraform Cloud run lifecycle. + More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks' + items: + description: 'Run tasks allow Terraform Cloud to interact with external + systems at specific points in the Terraform Cloud run lifecycle. + Only one of the fields `ID` or `Name` is allowed. At least one + of the fields `ID` or `Name` is mandatory. More information: - + https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks' + properties: + enforcementLevel: + default: advisory + description: Run Task Enforcement Level. + pattern: ^(advisory|mandatory)$ + type: string + id: + description: Run Task ID. + pattern: ^task-[a-zA-Z0-9]+$ + type: string + name: + description: Run Task Name. + minLength: 1 + type: string + stage: + default: post_plan + description: Run Task Stage. + pattern: ^(pre_apply|pre_plan|post_plan)$ + type: string + type: + default: workspace-tasks + description: Run Task Type. Must be "task". + pattern: ^(workspace-tasks)$ + type: string + required: + - enforcementLevel + - type + type: object + minItems: 1 + type: array runTriggers: description: 'Run triggers allow you to connect this workspace to one or more source workspaces. These connections allow runs to queue diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 287b3eee..44ca67ee 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -59,6 +59,8 @@ func TestControllersAPIs(t *testing.T) { reporterConfig.NoColor = true reporterConfig.Succinct = false + suiteConfig.LabelFilter = "runTask" + RunSpecs(t, "Controllers Suite", suiteConfig, reporterConfig) } diff --git a/controllers/workspace_controller.go b/controllers/workspace_controller.go index edb88e97..35e5a6a1 100644 --- a/controllers/workspace_controller.go +++ b/controllers/workspace_controller.go @@ -521,6 +521,16 @@ func (r *WorkspaceReconciler) reconcileWorkspace(ctx context.Context, w *workspa w.log.Info("Reconcile Remote State Sharing", "msg", "successfully reconcilied remote state sharing") r.Recorder.Eventf(&w.instance, corev1.EventTypeNormal, "ReconcileRemoteStateSharing", "Reconcilied remote state sharing in workspace ID %s", w.instance.Status.WorkspaceID) + // Reconcile Run Tasks + err = r.reconcileRunTasks(ctx, w) + if err != nil { + w.log.Error(err, "Reconcile Run Tasks", "msg", fmt.Sprintf("failed to reconcile run tasks in workspace ID %s", w.instance.Status.WorkspaceID)) + r.Recorder.Eventf(&w.instance, corev1.EventTypeWarning, "ReconcileRunTasks", "Failed to reconcile run tasks in workspace ID %s", w.instance.Status.WorkspaceID) + return err + } + w.log.Info("Reconcile Run Tasks", "msg", "successfully reconcilied run tasks") + r.Recorder.Eventf(&w.instance, corev1.EventTypeNormal, "ReconcileRunTasks", "Reconcilied run tasks in workspace ID %s", w.instance.Status.WorkspaceID) + // Update status once a workspace has been successfully updated return r.updateStatus(ctx, w, workspace) } diff --git a/controllers/workspace_controller_run_tasks.go b/controllers/workspace_controller_run_tasks.go new file mode 100644 index 00000000..93234e4c --- /dev/null +++ b/controllers/workspace_controller_run_tasks.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "context" + + tfc "github.com/hashicorp/go-tfe" +) + +func (r *WorkspaceReconciler) reconcileRunTasks(ctx context.Context, w *workspaceInstance) error { + w.log.Info("Reconcile Run Tasks", "msg", "new reconciliation event") + + for _, rt := range w.instance.Spec.RunTasks { + _, err := w.tfClient.Client.WorkspaceRunTasks.Create(ctx, w.instance.Status.WorkspaceID, tfc.WorkspaceRunTaskCreateOptions{ + Type: rt.Type, + EnforcementLevel: tfc.TaskEnforcementLevel(rt.EnforcementLevel), + Stage: (*tfc.Stage)(&rt.Stage), + RunTask: &tfc.RunTask{ + ID: rt.ID, + }, + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/controllers/workspace_controller_run_tasks_test.go b/controllers/workspace_controller_run_tasks_test.go new file mode 100644 index 00000000..ae84006d --- /dev/null +++ b/controllers/workspace_controller_run_tasks_test.go @@ -0,0 +1,131 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + appv1alpha2 "github.com/hashicorp/terraform-cloud-operator/api/v1alpha2" +) + +var _ = Describe("Workspace controller", Label("runTask"), Ordered, func() { + var ( + instance *appv1alpha2.Workspace + workspace = fmt.Sprintf("kubernetes-operator-%v", GinkgoRandomSeed()) + // runTaskName = "kubernetes-operator-run-task" // fmt.Sprintf("kubernetes-operator-run-task-%v", GinkgoRandomSeed()) + runTaskID = "task-cyALezxxQBU4sfUQ" // "" + ) + + // KNOWN ISSUE + // + // Run Task should be created dynamically before run tests and then removed once tests are done. + // However, due to a bug on the Terraform Cloud end, a Run Task cannot be removed immediately once the workspace is removed. + // The Run Task remains associated with the deleted workspace due to the "cool down" period of ~15 minutes. + // + // Need to report this issue. + + BeforeAll(func() { + // Set default Eventually timers + SetDefaultEventuallyTimeout(syncPeriod * 4) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + // Create a Run Task + // rt, err := tfClient.RunTasks.Create(ctx, organization, tfc.RunTaskCreateOptions{ + // Name: runTaskName, + // URL: "https://example.com", + // Category: "task", // MUST BE "task" + // Enabled: tfc.Bool(true), + // }) + // Expect(err).Should(Succeed()) + // Expect(rt).ShouldNot(BeNil()) + // runTaskID = rt.ID + }) + + // AfterAll(func() { + // err := tfClient.RunTasks.Delete(ctx, runTaskID) + // Expect(err).Should(Succeed()) + // }) + + BeforeEach(func() { + // Create a new workspace object for each test + instance = &appv1alpha2.Workspace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "app.terraform.io/v1alpha2", + Kind: "Workspace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + DeletionTimestamp: nil, + Finalizers: []string{}, + }, + Spec: appv1alpha2.WorkspaceSpec{ + Organization: organization, + Token: appv1alpha2.Token{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: namespacedName.Name, + }, + Key: secretKey, + }, + }, + Name: workspace, + RunTasks: []appv1alpha2.WorkspaceRunTask{ + { + // At least one of the fields `ID` or `Name` is mandatory. + // Set it up per test. + Type: "workspace-tasks", // MUST BE "workspace-tasks". + EnforcementLevel: "advisory", + Stage: "post_plan", + }, + }, + }, + Status: appv1alpha2.WorkspaceStatus{}, + } + }) + + AfterEach(func() { + // Delete the Kubernetes workspace object and wait until the controller finishes the reconciliation after deletion of the object + deleteWorkspace(instance, namespacedName) + }) + + Context("Workspace controller", func() { + It("can create run task by ID", func() { + instance.Spec.RunTasks[0].ID = runTaskID + // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation + createWorkspace(instance, namespacedName) + }) + + // It("can create run task by Name", func() { + // instance.Spec.RunTasks[0].ID = runTaskName + // // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation + // createWorkspace(instance, namespacedName) + // }) + + // It("can delete run task", func() { + // // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation + // createWorkspace(instance, namespacedName) + // // NEED VALIDATION HERE + // }) + + // It("can restore deleted run task", func() { + // // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation + // createWorkspace(instance, namespacedName) + // // NEED VALIDATION HERE + // }) + + // It("can restore changed run task", func() { + // // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation + // createWorkspace(instance, namespacedName) + // // NEED VALIDATION HERE + // }) + }) +}) diff --git a/docs/api-reference.md b/docs/api-reference.md index 6ece08b7..74cfa2a1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -421,6 +421,24 @@ _Appears in:_ | `name` _string_ | Agent Pool name. | +#### WorkspaceRunTask + + + +Run tasks allow Terraform Cloud to interact with external systems at specific points in the Terraform Cloud run lifecycle. Only one of the fields `ID` or `Name` is allowed. At least one of the fields `ID` or `Name` is mandatory. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks + +_Appears in:_ +- [WorkspaceSpec](#workspacespec) + +| Field | Description | +| --- | --- | +| `id` _string_ | Run Task ID. | +| `name` _string_ | Run Task Name. | +| `type` _string_ | Run Task Type. Must be "task". | +| `enforcementLevel` _string_ | Run Task Enforcement Level. | +| `stage` _string_ | Run Task Stage. | + + #### WorkspaceSpec @@ -440,6 +458,7 @@ _Appears in:_ | `description` _string_ | Workspace description. | | `agentPool` _[WorkspaceAgentPool](#workspaceagentpool)_ | Terraform Cloud Agents allow Terraform Cloud to communicate with isolated, private, or on-premises infrastructure. More information: - https://developer.hashicorp.com/terraform/cloud-docs/agents | | `executionMode` _string_ | Define where the Terraform code will be executed. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings#execution-mode | +| `runTasks` _[WorkspaceRunTask](#workspaceruntask) array_ | Run tasks allow Terraform Cloud to interact with external systems at specific points in the Terraform Cloud run lifecycle. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks | | `tags` _string array_ | Workspace tags are used to help identify and group together workspaces. | | `teamAccess` _[TeamAccess](#teamaccess) array_ | Terraform Cloud workspaces can only be accessed by users with the correct permissions. You can manage permissions for a workspace on a per-team basis. When a workspace is created, only the owners team and teams with the "manage workspaces" permission can access it, with full admin permissions. These teams' access can't be removed from a workspace. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/access | | `terraformVersion` _string_ | The version of Terraform to use for this workspace. If not specified, the latest available version will be used. More information: - https://www.terraform.io/cloud-docs/workspaces/settings#terraform-version |