Skip to content

Commit

Permalink
Add support for Run Tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
arybolovlev committed Feb 16, 2023
1 parent 8d67d9f commit 1b30d8f
Show file tree
Hide file tree
Showing 10 changed files with 439 additions and 6 deletions.
40 changes: 40 additions & 0 deletions api/v1alpha2/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions api/v1alpha2/workspace_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
104 changes: 101 additions & 3 deletions api/v1alpha2/workspace_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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")
}
})
Expand Down
20 changes: 20 additions & 0 deletions api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions config/crd/bases/app.terraform.io_workspaces.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions controllers/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ func TestControllersAPIs(t *testing.T) {
reporterConfig.NoColor = true
reporterConfig.Succinct = false

suiteConfig.LabelFilter = "runTask"

RunSpecs(t, "Controllers Suite", suiteConfig, reporterConfig)
}

Expand Down
10 changes: 10 additions & 0 deletions controllers/workspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
30 changes: 30 additions & 0 deletions controllers/workspace_controller_run_tasks.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 1b30d8f

Please sign in to comment.