diff --git a/.changes/unreleased/FEATURES-628-20250925-091657.yaml b/.changes/unreleased/FEATURES-628-20250925-091657.yaml new file mode 100644 index 00000000..256c31b0 --- /dev/null +++ b/.changes/unreleased/FEATURES-628-20250925-091657.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: '`AgentToken`: Introduce a new controller that manages tokens in arbitrary agent pools.' +time: 2025-09-25T09:16:57.344633+02:00 +custom: + PR: "628" diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index 0209951f..796bf284 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -8,7 +8,8 @@ controller: - any: - changed-files: - any-glob-to-any-file: - - 'controllers/*.go' + - 'internal/controller/*.go' + - 'cmd/*.go' crd: - any: diff --git a/.github/workflows/helm-end-to-end-tfc.yaml b/.github/workflows/helm-end-to-end-tfc.yaml index 41fc6fac..df16cff6 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.image.tag=${{ env.DOCKER_METADATA_OUTPUT_VERSION }} \ --set operator.syncPeriod=30s \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=5 \ --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 diff --git a/.github/workflows/helm-end-to-end-tfe.yaml b/.github/workflows/helm-end-to-end-tfe.yaml index fda3b068..a8d4a3fc 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.tfeAddress=${{ secrets.TFE_ADDRESS }} \ --set operator.syncPeriod=30s \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=5 \ --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 diff --git a/PROJECT b/PROJECT index 2f023277..61ba4927 100644 --- a/PROJECT +++ b/PROJECT @@ -47,4 +47,13 @@ resources: kind: Project path: github.com/hashicorp/hcp-terraform-operator/api/v1alpha2 version: v1alpha2 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: terraform.io + group: app + kind: AgentToken + path: github.com/hashicorp/hcp-terraform-operator/api/v1alpha2 + version: v1alpha2 version: "3" diff --git a/README.md b/README.md index 258d2ea7..ee6c0328 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Kubernetes Operator allows managing HCP Terraform / Terraform Enterprise resourc The Operator can manage the following types of resources: - `AgentPool` manages [HCP Terraform Agent Pools](https://developer.hashicorp.com/terraform/cloud-docs/agents/agent-pools), [HCP Terraform Agent Tokens](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens) and can perform TFC agent scaling +- `AgentToken` manages [HCP Terraform Agent Tokens](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens) - `Module` implements [API-driven Run Workflows](https://developer.hashicorp.com/terraform/cloud-docs/run/api) - `Project` manages [HCP Terraform Projects](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects) - `Workspace` manages [HCP Terraform Workspaces](https://developer.hashicorp.com/terraform/cloud-docs/workspaces) @@ -56,6 +57,7 @@ General usage documentation can be found [here](./docs/usage.md). Controllers usage guides: - [AgentPool](./docs/agentpool.md) +- [AgentToken](./docs/agenttoken.md) - [Module](./docs/module.md) - [Project](./docs/project.md) - [Workspace](./docs/workspace.md) @@ -110,6 +112,7 @@ If you encounter any issues with the Operator there are a number of ways how to ```console $ kubectl get agentpool + $ kubectl get agenttoken $ kubectl get module $ kubectl get project $ kubectl get workspace @@ -119,6 +122,7 @@ If you encounter any issues with the Operator there are a number of ways how to ```console $ kubectl describe agentpool + $ kubectl describe agenttoken $ kubectl describe module $ kubectl describe project $ kubectl describe workspace diff --git a/api/v1alpha2/agentpool_types.go b/api/v1alpha2/agentpool_types.go index 2842c909..091c6521 100644 --- a/api/v1alpha2/agentpool_types.go +++ b/api/v1alpha2/agentpool_types.go @@ -23,7 +23,7 @@ const ( // In `spec` only the field `Name` is allowed, the rest are used in `status`. // More infromation: // - https://developer.hashicorp.com/terraform/cloud-docs/agents -type AgentToken struct { +type AgentAPIToken struct { // Agent Token name. // //+kubebuilder:validation:MinLength:=1 @@ -135,7 +135,7 @@ type AgentPoolSpec struct { // //+kubebuilder:validation:MinItems:=1 //+optional - AgentTokens []*AgentToken `json:"agentTokens,omitempty"` + AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"` // Agent deployment settings //+optional @@ -179,7 +179,7 @@ type AgentPoolStatus struct { // List of the agent tokens generated by the controller. // //+optional - AgentTokens []*AgentToken `json:"agentTokens,omitempty"` + AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"` // Name of the agent deployment generated by the controller. // //+optional diff --git a/api/v1alpha2/agentpool_validation_test.go b/api/v1alpha2/agentpool_validation_test.go index 3bcb9190..ae30975f 100644 --- a/api/v1alpha2/agentpool_validation_test.go +++ b/api/v1alpha2/agentpool_validation_test.go @@ -15,7 +15,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { successCases := map[string]AgentPool{ "HasOnlyName": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", }, @@ -24,7 +24,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { }, "HasMultipleTokens": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", }, @@ -47,7 +47,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { errorCases := map[string]AgentPool{ "HasID": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", ID: "this", @@ -57,7 +57,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { }, "HasCreatedAt": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", CreatedAt: pointer.PointerOf(int64(1984)), @@ -67,7 +67,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { }, "HasLastUsedAt": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", LastUsedAt: pointer.PointerOf(int64(1984)), @@ -77,7 +77,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) { }, "HasDuplicateName": { Spec: AgentPoolSpec{ - AgentTokens: []*AgentToken{ + AgentTokens: []*AgentAPIToken{ { Name: "this", }, diff --git a/api/v1alpha2/agenttoken_types.go b/api/v1alpha2/agenttoken_types.go new file mode 100644 index 00000000..5675a76e --- /dev/null +++ b/api/v1alpha2/agenttoken_types.go @@ -0,0 +1,113 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The Management Policy defines how the controller will manage tokens in the specified Agent Pool. +// - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own. +// - `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it. +type AgentTokenManagementPolicy string + +const ( + AgentTokenManagementPolicyMerge AgentTokenManagementPolicy = "merge" + AgentTokenManagementPolicyOwner AgentTokenManagementPolicy = "owner" +) + +// The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted. +// - `retain`: When the custom resource is deleted, the operator will remove only the resource itself. +// The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified. +// - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret. +type AgentTokenDeletionPolicy string + +const ( + AgentTokenDeletionPolicyRetain AgentTokenDeletionPolicy = "retain" + AgentTokenDeletionPolicyDestroy AgentTokenDeletionPolicy = "destroy" +) + +// AgentTokenSpec defines the desired state of AgentToken. +type AgentTokenSpec 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"` + // The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted. + // - `retain`: When the custom resource is deleted, the operator will remove only the resource itself. + // The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified. + // - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret. + // Default: `retain`. + // + //+kubebuilder:validation:Enum:=retain;destroy + //+kubebuilder:default=retain + //+optional + DeletionPolicy AgentTokenDeletionPolicy `json:"deletionPolicy,omitempty"` + // The Agent Pool name or ID where the tokens will be managed. + AgentPool AgentPoolRef `json:"agentPool"` + // The Management Policy defines how the controller will manage tokens in the specified Agent Pool. + // - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own. + // - `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it. + // Default: `merge`. + // + //+kubebuilder:validation:Enum:=merge;owner + //+kubebuilder:default=merge + //+optional + ManagementPolicy AgentTokenManagementPolicy `json:"managementPolicy,omitempty"` + // List of the HCP Terraform Agent tokens to manage. + // + //+kubebuilder:validation:MinItems:=1 + AgentTokens []AgentAPIToken `json:"agentTokens"` + // secretName specifies the name of the Kubernetes Secret + // where the HCP Terraform Agent tokens are stored. + // + //+kubebuilder:validation:MinLength:=1 + SecretName string `json:"secretName"` +} + +// AgentTokenStatus defines the observed state of AgentToken. +type AgentTokenStatus struct { + // Real world state generation. + ObservedGeneration int64 `json:"observedGeneration"` + // Agent Pool where tokens are managed by the controller. + AgentPool *AgentPoolRef `json:"agentPool,omitempty"` + // List of the agent tokens managed by the controller. + // + //+optional + AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +//+kubebuilder:printcolumn:name="Agent Pool Name",type=string,JSONPath=`.status.agentPool.name` +//+kubebuilder:printcolumn:name="Agent Pool ID",type=string,JSONPath=`.status.agentPool.id` +//+kubebuilder:metadata:labels="app.terraform.io/crd-schema-version=v25.9.0" + +// AgentToken manages HCP Terraform Agent Tokens. +// More information: +// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens +type AgentToken struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AgentTokenSpec `json:"spec"` + Status AgentTokenStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// AgentTokenList contains a list of AgentToken. +type AgentTokenList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AgentToken `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AgentToken{}, &AgentTokenList{}) +} diff --git a/api/v1alpha2/agenttoken_validation.go b/api/v1alpha2/agenttoken_validation.go new file mode 100644 index 00000000..f8b6818a --- /dev/null +++ b/api/v1alpha2/agenttoken_validation.go @@ -0,0 +1,63 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func (t *AgentToken) ValidateSpec() error { + var allErrs field.ErrorList + + allErrs = append(allErrs, t.validateSpecAgentTokens()...) + + if len(allErrs) == 0 { + return nil + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: "", Kind: "AgentToken"}, + t.Name, + allErrs, + ) +} + +func (t *AgentToken) validateSpecAgentTokens() field.ErrorList { + allErrs := field.ErrorList{} + atn := make(map[string]int) + + for i, at := range t.Spec.AgentTokens { + f := field.NewPath("spec").Child(fmt.Sprintf("agentTokens[%d]", i)) + + if at.ID != "" { + allErrs = append(allErrs, field.Forbidden( + f.Child("id"), + "id is not allowed in the spec"), + ) + } + if at.CreatedAt != nil { + allErrs = append(allErrs, field.Forbidden( + f.Child("createdAt"), + "createdAt is not allowed in the spec"), + ) + } + if at.LastUsedAt != nil { + allErrs = append(allErrs, field.Forbidden( + f.Child("lastUsedAt"), + "lastUsedAt is not allowed in the spec"), + ) + } + + if _, ok := atn[at.Name]; ok { + allErrs = append(allErrs, field.Duplicate(f.Child("name"), at.Name)) + } + atn[at.Name] = i + } + + return allErrs +} diff --git a/api/v1alpha2/agenttoken_validation_test.go b/api/v1alpha2/agenttoken_validation_test.go new file mode 100644 index 00000000..945f04ea --- /dev/null +++ b/api/v1alpha2/agenttoken_validation_test.go @@ -0,0 +1,99 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package v1alpha2 + +import ( + "testing" + + "github.com/hashicorp/hcp-terraform-operator/internal/pointer" +) + +func TestValidateAgentTokenSpecAgentToken(t *testing.T) { + t.Parallel() + + successCases := map[string]AgentToken{ + "HasOnlyName": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + }, + }, + }, + }, + "HasMultipleTokens": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + }, + { + Name: "self", + }, + }, + }, + }, + } + + for n, c := range successCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecAgentTokens(); len(errs) != 0 { + t.Errorf("Unexpected validation errors: %v", errs) + } + }) + } + + errorCases := map[string]AgentToken{ + "HasID": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + ID: "this", + }, + }, + }, + }, + "HasCreatedAt": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + CreatedAt: pointer.PointerOf(int64(1984)), + }, + }, + }, + }, + "HasLastUsedAt": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + LastUsedAt: pointer.PointerOf(int64(1984)), + }, + }, + }, + }, + "HasDuplicateName": { + Spec: AgentTokenSpec{ + AgentTokens: []AgentAPIToken{ + { + Name: "this", + }, + { + Name: "this", + }, + }, + }, + }, + } + + for n, c := range errorCases { + t.Run(n, func(t *testing.T) { + if errs := c.validateSpecAgentTokens(); len(errs) == 0 { + t.Error("Unexpected failure, at least one error is expected") + } + }) + } +} diff --git a/api/v1alpha2/workspace_types.go b/api/v1alpha2/workspace_types.go index d98d568c..d0be087e 100644 --- a/api/v1alpha2/workspace_types.go +++ b/api/v1alpha2/workspace_types.go @@ -14,7 +14,7 @@ import ( // At least one of the fields `ID` or `Name` is mandatory. // More information: // - https://developer.hashicorp.com/terraform/cloud-docs/agents -type WorkspaceAgentPool struct { +type AgentPoolRef struct { // Agent Pool ID. // Must match pattern: `^apool-[a-zA-Z0-9]+$` // @@ -517,7 +517,7 @@ type WorkspaceSpec struct { // - https://developer.hashicorp.com/terraform/cloud-docs/agents // //+optional - AgentPool *WorkspaceAgentPool `json:"agentPool,omitempty"` + AgentPool *AgentPoolRef `json:"agentPool,omitempty"` // Define where the Terraform code will be executed. // Must be one of the following values: `agent`, `local`, `remote`. // Default: `remote`. diff --git a/api/v1alpha2/workspace_validation_test.go b/api/v1alpha2/workspace_validation_test.go index 8f313940..af131db1 100644 --- a/api/v1alpha2/workspace_validation_test.go +++ b/api/v1alpha2/workspace_validation_test.go @@ -17,7 +17,7 @@ func TestValidateWorkspaceSpecAgentPool(t *testing.T) { successCases := map[string]Workspace{ "HasOnlyID": { Spec: WorkspaceSpec{ - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ ID: "this", }, ExecutionMode: "agent", @@ -25,7 +25,7 @@ func TestValidateWorkspaceSpecAgentPool(t *testing.T) { }, "HasOnlyName": { Spec: WorkspaceSpec{ - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ Name: "this", }, ExecutionMode: "agent", @@ -44,7 +44,7 @@ func TestValidateWorkspaceSpecAgentPool(t *testing.T) { errorCases := map[string]Workspace{ "HasIDandName": { Spec: WorkspaceSpec{ - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ ID: "this", Name: "this", }, @@ -53,13 +53,13 @@ func TestValidateWorkspaceSpecAgentPool(t *testing.T) { }, "HasEmptyIDandName": { Spec: WorkspaceSpec{ - AgentPool: &WorkspaceAgentPool{}, + AgentPool: &AgentPoolRef{}, ExecutionMode: "agent", }, }, "HasInvalidExecutionMode": { Spec: WorkspaceSpec{ - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ ID: "this", Name: "this", }, @@ -84,7 +84,7 @@ func TestValidateWorkspaceSpecExecutionMode(t *testing.T) { "AgentWithAgentPoolWithID": { Spec: WorkspaceSpec{ ExecutionMode: "agent", - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ ID: "this", }, }, @@ -92,7 +92,7 @@ func TestValidateWorkspaceSpecExecutionMode(t *testing.T) { "AgentWithAgentPoolWithName": { Spec: WorkspaceSpec{ ExecutionMode: "agent", - AgentPool: &WorkspaceAgentPool{ + AgentPool: &AgentPoolRef{ Name: "this", }, }, @@ -609,7 +609,6 @@ func TestValidateWorkspaceSpecRemoteStateSharing(t *testing.T) { for n, c := range errorCases { t.Run(n, func(t *testing.T) { if errs := c.validateSpecRemoteStateSharing(); len(errs) == 0 { - // fmt.Println(errs) 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 d8fccce7..8682221c 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -12,6 +12,31 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentAPIToken) DeepCopyInto(out *AgentAPIToken) { + *out = *in + if in.CreatedAt != nil { + in, out := &in.CreatedAt, &out.CreatedAt + *out = new(int64) + **out = **in + } + if in.LastUsedAt != nil { + in, out := &in.LastUsedAt, &out.LastUsedAt + *out = new(int64) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentAPIToken. +func (in *AgentAPIToken) DeepCopy() *AgentAPIToken { + if in == nil { + return nil + } + out := new(AgentAPIToken) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentDeployment) DeepCopyInto(out *AgentDeployment) { *out = *in @@ -203,17 +228,32 @@ func (in *AgentPoolList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentPoolRef) DeepCopyInto(out *AgentPoolRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentPoolRef. +func (in *AgentPoolRef) DeepCopy() *AgentPoolRef { + if in == nil { + return nil + } + out := new(AgentPoolRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentPoolSpec) DeepCopyInto(out *AgentPoolSpec) { *out = *in in.Token.DeepCopyInto(&out.Token) if in.AgentTokens != nil { in, out := &in.AgentTokens, &out.AgentTokens - *out = make([]*AgentToken, len(*in)) + *out = make([]*AgentAPIToken, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] - *out = new(AgentToken) + *out = new(AgentAPIToken) (*in).DeepCopyInto(*out) } } @@ -245,11 +285,11 @@ func (in *AgentPoolStatus) DeepCopyInto(out *AgentPoolStatus) { *out = *in if in.AgentTokens != nil { in, out := &in.AgentTokens, &out.AgentTokens - *out = make([]*AgentToken, len(*in)) + *out = make([]*AgentAPIToken, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] - *out = new(AgentToken) + *out = new(AgentAPIToken) (*in).DeepCopyInto(*out) } } @@ -274,16 +314,10 @@ func (in *AgentPoolStatus) DeepCopy() *AgentPoolStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentToken) DeepCopyInto(out *AgentToken) { *out = *in - if in.CreatedAt != nil { - in, out := &in.CreatedAt, &out.CreatedAt - *out = new(int64) - **out = **in - } - if in.LastUsedAt != nil { - in, out := &in.LastUsedAt, &out.LastUsedAt - *out = new(int64) - **out = **in - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentToken. @@ -296,6 +330,101 @@ func (in *AgentToken) DeepCopy() *AgentToken { return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentToken) 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 *AgentTokenList) DeepCopyInto(out *AgentTokenList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AgentToken, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTokenList. +func (in *AgentTokenList) DeepCopy() *AgentTokenList { + if in == nil { + return nil + } + out := new(AgentTokenList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentTokenList) 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 *AgentTokenSpec) DeepCopyInto(out *AgentTokenSpec) { + *out = *in + in.Token.DeepCopyInto(&out.Token) + out.AgentPool = in.AgentPool + if in.AgentTokens != nil { + in, out := &in.AgentTokens, &out.AgentTokens + *out = make([]AgentAPIToken, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTokenSpec. +func (in *AgentTokenSpec) DeepCopy() *AgentTokenSpec { + if in == nil { + return nil + } + out := new(AgentTokenSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentTokenStatus) DeepCopyInto(out *AgentTokenStatus) { + *out = *in + if in.AgentPool != nil { + in, out := &in.AgentPool, &out.AgentPool + *out = new(AgentPoolRef) + **out = **in + } + if in.AgentTokens != nil { + in, out := &in.AgentTokens, &out.AgentTokens + *out = make([]*AgentAPIToken, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(AgentAPIToken) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTokenStatus. +func (in *AgentTokenStatus) DeepCopy() *AgentTokenStatus { + if in == nil { + return nil + } + out := new(AgentTokenStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigurationVersionStatus) DeepCopyInto(out *ConfigurationVersionStatus) { *out = *in @@ -988,21 +1117,6 @@ func (in *Workspace) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WorkspaceAgentPool) DeepCopyInto(out *WorkspaceAgentPool) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceAgentPool. -func (in *WorkspaceAgentPool) DeepCopy() *WorkspaceAgentPool { - if in == nil { - return nil - } - out := new(WorkspaceAgentPool) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceList) DeepCopyInto(out *WorkspaceList) { *out = *in @@ -1071,7 +1185,7 @@ func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { in.Token.DeepCopyInto(&out.Token) if in.AgentPool != nil { in, out := &in.AgentPool, &out.AgentPool - *out = new(WorkspaceAgentPool) + *out = new(AgentPoolRef) **out = **in } if in.RunTasks != nil { diff --git a/charts/hcp-terraform-operator/README.md b/charts/hcp-terraform-operator/README.md index fb2fa60f..c9e36645 100644 --- a/charts/hcp-terraform-operator/README.md +++ b/charts/hcp-terraform-operator/README.md @@ -42,6 +42,7 @@ $ helm install demo hashicorp/hcp-terraform-operator \ --set operator.syncPeriod=10m \ --set 'operator.watchedNamespaces={white,blue,red}' \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=5 \ --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 @@ -71,6 +72,7 @@ $ helm upgrade demo hashicorp/hcp-terraform-operator \ --namespace hcp-terraform-operator-system \ --set operator.syncPeriod=5m \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=10 \ --set controllers.project.workers=2 \ --set controllers.workspace.workers=20 @@ -186,6 +188,8 @@ For a more detailed explanation, please refer to the [FAQ](../../docs/faq.md#gen |-----|------|---------|-------------| | controllers.agentPool.syncPeriod | string | `"30s"` | The minimum frequency at which watched Agent Pool resources are reconciled. Format: 5s, 1m, etc. | | controllers.agentPool.workers | int | `1` | The number of the Agent Pool controller workers. | +| controllers.agentToken.syncPeriod | string | `"15m"` | The minimum frequency at which watched Agent Token resources are reconciled. Format: 5s, 1m, etc. | +| controllers.agentToken.workers | int | `1` | The number of the Agent Token controller workers. | | controllers.module.syncPeriod | string | `"5m"` | The minimum frequency at which watched Module resources are reconciled. Format: 5s, 1m, etc. | | controllers.module.workers | int | `1` | The number of the Module controller workers. | | controllers.project.syncPeriod | string | `"5m"` | The minimum frequency at which watched Project resources are reconciled. Format: 5s, 1m, etc. | diff --git a/charts/hcp-terraform-operator/README.md.gotmpl b/charts/hcp-terraform-operator/README.md.gotmpl index 0b350694..94ab8fd8 100644 --- a/charts/hcp-terraform-operator/README.md.gotmpl +++ b/charts/hcp-terraform-operator/README.md.gotmpl @@ -42,6 +42,7 @@ $ helm install demo hashicorp/hcp-terraform-operator \ --set operator.syncPeriod=10m \ --set 'operator.watchedNamespaces={white,blue,red}' \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=5 \ --set controllers.project.workers=5 \ --set controllers.workspace.workers=5 @@ -71,6 +72,7 @@ $ helm upgrade demo hashicorp/hcp-terraform-operator \ --namespace hcp-terraform-operator-system \ --set operator.syncPeriod=5m \ --set controllers.agentPool.workers=5 \ + --set controllers.agentToken.workers=5 \ --set controllers.module.workers=10 \ --set controllers.project.workers=2 \ --set controllers.workspace.workers=20 diff --git a/charts/hcp-terraform-operator/crds/app.terraform.io_agenttokens.yaml b/charts/hcp-terraform-operator/crds/app.terraform.io_agenttokens.yaml new file mode 100644 index 00000000..ada0fcaf --- /dev/null +++ b/charts/hcp-terraform-operator/crds/app.terraform.io_agenttokens.yaml @@ -0,0 +1,233 @@ +# 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.16.5 + labels: + app.terraform.io/crd-schema-version: v25.9.0 + name: agenttokens.app.terraform.io +spec: + group: app.terraform.io + names: + kind: AgentToken + listKind: AgentTokenList + plural: agenttokens + singular: agenttoken + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.agentPool.name + name: Agent Pool Name + type: string + - jsonPath: .status.agentPool.id + name: Agent Pool ID + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + AgentToken manages HCP Terraform Agent Tokens. + More information: + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens + 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: AgentTokenSpec defines the desired state of AgentToken. + properties: + agentPool: + description: The Agent Pool name or ID where the tokens will be managed. + properties: + id: + description: |- + Agent Pool ID. + Must match pattern: `^apool-[a-zA-Z0-9]+$` + pattern: ^apool-[a-zA-Z0-9]+$ + type: string + name: + description: Agent Pool name. + minLength: 1 + type: string + type: object + agentTokens: + description: List of the HCP Terraform Agent tokens to manage. + items: + description: |- + Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. + In `spec` only the field `Name` is allowed, the rest are used in `status`. + More infromation: + - https://developer.hashicorp.com/terraform/cloud-docs/agents + properties: + createdAt: + description: Timestamp of when the agent token was created. + format: int64 + type: integer + id: + description: Agent Token ID. + pattern: ^at-[a-zA-Z0-9]+$ + type: string + lastUsedAt: + description: Timestamp of when the agent token was last used. + format: int64 + type: integer + name: + description: Agent Token name. + minLength: 1 + type: string + required: + - name + type: object + minItems: 1 + type: array + deletionPolicy: + default: retain + description: |- + The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted. + - `retain`: When the custom resource is deleted, the operator will remove only the resource itself. + The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified. + - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret. + Default: `retain`. + enum: + - retain + - destroy + type: string + managementPolicy: + default: merge + description: |- + The Management Policy defines how the controller will manage tokens in the specified Agent Pool. + - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own. + - `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it. + Default: `merge`. + enum: + - merge + - owner + 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 + secretName: + description: |- + secretName specifies the name of the Kubernetes Secret + where the HCP Terraform Agent tokens are stored. + minLength: 1 + type: string + 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: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + 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: + - agentPool + - agentTokens + - organization + - secretName + - token + type: object + status: + description: AgentTokenStatus defines the observed state of AgentToken. + properties: + agentPool: + description: Agent Pool where tokens are managed by the controller. + properties: + id: + description: |- + Agent Pool ID. + Must match pattern: `^apool-[a-zA-Z0-9]+$` + pattern: ^apool-[a-zA-Z0-9]+$ + type: string + name: + description: Agent Pool name. + minLength: 1 + type: string + type: object + agentTokens: + description: List of the agent tokens managed by the controller. + items: + description: |- + Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. + In `spec` only the field `Name` is allowed, the rest are used in `status`. + More infromation: + - https://developer.hashicorp.com/terraform/cloud-docs/agents + properties: + createdAt: + description: Timestamp of when the agent token was created. + format: int64 + type: integer + id: + description: Agent Token ID. + pattern: ^at-[a-zA-Z0-9]+$ + type: string + lastUsedAt: + description: Timestamp of when the agent token was last used. + format: int64 + type: integer + name: + description: Agent Token name. + minLength: 1 + type: string + required: + - name + type: object + type: array + observedGeneration: + description: Real world state generation. + format: int64 + type: integer + required: + - observedGeneration + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/hcp-terraform-operator/templates/clusterrole_manager.yaml b/charts/hcp-terraform-operator/templates/clusterrole_manager.yaml index 440d9aff..dcfc1e5d 100644 --- a/charts/hcp-terraform-operator/templates/clusterrole_manager.yaml +++ b/charts/hcp-terraform-operator/templates/clusterrole_manager.yaml @@ -18,6 +18,7 @@ rules: - list - update - watch + - patch - apiGroups: - "" resources: @@ -29,6 +30,7 @@ rules: - app.terraform.io resources: - agentpools + - agenttokens - modules - projects - workspaces @@ -44,6 +46,7 @@ rules: - app.terraform.io resources: - agentpools/finalizers + - agenttokens/finalizers - modules/finalizers - projects/finalizers - workspaces/finalizers @@ -53,6 +56,7 @@ rules: - app.terraform.io resources: - agentpools/status + - agenttokens/status - modules/status - projects/status - workspaces/status diff --git a/charts/hcp-terraform-operator/templates/deployment.yaml b/charts/hcp-terraform-operator/templates/deployment.yaml index 6499acec..03356697 100644 --- a/charts/hcp-terraform-operator/templates/deployment.yaml +++ b/charts/hcp-terraform-operator/templates/deployment.yaml @@ -39,6 +39,8 @@ spec: - --sync-period={{ .Values.operator.syncPeriod }} - --agent-pool-workers={{ .Values.controllers.agentPool.workers }} - --agent-pool-sync-period={{ .Values.controllers.agentPool.syncPeriod }} + - --agent-token-workers={{ .Values.controllers.agentToken.workers }} + - --agent-token-sync-period={{ .Values.controllers.agentToken.syncPeriod }} - --module-workers={{ .Values.controllers.module.workers }} - --module-sync-period={{ .Values.controllers.module.syncPeriod }} - --project-workers={{ .Values.controllers.project.workers }} diff --git a/charts/hcp-terraform-operator/values.yaml b/charts/hcp-terraform-operator/values.yaml index 2885d927..5639ff8d 100644 --- a/charts/hcp-terraform-operator/values.yaml +++ b/charts/hcp-terraform-operator/values.yaml @@ -125,6 +125,11 @@ controllers: workers: 1 # -- The minimum frequency at which watched Agent Pool resources are reconciled. Format: 5s, 1m, etc. syncPeriod: 30s + agentToken: + # -- The number of the Agent Token controller workers. + workers: 1 + # -- The minimum frequency at which watched Agent Token resources are reconciled. Format: 5s, 1m, etc. + syncPeriod: 15m module: # -- The number of the Module controller workers. workers: 1 diff --git a/charts/test/unit/deployment_test.go b/charts/test/unit/deployment_test.go index 1358ec23..3cd6560a 100644 --- a/charts/test/unit/deployment_test.go +++ b/charts/test/unit/deployment_test.go @@ -48,6 +48,8 @@ func defaultDeployment() appsv1.Deployment { "--sync-period=1h", "--agent-pool-workers=1", "--agent-pool-sync-period=30s", + "--agent-token-workers=1", + "--agent-token-sync-period=15m", "--module-workers=1", "--module-sync-period=5m", "--project-workers=1", @@ -407,6 +409,8 @@ func TestDeploymentOperatorSyncPeriod(t *testing.T) { "--sync-period=4h", "--agent-pool-workers=1", "--agent-pool-sync-period=30s", + "--agent-token-workers=1", + "--agent-token-sync-period=15m", "--module-workers=1", "--module-sync-period=5m", "--project-workers=1", @@ -574,14 +578,16 @@ func TestDeploymentKubeRbacProxyResources(t *testing.T) { func TestDeploymentControllers(t *testing.T) { options := &helm.Options{ SetValues: map[string]string{ - "controllers.agentPool.workers": "5", - "controllers.agentPool.syncPeriod": "15m", - "controllers.module.workers": "5", - "controllers.module.syncPeriod": "15m", - "controllers.project.workers": "5", - "controllers.project.syncPeriod": "15m", - "controllers.workspace.workers": "5", - "controllers.workspace.syncPeriod": "15m", + "controllers.agentPool.workers": "5", + "controllers.agentPool.syncPeriod": "15m", + "controllers.agentToken.workers": "5", + "controllers.agentToken.syncPeriod": "15m", + "controllers.module.workers": "5", + "controllers.module.syncPeriod": "15m", + "controllers.project.workers": "5", + "controllers.project.syncPeriod": "15m", + "controllers.workspace.workers": "5", + "controllers.workspace.syncPeriod": "15m", }, Version: helmChartVersion, } @@ -591,6 +597,8 @@ func TestDeploymentControllers(t *testing.T) { "--sync-period=1h", "--agent-pool-workers=5", "--agent-pool-sync-period=15m", + "--agent-token-workers=5", + "--agent-token-sync-period=15m", "--module-workers=5", "--module-sync-period=15m", "--project-workers=5", diff --git a/charts/test/unit/rbac_clusster_role_manager_test.go b/charts/test/unit/rbac_clusster_role_manager_test.go index 3e746b91..c4eed5d3 100644 --- a/charts/test/unit/rbac_clusster_role_manager_test.go +++ b/charts/test/unit/rbac_clusster_role_manager_test.go @@ -48,6 +48,7 @@ func testRBACClusterRoleManagerRules(t *testing.T, rbac rbacv1.ClusterRole) { "list", "update", "watch", + "patch", }, APIGroups: []string{""}, Resources: []string{ @@ -76,6 +77,7 @@ func testRBACClusterRoleManagerRules(t *testing.T, rbac rbacv1.ClusterRole) { APIGroups: []string{"app.terraform.io"}, Resources: []string{ "agentpools", + "agenttokens", "modules", "projects", "workspaces", @@ -88,6 +90,7 @@ func testRBACClusterRoleManagerRules(t *testing.T, rbac rbacv1.ClusterRole) { APIGroups: []string{"app.terraform.io"}, Resources: []string{ "agentpools/finalizers", + "agenttokens/finalizers", "modules/finalizers", "projects/finalizers", "workspaces/finalizers", @@ -102,6 +105,7 @@ func testRBACClusterRoleManagerRules(t *testing.T, rbac rbacv1.ClusterRole) { APIGroups: []string{"app.terraform.io"}, Resources: []string{ "agentpools/status", + "agenttokens/status", "modules/status", "projects/status", "workspaces/status", diff --git a/cmd/main.go b/cmd/main.go index 5768ca99..2dee363c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -67,6 +67,12 @@ func main() { "The number of the Agent Pool controller workers.") flag.DurationVar(&controller.AgentPoolSyncPeriod, "agent-pool-sync-period", 30*time.Second, "The minimum frequency at which watched agent pool resources are reconciled. Format: 5s, 1m, etc.") + // AGENT TOKEN CONTROLLER OPTIONS + var agentTokenWorkers int + flag.IntVar(&agentTokenWorkers, "agent-token-workers", 1, + "The number of the Agent Token controller workers.") + flag.DurationVar(&controller.AgentTokenSyncPeriod, "agent-token-sync-period", 15*time.Minute, + "The minimum frequency at which watched agent token resources are reconciled. Format: 5s, 1m, etc.") // MODULE CONTROLLER OPTIONS var moduleWorkers int flag.IntVar(&moduleWorkers, "module-workers", 1, @@ -124,10 +130,11 @@ func main() { options := ctrl.Options{ Controller: config.Controller{ GroupKindConcurrency: map[string]int{ - "AgentPool.app.terraform.io": agentPoolWorkers, - "Module.app.terraform.io": moduleWorkers, - "Project.app.terraform.io": projectWorkers, - "Workspace.app.terraform.io": workspaceWorkers, + "AgentPool.app.terraform.io": agentPoolWorkers, + "AgentToken.app.terraform.io": agentTokenWorkers, + "Module.app.terraform.io": moduleWorkers, + "Project.app.terraform.io": projectWorkers, + "Workspace.app.terraform.io": workspaceWorkers, }, }, Scheme: scheme, @@ -171,6 +178,7 @@ func main() { setupLog.Info(fmt.Sprintf("Operator sync period: %s", syncPeriod)) setupLog.Info(fmt.Sprintf("Agent Pool sync period: %s", controller.AgentPoolSyncPeriod)) + setupLog.Info(fmt.Sprintf("Agent Token sync period: %s", controller.AgentTokenSyncPeriod)) setupLog.Info(fmt.Sprintf("Module sync period: %s", controller.ModuleSyncPeriod)) setupLog.Info(fmt.Sprintf("Project sync period: %s", controller.ProjectSyncPeriod)) setupLog.Info(fmt.Sprintf("Workspace sync period: %s", controller.WorkspaceSyncPeriod)) @@ -189,6 +197,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "AgentPool") os.Exit(1) } + if err = (&controller.AgentTokenReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("AgentTokenController"), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AgentToken") + os.Exit(1) + } if err = (&controller.ModuleReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/config/crd/bases/app.terraform.io_agenttokens.yaml b/config/crd/bases/app.terraform.io_agenttokens.yaml new file mode 100644 index 00000000..78cfb837 --- /dev/null +++ b/config/crd/bases/app.terraform.io_agenttokens.yaml @@ -0,0 +1,230 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + labels: + app.terraform.io/crd-schema-version: v25.9.0 + name: agenttokens.app.terraform.io +spec: + group: app.terraform.io + names: + kind: AgentToken + listKind: AgentTokenList + plural: agenttokens + singular: agenttoken + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.agentPool.name + name: Agent Pool Name + type: string + - jsonPath: .status.agentPool.id + name: Agent Pool ID + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + AgentToken manages HCP Terraform Agent Tokens. + More information: + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens + 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: AgentTokenSpec defines the desired state of AgentToken. + properties: + agentPool: + description: The Agent Pool name or ID where the tokens will be managed. + properties: + id: + description: |- + Agent Pool ID. + Must match pattern: `^apool-[a-zA-Z0-9]+$` + pattern: ^apool-[a-zA-Z0-9]+$ + type: string + name: + description: Agent Pool name. + minLength: 1 + type: string + type: object + agentTokens: + description: List of the HCP Terraform Agent tokens to manage. + items: + description: |- + Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. + In `spec` only the field `Name` is allowed, the rest are used in `status`. + More infromation: + - https://developer.hashicorp.com/terraform/cloud-docs/agents + properties: + createdAt: + description: Timestamp of when the agent token was created. + format: int64 + type: integer + id: + description: Agent Token ID. + pattern: ^at-[a-zA-Z0-9]+$ + type: string + lastUsedAt: + description: Timestamp of when the agent token was last used. + format: int64 + type: integer + name: + description: Agent Token name. + minLength: 1 + type: string + required: + - name + type: object + minItems: 1 + type: array + deletionPolicy: + default: retain + description: |- + The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted. + - `retain`: When the custom resource is deleted, the operator will remove only the resource itself. + The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified. + - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret. + Default: `retain`. + enum: + - retain + - destroy + type: string + managementPolicy: + default: merge + description: |- + The Management Policy defines how the controller will manage tokens in the specified Agent Pool. + - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own. + - `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it. + Default: `merge`. + enum: + - merge + - owner + 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 + secretName: + description: |- + secretName specifies the name of the Kubernetes Secret + where the HCP Terraform Agent tokens are stored. + minLength: 1 + type: string + 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: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + 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: + - agentPool + - agentTokens + - organization + - secretName + - token + type: object + status: + description: AgentTokenStatus defines the observed state of AgentToken. + properties: + agentPool: + description: Agent Pool where tokens are managed by the controller. + properties: + id: + description: |- + Agent Pool ID. + Must match pattern: `^apool-[a-zA-Z0-9]+$` + pattern: ^apool-[a-zA-Z0-9]+$ + type: string + name: + description: Agent Pool name. + minLength: 1 + type: string + type: object + agentTokens: + description: List of the agent tokens managed by the controller. + items: + description: |- + Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. + In `spec` only the field `Name` is allowed, the rest are used in `status`. + More infromation: + - https://developer.hashicorp.com/terraform/cloud-docs/agents + properties: + createdAt: + description: Timestamp of when the agent token was created. + format: int64 + type: integer + id: + description: Agent Token ID. + pattern: ^at-[a-zA-Z0-9]+$ + type: string + lastUsedAt: + description: Timestamp of when the agent token was last used. + format: int64 + type: integer + name: + description: Agent Token name. + minLength: 1 + type: string + required: + - name + type: object + type: array + observedGeneration: + description: Real world state generation. + format: int64 + type: integer + required: + - 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 7ffb30c2..f0797824 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -6,6 +6,7 @@ resources: - bases/app.terraform.io_modules.yaml - bases/app.terraform.io_agentpools.yaml - bases/app.terraform.io_projects.yaml +- bases/app.terraform.io_agenttokens.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index bceed6e4..47baf385 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -62,6 +62,8 @@ spec: - --sync-period=5m - --agent-pool-workers=1 - --agent-pool-sync-period=30s + - --agent-token-workers=1 + - --agent-token-sync-period=30s - --module-workers=1 - --module-sync-period=5m - --project-workers=1 diff --git a/config/manifests/bases/hcp-terraform-operator.clusterserviceversion.yaml b/config/manifests/bases/hcp-terraform-operator.clusterserviceversion.yaml index 3321a6fb..d5bab672 100644 --- a/config/manifests/bases/hcp-terraform-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/hcp-terraform-operator.clusterserviceversion.yaml @@ -38,6 +38,14 @@ spec: kind: AgentPool name: agentpools.app.terraform.io version: v1alpha2 + - description: |- + AgentToken manages HCP Terraform Agent Tokens. + More information: + - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens + displayName: Agent Token + kind: AgentToken + name: agenttokens.app.terraform.io + version: v1alpha2 - description: |- Module implements API-driven Run Workflows. More information: @@ -49,7 +57,7 @@ spec: - description: |- Project manages HCP Terraform Projects. More information: - - https://developer.hashicorp.com/terraform/cloud-docs/projects/manage + - https://developer.hashicorp.com/terraform/cloud-docs/projects/manage displayName: Project kind: Project name: projects.app.terraform.io diff --git a/config/rbac/agenttoken_editor_role.yaml b/config/rbac/agenttoken_editor_role.yaml new file mode 100644 index 00000000..c84bf484 --- /dev/null +++ b/config/rbac/agenttoken_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project hcp-terraform-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the app.terraform.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: hcp-terraform-operator + app.kubernetes.io/managed-by: kustomize + name: agenttoken-editor-role +rules: +- apiGroups: + - app.terraform.io + resources: + - agenttokens + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.terraform.io + resources: + - agenttokens/status + verbs: + - get diff --git a/config/rbac/agenttoken_viewer_role.yaml b/config/rbac/agenttoken_viewer_role.yaml new file mode 100644 index 00000000..535c5c07 --- /dev/null +++ b/config/rbac/agenttoken_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project hcp-terraform-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to app.terraform.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: hcp-terraform-operator + app.kubernetes.io/managed-by: kustomize + name: agenttoken-viewer-role +rules: +- apiGroups: + - app.terraform.io + resources: + - agenttokens + verbs: + - get + - list + - watch +- apiGroups: + - app.terraform.io + resources: + - agenttokens/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 98227558..3f69a786 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,9 +22,15 @@ resources: # if you do not want those helpers be installed with your Project. # - agentpool_editor_role.yaml # - agentpool_viewer_role.yaml +# - agenttoken_editor_role.yaml +# - agenttoken_viewer_role.yaml # - module_editor_role.yaml # - module_viewer_role.yaml # - project_editor_role.yaml # - project_viewer_role.yaml # - workspace_editor_role.yaml # - workspace_viewer_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the {{ .ProjectName }} itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 61aeb607..29c2d0d3 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -16,6 +16,7 @@ rules: - list - update - watch + - patch - apiGroups: - "" resources: @@ -27,6 +28,7 @@ rules: - app.terraform.io resources: - agentpools + - agenttokens - modules - projects - workspaces @@ -42,6 +44,7 @@ rules: - app.terraform.io resources: - agentpools/finalizers + - agenttokens/finalizers - modules/finalizers - projects/finalizers - workspaces/finalizers @@ -51,6 +54,7 @@ rules: - app.terraform.io resources: - agentpools/status + - agenttokens/status - modules/status - projects/status - workspaces/status diff --git a/config/samples/app_v1alpha2_agenttoken.yaml b/config/samples/app_v1alpha2_agenttoken.yaml new file mode 100644 index 00000000..83a43598 --- /dev/null +++ b/config/samples/app_v1alpha2_agenttoken.yaml @@ -0,0 +1,18 @@ +apiVersion: app.terraform.io/v1alpha2 +kind: AgentToken +metadata: + name: NAME +spec: + organization: HCP_TF_ORG_NAME + token: + secretKeyRef: + name: SECRET_NAME + key: SECRET_KEY + agentPool: + name: AGENT_POOL_NAME + agentTokens: + - name: token-a + - name: token-b + secretName: SECRET_NAME + deletionPolicy: destroy + managementPolicy: merge diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 416a6e4d..e779d0c3 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -4,4 +4,5 @@ resources: - app_v1alpha2_module.yaml - app_v1alpha2_agentpool.yaml - app_v1alpha2_project.yaml +- app_v1alpha2_agenttoken.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/docs/agenttoken.md b/docs/agenttoken.md new file mode 100644 index 00000000..1c77767f --- /dev/null +++ b/docs/agenttoken.md @@ -0,0 +1,40 @@ +# `AgentToken` + +The `AgentToken` controller allows managing tokens in arbitrary agent pools. The controller supports two policies that define its behavior in token management: + - `merge` — the controller manages its tokens alongside existing tokens in the pool, without modifying or deleting tokens it does not own. + - `owner` — the controller assumes full ownership of all tokens in the pool, managing and potentially modifying or deleting tokens, including those it did not create. + +The `merge` policy can be especially useful in multicluster environments, where the operator runs in different clusters and each instance manages its own tokens. + +## Agent Token Custom Resorce + +Below is a basic example of an `AgentToken` Custom Resource: + +Please refer to the [CRD](../config/crd/bases/app.terraform.io_agenttokens.yaml) and [API Reference](./api-reference.md#agenttoken) to get the full list of available options. + +```yaml +apiVersion: app.terraform.io/v1alpha2 +kind: AgentToken +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: hcp-terraform-operator + key: token + agentPool: + name: multik + agentTokens: + - name: token-a + - name: token-b + secretName: this + deletionPolicy: destroy + managementPolicy: merge +``` + +Once the above CR is applied, the Operator will create two tokens, `token-a` and `token-b`, in the agent pool `multik`. It will only manage these tokens (ensuring they exist) without affecting existing ones, because the default `spec.managementPolicy` is set to `merge`. + +If you have any questions, please check out the [FAQ](./faq.md#agent-token-controller) to see if you can find answers there. + +If you encounter any issues with the `AgentToken` controller please refer to the [Troubleshooting](../README.md#troubleshooting). diff --git a/docs/api-reference.md b/docs/api-reference.md index b9d6bec4..32c3e70b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -10,12 +10,37 @@ Package v1alpha2 contains API Schema definitions for the app v1alpha2 API group ### Resource Types - [AgentPool](#agentpool) +- [AgentToken](#agenttoken) +- [AgentTokenList](#agenttokenlist) - [Module](#module) - [Project](#project) - [Workspace](#workspace) +#### AgentAPIToken + + + +Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. +In `spec` only the field `Name` is allowed, the rest are used in `status`. +More infromation: + - https://developer.hashicorp.com/terraform/cloud-docs/agents + +_Appears in:_ +- [AgentPoolSpec](#agentpoolspec) +- [AgentPoolStatus](#agentpoolstatus) +- [AgentTokenSpec](#agenttokenspec) +- [AgentTokenStatus](#agenttokenstatus) + +| Field | Description | +| --- | --- | +| `name` _string_ | Agent Token name. | +| `id` _string_ | Agent Token ID. | +| `createdAt` _integer_ | Timestamp of when the agent token was created. | +| `lastUsedAt` _integer_ | Timestamp of when the agent token was last used. | + + #### AgentDeployment @@ -118,6 +143,27 @@ _Appears in:_ +#### AgentPoolRef + + + +AgentPool allows HCP Terraform to communicate with isolated, private, or on-premises infrastructure. +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/agents + +_Appears in:_ +- [AgentTokenSpec](#agenttokenspec) +- [AgentTokenStatus](#agenttokenstatus) +- [WorkspaceSpec](#workspacespec) + +| Field | Description | +| --- | --- | +| `id` _string_ | Agent Pool ID.
Must match pattern: `^apool-[a-zA-Z0-9]+$` | +| `name` _string_ | Agent Pool name. | + + #### AgentPoolSpec @@ -132,7 +178,7 @@ _Appears in:_ | `name` _string_ | Agent Pool name.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/agents/agent-pools | | `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. | -| `agentTokens` _[AgentToken](#agenttoken) array_ | List of the agent tokens to generate. | +| `agentTokens` _[AgentAPIToken](#agentapitoken) array_ | List of the agent tokens to generate. | | `agentDeployment` _[AgentDeployment](#agentdeployment)_ | Agent deployment settings | | `autoscaling` _[AgentDeploymentAutoscaling](#agentdeploymentautoscaling)_ | Agent deployment settings | | `deletionPolicy` _[AgentPoolDeletionPolicy](#agentpooldeletionpolicy)_ | The Deletion Policy specifies the behavior of the custom resource and its associated agent pool when the custom resource is deleted.
- `retain`: When you delete the custom resource, the operator will remove only the custom resource.
The HCP Terraform agent pool will be retained. The managed tokens will remain active on the HCP Terraform side; however, the corresponding secrets and managed agents will be removed.
- `destroy`: The operator will attempt to remove the managed HCP Terraform agent pool.
On success, the managed agents and the corresponding secret with tokens will be removed along with the custom resource.
On failure, the managed agents will be scaled down to 0, and the managed tokens, along with the corresponding secret, will be removed. The operator will continue attempting to remove the agent pool until it succeeds.
Default: `retain`. | @@ -144,21 +190,88 @@ _Appears in:_ -Agent Token is a secret token that a HCP Terraform Agent is used to connect to the HCP Terraform Agent Pool. -In `spec` only the field `Name` is allowed, the rest are used in `status`. -More infromation: - - https://developer.hashicorp.com/terraform/cloud-docs/agents +AgentToken manages HCP Terraform Agent Tokens. +More information: +- https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens _Appears in:_ -- [AgentPoolSpec](#agentpoolspec) -- [AgentPoolStatus](#agentpoolstatus) +- [AgentTokenList](#agenttokenlist) | Field | Description | | --- | --- | -| `name` _string_ | Agent Token name. | -| `id` _string_ | Agent Token ID. | -| `createdAt` _integer_ | Timestamp of when the agent token was created. | -| `lastUsedAt` _integer_ | Timestamp of when the agent token was last used. | +| `apiVersion` _string_ | `app.terraform.io/v1alpha2` +| `kind` _string_ | `AgentToken` +| `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` _[AgentTokenSpec](#agenttokenspec)_ | | + + +#### AgentTokenDeletionPolicy + +_Underlying type:_ _string_ + +The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted. + - `retain`: When the custom resource is deleted, the operator will remove only the resource itself. + The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified. + - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret. + +_Appears in:_ +- [AgentTokenSpec](#agenttokenspec) + + + +#### AgentTokenList + + + +AgentTokenList contains a list of AgentToken. + + + +| Field | Description | +| --- | --- | +| `apiVersion` _string_ | `app.terraform.io/v1alpha2` +| `kind` _string_ | `AgentTokenList` +| `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` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | +| `items` _[AgentToken](#agenttoken) array_ | | + + +#### AgentTokenManagementPolicy + +_Underlying type:_ _string_ + +The Management Policy defines how the controller will manage tokens in the specified Agent Pool. +- `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own. +- `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it. + +_Appears in:_ +- [AgentTokenSpec](#agenttokenspec) + + + +#### AgentTokenSpec + + + +AgentTokenSpec defines the desired state of AgentToken. + +_Appears in:_ +- [AgentToken](#agenttoken) + +| 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. | +| `deletionPolicy` _[AgentTokenDeletionPolicy](#agenttokendeletionpolicy)_ | The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted.
- `retain`: When the custom resource is deleted, the operator will remove only the resource itself.
The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified.
- `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret.
Default: `retain`. | +| `agentPool` _[AgentPoolRef](#agentpoolref)_ | The Agent Pool name or ID where the tokens will be managed. | +| `managementPolicy` _[AgentTokenManagementPolicy](#agenttokenmanagementpolicy)_ | The Management Policy defines how the controller will manage tokens in the specified Agent Pool.
- `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own.
- `owner` — the controller assumes full ownership of all agent tokens in the pool, managing and potentially modifying or deleting all tokens, including those not created by it.
Default: `merge`. | +| `agentTokens` _[AgentAPIToken](#agentapitoken) array_ | List of the HCP Terraform Agent tokens to manage. | +| `secretName` _string_ | secretName specifies the name of the Kubernetes Secret
where the HCP Terraform Agent tokens are stored. | + + #### ConfigurationVersionStatus @@ -684,6 +797,7 @@ Token refers to a Kubernetes Secret object within the same namespace as the Work _Appears in:_ - [AgentPoolSpec](#agentpoolspec) +- [AgentTokenSpec](#agenttokenspec) - [ModuleSpec](#modulespec) - [ProjectSpec](#projectspec) - [WorkspaceSpec](#workspacespec) @@ -807,25 +921,6 @@ More information: | `spec` _[WorkspaceSpec](#workspacespec)_ | | -#### WorkspaceAgentPool - - - -AgentPool allows HCP Terraform to communicate with isolated, private, or on-premises infrastructure. -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/agents - -_Appears in:_ -- [WorkspaceSpec](#workspacespec) - -| Field | Description | -| --- | --- | -| `id` _string_ | Agent Pool ID.
Must match pattern: `^apool-[a-zA-Z0-9]+$` | -| `name` _string_ | Agent Pool name. | - - #### WorkspaceProject @@ -884,7 +979,7 @@ _Appears in:_ | `applyRunTrigger` _string_ | Specifies the type of apply, whether manual or auto
Must be of value `auto` or `manual`
Default: `manual`
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings#auto-apply | | `allowDestroyPlan` _boolean_ | Allows a destroy plan to be created and applied.
Default: `true`.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings#destruction-and-deletion | | `description` _string_ | Workspace description. | -| `agentPool` _[WorkspaceAgentPool](#workspaceagentpool)_ | HCP Terraform Agents allow HCP Terraform to communicate with isolated, private, or on-premises infrastructure.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/agents | +| `agentPool` _[AgentPoolRef](#agentpoolref)_ | HCP Terraform Agents allow HCP Terraform to communicate with isolated, private, or on-premises infrastructure.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/agents | | `executionMode` _string_ | Define where the Terraform code will be executed.
Must be one of the following values: `agent`, `local`, `remote`.
Default: `remote`.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings#execution-mode | | `runTasks` _[WorkspaceRunTask](#workspaceruntask) array_ | Run tasks allow HCP Terraform to interact with external systems at specific points in the HCP Terraform run lifecycle.
More information:
- https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/run-tasks | | `tags` _[Tag](#tag) array_ | Workspace tags are used to help identify and group together workspaces.
Tags must be one or more characters; can include letters, numbers, colons, hyphens, and underscores; and must begin and end with a letter or number. | diff --git a/docs/examples/agentToken-basic.yaml b/docs/examples/agentToken-basic.yaml new file mode 100644 index 00000000..d682e62b --- /dev/null +++ b/docs/examples/agentToken-basic.yaml @@ -0,0 +1,31 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +--- +apiVersion: app.terraform.io/v1alpha2 +kind: AgentToken +metadata: + name: this +spec: + organization: kubernetes-operator + token: + secretKeyRef: + name: tfc-operator + key: token + agentPool: + name: multik + # Alternatively, you can use the Agent Pool ID: + # + # id: apool-nXkqizJF2XGyyXRG + agentTokens: + - name: token-a + - name: token-b + secretName: this + deletionPolicy: retain + # Alternatively, you can use different policy: + # + # deletionPolicy: destroy + managementPolicy: merge + # Alternatively, you can use different policy: + # + # managementPolicy: owner diff --git a/docs/faq.md b/docs/faq.md index 88a13c52..280154ca 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -76,6 +76,8 @@ The `--agent-pool-sync-period` is a `AgentPool` controller option that specifies the time interval for requeuing AgentPool resources, ensuring they will be reconciled. This time is set individually per resource and it helps avoid spike of the resources to reconcile. + The `--agent-token-sync-period` is a `AgentToken` controller option that specifies the time interval for requeuing AgentToken resources, ensuring they will be reconciled. This time is set individually per resource and it helps avoid spike of the resources to reconcile. + The `--module-sync-period` is a `Module` controller option that specifies the time interval for requeuing Module resources, ensuring they will be reconciled. This time is set individually per resource and it helps avoid spike of the resources to reconcile. The `--project-sync-period` is a `Project` controller option that specifies the time interval for requeuing Project resources, ensuring they will be reconciled. This time is set individually per resource and it helps avoid spike of the resources to reconcile. @@ -207,6 +209,12 @@ 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`. +## Agent Token Controller + +- **Where can I find Agent tokens?** + + Agent tokens are sensitive and will be stored in a Kubernetes Secret. The tokens will be saved in the Secret referenced by `spec.secretName`. The Secret can either already exist or be created by the controller if it doesn't. We strongly recommend using a unique name for this Secret to avoid data loss or unintentional overwrites. + ## Module Controller - **Where can I find Module outputs?** @@ -295,4 +303,4 @@ - **What is Auto Apply for Run Triggers?** - The field `spec.applyRunTrigger` specifies whether to to automatically or manually apply changes for runs that are created by run triggers from another workspace. Value must be set to auto or manual. + The field `spec.applyRunTrigger` specifies whether to to automatically or manually apply changes for runs that are created by run triggers from another workspace. Value must be set to auto or manual. diff --git a/internal/controller/agentpool_controller_autoscaling_test.go b/internal/controller/agentpool_controller_autoscaling_test.go index 87d8666d..2bbf5ee7 100644 --- a/internal/controller/agentpool_controller_autoscaling_test.go +++ b/internal/controller/agentpool_controller_autoscaling_test.go @@ -55,7 +55,7 @@ var _ = Describe("Agent Pool controller", Ordered, func() { Key: secretKey, }, }, - AgentTokens: []*appv1alpha2.AgentToken{ + AgentTokens: []*appv1alpha2.AgentAPIToken{ {Name: "token"}, }, AgentDeployment: &appv1alpha2.AgentDeployment{ diff --git a/internal/controller/agentpool_controller_deletion_policy_test.go b/internal/controller/agentpool_controller_deletion_policy_test.go index 1885468a..10c8cfb1 100644 --- a/internal/controller/agentpool_controller_deletion_policy_test.go +++ b/internal/controller/agentpool_controller_deletion_policy_test.go @@ -56,7 +56,7 @@ var _ = Describe("Agent Pool controller", Ordered, func() { Key: secretKey, }, }, - AgentTokens: []*appv1alpha2.AgentToken{ + AgentTokens: []*appv1alpha2.AgentAPIToken{ {Name: "first"}, {Name: "second"}, }, diff --git a/internal/controller/agentpool_controller_test.go b/internal/controller/agentpool_controller_test.go index 25c37582..e10199b3 100644 --- a/internal/controller/agentpool_controller_test.go +++ b/internal/controller/agentpool_controller_test.go @@ -59,7 +59,7 @@ var _ = Describe("Agent Pool controller", Ordered, func() { Key: secretKey, }, }, - AgentTokens: []*appv1alpha2.AgentToken{ + AgentTokens: []*appv1alpha2.AgentAPIToken{ {Name: "first"}, {Name: "second"}, }, @@ -123,7 +123,7 @@ var _ = Describe("Agent Pool controller", Ordered, func() { // ADD ONE MORE AGENT TOKEN Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) - instance.Spec.AgentTokens = append(instance.Spec.AgentTokens, &appv1alpha2.AgentToken{Name: "third"}) + instance.Spec.AgentTokens = append(instance.Spec.AgentTokens, &appv1alpha2.AgentAPIToken{Name: "third"}) Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) @@ -627,7 +627,7 @@ func testWorkspace(name, namespace, agentPoolName string) *appv1alpha2.Workspace }, Name: fmt.Sprintf("test-workspace-%v", randomNumber()), ExecutionMode: "agent", - AgentPool: &appv1alpha2.WorkspaceAgentPool{ + AgentPool: &appv1alpha2.AgentPoolRef{ Name: agentPoolName, }, }, diff --git a/internal/controller/agentpool_controller_tokens.go b/internal/controller/agentpool_controller_tokens.go index a2ef68de..7c7f90d8 100644 --- a/internal/controller/agentpool_controller_tokens.go +++ b/internal/controller/agentpool_controller_tokens.go @@ -69,7 +69,7 @@ func (r *AgentPoolReconciler) createToken(ctx context.Context, ap *agentPoolInst } ap.log.Info("Reconcile Agent Tokens", "msg", fmt.Sprintf("successfully updated Kubernets Secret %q with token %q", s.Name, token)) - ap.instance.Status.AgentTokens = append(ap.instance.Status.AgentTokens, &appv1alpha2.AgentToken{ + ap.instance.Status.AgentTokens = append(ap.instance.Status.AgentTokens, &appv1alpha2.AgentAPIToken{ Name: at.Description, ID: at.ID, CreatedAt: pointer.PointerOf(at.CreatedAt.Unix()), diff --git a/internal/controller/agenttoken_controller.go b/internal/controller/agenttoken_controller.go new file mode 100644 index 00000000..7882852d --- /dev/null +++ b/internal/controller/agenttoken_controller.go @@ -0,0 +1,421 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controller + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/go-logr/logr" + tfc "github.com/hashicorp/go-tfe" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + appv1alpha2 "github.com/hashicorp/hcp-terraform-operator/api/v1alpha2" + "github.com/hashicorp/hcp-terraform-operator/internal/pointer" + "github.com/hashicorp/hcp-terraform-operator/internal/slice" + "github.com/hashicorp/hcp-terraform-operator/version" +) + +// AgentTokenReconciler reconciles a AgentToken object +type AgentTokenReconciler struct { + client.Client + Recorder record.EventRecorder + Scheme *runtime.Scheme +} + +type agentTokenInstance struct { + instance appv1alpha2.AgentToken + + log logr.Logger + tfClient HCPTerraformClient +} + +//+kubebuilder:rbac:groups=apt.terraform.io,resources=agenttokens,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=apt.terraform.io,resources=agenttokens/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=apt.terraform.io,resources=agenttokens/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch +//+kubebuilder:rbac:groups="",resources=secrets,verbs=create;list;update;watch;patch + +func (r *AgentTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + t := agentTokenInstance{} + + t.log = log.Log.WithValues("agenttoken", req.NamespacedName) + t.log.Info("Agent Token Controller", "msg", "new reconciliation event") + + err := r.Client.Get(ctx, req.NamespacedName, &t.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) { + t.log.Info("Agent Token Controller", "msg", "the instance was removed no further action is required") + return doNotRequeue() + } + t.log.Error(err, "Agent Token Controller", "msg", "get instance object") + return requeueAfter(requeueInterval) + } + + t.log.Info("Spec Validation", "msg", "validating instance object spec") + if err := t.instance.ValidateSpec(); err != nil { + t.log.Error(err, "Spec Validation", "msg", "spec is invalid, exit from reconciliation") + r.Recorder.Event(&t.instance, corev1.EventTypeWarning, "SpecValidation", err.Error()) + return doNotRequeue() + } + t.log.Info("Spec Validation", "msg", "spec is valid") + + if needToAddFinalizer(&t.instance, agentTokenFinalizer) { + err := r.addFinalizer(ctx, &t.instance) + if err != nil { + t.log.Error(err, "Agent Token Controller", "msg", fmt.Sprintf("failed to add finalizer %s to the object", agentTokenFinalizer)) + r.Recorder.Eventf(&t.instance, corev1.EventTypeWarning, "AddFinalizer", "Failed to add finalizer %s to the object", agentTokenFinalizer) + return requeueOnErr(err) + } + t.log.Info("Agent Token Controller", "msg", fmt.Sprintf("successfully added finalizer %s to the object", agentTokenFinalizer)) + r.Recorder.Eventf(&t.instance, corev1.EventTypeNormal, "AddFinalizer", "Successfully added finalizer %s to the object", agentTokenFinalizer) + } + + err = r.getTerraformClient(ctx, &t) + if err != nil { + t.log.Error(err, "Agent Token Controller", "msg", "failed to get HCP Terraform client") + r.Recorder.Event(&t.instance, corev1.EventTypeWarning, "TerraformClient", "Failed to get HCP Terraform Client") + return requeueAfter(requeueInterval) + } + + err = r.reconcileToken(ctx, &t) + if err != nil { + t.log.Error(err, "Agent Token Controller", "msg", "Reconcile Agent Token") + r.Recorder.Event(&t.instance, corev1.EventTypeWarning, "ReconcileAgentToken", "Failed to Reconcile Agent Token") + return requeueAfter(requeueInterval) + } + t.log.Info("Agent Token Controller", "msg", "successfully reconcilied agent token") + r.Recorder.Event(&t.instance, corev1.EventTypeNormal, "ReconcileAgentToken", "Successfully reconcilied agent token") + + return requeueAfter(AgentTokenSyncPeriod) +} + +func (r *AgentTokenReconciler) addFinalizer(ctx context.Context, instance *appv1alpha2.AgentToken) error { + controllerutil.AddFinalizer(instance, agentTokenFinalizer) + + return r.Update(ctx, instance) +} + +func (r *AgentTokenReconciler) getTerraformClient(ctx context.Context, t *agentTokenInstance) error { + nn := types.NamespacedName{ + Namespace: t.instance.Namespace, + Name: t.instance.Spec.Token.SecretKeyRef.Name, + } + token, err := secretKeyRef(ctx, r.Client, nn, t.instance.Spec.Token.SecretKeyRef.Key) + 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 { + t.log.Info("Reconcile Agent Token", "msg", "client configured to skip TLS certificate verifications") + } + + httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: insecure} + + config := &tfc.Config{ + Token: token, + HTTPClient: httpClient, + Headers: http.Header{ + "User-Agent": []string{version.UserAgent}, + }, + } + t.tfClient.Client, err = tfc.NewClient(config) + + return err +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AgentTokenReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&appv1alpha2.AgentToken{}). + WithEventFilter(predicate.Or(genericPredicates())). + Complete(r) +} + +func (r *AgentTokenReconciler) removeFinalizer(ctx context.Context, t *agentTokenInstance) error { + controllerutil.RemoveFinalizer(&t.instance, agentTokenFinalizer) + + err := r.Update(ctx, &t.instance) + if err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to remove finalizer %s", agentTokenFinalizer)) + r.Recorder.Eventf(&t.instance, corev1.EventTypeWarning, "RemoveAgentToken", "Failed to remove finalizer %s", agentTokenFinalizer) + } + + return err +} + +func (r *AgentTokenReconciler) getAgentPoolIDByName(ctx context.Context, t *agentTokenInstance) (*tfc.AgentPool, error) { + spec := t.instance.Spec.AgentPool.Name + + listOpts := &tfc.AgentPoolListOptions{ + Query: spec, + ListOptions: tfc.ListOptions{ + PageSize: maxPageSize, + }, + } + for { + agentPoolIDs, err := t.tfClient.Client.AgentPools.List(ctx, t.instance.Spec.Organization, listOpts) + if err != nil { + return nil, err + } + for _, a := range agentPoolIDs.Items { + if a.Name == spec { + return a, nil + } + } + if agentPoolIDs.NextPage == 0 { + break + } + listOpts.PageNumber = agentPoolIDs.NextPage + } + + return nil, fmt.Errorf("agent pool ID not found for agent pool name %q", spec) +} + +func (r *AgentTokenReconciler) getAgentPoolID(ctx context.Context, t *agentTokenInstance) (*tfc.AgentPool, error) { + specAgentPool := t.instance.Spec.AgentPool + + if specAgentPool.Name != "" { + t.log.Info("Reconcile Agent Pool", "msg", "getting agent pool ID by name") + return r.getAgentPoolIDByName(ctx, t) + } + + t.log.Info("Reconcile Agent Pool", "msg", "getting agent pool ID from the spec.AgentPool.ID") + + return t.tfClient.Client.AgentPools.Read(ctx, specAgentPool.ID) +} + +func (r *AgentTokenReconciler) getAgentPool(ctx context.Context, t *agentTokenInstance) (*tfc.AgentPool, error) { + spec := t.instance.Spec.AgentPool + + if spec.Name != "" { + t.log.Info("Reconcile Agent Token", "msg", "getting agent pool by name") + return r.getAgentPoolIDByName(ctx, t) + } + + t.log.Info("Reconcile Agent Token", "msg", "getting agent pool by ID") + return r.getAgentPoolID(ctx, t) +} + +func (r *AgentTokenReconciler) updateStatusAgentPool(ctx context.Context, t *agentTokenInstance) error { + pool, err := r.getAgentPool(ctx, t) + if err != nil { + return err + } + + t.instance.Status.AgentPool = &appv1alpha2.AgentPoolRef{ + ID: pool.ID, + Name: pool.Name, + } + + return nil +} + +func (r *AgentTokenReconciler) listAgentTokens(ctx context.Context, t *agentTokenInstance) (map[string]string, error) { + if t.instance.Status.AgentPool == nil { + if err := r.updateStatusAgentPool(ctx, t); err != nil { + return nil, err + } + } + m := make(map[string]string) + tokens, err := t.tfClient.Client.AgentTokens.List(ctx, t.instance.Status.AgentPool.ID) + if err == tfc.ErrResourceNotFound { + if err := r.updateStatusAgentPool(ctx, t); err != nil { + return nil, err + } + tokens, err := t.tfClient.Client.AgentTokens.List(ctx, t.instance.Status.AgentPool.ID) + if err != nil { + return nil, err + } + for _, token := range tokens.Items { + m[token.ID] = token.Description + } + return m, err + } + for _, token := range tokens.Items { + m[token.ID] = token.Description + } + + return m, err +} + +func (r *AgentTokenReconciler) createToken(ctx context.Context, t *agentTokenInstance, name string) error { + nn := types.NamespacedName{ + Namespace: t.instance.Namespace, + Name: t.instance.Spec.SecretName, + } + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("creating a new agent token %q", name)) + at, err := t.tfClient.Client.AgentTokens.Create(ctx, t.instance.Status.AgentPool.ID, tfc.AgentTokenCreateOptions{ + Description: &name, + }) + if err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to create a new token %q", name)) + return err + } + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("successfully created a new agent token %q %q", name, at.ID)) + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: t.instance.Namespace, + Name: t.instance.Spec.SecretName, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, r.Client, s, func() error { + if err := controllerutil.SetControllerReference(&t.instance, s, r.Scheme); err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to set controller reference to secret=%q namespace=%q", nn.Name, nn.Namespace)) + return err + } + s.Annotations = map[string]string{ + "app.terraform.io/agent-pool-id": t.instance.Status.AgentPool.ID, + "app.terraform.io/agent-pool-name": t.instance.Status.AgentPool.Name, + } + if s.Data == nil { + s.Data = make(map[string][]byte) + } + s.Data[at.Description] = []byte(at.Token) + return nil + }) + if err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("unable to create key=%q in secret=%q namespace=%q", name, nn.Name, nn.Namespace)) + return err + } + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("successfully created key=%q in secret=%q namespace=%q", name, nn.Name, nn.Namespace)) + + t.instance.Status.AgentTokens = append(t.instance.Status.AgentTokens, &appv1alpha2.AgentAPIToken{ + Name: at.Description, + ID: at.ID, + CreatedAt: pointer.PointerOf(at.CreatedAt.Unix()), + LastUsedAt: pointer.PointerOf(at.LastUsedAt.Unix()), + }) + + return nil +} + +func (r *AgentTokenReconciler) removeToken(ctx context.Context, t *agentTokenInstance, id string) error { + for i, token := range t.instance.Status.AgentTokens { + if token.ID == id { + err := t.tfClient.Client.AgentTokens.Delete(ctx, id) + if err != nil && err != tfc.ErrResourceNotFound { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to remove token %q", id)) + return err + } + s := &corev1.Secret{} + nn := types.NamespacedName{ + Namespace: t.instance.Namespace, + Name: t.instance.Spec.SecretName, + } + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("remove key=%q from in secret=%q namespace=%q", id, nn.Name, nn.Namespace)) + if err := r.Client.Get(ctx, nn, s); err != nil { + if apierrors.IsNotFound(err) { + t.instance.Status.AgentTokens = slice.RemoveFromSlice(t.instance.Status.AgentTokens, i) + return nil + } + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to get secret=%q namespace=%q", nn.Name, nn.Namespace)) + return err + } + // + patch := client.MergeFrom(s.DeepCopy()) + delete(s.Data, token.Name) + if err := r.Client.Patch(ctx, s, patch); err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("unable to remove key=%q in secret=%q namespace=%q", token.Name, nn.Name, nn.Namespace)) + return err + } + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("successfully removed key=%q in secret=%q namespace=%q", token.Name, nn.Name, nn.Namespace)) + t.instance.Status.AgentTokens = slice.RemoveFromSlice(t.instance.Status.AgentTokens, i) + return nil + } + } + + return nil +} + +func (r *AgentTokenReconciler) reconcileToken(ctx context.Context, t *agentTokenInstance) error { + t.log.Info("Reconcile Agent Token", "msg", "reconciling agent token") + + // verify whether the Kubernetes object has been marked as deleted and if so delete the tokens + if isDeletionCandidate(&t.instance, agentTokenFinalizer) { + t.log.Info("Reconcile Agent Token", "msg", "object marked as deleted, need to delete tokens first") + r.Recorder.Event(&t.instance, corev1.EventTypeNormal, "ReconcileAgentToken", "Object marked as deleted, need to delete tokens first") + return r.deleteAgentToken(ctx, t) + } + + tokens, err := r.listAgentTokens(ctx, t) + if err != nil { + return err + } + + statusTokens := make(map[string]string, len(t.instance.Status.AgentTokens)) + for _, token := range t.instance.Status.AgentTokens { + statusTokens[token.Name] = token.ID + } + + // Clean up. + for _, token := range t.instance.Spec.AgentTokens { + if tokenID, ok := statusTokens[token.Name]; ok { + delete(statusTokens, token.Name) + if _, ok := tokens[tokenID]; ok { + delete(tokens, tokenID) + continue + } + if err := r.removeToken(ctx, t, tokenID); err != nil { + return err + } + } + if err := r.createToken(ctx, t, token.Name); err != nil { + return err + } + } + for _, id := range statusTokens { + if err := r.removeToken(ctx, t, id); err != nil { + return err + } + } + + switch t.instance.Spec.ManagementPolicy { + case appv1alpha2.AgentTokenManagementPolicyMerge: + // This remains no-op. + case appv1alpha2.AgentTokenManagementPolicyOwner: + for id := range tokens { + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("removing agent token %q", id)) + err := t.tfClient.Client.AgentTokens.Delete(ctx, id) + if err != nil && err != tfc.ErrResourceNotFound { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to remove agent token %q", id)) + return err + } + if err := r.removeToken(ctx, t, id); err != nil { + return err + } + } + } + + t.instance.Status.ObservedGeneration = t.instance.Generation + + return r.Status().Update(ctx, &t.instance) +} diff --git a/internal/controller/agenttoken_controller_deletion_policy.go b/internal/controller/agenttoken_controller_deletion_policy.go new file mode 100644 index 00000000..ced31e93 --- /dev/null +++ b/internal/controller/agenttoken_controller_deletion_policy.go @@ -0,0 +1,70 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + tfc "github.com/hashicorp/go-tfe" + appv1alpha2 "github.com/hashicorp/hcp-terraform-operator/api/v1alpha2" +) + +func (r *AgentTokenReconciler) deleteAgentToken(ctx context.Context, t *agentTokenInstance) error { + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("deletion policy is %s", t.instance.Spec.DeletionPolicy)) + + if t.instance.Status.AgentPool == nil && t.instance.Status.AgentPool.ID == "" { + t.log.Info("Reconcile Agent Token", "msg", fmt.Sprintf("Agent Pool ID is not set, remove finalizer %s", agentPoolFinalizer)) + return r.removeFinalizer(ctx, t) + } + + switch t.instance.Spec.DeletionPolicy { + case appv1alpha2.AgentTokenDeletionPolicyRetain: + nn := types.NamespacedName{ + Namespace: t.instance.Namespace, + Name: t.instance.Spec.SecretName, + } + s := &corev1.Secret{} + if err := r.Client.Get(ctx, nn, s); err != nil { + if apierrors.IsNotFound(err) { + return r.removeFinalizer(ctx, t) + } + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to get secret=%q namespace=%q", nn.Name, nn.Namespace)) + return err + } + patch := client.MergeFrom(s.DeepCopy()) + if err := controllerutil.RemoveControllerReference(&t.instance, s, r.Scheme); err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("failed to remove controller reference from secret=%q namespace=%q", nn.Name, nn.Namespace)) + return err + } + if err := r.Client.Patch(ctx, s, patch); err != nil { + t.log.Error(err, "Reconcile Agent Token", "msg", fmt.Sprintf("unable to patch secret=%q namespace=%q", nn.Name, nn.Namespace)) + return err + } + case appv1alpha2.AgentTokenDeletionPolicyDestroy: + if len(t.instance.Status.AgentTokens) > 0 { + t.log.Info("Reconcile Agent Token", "msg", "remove tokens") + for _, token := range t.instance.Status.AgentTokens { + err := t.tfClient.Client.AgentTokens.Delete(ctx, token.ID) + if err != nil && err != tfc.ErrResourceNotFound { + t.log.Error(err, "Reconcile Agent Pool", "msg", fmt.Sprintf("failed to remove token %s", token.ID)) + return err + } + err = r.removeToken(ctx, t, token.ID) + if err != nil { + return err + } + } + t.log.Info("Reconcile Agent Pool", "msg", "successfully deleted tokens") + } + } + + return r.removeFinalizer(ctx, t) +} diff --git a/internal/controller/agenttoken_controller_deletion_policy_test.go b/internal/controller/agenttoken_controller_deletion_policy_test.go new file mode 100644 index 00000000..50b67003 --- /dev/null +++ b/internal/controller/agenttoken_controller_deletion_policy_test.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controller + +// TODO diff --git a/internal/controller/agenttoken_controller_test.go b/internal/controller/agenttoken_controller_test.go new file mode 100644 index 00000000..0ef23db5 --- /dev/null +++ b/internal/controller/agenttoken_controller_test.go @@ -0,0 +1,254 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package controller + +import ( + "context" + "fmt" + "maps" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + tfc "github.com/hashicorp/go-tfe" + appv1alpha2 "github.com/hashicorp/hcp-terraform-operator/api/v1alpha2" + "github.com/hashicorp/hcp-terraform-operator/internal/slice" +) + +var _ = Describe("AgentToken Controller", Ordered, func() { + var ( + instance *appv1alpha2.AgentToken + namespacedName = newNamespacedName() + poolName = fmt.Sprintf("kubernetes-operator-agent-pool-%v", randomNumber()) + ) + + BeforeAll(func() { + // Set default Eventually timers + SetDefaultEventuallyTimeout(syncPeriod * 4) + SetDefaultEventuallyPollingInterval(2 * time.Second) + }) + + BeforeEach(func() { + // Create a new agent pool object for each test + instance = &appv1alpha2.AgentToken{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "app.terraform.io/v1alpha2", + Kind: "AgentToken", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + DeletionTimestamp: nil, + Finalizers: []string{}, + }, + Spec: appv1alpha2.AgentTokenSpec{ + Organization: organization, + Token: appv1alpha2.Token{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretNamespacedName.Name, + }, + Key: secretKey, + }, + }, + DeletionPolicy: appv1alpha2.AgentTokenDeletionPolicyRetain, + AgentTokens: []appv1alpha2.AgentAPIToken{ + {Name: "first"}, + {Name: "second"}, + }, + ManagementPolicy: appv1alpha2.AgentTokenManagementPolicyMerge, + SecretName: namespacedName.Name, + }, + Status: appv1alpha2.AgentTokenStatus{}, + } + }) + + AfterEach(func() { + // DELETE AGENT POOL + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + + Eventually(func() bool { + err := k8sClient.Get(ctx, namespacedName, instance) + return errors.IsNotFound(err) + }).Should(BeTrue()) + + Eventually(func() bool { + if instance.Status.AgentPool == nil { + return true + } + err := tfClient.AgentPools.Delete(ctx, instance.Status.AgentPool.ID) + return err == tfc.ErrResourceNotFound || err == nil + }).Should(BeTrue()) + }) + + Context("When reconciling a resource", func() { + It("should successfully manage tokens with merge policy", func() { + // CREATE AGENT POOL WITH ONE TOKEN + pool := createAgentPoolWithToken(poolName) + // CREATE KUBERNETS ITEM + instance.Spec.AgentPool = appv1alpha2.AgentPoolRef{ + ID: pool.ID, + } + instance.Spec.SecretName = string(appv1alpha2.AgentTokenManagementPolicyMerge) + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ObservedGeneration == instance.Generation + }).Should(BeTrue()) + validateTokensPolicyMerge(ctx, instance) + validateTokensSecretSync(ctx, instance) + // ADD ONE MORE TOKEN + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.AgentTokens = append(instance.Spec.AgentTokens, appv1alpha2.AgentAPIToken{ + Name: "third", + }) + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + validateTokensPolicyMerge(ctx, instance) + validateTokensSecretSync(ctx, instance) + // REMOVE ONE TOKEN + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.AgentTokens = slice.RemoveFromSlice(instance.Spec.AgentTokens, len(instance.Spec.AgentTokens)-1) + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + validateTokensPolicyMerge(ctx, instance) + validateTokensSecretSync(ctx, instance) + // CAN RESTORE TOKEN + Expect(tfClient.AgentTokens.Delete(ctx, instance.Status.AgentTokens[0].ID)).Should(Succeed()) + validateTokensPolicyMerge(ctx, instance) + validateTokensSecretSync(ctx, instance) + }) + It("should successfully manage tokens with owner policy", func() { + // CREATE AGENT POOL WITH ONE TOKEN + pool := createAgentPoolWithToken(poolName) + // CREATE KUBERNETS ITEM + instance.Spec.AgentPool = appv1alpha2.AgentPoolRef{ + ID: pool.ID, + } + instance.Spec.ManagementPolicy = appv1alpha2.AgentTokenManagementPolicyOwner + instance.Spec.SecretName = string(appv1alpha2.AgentTokenManagementPolicyOwner) + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + Eventually(func() bool { + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + return instance.Status.ObservedGeneration == instance.Generation + }).Should(BeTrue()) + validateTokensPolicyOwner(ctx, instance) + validateTokensSecretSync(ctx, instance) + // ADD ONE MORE TOKEN + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.AgentTokens = append(instance.Spec.AgentTokens, appv1alpha2.AgentAPIToken{ + Name: "third", + }) + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + validateTokensPolicyOwner(ctx, instance) + validateTokensSecretSync(ctx, instance) + // REMOVE ONE TOKEN + Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) + instance.Spec.AgentTokens = slice.RemoveFromSlice(instance.Spec.AgentTokens, len(instance.Spec.AgentTokens)-1) + Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) + validateTokensPolicyOwner(ctx, instance) + validateTokensSecretSync(ctx, instance) + // CAN RESTORE TOKEN + Expect(tfClient.AgentTokens.Delete(ctx, instance.Status.AgentTokens[0].ID)).Should(Succeed()) + validateTokensPolicyOwner(ctx, instance) + validateTokensSecretSync(ctx, instance) + }) + }) +}) + +func validateTokensSecretSync(ctx context.Context, instance *appv1alpha2.AgentToken) { + snn := types.NamespacedName{ + Namespace: instance.Namespace, + Name: instance.Spec.SecretName, + } + nn := getNamespacedName(instance) + Eventually(func() bool { + s := &corev1.Secret{} + Expect(k8sClient.Get(ctx, snn, s)).Should(Succeed()) + st := make(map[string]struct{}) + for name := range s.Data { + st[name] = struct{}{} + } + + Expect(k8sClient.Get(ctx, nn, instance)).Should(Succeed()) + kt := make(map[string]struct{}) + for _, t := range instance.Status.AgentTokens { + kt[t.Name] = struct{}{} + } + + return maps.Equal(st, kt) + }).Should(BeTrue()) +} + +func validateTokensPolicyOwner(ctx context.Context, instance *appv1alpha2.AgentToken) { + nn := getNamespacedName(instance) + Eventually(func() bool { + Expect(k8sClient.Get(ctx, nn, instance)).Should(Succeed()) + if instance.Generation != instance.Status.ObservedGeneration { + return false + } + at, err := tfClient.AgentTokens.List(ctx, instance.Status.AgentPool.ID) + Expect(err).Should(Succeed()) + Expect(at).ShouldNot(BeNil()) + + ct := make(map[string]struct{}, len(at.Items)) + for _, t := range at.Items { + ct[t.ID] = struct{}{} + } + + kt := make(map[string]struct{}, len(instance.Status.AgentTokens)) + for _, t := range instance.Status.AgentTokens { + kt[t.ID] = struct{}{} + } + + return maps.Equal(ct, kt) + }).Should(BeTrue()) +} + +func validateTokensPolicyMerge(ctx context.Context, instance *appv1alpha2.AgentToken) { + nn := getNamespacedName(instance) + Eventually(func() bool { + Expect(k8sClient.Get(ctx, nn, instance)).Should(Succeed()) + if instance.Generation != instance.Status.ObservedGeneration { + return false + } + at, err := tfClient.AgentTokens.List(ctx, instance.Status.AgentPool.ID) + Expect(err).Should(Succeed()) + Expect(at).ShouldNot(BeNil()) + if len(at.Items) != len(instance.Spec.AgentTokens)+1 { + return false + } + + tokens := make(map[string]struct{}, len(at.Items)) + for _, t := range at.Items { + tokens[t.ID] = struct{}{} + } + + for _, t := range instance.Status.AgentTokens { + if _, ok := tokens[t.ID]; !ok { + return false + } + } + return true + }).Should(BeTrue()) +} + +func createAgentPoolWithToken(name string) *tfc.AgentPool { + // CREATE AGENT POOL + pool, err := tfClient.AgentPools.Create(ctx, organization, tfc.AgentPoolCreateOptions{ + Name: tfc.String(name), + }) + Expect(err).Should(Succeed()) + // CREATE AGENT TOKEN + _, err = tfClient.AgentTokens.Create(ctx, pool.ID, tfc.AgentTokenCreateOptions{ + Description: tfc.String("token"), + }) + Expect(err).Should(Succeed()) + + return pool +} diff --git a/internal/controller/consts.go b/internal/controller/consts.go index 6a077562..3f2e43fa 100644 --- a/internal/controller/consts.go +++ b/internal/controller/consts.go @@ -22,6 +22,11 @@ const ( agentPoolFinalizer = "agentpool.app.terraform.io/finalizer" ) +// AGENT TOKEN CONTROLLER'S CONSTANTS +const ( + agentTokenFinalizer = "agenttoken.app.terraform.io/finalizer" +) + // MODULE CONTROLLER'S CONSTANTS const ( requeueConfigurationUploadInterval = 10 * time.Second diff --git a/internal/controller/flags.go b/internal/controller/flags.go index 48b4e2fb..f1d3cb47 100644 --- a/internal/controller/flags.go +++ b/internal/controller/flags.go @@ -8,8 +8,9 @@ import ( ) var ( - AgentPoolSyncPeriod time.Duration - ModuleSyncPeriod time.Duration - ProjectSyncPeriod time.Duration - WorkspaceSyncPeriod time.Duration + AgentPoolSyncPeriod time.Duration + AgentTokenSyncPeriod time.Duration + ModuleSyncPeriod time.Duration + ProjectSyncPeriod time.Duration + WorkspaceSyncPeriod time.Duration ) diff --git a/internal/controller/predicates.go b/internal/controller/predicates.go index 4cb5700e..fc57b442 100644 --- a/internal/controller/predicates.go +++ b/internal/controller/predicates.go @@ -94,6 +94,7 @@ func workspacePredicates() predicate.Predicate { func deletionTimestampPredicate(o client.Object) bool { finalizers := []string{ agentPoolFinalizer, + agentTokenFinalizer, moduleFinalizer, projectFinalizer, workspaceFinalizer, diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 5ecd6fc6..1f78aacf 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -109,6 +109,7 @@ var _ = BeforeSuite(func() { err = appv1alpha2.AddToScheme(scheme.Scheme) Expect(err).ToNot(HaveOccurred()) + //+kubebuilder:scaffold:scheme if organization == "" { @@ -154,10 +155,11 @@ var _ = BeforeSuite(func() { }, Controller: config.Controller{ GroupKindConcurrency: map[string]int{ - "AgentPool.app.terraform.io": 5, - "Module.app.terraform.io": 5, - "Project.app.terraform.io": 5, - "Workspace.app.terraform.io": 5, + "AgentPool.app.terraform.io": 5, + "AgentToken.app.terraform.io": 5, + "Module.app.terraform.io": 5, + "Project.app.terraform.io": 5, + "Workspace.app.terraform.io": 5, }, }, }) @@ -170,6 +172,13 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&AgentTokenReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("AgentTokenController"), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + err = (&ModuleReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), diff --git a/internal/controller/workspace_controller_agents_test.go b/internal/controller/workspace_controller_agents_test.go index 6738a481..cb7347dd 100644 --- a/internal/controller/workspace_controller_agents_test.go +++ b/internal/controller/workspace_controller_agents_test.go @@ -80,27 +80,27 @@ var _ = Describe("Workspace controller", Ordered, func() { Context("Agent Execution Mode", func() { It("can handle agent pool by name", func() { - instance.Spec.AgentPool = &appv1alpha2.WorkspaceAgentPool{Name: agentPoolName} + instance.Spec.AgentPool = &appv1alpha2.AgentPoolRef{Name: agentPoolName} // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation createWorkspace(instance) isReconciledAgentPoolByName(instance) // Update the Agent Pool by Name Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) - instance.Spec.AgentPool = &appv1alpha2.WorkspaceAgentPool{Name: agentPoolName2} + instance.Spec.AgentPool = &appv1alpha2.AgentPoolRef{Name: agentPoolName2} Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) isReconciledAgentPoolByName(instance) }) It("can handle agent pool by id", func() { - instance.Spec.AgentPool = &appv1alpha2.WorkspaceAgentPool{ID: agentPoolID} + instance.Spec.AgentPool = &appv1alpha2.AgentPoolRef{ID: agentPoolID} // Create a new Kubernetes workspace object and wait until the controller finishes the reconciliation createWorkspace(instance) isReconciledAgentPoolByID(instance) // Update the Agent Pool by ID Expect(k8sClient.Get(ctx, namespacedName, instance)).Should(Succeed()) - instance.Spec.AgentPool = &appv1alpha2.WorkspaceAgentPool{ID: agentPoolID2} + instance.Spec.AgentPool = &appv1alpha2.AgentPoolRef{ID: agentPoolID2} Expect(k8sClient.Update(ctx, instance)).Should(Succeed()) isReconciledAgentPoolByID(instance) })