Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚀 Add project support #300

Merged
merged 10 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## 2.1.0 (UNRELEASED)

ENHANCEMENT:

* `Workspace`: Add the ability to configure the project for the workspace via a new field `spec.project.[id | name]`. [[GH-300](https://github.com/hashicorp/terraform-cloud-operator/pull/300)]

BUG FIXES:

* `Module`: fix an issue when initiating foreground cascading deletion results in two destroy runs being triggered, and even after both runs are successfully executed, a module object persists in Kubernetes. [[GH-301](https://github.com/hashicorp/terraform-cloud-operator/pull/301)]
Expand Down
26 changes: 26 additions & 0 deletions api/v1alpha2/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,25 @@ type Notification struct {
EmailUsers []string `json:"emailUsers,omitempty"`
}

// Projects let you organize your workspaces into groups.
// 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/tutorials/cloud/projects
type WorkspaceProject struct {
// Project ID.
// Must match pattern: ^prj-[a-zA-Z0-9]+$
//
//+kubebuilder:validation:Pattern:="^prj-[a-zA-Z0-9]+$"
//+optional
ID string `json:"id,omitempty"`
// Project name.
//
//+kubebuilder:validation:MinLength:=1
//+optional
Name string `json:"name,omitempty"`
}

// WorkspaceSpec defines the desired state of Workspace.
type WorkspaceSpec struct {
// Workspace name.
Expand Down Expand Up @@ -526,6 +545,13 @@ type WorkspaceSpec struct {
//+kubebuilder:validation:MinItems:=1
//+optional
Notifications []Notification `json:"notifications,omitempty"`
// Projects let you organize your workspaces into groups.
// Default: default organization project.
// More information:
// - https://developer.hashicorp.com/terraform/tutorials/cloud/projects
//
//+optional
Project *WorkspaceProject `json:"project,omitempty"`
}

type RunStatus struct {
Expand Down
30 changes: 30 additions & 0 deletions api/v1alpha2/workspace_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func (w *Workspace) ValidateSpec() error {
allErrs = append(allErrs, w.validateSpecRunTasks()...)
allErrs = append(allErrs, w.validateSpecRunTriggers()...)
allErrs = append(allErrs, w.validateSpecSSHKey()...)
allErrs = append(allErrs, w.validateSpecProject()...)

if len(allErrs) == 0 {
return nil
Expand Down Expand Up @@ -429,6 +430,35 @@ func (w *Workspace) validateSpecSSHKey() field.ErrorList {
return allErrs
}

func (w *Workspace) validateSpecProject() field.ErrorList {
allErrs := field.ErrorList{}
spec := w.Spec.Project

if spec == nil {
return allErrs
}

f := field.NewPath("spec").Child("project")

if spec.ID == "" && spec.Name == "" {
allErrs = append(allErrs, field.Invalid(
f,
"",
"one of the field ID or Name must be set"),
)
}

if spec.ID != "" && spec.Name != "" {
allErrs = append(allErrs, field.Invalid(
f,
"",
"only one of the field ID or Name is allowed"),
)
}

return allErrs
}

// TODO:Validation
//
// + EnvironmentVariables names duplicate: spec.environmentVariables[].name
Expand Down
56 changes: 56 additions & 0 deletions api/v1alpha2/workspace_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,3 +839,59 @@ func TestValidateWorkspaceSpecSSHKey(t *testing.T) {
})
}
}

func TestValidateWorkspaceSpecProject(t *testing.T) {
successCases := map[string]Workspace{
"HasOnlyID": {
Spec: WorkspaceSpec{
Project: &WorkspaceProject{
ID: "this",
},
},
},
"HasOnlyName": {
Spec: WorkspaceSpec{
Project: &WorkspaceProject{
Name: "this",
},
},
},
"HasEmptyProject": {
Spec: WorkspaceSpec{
Project: nil,
},
},
}

for n, c := range successCases {
t.Run(n, func(t *testing.T) {
if errs := c.validateSpecProject(); len(errs) != 0 {
t.Errorf("Unexpected validation errors: %v", errs)
}
})
}

errorCases := map[string]Workspace{
"HasIDandName": {
Spec: WorkspaceSpec{
Project: &WorkspaceProject{
ID: "this",
Name: "this",
},
},
},
"HasEmptyIDandName": {
Spec: WorkspaceSpec{
Project: &WorkspaceProject{},
},
},
}

for n, c := range errorCases {
t.Run(n, func(t *testing.T) {
if errs := c.validateSpecProject(); len(errs) == 0 {
t.Error("Unexpected failure, at least one error is expected")
}
})
}
}
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.

Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,19 @@ spec:
More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations'
minLength: 1
type: string
project:
description: 'Projects let you organize your workspaces into groups.
Default: default organization project. More information: - https://developer.hashicorp.com/terraform/tutorials/cloud/projects'
properties:
id:
description: 'Project ID. Must match pattern: ^prj-[a-zA-Z0-9]+$'
pattern: ^prj-[a-zA-Z0-9]+$
type: string
name:
description: Project name.
minLength: 1
type: string
type: object
remoteStateSharing:
description: 'Remote state access between workspaces. By default,
new workspaces in Terraform Cloud do not allow other workspaces
Expand Down
13 changes: 13 additions & 0 deletions config/crd/bases/app.terraform.io_workspaces.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,19 @@ spec:
More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations'
minLength: 1
type: string
project:
description: 'Projects let you organize your workspaces into groups.
Default: default organization project. More information: - https://developer.hashicorp.com/terraform/tutorials/cloud/projects'
properties:
id:
description: 'Project ID. Must match pattern: ^prj-[a-zA-Z0-9]+$'
pattern: ^prj-[a-zA-Z0-9]+$
type: string
name:
description: Project name.
minLength: 1
type: string
type: object
remoteStateSharing:
description: 'Remote state access between workspaces. By default,
new workspaces in Terraform Cloud do not allow other workspaces
Expand Down
2 changes: 2 additions & 0 deletions controllers/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ var _ = BeforeSuite(func() {
}
// Terraform Cloud Client
tfClient, err = tfc.NewClient(&tfc.Config{Token: os.Getenv("TFC_TOKEN")})
Expect(err).ToNot(HaveOccurred())
Expect(tfClient).ToNot(BeNil())
httpClient := tfc.DefaultConfig().HTTPClient
insecure := false
if v, ok := os.LookupEnv("TFC_TLS_SKIP_VERIFY"); ok {
Expand Down
33 changes: 33 additions & 0 deletions controllers/workspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,17 @@ func (r *WorkspaceReconciler) createWorkspace(ctx context.Context, w *workspaceI
options.GlobalRemoteState = tfc.Bool(spec.RemoteStateSharing.AllWorkspaces)
}

if spec.Project != nil {
prjID, err := r.getProjectID(ctx, w)
if err != nil {
w.log.Error(err, "Reconcile Workspace", "msg", "failed to get project ID")
r.Recorder.Event(&w.instance, corev1.EventTypeWarning, "ReconcileWorkspace", "Failed to get project ID")
return nil, err
}
w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("project ID %s will be used", prjID))
options.Project = &tfc.Project{ID: prjID}
}

workspace, err := w.tfClient.Client.Workspaces.Create(ctx, spec.Organization, options)
if err != nil {
w.log.Error(err, "Reconcile Workspace", "msg", "failed to create a new workspace")
Expand Down Expand Up @@ -397,6 +408,7 @@ func (r *WorkspaceReconciler) updateWorkspace(ctx context.Context, w *workspaceI
return ws, err
}
}

if spec.VersionControl != nil {
updateOptions.VCSRepo = &tfc.VCSRepoOptions{
OAuthTokenID: tfc.String(spec.VersionControl.OAuthTokenID),
Expand All @@ -406,6 +418,27 @@ func (r *WorkspaceReconciler) updateWorkspace(ctx context.Context, w *workspaceI
updateOptions.FileTriggersEnabled = tfc.Bool(false)
}

if spec.Project != nil {
prjID, err := r.getProjectID(ctx, w)
if err != nil {
w.log.Error(err, "Reconcile Workspace", "msg", "failed to get project ID")
r.Recorder.Event(&w.instance, corev1.EventTypeWarning, "ReconcileWorkspace", "Failed to get project ID")
return nil, err
}
w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("project ID %s will be used", prjID))
updateOptions.Project = &tfc.Project{ID: prjID}
} else {
// Setting up `Project` to nil(tfc.WorkspaceUpdateOptions{Project: nil}) won't move the workspace to the default project after the update.
org, err := w.tfClient.Client.Organizations.Read(ctx, w.instance.Spec.Organization)
if err != nil {
w.log.Error(err, "Reconcile Workspace", "msg", "failed to get organization")
r.Recorder.Event(&w.instance, corev1.EventTypeWarning, "ReconcileWorkspace", "Failed to get organization")
return nil, err
}
w.log.Info("Reconcile Workspace", "msg", fmt.Sprintf("default project ID %s will be used", org.DefaultProject.ID))
updateOptions.Project = &tfc.Project{ID: org.DefaultProject.ID}
}

return w.tfClient.Client.Workspaces.UpdateByID(ctx, w.instance.Status.WorkspaceID, updateOptions)
}

Expand Down
46 changes: 46 additions & 0 deletions controllers/workspace_controller_projects.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package controllers

import (
"context"
"fmt"

tfc "github.com/hashicorp/go-tfe"
)

func (r *WorkspaceReconciler) getProjectIDByName(ctx context.Context, w *workspaceInstance) (string, error) {
projectName := w.instance.Spec.Project.Name

projectIDs, err := w.tfClient.Client.Projects.List(ctx, w.instance.Spec.Organization, &tfc.ProjectListOptions{
Name: projectName,
})
if err != nil {
return "", err
}

for _, p := range projectIDs.Items {
if p.Name == projectName {
return p.ID, nil
}
}

return "", fmt.Errorf("project ID not found for project name %q", projectName)
}

func (r *WorkspaceReconciler) getProjectID(ctx context.Context, w *workspaceInstance) (string, error) {
specProject := w.instance.Spec.Project

if specProject == nil {
return "", fmt.Errorf("'spec.Project' is not set")
}

if specProject.Name != "" {
w.log.Info("Reconcile Project", "msg", "getting project ID by name")
return r.getProjectIDByName(ctx, w)
}

w.log.Info("Reconcile Project", "msg", "getting project ID from the spec.Project.ID")
return specProject.ID, nil
}
Loading