diff --git a/.github/workflows/helm-end-to-end-tfc.yaml b/.github/workflows/helm-end-to-end-tfc.yaml index ef87cd55..681fe260 100644 --- a/.github/workflows/helm-end-to-end-tfc.yaml +++ b/.github/workflows/helm-end-to-end-tfc.yaml @@ -81,6 +81,7 @@ jobs: --set operator.syncPeriod=30s \ --set controllers.agentPool.workers=5 \ --set controllers.module.workers=5 \ + --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 - name: Run end-to-end test suite diff --git a/.github/workflows/helm-end-to-end-tfe.yaml b/.github/workflows/helm-end-to-end-tfe.yaml index 6ceb17e6..7b0e11bf 100644 --- a/.github/workflows/helm-end-to-end-tfe.yaml +++ b/.github/workflows/helm-end-to-end-tfe.yaml @@ -83,6 +83,7 @@ jobs: --set operator.syncPeriod=30s \ --set controllers.agentPool.workers=5 \ --set controllers.module.workers=5 \ + --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 - name: Run end-to-end test suite diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b332b76..f810ac07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## 2.2.0 (UNRELEASED) +FEATURES: + +* `Project`: add a new controller `Project` that allows managing Terraform Cloud Projects. [[GH-309](https://github.com/hashicorp/terraform-cloud-operator/pull/309)]. + DEPENDENCIES: * Bump `k8s.io/api` from 0.27.7 to 0.27.8. [[GH-306](https://github.com/hashicorp/terraform-cloud-operator/pull/306)] diff --git a/PROJECT b/PROJECT index 67824da3..a668d09d 100644 --- a/PROJECT +++ b/PROJECT @@ -1,3 +1,7 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html componentConfig: true domain: terraform.io layout: @@ -35,4 +39,13 @@ resources: kind: AgentPool path: github.com/hashicorp/terraform-cloud-operator/api/v1alpha2 version: v1alpha2 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: terraform.io + group: app + kind: Project + path: github.com/hashicorp/terraform-cloud-operator/api/v1alpha2 + version: v1alpha2 version: "3" diff --git a/README.md b/README.md index ab9ff3fd..6780fe54 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,14 @@ The Operator can manage the following types of resources: - `AgentPool` manages [Terraform Cloud Agent Pools](https://developer.hashicorp.com/terraform/cloud-docs/agents/agent-pools), [Terraform Cloud Agent Tokens](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens) and can perform TFC agent scaling - `Module` implements [API-driven Run Workflows](https://developer.hashicorp.com/terraform/cloud-docs/run/api) +- `Project` manages [Terraform Cloud Projects](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects) - `Workspace` manages [Terraform Cloud Workspaces](https://developer.hashicorp.com/terraform/cloud-docs/workspaces) ## Getting started To get started see our tutorials on the HashiCorp Developer Portal: +- [Terraform Cloud Operator for Kubernetes overview](https://developer.hashicorp.com/terraform/cloud-docs/integrations/kubernetes) - [Deploy infrastructure with the Terraform Cloud Kubernetes Operator v2](https://developer.hashicorp.com/terraform/tutorials/kubernetes/kubernetes-operator-v2) - [Manage agent pools with the Terraform Cloud Kubernetes Operator v2](https://developer.hashicorp.com/terraform/tutorials/kubernetes/kubernetes-operator-v2-agentpool) - [Terraform Cloud Kubernetes Operator v2 Migration Guide](https://developer.hashicorp.com/terraform/cloud-docs/integrations/kubernetes/ops-v2-migration) @@ -31,7 +33,7 @@ To get started see our tutorials on the HashiCorp Developer Portal: ### Supported Features -The full list of supported Terraform Cloud features can be found [here](./docs/features.md). +The full list of supported Terraform Cloud features can be found on our [Developer portal](https://developer.hashicorp.com/terraform/cloud-docs/integrations/kubernetes#supported-terraform-cloud-features). ### Installation @@ -55,6 +57,7 @@ Controllers usage guides: - [AgentPool](./docs/agentpool.md) - [Module](./docs/module.md) +- [Project](./docs/project.md) - [Workspace](./docs/workspace.md) ### Metrics @@ -106,6 +109,7 @@ If you encounter any issues with the Operator there are a number of ways how to ```console $ kubectl get agentpool $ kubectl get module + $ kubectl get project $ kubectl get workspace ``` @@ -114,6 +118,7 @@ If you encounter any issues with the Operator there are a number of ways how to ```console $ kubectl describe agentpool $ kubectl describe module + $ kubectl describe project $ kubectl describe workspace ``` diff --git a/api/v1alpha2/project_helpers.go b/api/v1alpha2/project_helpers.go new file mode 100644 index 00000000..e23c1ad0 --- /dev/null +++ b/api/v1alpha2/project_helpers.go @@ -0,0 +1,8 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +func (p *Project) IsCreationCandidate() bool { + return p.Status.ID == "" +} diff --git a/api/v1alpha2/project_helpers_test.go b/api/v1alpha2/project_helpers_test.go new file mode 100644 index 00000000..959817be --- /dev/null +++ b/api/v1alpha2/project_helpers_test.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + "testing" +) + +const ( + projectFinalizer = "project.app.terraform.io/finalizer" +) + +func TestIsCreationCandidate(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + project Project + expected bool + }{ + "HasID": { + Project{Status: ProjectStatus{ID: "prj-this"}}, + false, + }, + "DoesNotHaveID": { + Project{Status: ProjectStatus{ID: ""}}, + true, + }, + } + + for n, c := range cases { + t.Run(n, func(t *testing.T) { + out := c.project.IsCreationCandidate() + if out != c.expected { + t.Fatalf("Error matching output and expected: %#v vs %#v", out, c.expected) + } + }) + } +} diff --git a/api/v1alpha2/project_types.go b/api/v1alpha2/project_types.go new file mode 100644 index 00000000..eae2f22c --- /dev/null +++ b/api/v1alpha2/project_types.go @@ -0,0 +1,190 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + tfc "github.com/hashicorp/go-tfe" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Custom permissions let you assign specific, finer-grained permissions to a team than the broader fixed permission sets provide. +// More information: +// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions +// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-workspace-permissions +type CustomProjectPermissions struct { + // Project access. + // Must be one of the following values: `delete`, `read`, `update`. + // Default: `read`. + // + //+kubebuilder:validation:Enum:=delete;read;update + //+kubebuilder:default:=read + //+optional + ProjectAccess tfc.ProjectSettingsPermissionType `json:"projectAccess,omitempty"` + // Team management. + // Must be one of the following values: `manage`, `none`, `read`. + // Default: `none`. + // + //+kubebuilder:validation:Enum:=manage;none;read + //+kubebuilder:default:=none + //+optional + TeamManagement tfc.ProjectTeamsPermissionType `json:"teamManagement,omitempty"` + // Allow users to create workspaces in the project. + // This grants read access to all workspaces in the project. + // Default: `false`. + // + //+kubebuilder:default:=false + //+optional + CreateWorkspace bool `json:"createWorkspace,omitempty"` + // Allows users to delete workspaces in the project. + // Default: `false`. + // + //+kubebuilder:default:=false + //+optional + DeleteWorkspace bool `json:"deleteWorkspace,omitempty"` + // Allows users to move workspaces out of the project. + // A user must have this permission on both the source and destination project to successfully move a workspace from one project to another. + // Default: `false`. + // + //+kubebuilder:default:=false + //+optional + MoveWorkspace bool `json:"moveWorkspace,omitempty"` + // Allows users to manually lock the workspace to temporarily prevent runs. + // When a workspace's execution mode is set to "local", users must have this permission to perform local CLI runs using the workspace's state. + // Default: `false`. + // + //+kubebuilder:default:=false + //+optional + LockWorkspace bool `json:"lockWorkspace,omitempty"` + // Run access. + // Must be one of the following values: `apply`, `plan`, `read`. + // Default: `read`. + // + //+kubebuilder:validation:Enum:=apply;plan;read + //+kubebuilder:default:=read + //+optional + Runs tfc.WorkspaceRunsPermissionType `json:"runs,omitempty"` + // Manage Workspace Run Tasks. + // Default: `false`. + // + //+kubebuilder:validation:default:=false + //+optional + RunTasks bool `json:"runTasks,omitempty"` + // Download Sentinel mocks. + // Must be one of the following values: `none`, `read`. + // Default: `none`. + // + //+kubebuilder:validation:Enum:=none;read + //+kubebuilder:default:=none + //+optional + SentinelMocks tfc.WorkspaceSentinelMocksPermissionType `json:"sentinelMocks,omitempty"` + // State access. + // Must be one of the following values: `none`, `read`, `read-outputs`, `write`. + // Default: `none`. + // + //+kubebuilder:validation:Enum:=none;read;read-outputs;write + //+kubebuilder:default:=none + //+optional + StateVersions tfc.WorkspaceStateVersionsPermissionType `json:"stateVersions,omitempty"` + // Variable access. + // Must be one of the following values: `none`, `read`, `write`. + // Default: `none`. + // + //+kubebuilder:validation:Enum:=none;read;write + //+kubebuilder:default:=none + //+optional + Variables tfc.WorkspaceVariablesPermissionType `json:"variables,omitempty"` +} + +// Terraform Cloud's access model is team-based. In order to perform an action within a Terraform Cloud organization, +// users must belong to a team that has been granted the appropriate permissions. +// You can assign project-specific permissions to teams. +// More information: +// - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions +// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions +type ProjectTeamAccess struct { + // Team to grant access. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/teams + Team Team `json:"team"` + // There are two ways to choose which permissions a given team has on a project: fixed permission sets, and custom permissions. + // Must be one of the following values: `admin`, `custom`, `maintain`, `read`, `write`. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-project-permissions + // + //+kubebuilder:validation:Enum:=admin;custom;maintain;read;write + Access tfc.TeamProjectAccessType `json:"access"` + // Custom permissions let you assign specific, finer-grained permissions to a team than the broader fixed permission sets provide. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions + // + //+optional + Custom *CustomProjectPermissions `json:"custom,omitempty"` +} + +// ProjectSpec defines the desired state of Project. +// More information: +// - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects +type ProjectSpec struct { + // Organization name where the Workspace will be created. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations + // + //+kubebuilder:validation:MinLength:=1 + Organization string `json:"organization"` + // API Token to be used for API calls. + Token Token `json:"token"` + // Name of the Project. + // + //+kubebuilder:validation:MinLength:=1 + Name string `json:"name"` + + // Terraform Cloud's access model is team-based. In order to perform an action within a Terraform Cloud organization, + // users must belong to a team that has been granted the appropriate permissions. + // You can assign project-specific permissions to teams. + // More information: + // - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions + // - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions + // + //+kubebuilder:validation:MinItems:=1 + //+optional + TeamAccess []*ProjectTeamAccess `json:"teamAccess,omitempty"` +} + +// ProjectStatus defines the observed state of Project. +type ProjectStatus struct { + // Real world state generation. + ObservedGeneration int64 `json:"observedGeneration"` + // Project ID. + ID string `json:"id"` + // Project name. + Name string `json:"name"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="Project Name",type=string,JSONPath=`.status.name` +//+kubebuilder:printcolumn:name="Project ID",type=string,JSONPath=`.status.id` + +// Project is the Schema for the projects API +type Project struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ProjectSpec `json:"spec"` + Status ProjectStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// ProjectList contains a list of Project +type ProjectList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Project `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Project{}, &ProjectList{}) +} diff --git a/api/v1alpha2/project_validation.go b/api/v1alpha2/project_validation.go new file mode 100644 index 00000000..5bdfce9d --- /dev/null +++ b/api/v1alpha2/project_validation.go @@ -0,0 +1,106 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + "fmt" + + tfc "github.com/hashicorp/go-tfe" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func (p *Project) ValidateSpec() error { + var allErrs field.ErrorList + + allErrs = append(allErrs, p.validateSpecTeamAccess()...) + + if len(allErrs) == 0 { + return nil + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: "", Kind: "Project"}, + p.Name, + allErrs, + ) +} + +func (p *Project) validateSpecTeamAccess() field.ErrorList { + allErrs := field.ErrorList{} + + allErrs = append(allErrs, p.validateSpecTeamAccessCustom()...) + allErrs = append(allErrs, p.validateSpecTeamAccessTeam()...) + + return allErrs +} + +func (p *Project) validateSpecTeamAccessCustom() field.ErrorList { + allErrs := field.ErrorList{} + + for i, ta := range p.Spec.TeamAccess { + f := field.NewPath("spec").Child(fmt.Sprintf("[%d]", i)) + if ta.Access == tfc.TeamProjectAccessCustom { + if ta.Custom == nil { + allErrs = append(allErrs, field.Required( + f, + fmt.Sprintf("'spec.teamAccess.custom' must be set when 'spec.teamAccess' is set to %q", tfc.TeamProjectAccessCustom), + )) + } + } else { + if ta.Custom != nil { + allErrs = append(allErrs, field.Invalid( + f, + "", + fmt.Sprintf("'spec.teamAccess.custom' cannot be used when 'spec.teamAccess' is set to %s", ta.Access), + )) + } + } + } + + return allErrs +} + +func (p *Project) validateSpecTeamAccessTeam() field.ErrorList { + allErrs := field.ErrorList{} + + tai := make(map[string]int) + tan := make(map[string]int) + + for i, ta := range p.Spec.TeamAccess { + f := field.NewPath("spec").Child(fmt.Sprintf("[%d]", i)) + if ta.Team.ID == "" && ta.Team.Name == "" { + allErrs = append(allErrs, field.Invalid( + f, + "", + "one of the field ID or Name must be set"), + ) + } + + if ta.Team.ID != "" && ta.Team.Name != "" { + allErrs = append(allErrs, field.Invalid( + f, + "", + "only one of the field ID or Name is allowed"), + ) + } + + if ta.Team.ID != "" { + if _, ok := tai[ta.Team.ID]; ok { + allErrs = append(allErrs, field.Duplicate(f.Child("ID"), ta.Team.ID)) + } + tai[ta.Team.ID] = i + } + + if ta.Team.Name != "" { + if _, ok := tan[ta.Team.Name]; ok { + allErrs = append(allErrs, field.Duplicate(f.Child("Name"), ta.Team.Name)) + } + tan[ta.Team.Name] = i + } + } + + return allErrs +} diff --git a/api/v1alpha2/project_validation_test.go b/api/v1alpha2/project_validation_test.go new file mode 100644 index 00000000..c9d92ff3 --- /dev/null +++ b/api/v1alpha2/project_validation_test.go @@ -0,0 +1,207 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + "testing" + + tfc "github.com/hashicorp/go-tfe" +) + +func TestValidateSpecTeamAccess(t *testing.T) { + t.Parallel() + + successCases := map[string]Project{ + "CustomTeamAccessCustomsIsSet": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Access: tfc.TeamProjectAccessCustom, + Custom: &CustomProjectPermissions{ + CreateWorkspace: true, + }, + }, + }, + }, + }, + "AdminTeamAccessCustomIsNotSet": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Access: tfc.TeamProjectAccessAdmin, + Custom: nil, + }, + }, + }, + }, + } + + for n, c := range successCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecTeamAccessCustom(); len(errs) != 0 { + t.Errorf("Unexpected validation errors: %v", errs) + } + }) + } + + errorCases := map[string]Project{ + "CustomTeamAccessCustomIsNotSet": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Access: tfc.TeamProjectAccessCustom, + Custom: nil, + }, + }, + }, + }, + "AdminTeamAccessCustomIsSet": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Access: tfc.TeamProjectAccessAdmin, + Custom: &CustomProjectPermissions{ + CreateWorkspace: true, + }, + }, + }, + }, + }, + } + + for n, c := range errorCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecTeamAccessCustom(); len(errs) == 0 { + t.Error("Unexpected failure, at least one error is expected") + } + }) + } +} + +func TestValidateSpecTeamAccessTeam(t *testing.T) { + t.Parallel() + + successCases := map[string]Project{ + "HasTeamsWithID": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + ID: "this", + }, + }, + { + Team: Team{ + ID: "self", + }, + }, + }, + }, + }, + "HasTeamsWithName": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + Name: "this", + }, + }, + { + Team: Team{ + Name: "self", + }, + }, + }, + }, + }, + "HasTeamsWithIDandName": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + ID: "this", + }, + }, + { + Team: Team{ + Name: "self", + }, + }, + }, + }, + }, + } + + for n, c := range successCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecTeamAccessTeam(); len(errs) != 0 { + t.Errorf("Unexpected validation errors: %v", errs) + } + }) + } + + errorCases := map[string]Project{ + "HasTeamsWithDuplicateID": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + ID: "this", + }, + }, + { + Team: Team{ + ID: "this", + }, + }, + }, + }, + }, + "HasTeamsWithDuplicateName": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + Name: "this", + }, + }, + { + Team: Team{ + Name: "this", + }, + }, + }, + }, + }, + "HasTeamWithIDandName": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{ + ID: "this", + Name: "this", + }, + }, + }, + }, + }, + "HasTeamWithoutIDandName": { + Spec: ProjectSpec{ + TeamAccess: []*ProjectTeamAccess{ + { + Team: Team{}, + }, + }, + }, + }, + } + + for n, c := range errorCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecTeamAccessTeam(); 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 32dccdde..1303753b 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -298,6 +298,21 @@ func (in *CustomPermissions) DeepCopy() *CustomPermissions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomProjectPermissions) DeepCopyInto(out *CustomProjectPermissions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomProjectPermissions. +func (in *CustomProjectPermissions) DeepCopy() *CustomProjectPermissions { + if in == nil { + return nil + } + out := new(CustomProjectPermissions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Module) DeepCopyInto(out *Module) { *out = *in @@ -528,6 +543,128 @@ func (in *OutputStatus) DeepCopy() *OutputStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Project) DeepCopyInto(out *Project) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Project. +func (in *Project) DeepCopy() *Project { + if in == nil { + return nil + } + out := new(Project) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Project) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectList) DeepCopyInto(out *ProjectList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Project, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectList. +func (in *ProjectList) DeepCopy() *ProjectList { + if in == nil { + return nil + } + out := new(ProjectList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) { + *out = *in + in.Token.DeepCopyInto(&out.Token) + if in.TeamAccess != nil { + in, out := &in.TeamAccess, &out.TeamAccess + *out = make([]*ProjectTeamAccess, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(ProjectTeamAccess) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec. +func (in *ProjectSpec) DeepCopy() *ProjectSpec { + if in == nil { + return nil + } + out := new(ProjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectStatus) DeepCopyInto(out *ProjectStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectStatus. +func (in *ProjectStatus) DeepCopy() *ProjectStatus { + if in == nil { + return nil + } + out := new(ProjectStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectTeamAccess) DeepCopyInto(out *ProjectTeamAccess) { + *out = *in + out.Team = in.Team + if in.Custom != nil { + in, out := &in.Custom, &out.Custom + *out = new(CustomProjectPermissions) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectTeamAccess. +func (in *ProjectTeamAccess) DeepCopy() *ProjectTeamAccess { + if in == nil { + return nil + } + out := new(ProjectTeamAccess) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemoteStateSharing) DeepCopyInto(out *RemoteStateSharing) { *out = *in diff --git a/charts/terraform-cloud-operator/README.md b/charts/terraform-cloud-operator/README.md index 6ba04985..38c728ed 100644 --- a/charts/terraform-cloud-operator/README.md +++ b/charts/terraform-cloud-operator/README.md @@ -38,6 +38,7 @@ $ helm install demo hashicorp/terraform-cloud-operator \ --set 'operator.watchedNamespaces={white,blue,red}' \ --set controllers.agentPool.workers=5 \ --set controllers.module.workers=5 \ + --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 ``` @@ -66,6 +67,7 @@ $ helm upgrade demo hashicorp/terraform-cloud-operator \ --set operator.syncPeriod=5m \ --set controllers.agentPool.workers=5 \ --set controllers.module.workers=10 \ + --set controllers.project.workers=2 \ --set controllers.workspace.workers=20 ``` @@ -97,5 +99,6 @@ In the above example, the Operator will watch all namespaces in the Kubernetes c | kubeRbacProxy.resources.requests.memory | string | "64Mi" | Guaranteed minimum amount of memory to be used by a container. | | controllers.agentPool.workers | int | 1 | The number of the Agent Pool controller workers. | | controllers.module.workers | int | 1 | The number of the Module controller workers. | +| controllers.project.workers | int | 1 | The number of the Project controller workers. | | controllers.workspace.workers | int | 1 | The number of the Workspace controller workers. | | customCAcertificates | string | "" | Custom Certificate Authority bundle to validate API TLS certificates. Expects a path to a CRT file containing concatenated certificates. | diff --git a/charts/terraform-cloud-operator/crds/app.terraform.io_projects.yaml b/charts/terraform-cloud-operator/crds/app.terraform.io_projects.yaml new file mode 100644 index 00000000..f67d73c1 --- /dev/null +++ b/charts/terraform-cloud-operator/crds/app.terraform.io_projects.yaml @@ -0,0 +1,249 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.3 + creationTimestamp: null + name: projects.app.terraform.io +spec: + group: app.terraform.io + names: + kind: Project + listKind: ProjectList + plural: projects + singular: project + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.name + name: Project Name + type: string + - jsonPath: .status.id + name: Project ID + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: Project is the Schema for the projects API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: 'ProjectSpec defines the desired state of Project. More information: + - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects' + properties: + name: + description: Name of the Project. + minLength: 1 + type: string + organization: + description: 'Organization name where the Workspace will be created. + More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations' + minLength: 1 + type: string + teamAccess: + description: 'Terraform Cloud''s access model is team-based. In order + to perform an action within a Terraform Cloud organization, users + must belong to a team that has been granted the appropriate permissions. + You can assign project-specific permissions to teams. More information: + - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions' + items: + description: 'Terraform Cloud''s access model is team-based. In + order to perform an action within a Terraform Cloud organization, + users must belong to a team that has been granted the appropriate + permissions. You can assign project-specific permissions to teams. + More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions' + properties: + access: + description: 'There are two ways to choose which permissions + a given team has on a project: fixed permission sets, and + custom permissions. Must be one of the following values: `admin`, + `custom`, `maintain`, `read`, `write`. More information: - + https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-project-permissions' + enum: + - admin + - custom + - maintain + - read + - write + type: string + custom: + description: 'Custom permissions let you assign specific, finer-grained + permissions to a team than the broader fixed permission sets + provide. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions' + properties: + createWorkspace: + default: false + description: 'Allow users to create workspaces in the project. + This grants read access to all workspaces in the project. + Default: `false`.' + type: boolean + deleteWorkspace: + default: false + description: 'Allows users to delete workspaces in the project. + Default: `false`.' + type: boolean + lockWorkspace: + default: false + description: 'Allows users to manually lock the workspace + to temporarily prevent runs. When a workspace''s execution + mode is set to "local", users must have this permission + to perform local CLI runs using the workspace''s state. + Default: `false`.' + type: boolean + moveWorkspace: + default: false + description: 'Allows users to move workspaces out of the + project. A user must have this permission on both the + source and destination project to successfully move a + workspace from one project to another. Default: `false`.' + type: boolean + projectAccess: + default: read + description: 'Project access. Must be one of the following + values: `delete`, `read`, `update`. Default: `read`.' + enum: + - delete + - read + - update + type: string + runTasks: + description: 'Manage Workspace Run Tasks. Default: `false`.' + type: boolean + runs: + default: read + description: 'Run access. Must be one of the following values: + `apply`, `plan`, `read`. Default: `read`.' + enum: + - apply + - plan + - read + type: string + sentinelMocks: + default: none + description: 'Download Sentinel mocks. Must be one of the + following values: `none`, `read`. Default: `none`.' + enum: + - none + - read + type: string + stateVersions: + default: none + description: 'State access. Must be one of the following + values: `none`, `read`, `read-outputs`, `write`. Default: + `none`.' + enum: + - none + - read + - read-outputs + - write + type: string + teamManagement: + default: none + description: 'Team management. Must be one of the following + values: `manage`, `none`, `read`. Default: `none`.' + enum: + - manage + - none + - read + type: string + variables: + default: none + description: 'Variable access. Must be one of the following + values: `none`, `read`, `write`. Default: `none`.' + enum: + - none + - read + - write + type: string + type: object + team: + description: 'Team to grant access. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/teams' + properties: + id: + description: 'Team ID. Must match pattern: ^team-[a-zA-Z0-9]+$' + pattern: ^team-[a-zA-Z0-9]+$ + type: string + name: + description: Team name. + minLength: 1 + type: string + type: object + required: + - access + - team + type: object + minItems: 1 + type: array + token: + description: API Token to be used for API calls. + properties: + secretKeyRef: + description: Selects a key of a secret in the workspace's namespace + properties: + key: + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: Specify whether the Secret or its key must be + defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + required: + - secretKeyRef + type: object + required: + - name + - organization + - token + type: object + status: + description: ProjectStatus defines the observed state of Project. + properties: + id: + description: Project ID. + type: string + name: + description: Project name. + type: string + observedGeneration: + description: Real world state generation. + format: int64 + type: integer + required: + - id + - name + - observedGeneration + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/terraform-cloud-operator/templates/clusterrole.yaml b/charts/terraform-cloud-operator/templates/clusterrole.yaml index befa0fb6..ab902057 100644 --- a/charts/terraform-cloud-operator/templates/clusterrole.yaml +++ b/charts/terraform-cloud-operator/templates/clusterrole.yaml @@ -96,6 +96,32 @@ rules: - get - patch - update +- apiGroups: + - app.terraform.io + resources: + - projects + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.terraform.io + resources: + - projects/finalizers + verbs: + - update +- apiGroups: + - app.terraform.io + resources: + - projects/status + verbs: + - get + - patch + - update - apiGroups: - app.terraform.io resources: diff --git a/charts/terraform-cloud-operator/templates/deployment.yaml b/charts/terraform-cloud-operator/templates/deployment.yaml index 5579fe26..4b096672 100644 --- a/charts/terraform-cloud-operator/templates/deployment.yaml +++ b/charts/terraform-cloud-operator/templates/deployment.yaml @@ -29,6 +29,7 @@ spec: - --sync-period={{ .Values.operator.syncPeriod }} - --agent-pool-workers={{ .Values.controllers.agentPool.workers }} - --module-workers={{ .Values.controllers.module.workers }} + - --project-workers={{ .Values.controllers.project.workers }} - --workspace-workers={{ .Values.controllers.workspace.workers }} {{- range .Values.operator.watchedNamespaces }} - --namespace={{ . }} diff --git a/charts/terraform-cloud-operator/values.yaml b/charts/terraform-cloud-operator/values.yaml index ac7dee2c..29535348 100644 --- a/charts/terraform-cloud-operator/values.yaml +++ b/charts/terraform-cloud-operator/values.yaml @@ -53,6 +53,9 @@ controllers: module: # The number of the Module controller workers. workers: 1 + project: + # The number of the Project controller workers. + workers: 1 workspace: # The number of the Workspace controller workers. workers: 1 diff --git a/config/crd/bases/app.terraform.io_projects.yaml b/config/crd/bases/app.terraform.io_projects.yaml new file mode 100644 index 00000000..236e3ba1 --- /dev/null +++ b/config/crd/bases/app.terraform.io_projects.yaml @@ -0,0 +1,246 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.3 + creationTimestamp: null + name: projects.app.terraform.io +spec: + group: app.terraform.io + names: + kind: Project + listKind: ProjectList + plural: projects + singular: project + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.name + name: Project Name + type: string + - jsonPath: .status.id + name: Project ID + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: Project is the Schema for the projects API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: 'ProjectSpec defines the desired state of Project. More information: + - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects' + properties: + name: + description: Name of the Project. + minLength: 1 + type: string + organization: + description: 'Organization name where the Workspace will be created. + More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations' + minLength: 1 + type: string + teamAccess: + description: 'Terraform Cloud''s access model is team-based. In order + to perform an action within a Terraform Cloud organization, users + must belong to a team that has been granted the appropriate permissions. + You can assign project-specific permissions to teams. More information: + - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions' + items: + description: 'Terraform Cloud''s access model is team-based. In + order to perform an action within a Terraform Cloud organization, + users must belong to a team that has been granted the appropriate + permissions. You can assign project-specific permissions to teams. + More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions' + properties: + access: + description: 'There are two ways to choose which permissions + a given team has on a project: fixed permission sets, and + custom permissions. Must be one of the following values: `admin`, + `custom`, `maintain`, `read`, `write`. More information: - + https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-project-permissions' + enum: + - admin + - custom + - maintain + - read + - write + type: string + custom: + description: 'Custom permissions let you assign specific, finer-grained + permissions to a team than the broader fixed permission sets + provide. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions' + properties: + createWorkspace: + default: false + description: 'Allow users to create workspaces in the project. + This grants read access to all workspaces in the project. + Default: `false`.' + type: boolean + deleteWorkspace: + default: false + description: 'Allows users to delete workspaces in the project. + Default: `false`.' + type: boolean + lockWorkspace: + default: false + description: 'Allows users to manually lock the workspace + to temporarily prevent runs. When a workspace''s execution + mode is set to "local", users must have this permission + to perform local CLI runs using the workspace''s state. + Default: `false`.' + type: boolean + moveWorkspace: + default: false + description: 'Allows users to move workspaces out of the + project. A user must have this permission on both the + source and destination project to successfully move a + workspace from one project to another. Default: `false`.' + type: boolean + projectAccess: + default: read + description: 'Project access. Must be one of the following + values: `delete`, `read`, `update`. Default: `read`.' + enum: + - delete + - read + - update + type: string + runTasks: + description: 'Manage Workspace Run Tasks. Default: `false`.' + type: boolean + runs: + default: read + description: 'Run access. Must be one of the following values: + `apply`, `plan`, `read`. Default: `read`.' + enum: + - apply + - plan + - read + type: string + sentinelMocks: + default: none + description: 'Download Sentinel mocks. Must be one of the + following values: `none`, `read`. Default: `none`.' + enum: + - none + - read + type: string + stateVersions: + default: none + description: 'State access. Must be one of the following + values: `none`, `read`, `read-outputs`, `write`. Default: + `none`.' + enum: + - none + - read + - read-outputs + - write + type: string + teamManagement: + default: none + description: 'Team management. Must be one of the following + values: `manage`, `none`, `read`. Default: `none`.' + enum: + - manage + - none + - read + type: string + variables: + default: none + description: 'Variable access. Must be one of the following + values: `none`, `read`, `write`. Default: `none`.' + enum: + - none + - read + - write + type: string + type: object + team: + description: 'Team to grant access. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/teams' + properties: + id: + description: 'Team ID. Must match pattern: ^team-[a-zA-Z0-9]+$' + pattern: ^team-[a-zA-Z0-9]+$ + type: string + name: + description: Team name. + minLength: 1 + type: string + type: object + required: + - access + - team + type: object + minItems: 1 + type: array + token: + description: API Token to be used for API calls. + properties: + secretKeyRef: + description: Selects a key of a secret in the workspace's namespace + properties: + key: + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: Specify whether the Secret or its key must be + defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + required: + - secretKeyRef + type: object + required: + - name + - organization + - token + type: object + status: + description: ProjectStatus defines the observed state of Project. + properties: + id: + description: Project ID. + type: string + name: + description: Project name. + type: string + observedGeneration: + description: Real world state generation. + format: int64 + type: integer + required: + - id + - name + - observedGeneration + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1298de3b..109858bd 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/app.terraform.io_workspaces.yaml - bases/app.terraform.io_modules.yaml - bases/app.terraform.io_agentpools.yaml +- bases/app.terraform.io_projects.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -13,6 +14,7 @@ patchesStrategicMerge: #- patches/webhook_in_workspaces.yaml #- patches/webhook_in_modules.yaml #- patches/webhook_in_agentpools.yaml +#- patches/webhook_in_projects.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -20,6 +22,7 @@ patchesStrategicMerge: #- patches/cainjection_in_workspaces.yaml #- patches/cainjection_in_modules.yaml #- patches/cainjection_in_agentpools.yaml +#- patches/cainjection_in_projects.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_projects.yaml b/config/crd/patches/cainjection_in_projects.yaml new file mode 100644 index 00000000..1252edf3 --- /dev/null +++ b/config/crd/patches/cainjection_in_projects.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: projects.app.terraform.io diff --git a/config/crd/patches/webhook_in_projects.yaml b/config/crd/patches/webhook_in_projects.yaml new file mode 100644 index 00000000..3b5aeddd --- /dev/null +++ b/config/crd/patches/webhook_in_projects.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: projects.app.terraform.io +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/project_editor_role.yaml b/config/rbac/project_editor_role.yaml new file mode 100644 index 00000000..810f74ee --- /dev/null +++ b/config/rbac/project_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit projects. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: project-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: terraform-cloud-operator + app.kubernetes.io/part-of: terraform-cloud-operator + app.kubernetes.io/managed-by: kustomize + name: project-editor-role +rules: +- apiGroups: + - app.terraform.io + resources: + - projects + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.terraform.io + resources: + - projects/status + verbs: + - get diff --git a/config/rbac/project_viewer_role.yaml b/config/rbac/project_viewer_role.yaml new file mode 100644 index 00000000..b796827e --- /dev/null +++ b/config/rbac/project_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view projects. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: project-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: terraform-cloud-operator + app.kubernetes.io/part-of: terraform-cloud-operator + app.kubernetes.io/managed-by: kustomize + name: project-viewer-role +rules: +- apiGroups: + - app.terraform.io + resources: + - projects + verbs: + - get + - list + - watch +- apiGroups: + - app.terraform.io + resources: + - projects/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7564f5fc..a8523e09 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -108,6 +108,32 @@ rules: - get - patch - update +- apiGroups: + - app.terraforp.io + resources: + - projects + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.terraforp.io + resources: + - projects/finalizers + verbs: + - update +- apiGroups: + - app.terraforp.io + resources: + - projects/status + verbs: + - get + - patch + - update - apiGroups: - apps resources: diff --git a/config/samples/app_v1alpha2_project.yaml b/config/samples/app_v1alpha2_project.yaml new file mode 100644 index 00000000..6d0c755d --- /dev/null +++ b/config/samples/app_v1alpha2_project.yaml @@ -0,0 +1,12 @@ +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + labels: + app.kubernetes.io/name: project + app.kubernetes.io/instance: project-sample + app.kubernetes.io/part-of: terraform-cloud-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: terraform-cloud-operator + name: project-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 27166e6b..be7a2494 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,4 +3,5 @@ resources: - app_v1alpha2_workspace.yaml - app_v1alpha2_module.yaml - app_v1alpha2_agentpool.yaml +- app_v1alpha2_project.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controllers/consts.go b/controllers/consts.go index 12fea43a..e287c99c 100644 --- a/controllers/consts.go +++ b/controllers/consts.go @@ -56,6 +56,11 @@ output "{{ $o.Name }}" { ` ) +// PROJECT CONTROLLER'S CONSTANTS +const ( + projectFinalizer = "project.app.terraform.io/finalizer" +) + // WORKSPACE CONTROLLER'S CONSTANTS const ( workspaceFinalizerAlpha1 = "finalizer.workspace.app.terraform.io" diff --git a/controllers/project_controller.go b/controllers/project_controller.go new file mode 100644 index 00000000..3dbedbf5 --- /dev/null +++ b/controllers/project_controller.go @@ -0,0 +1,369 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/go-logr/logr" + tfc "github.com/hashicorp/go-tfe" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + appv1alpha2 "github.com/hashicorp/terraform-cloud-operator/api/v1alpha2" +) + +// ProjectReconciler reconciles a Project object +type ProjectReconciler struct { + client.Client + Recorder record.EventRecorder + Scheme *runtime.Scheme +} + +type projectInstance struct { + instance appv1alpha2.Project + + log logr.Logger + tfClient TerraformCloudClient +} + +//+kubebuilder:rbac:groups=app.terraforp.io,resources=projects,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=app.terraforp.io,resources=projects/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=app.terraforp.io,resources=projects/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + p := projectInstance{} + + p.log = log.Log.WithValues("project", req.NamespacedName) + p.log.Info("Project Controller", "msg", "new reconciliation event") + + err := r.Client.Get(ctx, req.NamespacedName, &p.instance) + if err != nil { + // 'Not found' error occurs when an object is removed from the Kubernetes + // No actions are required in this case + if errors.IsNotFound(err) { + p.log.Info("Project Controller", "msg", "the instance was removed no further action is required") + return doNotRequeue() + } + p.log.Error(err, "Project Controller", "msg", "get instance object") + return requeueAfter(requeueInterval) + } + + p.log.Info("Spec Validation", "msg", "validating instance object spec") + if err := p.instance.ValidateSpec(); err != nil { + p.log.Error(err, "Spec Validation", "msg", "spec is invalid, exit from reconciliation") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "SpecValidation", err.Error()) + return doNotRequeue() + } + p.log.Info("Spec Validation", "msg", "spec is valid") + + if needToAddFinalizer(&p.instance, projectFinalizer) { + err := r.addFinalizer(ctx, &p.instance) + if err != nil { + p.log.Error(err, "Project Controller", "msg", fmt.Sprintf("failed to add finalizer %s to the object", projectFinalizer)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "AddFinalizer", "Failed to add finalizer %s to the object", projectFinalizer) + return requeueOnErr(err) + } + p.log.Info("Project Controller", "msg", fmt.Sprintf("successfully added finalizer %s to the object", projectFinalizer)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeNormal, "AddFinalizer", "Successfully added finalizer %s to the object", projectFinalizer) + } + + err = r.getTerraformClient(ctx, &p) + if err != nil { + p.log.Error(err, "Project Controller", "msg", "failed to get terraform cloud client") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "TerraformClient", "Failed to get Terraform Client") + return requeueAfter(requeueInterval) + } + + err = r.reconcileProject(ctx, &p) + if err != nil { + p.log.Error(err, "Project Controller", "msg", "reconcile project") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to reconcile project") + return requeueAfter(requeueInterval) + } + p.log.Info("Project Controller", "msg", "successfully reconcilied project") + r.Recorder.Eventf(&p.instance, corev1.EventTypeNormal, "ReconcileProject", "Successfully reconcilied project ID %s", p.instance.Status.ID) + + return doNotRequeue() +} + +func (r *ProjectReconciler) addFinalizer(ctx context.Context, instance *appv1alpha2.Project) error { + controllerutil.AddFinalizer(instance, projectFinalizer) + + return r.Update(ctx, instance) +} + +func (r *ProjectReconciler) getSecret(ctx context.Context, name types.NamespacedName) (*corev1.Secret, error) { + secret := &corev1.Secret{} + err := r.Client.Get(ctx, name, secret) + + return secret, err +} + +func (r *ProjectReconciler) getToken(ctx context.Context, instance *appv1alpha2.Project) (string, error) { + var secret *corev1.Secret + + secretName := instance.Spec.Token.SecretKeyRef.Name + secretKey := instance.Spec.Token.SecretKeyRef.Key + + objectKey := types.NamespacedName{ + Namespace: instance.Namespace, + Name: secretName, + } + secret, err := r.getSecret(ctx, objectKey) + if err != nil { + return "", err + } + + if token, ok := secret.Data[secretKey]; ok { + return strings.TrimSuffix(string(token), "\n"), nil + } + return "", fmt.Errorf("token key %s does not exist in the secret %s", secretKey, secretName) +} + +func (r *ProjectReconciler) getTerraformClient(ctx context.Context, p *projectInstance) error { + token, err := r.getToken(ctx, &p.instance) + if err != nil { + return err + } + + httpClient := tfc.DefaultConfig().HTTPClient + insecure := false + + if v, ok := os.LookupEnv("TFC_TLS_SKIP_VERIFY"); ok { + insecure, err = strconv.ParseBool(v) + if err != nil { + return err + } + } + + if insecure { + p.log.Info("Reconcile Project", "msg", "client configured to skip TLS certificate verifications") + } + + httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: insecure} + + config := &tfc.Config{ + Token: token, + HTTPClient: httpClient, + } + p.tfClient.Client, err = tfc.NewClient(config) + + return err +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&appv1alpha2.Project{}). + WithEventFilter(handlePredicates()). + Complete(r) +} + +func (r *ProjectReconciler) updateStatus(ctx context.Context, p *projectInstance, project *tfc.Project) error { + p.instance.Status.ObservedGeneration = p.instance.Generation + p.instance.Status.ID = project.ID + p.instance.Status.Name = project.Name + + return r.Status().Update(ctx, &p.instance) +} + +func (r *ProjectReconciler) removeFinalizer(ctx context.Context, p *projectInstance) error { + controllerutil.RemoveFinalizer(&p.instance, projectFinalizer) + + err := r.Update(ctx, &p.instance) + if err != nil { + p.log.Error(err, "Reconcile Project", "msg", fmt.Sprintf("failed to remove finazlier %s", projectFinalizer)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "RemoveProject", "Failed to remove finazlier %s", projectFinalizer) + } + + return err +} + +func needToUpdateProject(instance *appv1alpha2.Project, project *tfc.Project) bool { + // generation changed + if instance.Generation != instance.Status.ObservedGeneration { + return true + } + + // name changed + if instance.Spec.Name != project.Name { + return true + } + + return false +} + +func (r *ProjectReconciler) createProject(ctx context.Context, p *projectInstance) (*tfc.Project, error) { + spec := p.instance.Spec + options := tfc.ProjectCreateOptions{ + Name: spec.Name, + } + + project, err := p.tfClient.Client.Projects.Create(ctx, spec.Organization, options) + if err != nil { + p.log.Error(err, "Reconcile Project", "msg", "failed to create a new project") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to create a new project") + return nil, err + } + + p.instance.Status = appv1alpha2.ProjectStatus{ + ID: project.ID, + } + + return project, nil +} + +func (r *ProjectReconciler) readProject(ctx context.Context, p *projectInstance) (*tfc.Project, error) { + return p.tfClient.Client.Projects.Read(ctx, p.instance.Status.ID) +} + +func (r *ProjectReconciler) updateProject(ctx context.Context, p *projectInstance, project *tfc.Project) (*tfc.Project, error) { + updateOptions := tfc.ProjectUpdateOptions{} + spec := p.instance.Spec + + if project.Name != spec.Name { + updateOptions.Name = tfc.String(spec.Name) + } + + return p.tfClient.Client.Projects.Update(ctx, p.instance.Status.ID, updateOptions) +} + +func (r *ProjectReconciler) deleteProject(ctx context.Context, p *projectInstance) error { + // if the Kubernetes object doesn't have project ID, it means it a project was never created + // in this case, remove the finalizer and let Kubernetes remove the object permanently + if p.instance.Status.ID == "" { + p.log.Info("Reconcile Project", "msg", fmt.Sprintf("status.ID is empty, remove finazlier %s", projectFinalizer)) + return r.removeFinalizer(ctx, p) + } + err := p.tfClient.Client.Projects.Delete(ctx, p.instance.Status.ID) + if err != nil { + // if project wasn't found, it means it was deleted from the TF Cloud bypass the operator + // in this case, remove the finalizer and let Kubernetes remove the object permanently + if err == tfc.ErrResourceNotFound { + p.log.Info("Reconcile Project", "msg", fmt.Sprintf("Project ID %s not found, remove finazlier", p.instance.Status.ID)) + return r.removeFinalizer(ctx, p) + } + p.log.Error(err, "Reconcile Project", "msg", fmt.Sprintf("failed to delete Project ID %s, retry later", projectFinalizer)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to delete Project ID %s, retry later", p.instance.Status.ID) + return err + } + + p.log.Info("Reconcile Project", "msg", fmt.Sprintf("project ID %s has been deleted, remove finazlier", p.instance.Status.ID)) + return r.removeFinalizer(ctx, p) +} + +func (r *ProjectReconciler) reconcileProject(ctx context.Context, p *projectInstance) error { + p.log.Info("Reconcile Project", "msg", "reconciling project") + + var project *tfc.Project + var err error + + defer func() { + // Update the status with the Project ID. This is useful if the reconciliation failed. + // An example here would be the case when the project has been created successfully, + // but further reconciliation steps failed. + // + // If a Project creation operation failed, we don't have a project object + // and thus don't update the status. An example here would be the case when the project name has already been taken. + // + // Cannot call updateStatus method since it updated multiple fields and can break reconciliation logic. + // + // TODO: + // - Use conditions(https://maelvls.dev/kubernetes-conditions/) + // - Let Objects update their own status conditions + // - Simplify updateStatus method in a way it could be called anytime + if project != nil && project.ID != "" { + p.instance.Status.ID = project.ID + err = r.Status().Update(ctx, &p.instance) + if err != nil { + p.log.Error(err, "Project Controller", "msg", "update status with project ID") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to update status with project ID") + } + } + }() + + // verify whether the Kubernetes object has been marked as deleted and if so delete the project + if isDeletionCandidate(&p.instance, projectFinalizer) { + p.log.Info("Reconcile Project", "msg", "object marked as deleted, need to delete project first") + r.Recorder.Event(&p.instance, corev1.EventTypeNormal, "ReconcileProject", "Object marked as deleted, need to delete project first") + return r.deleteProject(ctx, p) + } + + // create a new project if project ID is unknown(means it was never created by the controller) + // this condition will work just one time, when a new Kubernetes object is created + if p.instance.IsCreationCandidate() { + p.log.Info("Reconcile Project", "msg", "status.ID is empty, creating a new project") + r.Recorder.Event(&p.instance, corev1.EventTypeNormal, "ReconcileProject", "Status.ID is empty, creating a new project") + _, err = r.createProject(ctx, p) + if err != nil { + p.log.Error(err, "Reconcile Project", "msg", "failed to create a new project") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to create a new project") + return err + } + p.log.Info("Reconcile Project", "msg", "successfully created a new project") + r.Recorder.Eventf(&p.instance, corev1.EventTypeNormal, "ReconcileProject", "Successfully created a new project with ID %s", p.instance.Status.ID) + } + + // read the Terraform Cloud project to compare it with the Kubernetes object spec + project, err = r.readProject(ctx, p) + if err != nil { + // 'ResourceNotFound' means that the TF Cloud project was removed from the TF Cloud bypass the operator + if err == tfc.ErrResourceNotFound { + p.log.Info("Reconcile Project", "msg", "project not found, creating a new project") + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Project ID %s not found, creating a new project", p.instance.Status.ID) + project, err = r.createProject(ctx, p) + if err != nil { + p.log.Error(err, "Reconcile Project", "msg", "failed to create a new project") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to create a new project") + return err + } + p.log.Info("Reconcile Project", "msg", "successfully created a new project") + r.Recorder.Eventf(&p.instance, corev1.EventTypeNormal, "ReconcileProject", "Successfully created a new project with ID %s", p.instance.Status.ID) + } else { + p.log.Error(err, "Reconcile Project", "msg", fmt.Sprintf("failed to read project ID %s", p.instance.Status.ID)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to read project ID %s", p.instance.Status.ID) + return err + } + } + + // update project if any changes have been made in the Kubernetes object spec or Terraform Cloud project + if needToUpdateProject(&p.instance, project) { + p.log.Info("Reconcile Project", "msg", fmt.Sprintf("observed and desired states are not matching, need to update project ID %s", p.instance.Status.ID)) + project, err = r.updateProject(ctx, p, project) + if err != nil { + p.log.Error(err, "Reconcile Project", "msg", fmt.Sprintf("failed to update project ID %s", p.instance.Status.ID)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "ReconcileProject", "Failed to update Project ID %s", p.instance.Status.ID) + return err + } + } else { + p.log.Info("Reconcile Project", "msg", fmt.Sprintf("observed and desired states are matching, no need to update Project ID %s", p.instance.Status.ID)) + } + + // Reconcile Team Access + err = r.reconcileTeamAccess(ctx, p) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", fmt.Sprintf("failed to reconcile team access in project ID %s", p.instance.Status.ID)) + r.Recorder.Eventf(&p.instance, corev1.EventTypeWarning, "ReconcileTeamAccess", "Failed to reconcile team access in project ID %s", p.instance.Status.ID) + return err + } + p.log.Info("Reconcile Team Access", "msg", "successfully reconcilied team access") + r.Recorder.Eventf(&p.instance, corev1.EventTypeNormal, "ReconcileTeamAccess", "Reconcilied team access in project ID %s", p.instance.Status.ID) + + return r.updateStatus(ctx, p, project) +} diff --git a/controllers/project_controller_team_access.go b/controllers/project_controller_team_access.go new file mode 100644 index 00000000..98d2d981 --- /dev/null +++ b/controllers/project_controller_team_access.go @@ -0,0 +1,278 @@ +// // Copyright (c) HashiCorp, Inc. +// // SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "context" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + tfc "github.com/hashicorp/go-tfe" + corev1 "k8s.io/api/core/v1" +) + +func (r *ProjectReconciler) getInstanceTeamAccess(ctx context.Context, p *projectInstance) (map[string]*tfc.TeamProjectAccess, error) { + o := map[string]*tfc.TeamProjectAccess{} + + if p.instance.Spec.TeamAccess == nil { + return o, nil + } + + teams, err := r.getTeams(ctx, p) + if err != nil { + return o, err + } + + for _, ta := range p.instance.Spec.TeamAccess { + tID, err := getTeamID(teams, ta.Team) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to get team ID") + r.Recorder.Event(&p.instance, corev1.EventTypeWarning, "ReconcileTeamAccess", "Failed to get team ID") + return o, err + } + + o[tID] = &tfc.TeamProjectAccess{ + Access: ta.Access, + Team: &tfc.Team{ + ID: tID, + }, + } + if ta.Access == tfc.TeamProjectAccessCustom { + o[tID].WorkspaceAccess = &tfc.TeamProjectAccessWorkspacePermissions{ + WorkspaceRunsPermission: ta.Custom.Runs, + WorkspaceSentinelMocksPermission: ta.Custom.SentinelMocks, + WorkspaceStateVersionsPermission: ta.Custom.StateVersions, + WorkspaceVariablesPermission: ta.Custom.Variables, + WorkspaceCreatePermission: ta.Custom.CreateWorkspace, + WorkspaceLockingPermission: ta.Custom.LockWorkspace, + WorkspaceMovePermission: ta.Custom.MoveWorkspace, + WorkspaceDeletePermission: ta.Custom.DeleteWorkspace, + WorkspaceRunTasksPermission: ta.Custom.RunTasks, + } + o[tID].ProjectAccess = &tfc.TeamProjectAccessProjectPermissions{ + ProjectSettingsPermission: ta.Custom.ProjectAccess, + ProjectTeamsPermission: ta.Custom.TeamManagement, + } + } + } + + return o, nil +} + +func (r *ProjectReconciler) getWorkspaceTeamAccess(ctx context.Context, p *projectInstance) (map[string]*tfc.TeamProjectAccess, error) { + o := map[string]*tfc.TeamProjectAccess{} + + t, err := p.tfClient.Client.TeamProjectAccess.List(ctx, tfc.TeamProjectAccessListOptions{ProjectID: p.instance.Status.ID}) + if err != nil { + return o, err + } + + for _, ta := range t.Items { + o[ta.Team.ID] = ta + } + return o, nil +} + +func (r *ProjectReconciler) getTeams(ctx context.Context, p *projectInstance) (map[string]*tfc.Team, error) { + teams := make(map[string]*tfc.Team) + + fTeams := []string{} + for _, t := range p.instance.Spec.TeamAccess { + if t.Team.Name != "" { + fTeams = append(fTeams, t.Team.Name) + } + } + + tl, err := p.tfClient.Client.Teams.List(ctx, p.instance.Spec.Organization, &tfc.TeamListOptions{ + Names: fTeams, + }) + if err != nil { + return teams, err + } + + for _, t := range tl.Items { + teams[t.Name] = t + } + + return teams, nil +} + +func teamProjectAccessDifference(a, b map[string]*tfc.TeamProjectAccess) map[string]*tfc.TeamProjectAccess { + d := make(map[string]*tfc.TeamProjectAccess) + + for k, v := range a { + if _, ok := b[k]; !ok { + d[k] = v + } + } + + return d +} + +func getTeamProjectAccessToCreate(ctx context.Context, specTeamAccess, projectTeamAccess map[string]*tfc.TeamProjectAccess) map[string]*tfc.TeamProjectAccess { + return teamProjectAccessDifference(specTeamAccess, projectTeamAccess) +} + +func getTeamProjectAccessToDelete(ctx context.Context, specTeamAccess, projectTeamAccess map[string]*tfc.TeamProjectAccess) map[string]*tfc.TeamProjectAccess { + return teamProjectAccessDifference(projectTeamAccess, specTeamAccess) +} + +func getTeamProjectAccessToUpdate(ctx context.Context, specTeamAccess, workspaceTeamAccess map[string]*tfc.TeamProjectAccess) map[string]*tfc.TeamProjectAccess { + ta := make(map[string]*tfc.TeamProjectAccess) + + if len(specTeamAccess) == 0 || len(workspaceTeamAccess) == 0 { + return ta + } + + for ik, iv := range specTeamAccess { + if wv, ok := workspaceTeamAccess[ik]; ok { + iv.ID = wv.ID + if iv.Access == tfc.TeamProjectAccessCustom { + if !cmp.Equal(iv, wv, cmpopts.IgnoreFields(tfc.TeamProjectAccess{}, "ID", "Team", "Project")) { + ta[ik] = iv + } + } else { + if iv.Access != wv.Access { + ta[ik] = iv + } + } + } + } + + return ta +} + +func (r *ProjectReconciler) createTeamProjectAccess(ctx context.Context, p *projectInstance, createTeamAccess map[string]*tfc.TeamProjectAccess) error { + projectID := p.instance.Status.ID + for tID, v := range createTeamAccess { + option := tfc.TeamProjectAccessAddOptions{ + Project: &tfc.Project{ + ID: projectID, + }, + Team: &tfc.Team{ + ID: tID, + }, + Access: v.Access, + } + + if v.Access == tfc.TeamProjectAccessCustom { + option.ProjectAccess = &tfc.TeamProjectAccessProjectPermissionsOptions{ + Settings: &v.ProjectAccess.ProjectSettingsPermission, + Teams: &v.ProjectAccess.ProjectTeamsPermission, + } + option.WorkspaceAccess = &tfc.TeamProjectAccessWorkspacePermissionsOptions{ + Runs: &v.WorkspaceAccess.WorkspaceRunsPermission, + SentinelMocks: &v.WorkspaceAccess.WorkspaceSentinelMocksPermission, + StateVersions: &v.WorkspaceAccess.WorkspaceStateVersionsPermission, + Variables: &v.WorkspaceAccess.WorkspaceVariablesPermission, + Create: &v.WorkspaceAccess.WorkspaceCreatePermission, + Locking: &v.WorkspaceAccess.WorkspaceLockingPermission, + Move: &v.WorkspaceAccess.WorkspaceMovePermission, + Delete: &v.WorkspaceAccess.WorkspaceDeletePermission, + RunTasks: &v.WorkspaceAccess.WorkspaceRunTasksPermission, + } + } + + _, err := p.tfClient.Client.TeamProjectAccess.Add(ctx, option) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to create a new team access") + return err + } + } + + return nil +} + +func (r *ProjectReconciler) deleteTeamAccess(ctx context.Context, p *projectInstance, deleteTeamAccess map[string]*tfc.TeamProjectAccess) error { + for _, v := range deleteTeamAccess { + err := p.tfClient.Client.TeamProjectAccess.Remove(ctx, v.ID) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to delete team access") + return err + } + } + + return nil +} + +func (r *ProjectReconciler) updateTeamAccess(ctx context.Context, p *projectInstance, updateTeamAccess map[string]*tfc.TeamProjectAccess) error { + for _, v := range updateTeamAccess { + p.log.Info("Reconcile Team Access", "msg", "updating team access") + option := tfc.TeamProjectAccessUpdateOptions{ + Access: &v.Access, + } + + if v.Access == tfc.TeamProjectAccessCustom { + option.ProjectAccess = &tfc.TeamProjectAccessProjectPermissionsOptions{ + Settings: &v.ProjectAccess.ProjectSettingsPermission, + Teams: &v.ProjectAccess.ProjectTeamsPermission, + } + option.WorkspaceAccess = &tfc.TeamProjectAccessWorkspacePermissionsOptions{ + Runs: &v.WorkspaceAccess.WorkspaceRunsPermission, + SentinelMocks: &v.WorkspaceAccess.WorkspaceSentinelMocksPermission, + StateVersions: &v.WorkspaceAccess.WorkspaceStateVersionsPermission, + Variables: &v.WorkspaceAccess.WorkspaceVariablesPermission, + Create: &v.WorkspaceAccess.WorkspaceCreatePermission, + Locking: &v.WorkspaceAccess.WorkspaceLockingPermission, + Move: &v.WorkspaceAccess.WorkspaceMovePermission, + Delete: &v.WorkspaceAccess.WorkspaceDeletePermission, + RunTasks: &v.WorkspaceAccess.WorkspaceRunTasksPermission, + } + } + + _, err := p.tfClient.Client.TeamProjectAccess.Update(ctx, v.ID, option) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to update team access") + return err + } + } + + return nil +} + +func (r *ProjectReconciler) reconcileTeamAccess(ctx context.Context, p *projectInstance) error { + p.log.Info("Reconcile Team Access", "msg", "new reconciliation event") + + specTeamAccess, err := r.getInstanceTeamAccess(ctx, p) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to get instance team access") + return err + } + + projectTeamAccess, err := r.getWorkspaceTeamAccess(ctx, p) + if err != nil { + p.log.Error(err, "Reconcile Team Access", "msg", "failed to get project team access") + return err + } + + createTeamAccess := getTeamProjectAccessToCreate(ctx, specTeamAccess, projectTeamAccess) + if len(createTeamAccess) > 0 { + p.log.Info("Reconcile Team Access", "msg", fmt.Sprintf("creating %d team accesses", len(createTeamAccess))) + err := r.createTeamProjectAccess(ctx, p, createTeamAccess) + if err != nil { + return err + } + } + + updateTeamAccess := getTeamProjectAccessToUpdate(ctx, specTeamAccess, projectTeamAccess) + if len(updateTeamAccess) > 0 { + p.log.Info("Reconcile Team Access", "msg", fmt.Sprintf("updating %d team accesses", len(updateTeamAccess))) + err := r.updateTeamAccess(ctx, p, updateTeamAccess) + if err != nil { + return err + } + } + + deleteTeamAccess := getTeamProjectAccessToDelete(ctx, specTeamAccess, projectTeamAccess) + if len(deleteTeamAccess) > 0 { + p.log.Info("Reconcile Team Access", "msg", fmt.Sprintf("deleting %d team accesses", len(deleteTeamAccess))) + err := r.deleteTeamAccess(ctx, p, deleteTeamAccess) + if err != nil { + return err + } + } + + return nil +} diff --git a/controllers/project_controller_team_access_test.go b/controllers/project_controller_team_access_test.go new file mode 100644 index 00000000..6ef0cd8e --- /dev/null +++ b/controllers/project_controller_team_access_test.go @@ -0,0 +1,289 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "fmt" + "time" + + tfc "github.com/hashicorp/go-tfe" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + appv1alpha2 "github.com/hashicorp/terraform-cloud-operator/api/v1alpha2" +) + +var _ = Describe("Project controller", Ordered, func() { + var ( + instance *appv1alpha2.Project + team *tfc.Team + project = fmt.Sprintf("kubernetes-operator-%v", GinkgoRandomSeed()) + ) + + BeforeAll(func() { + // Set default Eventually timers + SetDefaultEventuallyTimeout(syncPeriod * 4) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + // Create new teams + team = createTeam(fmt.Sprintf("%s-team", project)) + }) + + AfterAll(func() { + err := tfClient.Teams.Delete(ctx, team.ID) + Expect(err).Should(Succeed()) + }) + + BeforeEach(func() { + // Create a new project object for each test + instance = &appv1alpha2.Project{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "app.terraform.io/v1alpha2", + Kind: "Project", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + DeletionTimestamp: nil, + Finalizers: []string{}, + }, + Spec: appv1alpha2.ProjectSpec{ + Organization: organization, + Token: appv1alpha2.Token{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: namespacedName.Name, + }, + Key: secretKey, + }, + }, + Name: project, + }, + Status: appv1alpha2.ProjectStatus{}, + } + }) + + AfterEach(func() { + // Delete the Kubernetes Project object and wait until the controller finishes the reconciliation after deletion of the object + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + Eventually(func() bool { + err := k8sClient.Get(ctx, namespacedName, instance) + // The Kubernetes client will return error 'NotFound' on the Get operation once the object is deleted + return errors.IsNotFound(err) + }).Should(BeTrue()) + + // Make sure that the Terraform Cloud project is deleted + Eventually(func() bool { + err := tfClient.Projects.Delete(ctx, instance.Status.ID) + // The Terraform Cloud client will return the error 'ResourceNotFound' once the project does not exist + return err == tfc.ErrResourceNotFound || err == nil + }).Should(BeTrue()) + }) + + Context("Project Team Access", func() { + It("can handle pre-set team access", func() { + instance.Spec.TeamAccess = []*appv1alpha2.ProjectTeamAccess{ + { + Team: appv1alpha2.Team{ + Name: team.Name, + }, + Access: tfc.TeamProjectAccessAdmin, + }, + } + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + isProjectTeamAccessReconciled(instance) + + prjTeamAccess := buildProjectTeamAccessByName(instance.Status.ID, nil) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + }) + + It("can handle custom team access", func() { + instance.Spec.TeamAccess = []*appv1alpha2.ProjectTeamAccess{ + { + Team: appv1alpha2.Team{ + Name: team.Name, + }, + Access: tfc.TeamProjectAccessCustom, + Custom: &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + }, + }, + } + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + isProjectTeamAccessReconciled(instance) + + prjTeamAccess := buildProjectTeamAccessByName(instance.Status.ID, &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + TeamManagement: "none", + CreateWorkspace: false, + DeleteWorkspace: false, + MoveWorkspace: false, + LockWorkspace: false, + Runs: "read", + RunTasks: false, + SentinelMocks: "none", + StateVersions: "none", + Variables: "none", + }) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + }) + + It("can handle update from pre-set to custom team access", func() { + instance.Spec.TeamAccess = []*appv1alpha2.ProjectTeamAccess{ + { + Team: appv1alpha2.Team{ + Name: team.Name, + }, + Access: tfc.TeamProjectAccessAdmin, + }, + } + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + isProjectTeamAccessReconciled(instance) + + prjTeamAccess := buildProjectTeamAccessByName(instance.Status.ID, nil) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + + // UPDATE TEAM ACCESS + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.TeamAccess = []*appv1alpha2.ProjectTeamAccess{ + { + Team: appv1alpha2.Team{ + ID: team.ID, + }, + Access: tfc.TeamProjectAccessCustom, + Custom: &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + }, + }, + } + + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ObservedGeneration == instance.Generation + }).Should(BeTrue()) + + prjTeamAccess = buildProjectTeamAccessByID(instance.Status.ID, &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + TeamManagement: "none", + CreateWorkspace: false, + DeleteWorkspace: false, + MoveWorkspace: false, + LockWorkspace: false, + Runs: "read", + RunTasks: false, + SentinelMocks: "none", + StateVersions: "none", + Variables: "none", + }) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + }) + + It("can handle update from custom to pre-set team access", func() { + instance.Spec.TeamAccess = append(instance.Spec.TeamAccess, &appv1alpha2.ProjectTeamAccess{ + Team: appv1alpha2.Team{ + Name: team.Name, + }, + Access: tfc.TeamProjectAccessCustom, + Custom: &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + }, + }) + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + isProjectTeamAccessReconciled(instance) + + prjTeamAccess := buildProjectTeamAccessByName(instance.Status.ID, &appv1alpha2.CustomProjectPermissions{ + ProjectAccess: tfc.ProjectSettingsPermissionRead, + TeamManagement: "none", + CreateWorkspace: false, + DeleteWorkspace: false, + MoveWorkspace: false, + LockWorkspace: false, + Runs: "read", + RunTasks: false, + SentinelMocks: "none", + StateVersions: "none", + Variables: "none", + }) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + + // UPDATE TEAM ACCESS + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.TeamAccess = []*appv1alpha2.ProjectTeamAccess{ + { + Team: appv1alpha2.Team{ + ID: team.ID, + }, + Access: tfc.TeamProjectAccessAdmin, + }, + } + + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ObservedGeneration == instance.Generation + }).Should(BeTrue()) + + prjTeamAccess = buildProjectTeamAccessByID(instance.Status.ID, nil) + Expect(prjTeamAccess).Should(ConsistOf(instance.Spec.TeamAccess)) + }) + }) +}) + +func isProjectTeamAccessReconciled(instance *appv1alpha2.Project) { + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + Expect(instance.Spec.TeamAccess).ShouldNot(BeNil()) + + teamAccesses, err := tfClient.TeamProjectAccess.List(ctx, tfc.TeamProjectAccessListOptions{ProjectID: instance.Status.ID}) + Expect(err).Should(Succeed()) + Expect(teamAccesses).ShouldNot(BeNil()) + + return len(teamAccesses.Items) == len(instance.Spec.TeamAccess) + }).Should(BeTrue()) +} + +func buildProjectTeamAccessByName(ID string, custom *appv1alpha2.CustomProjectPermissions) []*appv1alpha2.ProjectTeamAccess { + return buildProjectTeamAccess(ID, true, custom) +} + +func buildProjectTeamAccessByID(ID string, custom *appv1alpha2.CustomProjectPermissions) []*appv1alpha2.ProjectTeamAccess { + return buildProjectTeamAccess(ID, false, custom) +} + +func buildProjectTeamAccess(ID string, withTeamName bool, custom *appv1alpha2.CustomProjectPermissions) []*appv1alpha2.ProjectTeamAccess { + teamAccesses, err := tfClient.TeamProjectAccess.List(ctx, tfc.TeamProjectAccessListOptions{ProjectID: ID}) + Expect(err).Should(Succeed()) + Expect(teamAccesses).ShouldNot(BeNil()) + + prjTeamAccess := make([]*appv1alpha2.ProjectTeamAccess, len(teamAccesses.Items)) + + for i, teamAccess := range teamAccesses.Items { + t, err := tfClient.Teams.Read(ctx, teamAccess.Team.ID) + Expect(err).Should(Succeed()) + Expect(t).ShouldNot(BeNil()) + team := appv1alpha2.Team{} + if withTeamName { + team.Name = t.Name + } else { + team.ID = t.ID + } + prjTeamAccess[i] = &appv1alpha2.ProjectTeamAccess{ + Team: team, + Access: teamAccess.Access, + Custom: custom, + } + } + + return prjTeamAccess +} diff --git a/controllers/project_controller_test.go b/controllers/project_controller_test.go new file mode 100644 index 00000000..c642e4aa --- /dev/null +++ b/controllers/project_controller_test.go @@ -0,0 +1,157 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controllers + +import ( + "fmt" + "time" + + tfc "github.com/hashicorp/go-tfe" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + appv1alpha2 "github.com/hashicorp/terraform-cloud-operator/api/v1alpha2" +) + +var _ = Describe("Project controller", Ordered, func() { + var ( + instance *appv1alpha2.Project + project = fmt.Sprintf("kubernetes-operator-%v", GinkgoRandomSeed()) + ) + + BeforeAll(func() { + if cloudEndpoint != tfcDefaultAddress { + Skip("Does not run against TFC, skip this test") + } + // Set default Eventually timers + SetDefaultEventuallyTimeout(syncPeriod * 4) + SetDefaultEventuallyPollingInterval(2 * time.Second) + }) + + BeforeEach(func() { + // Create a new project object for each test + instance = &appv1alpha2.Project{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "app.terraform.io/v1alpha2", + Kind: "Project", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + DeletionTimestamp: nil, + Finalizers: []string{}, + }, + Spec: appv1alpha2.ProjectSpec{ + Organization: organization, + Token: appv1alpha2.Token{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: namespacedName.Name, + }, + Key: secretKey, + }, + }, + Name: project, + }, + Status: appv1alpha2.ProjectStatus{}, + } + }) + + AfterEach(func() { + // Delete the Kubernetes Project object and wait until the controller finishes the reconciliation after deletion of the object + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + Eventually(func() bool { + err := k8sClient.Get(ctx, namespacedName, instance) + // The Kubernetes client will return error 'NotFound' on the Get operation once the object is deleted + return errors.IsNotFound(err) + }).Should(BeTrue()) + + // Make sure that the Terraform Cloud project is deleted + Eventually(func() bool { + err := tfClient.Projects.Delete(ctx, instance.Status.ID) + // The Terraform Cloud client will return the error 'ResourceNotFound' once the workspace does not exist + return err == tfc.ErrResourceNotFound || err == nil + }).Should(BeTrue()) + }) + + Context("Project controller", func() { + It("can create and delete a project", func() { + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + }) + It("can restore a project", func() { + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + + initProjectID := instance.Status.ID + + // Delete the Terraform Cloud project + Expect(tfClient.Projects.Delete(ctx, instance.Status.ID)).Should(Succeed()) + + // Wait until the controller re-creates the project and updates Status.ID with a new valid project ID + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ID != initProjectID + }).Should(BeTrue()) + + // The Kubernetes project object should have Status.ID with the valid project ID + Expect(instance.Status.ID).Should(HavePrefix("prj-")) + }) + It("can change basic project attributes", func() { + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + + // Update the Kubernetes project object Name + instance.Spec.Name = fmt.Sprintf("%v-new", instance.Spec.Name) + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + + // Wait until the controller updates Terraform Cloud workspace + Eventually(func() bool { + prj, err := tfClient.Projects.Read(ctx, instance.Status.ID) + Expect(prj).ShouldNot(BeNil()) + Expect(err).Should(Succeed()) + return prj.Name == instance.Status.Name + }).Should(BeTrue()) + }) + It("can revert external changes", func() { + // Create a new Kubernetes project object and wait until the controller finishes the reconciliation + createProject(instance, namespacedName) + + // Change the Terraform Cloud project name + prj, err := tfClient.Projects.Update(ctx, instance.Status.ID, tfc.ProjectUpdateOptions{ + Name: tfc.String(fmt.Sprintf("%v-new", instance.Spec.Name)), + }) + Expect(prj).ShouldNot(BeNil()) + Expect(err).Should(Succeed()) + + // Wait until the controller updates Terraform Cloud project + Eventually(func() bool { + err := k8sClient.Get(ctx, namespacedName, instance) + Expect(err).Should(Succeed()) + prj, err := tfClient.Projects.Read(ctx, instance.Status.ID) + Expect(prj).ShouldNot(BeNil()) + Expect(err).Should(Succeed()) + + return prj.Name == instance.Status.Name + }).Should(BeTrue()) + }) + }) +}) + +func createProject(instance *appv1alpha2.Project, namespacedName types.NamespacedName) { + // Create a new Kubernetes project object + Expect(k8sClient.Create(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 project object should have Status.ID with the valid project ID + Expect(instance.Status.ID).Should(HavePrefix("prj-")) +} diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 8c236444..694337a9 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + tfc "github.com/hashicorp/go-tfe" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -29,8 +30,6 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - tfc "github.com/hashicorp/go-tfe" - appv1alpha2 "github.com/hashicorp/terraform-cloud-operator/api/v1alpha2" //+kubebuilder:scaffold:imports ) @@ -145,15 +144,16 @@ var _ = BeforeSuite(func() { "Workspace.app.terraform.io": 5, "Module.app.terraform.io": 5, "AgentPool.app.terraform.io": 5, + "Project.app.terraform.io": 5, }, }, }) Expect(err).ToNot(HaveOccurred()) - err = (&WorkspaceReconciler{ + err = (&AgentPoolReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), - Recorder: k8sManager.GetEventRecorderFor("WorkspaceController"), + Recorder: k8sManager.GetEventRecorderFor("AgentPoolController"), }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) @@ -164,10 +164,17 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) - err = (&AgentPoolReconciler{ + err = (&ProjectReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), - Recorder: k8sManager.GetEventRecorderFor("AgentPoolController"), + Recorder: k8sManager.GetEventRecorderFor("ProjectController"), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + err = (&WorkspaceReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("WorkspaceController"), }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) diff --git a/controllers/workspace_controller_projects_test.go b/controllers/workspace_controller_projects_test.go index a52cc8dc..d4bc7d67 100644 --- a/controllers/workspace_controller_projects_test.go +++ b/controllers/workspace_controller_projects_test.go @@ -34,8 +34,8 @@ var _ = Describe("Workspace controller", Ordered, func() { SetDefaultEventuallyPollingInterval(2 * time.Second) // Create Projects - projectID = createProject(projectName) - projectID2 = createProject(projectName2) + projectID = createTestProject(projectName) + projectID2 = createTestProject(projectName2) }) AfterAll(func() { @@ -134,7 +134,7 @@ var _ = Describe("Workspace controller", Ordered, func() { }) }) -func createProject(projectName string) string { +func createTestProject(projectName string) string { prj, err := tfClient.Projects.Create(ctx, organization, tfc.ProjectCreateOptions{ Name: projectName, }) diff --git a/docs/api-reference.md b/docs/api-reference.md index 1c7a919f..e3f36bb3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -11,6 +11,7 @@ Package v1alpha2 contains API Schema definitions for the app v1alpha2 API group ### Resource Types - [AgentPool](#agentpool) - [Module](#module) +- [Project](#project) - [Workspace](#workspace) @@ -27,7 +28,7 @@ _Appears in:_ | Field | Description | | --- | --- | | `replicas` _integer_ | | -| `spec` _[PodSpec](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#podspec-v1-core)_ | | +| `spec` _[PodSpec](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#podspec-v1-core)_ | | #### AgentDeploymentAutoscaling @@ -59,7 +60,7 @@ _Appears in:_ | Field | Description | | --- | --- | | `desiredReplicas` _integer_ | Desired number of agent replicas | -| `lastScalingEvent` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#time-v1-meta)_ | Last time the agent pool was scaledx | +| `lastScalingEvent` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta)_ | Last time the agent pool was scaledx | #### AgentPool @@ -76,7 +77,7 @@ AgentPool is the Schema for the agentpools API. | `kind` _string_ | `AgentPool` | `kind` _string_ | Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | `spec` _[AgentPoolSpec](#agentpoolspec)_ | | @@ -167,6 +168,30 @@ _Appears in:_ | `workspaceLocking` _boolean_ | Lock/unlock workspace. Default: `false`. | +#### CustomProjectPermissions + + + +Custom permissions let you assign specific, finer-grained permissions to a team than the broader fixed permission sets provide. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-workspace-permissions + +_Appears in:_ +- [ProjectTeamAccess](#projectteamaccess) + +| Field | Description | +| --- | --- | +| `projectAccess` _[ProjectSettingsPermissionType](#projectsettingspermissiontype)_ | Project access. Must be one of the following values: `delete`, `read`, `update`. Default: `read`. | +| `teamManagement` _[ProjectTeamsPermissionType](#projectteamspermissiontype)_ | Team management. Must be one of the following values: `manage`, `none`, `read`. Default: `none`. | +| `createWorkspace` _boolean_ | Allow users to create workspaces in the project. This grants read access to all workspaces in the project. Default: `false`. | +| `deleteWorkspace` _boolean_ | Allows users to delete workspaces in the project. Default: `false`. | +| `moveWorkspace` _boolean_ | Allows users to move workspaces out of the project. A user must have this permission on both the source and destination project to successfully move a workspace from one project to another. Default: `false`. | +| `lockWorkspace` _boolean_ | Allows users to manually lock the workspace to temporarily prevent runs. When a workspace's execution mode is set to "local", users must have this permission to perform local CLI runs using the workspace's state. Default: `false`. | +| `runs` _[WorkspaceRunsPermissionType](#workspacerunspermissiontype)_ | Run access. Must be one of the following values: `apply`, `plan`, `read`. Default: `read`. | +| `runTasks` _boolean_ | Manage Workspace Run Tasks. Default: `false`. | +| `sentinelMocks` _[WorkspaceSentinelMocksPermissionType](#workspacesentinelmockspermissiontype)_ | Download Sentinel mocks. Must be one of the following values: `none`, `read`. Default: `none`. | +| `stateVersions` _[WorkspaceStateVersionsPermissionType](#workspacestateversionspermissiontype)_ | State access. Must be one of the following values: `none`, `read`, `read-outputs`, `write`. Default: `none`. | +| `variables` _[WorkspaceVariablesPermissionType](#workspacevariablespermissiontype)_ | Variable access. Must be one of the following values: `none`, `read`, `write`. Default: `none`. | + + #### Module @@ -181,7 +206,7 @@ Module is the Schema for the modules API Module implements the API-driven Run Wo | `kind` _string_ | `Module` | `kind` _string_ | Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | `spec` _[ModuleSpec](#modulespec)_ | | @@ -314,6 +339,59 @@ _Appears in:_ | `runID` _string_ | Run ID of the latest run that updated the outputs. | +#### Project + + + +Project is the Schema for the projects API + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `app.terraform.io/v1alpha2` +| `kind` _string_ | `Project` +| `kind` _string_ | Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `spec` _[ProjectSpec](#projectspec)_ | | + + +#### ProjectSpec + + + +ProjectSpec defines the desired state of Project. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects + +_Appears in:_ +- [Project](#project) + +| Field | Description | +| --- | --- | +| `organization` _string_ | Organization name where the Workspace will be created. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations | +| `token` _[Token](#token)_ | API Token to be used for API calls. | +| `name` _string_ | Name of the Project. | +| `teamAccess` _[ProjectTeamAccess](#projectteamaccess) array_ | Terraform Cloud's access model is team-based. In order to perform an action within a Terraform Cloud organization, users must belong to a team that has been granted the appropriate permissions. You can assign project-specific permissions to teams. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions | + + + + +#### ProjectTeamAccess + + + +Terraform Cloud's access model is team-based. In order to perform an action within a Terraform Cloud organization, users must belong to a team that has been granted the appropriate permissions. You can assign project-specific permissions to teams. More information: - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects#permissions - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions + +_Appears in:_ +- [ProjectSpec](#projectspec) + +| Field | Description | +| --- | --- | +| `team` _[Team](#team)_ | Team to grant access. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/teams | +| `access` _[TeamProjectAccessType](#teamprojectaccesstype)_ | There are two ways to choose which permissions a given team has on a project: fixed permission sets, and custom permissions. Must be one of the following values: `admin`, `custom`, `maintain`, `read`, `write`. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#project-permissions - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#general-project-permissions | +| `custom` _[CustomProjectPermissions](#customprojectpermissions)_ | Custom permissions let you assign specific, finer-grained permissions to a team than the broader fixed permission sets provide. More information: - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions#custom-project-permissions | + + #### RemoteStateSharing @@ -410,6 +488,7 @@ _Appears in:_ Teams are groups of Terraform Cloud users within an organization. If a user belongs to at least one team in an organization, they are considered a member of that organization. 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/users-teams-organizations/teams _Appears in:_ +- [ProjectTeamAccess](#projectteamaccess) - [TeamAccess](#teamaccess) | Field | Description | @@ -443,11 +522,12 @@ Token refers to a Kubernetes Secret object within the same namespace as the Work _Appears in:_ - [AgentPoolSpec](#agentpoolspec) - [ModuleSpec](#modulespec) +- [ProjectSpec](#projectspec) - [WorkspaceSpec](#workspacespec) | Field | Description | | --- | --- | -| `secretKeyRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#secretkeyselector-v1-core)_ | Selects a key of a secret in the workspace's namespace | +| `secretKeyRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | Selects a key of a secret in the workspace's namespace | #### ValueFrom @@ -461,8 +541,8 @@ _Appears in:_ | Field | Description | | --- | --- | -| `configMapKeyRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#configmapkeyselector-v1-core)_ | Selects a key of a ConfigMap. | -| `secretKeyRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#secretkeyselector-v1-core)_ | Selects a key of a Secret. | +| `configMapKeyRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | Selects a key of a ConfigMap. | +| `secretKeyRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | Selects a key of a Secret. | #### Variable @@ -514,7 +594,7 @@ Workspace is the Schema for the workspaces API | `kind` _string_ | `Workspace` | `kind` _string_ | Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | `spec` _[WorkspaceSpec](#workspacespec)_ | | diff --git a/docs/config.yaml b/docs/config.yaml index 0084cc79..6a25fc05 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -6,8 +6,9 @@ processor: ignoreTypes: - "AgentPoolList$" - "ModuleList$" + - "ProjectList$" - "WorkspaceList$" ignoreFields: - "status$" render: - kubernetesVersion: 1.26 + kubernetesVersion: 1.27 diff --git a/docs/examples/project-basic.yaml b/docs/examples/project-basic.yaml new file mode 100644 index 00000000..b899530c --- /dev/null +++ b/docs/examples/project-basic.yaml @@ -0,0 +1,15 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +--- +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + name: project-demo diff --git a/docs/examples/project-customTeamAccess.yaml b/docs/examples/project-customTeamAccess.yaml new file mode 100644 index 00000000..bf60caaa --- /dev/null +++ b/docs/examples/project-customTeamAccess.yaml @@ -0,0 +1,31 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +--- +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + name: project-demo + teamAccess: + - team: + name: demo + access: custom + custom: + projectAccess: read + teamManagement: read + createWorkspace: false + deleteWorkspace: false + moveWorkspace: false + lockWorkspace: false + runs: read + runTasks: false + sentinelMocks: read + stateVersions: read-outputs + variables: read diff --git a/docs/examples/project-teamAccess.yaml b/docs/examples/project-teamAccess.yaml new file mode 100644 index 00000000..d7d8fffe --- /dev/null +++ b/docs/examples/project-teamAccess.yaml @@ -0,0 +1,19 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +--- +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + name: project-demo + teamAccess: + - team: + name: demo + access: admin diff --git a/docs/faq.md b/docs/faq.md index cffd4cd8..fb9a1454 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -129,33 +129,23 @@ Please refer to the [CRDs](../config/crd/bases) and [API Reference](./api-reference.md) to see if the feature you use supports `ID` or `Name`. -## Workspace Controller - -- **Can a single deployment of the Operator manage the Workspaces of different Organizations?** - - Yes. The Workspace resource has mandatory fields `spec.organization` and `spec.token`. The Operator manages workspaces based on these credentials. - -- **Where can I find Workspace outputs?** - - Non-sensitive outputs will be saved in a ConfigMap. Sensitive outputs will be saved in a Secret. In both cases, the name of the corresponding Kubernetes resource will be generated automatically and has the following pattern: `-outputs`. +## Agent Pool Controller -- **What version of Terraform is utilized in the Workplace?** +- **Where can I find Agent tokens?** - If the `spec.terraformVersion` is configured, the Operator ensures that the specified version will be utilized. + The Agent tokens are sensitive and will be saved in a Secret. The name of the Secret object will be generated automatically and has the following pattern: `-agent-pool`. - If the `spec.terraformVersion` is not configured, i.e. empty, the latest available Terraform version will be picked up during the Workspace creation and the same version will be utilized till it gets updated via the Workspace manifest. +- **Does the Operator restore tokens if I delete the whole Secret containing the Agent Tokens or a single token from it?** - Regardless of the scenario, you can always refer to `status.terraformVersion` to determine the version of Terraform being used in the Workplace. + No. You will have to update the Custom Resource to re-create tokens. -- **Can I create a workspace or move the one that already exists to a specific project?** +- **What will happen if I delete an Agent Pool Customer Resource?** - Yes, you can do this. Bear in mind that a project must exist before referring to it; otherwise, the create or update operation will fail: - - - If this is a new workspace and the referred project doesn’t exist, the workspace creation will fail with a corresponding error/event message. + The Agent Pool controller will delete Agent Pool from Terraform Cloud, as well as the Kubernetes Secret that stores the Agent Tokens that were generated for this pool. - - If this involves migrating an existing workspace and the referred project doesn’t exist, the workspace will remain within the same project, and a corresponding error/event message will be provided. +- **What triggers Agents scaling?** - If the `spec.project` field is not specified, the workspace will be created or moved to the default project. + The Operator regularly monitors specific workspaces and boosts the agent count when pending runs are detected. The maximum number of agents can be increased up to the value defined in `autoscaling.maxReplicas` or limited by the license, depending on which limit is reached first. If there are no pending runs, the Operator will reduce the number of agents to the specified value in `autoscaling.minReplicas` within the timeframe of `autoscaling.cooldownPeriodSeconds`. ## Module Controller @@ -171,20 +161,36 @@ $ kubectl patch module --type=merge --patch '{"spec": {"restartedAt": "'`date -u -Iseconds`'"}}' ``` -## Agent Pool Controller +## Project Controller -- **Where can I find Agent tokens?** +- **Can I delete a project that has workspaces in it?** - The Agent tokens are sensitive and will be saved in a Secret. The name of the Secret object will be generated automatically and has the following pattern: `-agent-pool`. + No, you can only delete a project if it is empty and you have the proper permissions. -- **Does the Operator restore tokens if I delete the whole Secret containing the Agent Tokens or a single token from it?** +## Workspace Controller - No. You will have to update the Custom Resource to re-create tokens. +- **Can a single deployment of the Operator manage the Workspaces of different Organizations?** -- **What will happen if I delete an Agent Pool Customer Resource?** + Yes. The Workspace resource has mandatory fields `spec.organization` and `spec.token`. The Operator manages workspaces based on these credentials. - The Agent Pool controller will delete Agent Pool from Terraform Cloud, as well as the Kubernetes Secret that stores the Agent Tokens that were generated for this pool. +- **Where can I find Workspace outputs?** -- **What triggers Agents scaling?** + Non-sensitive outputs will be saved in a ConfigMap. Sensitive outputs will be saved in a Secret. In both cases, the name of the corresponding Kubernetes resource will be generated automatically and has the following pattern: `-outputs`. - The Operator regularly monitors specific workspaces and boosts the agent count when pending runs are detected. The maximum number of agents can be increased up to the value defined in `autoscaling.maxReplicas` or limited by the license, depending on which limit is reached first. If there are no pending runs, the Operator will reduce the number of agents to the specified value in `autoscaling.minReplicas` within the timeframe of `autoscaling.cooldownPeriodSeconds`. +- **What version of Terraform is utilized in the Workplace?** + + If the `spec.terraformVersion` is configured, the Operator ensures that the specified version will be utilized. + + If the `spec.terraformVersion` is not configured, i.e. empty, the latest available Terraform version will be picked up during the Workspace creation and the same version will be utilized till it gets updated via the Workspace manifest. + + Regardless of the scenario, you can always refer to `status.terraformVersion` to determine the version of Terraform being used in the Workplace. + +- **Can I create a workspace or move the one that already exists to a specific project?** + + Yes, you can do this. Bear in mind that a project must exist before referring to it; otherwise, the create or update operation will fail: + + - If this is a new workspace and the referred project doesn’t exist, the workspace creation will fail with a corresponding error/event message. + + - If this involves migrating an existing workspace and the referred project doesn’t exist, the workspace will remain within the same project, and a corresponding error/event message will be provided. + + If the `spec.project` field is not specified, the workspace will be created or moved to the default project. diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index ac14d748..00000000 --- a/docs/features.md +++ /dev/null @@ -1,143 +0,0 @@ -# Supported Terraform Cloud Features - -The Terraform Cloud Operator allows you to interact with various aspects of Terraform Cloud, such as agent pools, agent scaling, Terraform module execution, and workspace management, through Kubernetes controllers. These controllers enable you to automate and manage Terraform Cloud resources using custom resources in Kubernetes. Let's break down the mentioned features. - -## Agent Pool - -Agent pools in Terraform Cloud are used to manage the execution environment for Terraform runs. The Terraform Cloud Operator likely allows you to create and manage agent pools as part of your Kubernetes infrastructure. For example, you might create a custom resource in Kubernetes to define an agent pool and let the operator handle its provisioning and scaling. - -Let's take a look at how to create a new agent pool with the name `agent-pool-development` and generate a single agent token to it with the name `token-red`: - -```yaml ---- -apiVersion: app.terraform.io/v1alpha2 -kind: AgentPool -metadata: - name: this -spec: - organization: kubernetes-operator - token: - secretKeyRef: - name: tfc-operator - key: token - name: agent-pool-development - agentTokens: - - name: token-red -``` - -The token that is generated, named "token-red," will be accessible within a Kubernetes secret. - -We can expand our example by introducing the agent auto-scaling feature. - -```yaml ---- -apiVersion: app.terraform.io/v1alpha2 -kind: AgentPool -metadata: - name: this -spec: - organization: kubernetes-operator - token: - secretKeyRef: - name: tfc-operator - key: token - name: agent-pool-development - agentTokens: - - name: token-red - agentDeployment: - replicas: 1 - autoscaling: - targetWorkspaces: - - name: us-west-development - - id: ws-NUVHA9feCXzAmPHx - - wildcardName: eu-development-* - minReplicas: 1 - maxReplicas: 3 -``` - -The operator will ensure that at least one agent Pod is continuously running, and it can dynamically scale the number of Pods up to a maximum of three based on the workload or resource demand. To achieve this, the operator will monitor the resource demand by observing the load of the designated workspaces, which can be specified by their name, ID, or through wildcard patterns. When the workload decreases, the operator will scale down the node to release valuable resources. - -To explore more advanced options, please refer to the [API reference](./api-reference.md#agentpool) documentation. - -## Module - -The Module controller enforces an [API-driven Run workflow](https://developer.hashicorp.com/terraform/cloud-docs/run/api) and enables the execution of Terraform modules within various workspaces as needed. - -Let's take a look at how to run module `redeux/terraform-cloud-agent/kubernetes` with a specific version `1.0.1` within the designated workspace named `workspace-name`. - -```yaml ---- -apiVersion: app.terraform.io/v1alpha2 -kind: Module -metadata: - name: this -spec: - organization: kubernetes-operator - token: - secretKeyRef: - name: tfc-operator - key: token - module: - source: redeux/terraform-cloud-agent/kubernetes - version: 1.0.1 - workspace: - name: workspace-name - variables: - - name: variable_a - - name: variable_b - outputs: - - name: output_a - - name: output_b -``` - -The operator will transmit the variables `variable_a` and `variable_b` to the module and synchronize the outputs `output_a` and `output_b` with either a Kubernetes secret or a config map, depending on the sensitivity of the output. The variables need to be accessible within the workspace. - -To explore more advanced options, please refer to the [API reference](./api-reference.md#module) documentation. - -## Workspace - -Workspaces in Terraform Cloud are used to organize and manage Terraform configurations. The operator may allow you to create, configure, and manage workspaces directly from Kubernetes, simplifying workspace management. - -Let's take a closed look at how to create a workspace. - -```yaml ---- -apiVersion: app.terraform.io/v1alpha2 -kind: Workspace -metadata: - name: this -spec: - organization: kubernetes-operator - token: - secretKeyRef: - name: tfc-operator - key: token - name: us-west-development - description: US West development workspace - terraformVersion: 1.6.2 - applyMethod: auto - agentPool: - name: ap-us-west-development - terraformVariables: - - name: nodes - value: 2 - - name: rds-secret - sensitive: true - valueFrom: - secretKeyRef: - name: us-west-development-secrets - key: rds-secret - runTasks: - - name: rt-us-west-development - stage: pre_plan -``` - -The operator will establish a new workspace named `us-west-development` with Terraform version `1.6.2`. This workspace will have two variables, namely `nodes` and `rds-secret`. The variable `rds-secret` is treated as sensitive, and it will be sourced from a Kubernetes secret named `us-west-development-secrets`. - -The Terraform code will be automatically executed, due to the option `applyMethod` set to `auto`, and this will occur on an agent originating from the `ap-us-west-development` agent pool. Furthermore, the run task, denoted as `rt-us-west-development`, is scheduled to run at the `pre_plan` stage. - -The operator will also manage the synchronization of Terraform code execution outputs. This synchronization process will either involve a Kubernetes secret or a config map, depending on the sensitivity of the output data. - -It's important to note that any external modifications made to the operator's setup will be rolled back to match the state specified in the custom resource definition. - -To explore more advanced options, please refer to the [API reference](./api-reference.md#workspace) documentation. diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 00000000..a39fd7d9 --- /dev/null +++ b/docs/project.md @@ -0,0 +1,49 @@ +# `Project` + +`Project` controller allows managing Terraform Cloud Projects via Kubernetes Custom Resources. + +Please refer to the [CRD](../config/crd/bases/app.terraform.io_projects.yaml) and [API Reference](./api-reference.md#project) to get the full list of available options. + +Below is a basic example of a Project Custom Resource: + +```yaml +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + name: project-demo +``` + +Once the above CR is applied, the Operator creates a new project `project-demo` under the `kubernetes-operator` organization. + +The example can be extended with team access permission support: + +```yaml +apiVersion: app.terraform.io/v1alpha2 +kind: Project +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + name: project-demo + teamAccess: + - team: + name: demo + access: admin +``` + +The team `demo` will get `Admin` [permission group](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/permissions) access to workspaces under the project `project-demo`. + +If you have any questions, please check out the [FAQ](./faq.md#project-controller). + +If you encounter any issues with the `Project` controller please refer to the [Troubleshooting](../README.md#troubleshooting). diff --git a/docs/workspace.md b/docs/workspace.md index 9ed2ba9e..6a7fc4fb 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -1,6 +1,6 @@ # `Workspace` -`Workspace` controller allows managing Terraform Cloud Workspace via Kubernetes Custom Resources. +`Workspace` controller allows managing Terraform Cloud Workspaces via Kubernetes Custom Resources. Please refer to the [CRD](../config/crd/bases/app.terraform.io_workspaces.yaml) and [API Reference](./api-reference.md#workspace) to get the full list of available options. diff --git a/main.go b/main.go index ab9704d9..7626c458 100644 --- a/main.go +++ b/main.go @@ -60,12 +60,15 @@ func main() { var agentPoolWorkers int flag.IntVar(&agentPoolWorkers, "agent-pool-workers", 1, "The number of the Agent Pool controller workers.") - var workspaceWorkers int - flag.IntVar(&workspaceWorkers, "workspace-workers", 1, - "The number of the Workspace controller workers.") var moduleWorkers int flag.IntVar(&moduleWorkers, "module-workers", 1, "The number of the Module controller workers.") + var projectWorkers int + flag.IntVar(&projectWorkers, "project-workers", 1, + "The number of the Workspace controller workers.") + var workspaceWorkers int + flag.IntVar(&workspaceWorkers, "workspace-workers", 1, + "The number of the Workspace controller workers.") flag.Parse() @@ -96,9 +99,10 @@ func main() { options := ctrl.Options{ Controller: config.Controller{ GroupKindConcurrency: map[string]int{ - "Workspace.app.terraform.io": workspaceWorkers, - "Module.app.terraform.io": moduleWorkers, "AgentPool.app.terraform.io": agentPoolWorkers, + "Module.app.terraform.io": moduleWorkers, + "Workspace.app.terraform.io": workspaceWorkers, + "Project.app.terraform.io": projectWorkers, }, }, Scheme: scheme, @@ -137,12 +141,12 @@ func main() { os.Exit(1) } - if err = (&controllers.WorkspaceReconciler{ + if err = (&controllers.AgentPoolReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("WorkspaceController"), + Recorder: mgr.GetEventRecorderFor("AgentPoolController"), }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Workspace") + setupLog.Error(err, "unable to create controller", "controller", "AgentPool") os.Exit(1) } if err = (&controllers.ModuleReconciler{ @@ -153,12 +157,20 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Module") os.Exit(1) } - if err = (&controllers.AgentPoolReconciler{ + if err = (&controllers.ProjectReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("AgentPoolController"), + Recorder: mgr.GetEventRecorderFor("ProjectController"), }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "AgentPool") + setupLog.Error(err, "unable to create controller", "controller", "Project") + os.Exit(1) + } + if err = (&controllers.WorkspaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("WorkspaceController"), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Workspace") os.Exit(1) } //+kubebuilder:scaffold:builder