Skip to content

Commit 27f844f

Browse files
authored
Add new Agent Token controller (#628)
1 parent 27d0488 commit 27f844f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+2073
-113
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: FEATURES
2+
body: '`AgentToken`: Introduce a new controller that manages tokens in arbitrary agent pools.'
3+
time: 2025-09-25T09:16:57.344633+02:00
4+
custom:
5+
PR: "628"

.github/pr-labeler.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ controller:
88
- any:
99
- changed-files:
1010
- any-glob-to-any-file:
11-
- 'controllers/*.go'
11+
- 'internal/controller/*.go'
12+
- 'cmd/*.go'
1213

1314
crd:
1415
- any:

.github/workflows/helm-end-to-end-tfc.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ jobs:
8181
--set operator.image.tag=${{ env.DOCKER_METADATA_OUTPUT_VERSION }} \
8282
--set operator.syncPeriod=30s \
8383
--set controllers.agentPool.workers=5 \
84+
--set controllers.agentToken.workers=5 \
8485
--set controllers.module.workers=5 \
8586
--set controllers.project.workers=5 \
8687
--set controllers.workspace.workers=5

.github/workflows/helm-end-to-end-tfe.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ jobs:
8383
--set operator.tfeAddress=${{ secrets.TFE_ADDRESS }} \
8484
--set operator.syncPeriod=30s \
8585
--set controllers.agentPool.workers=5 \
86+
--set controllers.agentToken.workers=5 \
8687
--set controllers.module.workers=5 \
8788
--set controllers.project.workers=5 \
8889
--set controllers.workspace.workers=5

PROJECT

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,13 @@ resources:
4747
kind: Project
4848
path: github.com/hashicorp/hcp-terraform-operator/api/v1alpha2
4949
version: v1alpha2
50+
- api:
51+
crdVersion: v1
52+
namespaced: true
53+
controller: true
54+
domain: terraform.io
55+
group: app
56+
kind: AgentToken
57+
path: github.com/hashicorp/hcp-terraform-operator/api/v1alpha2
58+
version: v1alpha2
5059
version: "3"

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Kubernetes Operator allows managing HCP Terraform / Terraform Enterprise resourc
1616
The Operator can manage the following types of resources:
1717

1818
- `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
19+
- `AgentToken` manages [HCP Terraform Agent Tokens](https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens)
1920
- `Module` implements [API-driven Run Workflows](https://developer.hashicorp.com/terraform/cloud-docs/run/api)
2021
- `Project` manages [HCP Terraform Projects](https://developer.hashicorp.com/terraform/cloud-docs/workspaces/organize-workspaces-with-projects)
2122
- `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).
5657
Controllers usage guides:
5758

5859
- [AgentPool](./docs/agentpool.md)
60+
- [AgentToken](./docs/agenttoken.md)
5961
- [Module](./docs/module.md)
6062
- [Project](./docs/project.md)
6163
- [Workspace](./docs/workspace.md)
@@ -110,6 +112,7 @@ If you encounter any issues with the Operator there are a number of ways how to
110112

111113
```console
112114
$ kubectl get agentpool <NAME>
115+
$ kubectl get agenttoken <NAME>
113116
$ kubectl get module <NAME>
114117
$ kubectl get project <NAME>
115118
$ kubectl get workspace <NAME>
@@ -119,6 +122,7 @@ If you encounter any issues with the Operator there are a number of ways how to
119122

120123
```console
121124
$ kubectl describe agentpool <NAME>
125+
$ kubectl describe agenttoken <NAME>
122126
$ kubectl describe module <NAME>
123127
$ kubectl describe project <NAME>
124128
$ kubectl describe workspace <NAME>

api/v1alpha2/agentpool_types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const (
2323
// In `spec` only the field `Name` is allowed, the rest are used in `status`.
2424
// More infromation:
2525
// - https://developer.hashicorp.com/terraform/cloud-docs/agents
26-
type AgentToken struct {
26+
type AgentAPIToken struct {
2727
// Agent Token name.
2828
//
2929
//+kubebuilder:validation:MinLength:=1
@@ -135,7 +135,7 @@ type AgentPoolSpec struct {
135135
//
136136
//+kubebuilder:validation:MinItems:=1
137137
//+optional
138-
AgentTokens []*AgentToken `json:"agentTokens,omitempty"`
138+
AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"`
139139

140140
// Agent deployment settings
141141
//+optional
@@ -179,7 +179,7 @@ type AgentPoolStatus struct {
179179
// List of the agent tokens generated by the controller.
180180
//
181181
//+optional
182-
AgentTokens []*AgentToken `json:"agentTokens,omitempty"`
182+
AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"`
183183
// Name of the agent deployment generated by the controller.
184184
//
185185
//+optional

api/v1alpha2/agentpool_validation_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
1515
successCases := map[string]AgentPool{
1616
"HasOnlyName": {
1717
Spec: AgentPoolSpec{
18-
AgentTokens: []*AgentToken{
18+
AgentTokens: []*AgentAPIToken{
1919
{
2020
Name: "this",
2121
},
@@ -24,7 +24,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
2424
},
2525
"HasMultipleTokens": {
2626
Spec: AgentPoolSpec{
27-
AgentTokens: []*AgentToken{
27+
AgentTokens: []*AgentAPIToken{
2828
{
2929
Name: "this",
3030
},
@@ -47,7 +47,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
4747
errorCases := map[string]AgentPool{
4848
"HasID": {
4949
Spec: AgentPoolSpec{
50-
AgentTokens: []*AgentToken{
50+
AgentTokens: []*AgentAPIToken{
5151
{
5252
Name: "this",
5353
ID: "this",
@@ -57,7 +57,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
5757
},
5858
"HasCreatedAt": {
5959
Spec: AgentPoolSpec{
60-
AgentTokens: []*AgentToken{
60+
AgentTokens: []*AgentAPIToken{
6161
{
6262
Name: "this",
6363
CreatedAt: pointer.PointerOf(int64(1984)),
@@ -67,7 +67,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
6767
},
6868
"HasLastUsedAt": {
6969
Spec: AgentPoolSpec{
70-
AgentTokens: []*AgentToken{
70+
AgentTokens: []*AgentAPIToken{
7171
{
7272
Name: "this",
7373
LastUsedAt: pointer.PointerOf(int64(1984)),
@@ -77,7 +77,7 @@ func TestValidateAgentPoolSpecAgentToken(t *testing.T) {
7777
},
7878
"HasDuplicateName": {
7979
Spec: AgentPoolSpec{
80-
AgentTokens: []*AgentToken{
80+
AgentTokens: []*AgentAPIToken{
8181
{
8282
Name: "this",
8383
},

api/v1alpha2/agenttoken_types.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package v1alpha2
5+
6+
import (
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
)
9+
10+
// The Management Policy defines how the controller will manage tokens in the specified Agent Pool.
11+
// - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own.
12+
// - `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.
13+
type AgentTokenManagementPolicy string
14+
15+
const (
16+
AgentTokenManagementPolicyMerge AgentTokenManagementPolicy = "merge"
17+
AgentTokenManagementPolicyOwner AgentTokenManagementPolicy = "owner"
18+
)
19+
20+
// The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted.
21+
// - `retain`: When the custom resource is deleted, the operator will remove only the resource itself.
22+
// The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified.
23+
// - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret.
24+
type AgentTokenDeletionPolicy string
25+
26+
const (
27+
AgentTokenDeletionPolicyRetain AgentTokenDeletionPolicy = "retain"
28+
AgentTokenDeletionPolicyDestroy AgentTokenDeletionPolicy = "destroy"
29+
)
30+
31+
// AgentTokenSpec defines the desired state of AgentToken.
32+
type AgentTokenSpec struct {
33+
// Organization name where the Workspace will be created.
34+
// More information:
35+
// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/organizations
36+
//
37+
//+kubebuilder:validation:MinLength:=1
38+
Organization string `json:"organization"`
39+
// API Token to be used for API calls.
40+
Token Token `json:"token"`
41+
// The Deletion Policy defines how managed tokens and Kubernetes Secrets should be handled when the custom resource is deleted.
42+
// - `retain`: When the custom resource is deleted, the operator will remove only the resource itself.
43+
// The managed HCP Terraform Agent tokens will remain active on the HCP Terraform side, and the corresponding Kubernetes Secret will not be modified.
44+
// - `destroy`: The operator will attempt to delete the managed HCP Terraform Agent tokens and remove the corresponding Kubernetes Secret.
45+
// Default: `retain`.
46+
//
47+
//+kubebuilder:validation:Enum:=retain;destroy
48+
//+kubebuilder:default=retain
49+
//+optional
50+
DeletionPolicy AgentTokenDeletionPolicy `json:"deletionPolicy,omitempty"`
51+
// The Agent Pool name or ID where the tokens will be managed.
52+
AgentPool AgentPoolRef `json:"agentPool"`
53+
// The Management Policy defines how the controller will manage tokens in the specified Agent Pool.
54+
// - `merge` — the controller will manage its tokens alongside any existing tokens in the pool, without modifying or deleting tokens it does not own.
55+
// - `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.
56+
// Default: `merge`.
57+
//
58+
//+kubebuilder:validation:Enum:=merge;owner
59+
//+kubebuilder:default=merge
60+
//+optional
61+
ManagementPolicy AgentTokenManagementPolicy `json:"managementPolicy,omitempty"`
62+
// List of the HCP Terraform Agent tokens to manage.
63+
//
64+
//+kubebuilder:validation:MinItems:=1
65+
AgentTokens []AgentAPIToken `json:"agentTokens"`
66+
// secretName specifies the name of the Kubernetes Secret
67+
// where the HCP Terraform Agent tokens are stored.
68+
//
69+
//+kubebuilder:validation:MinLength:=1
70+
SecretName string `json:"secretName"`
71+
}
72+
73+
// AgentTokenStatus defines the observed state of AgentToken.
74+
type AgentTokenStatus struct {
75+
// Real world state generation.
76+
ObservedGeneration int64 `json:"observedGeneration"`
77+
// Agent Pool where tokens are managed by the controller.
78+
AgentPool *AgentPoolRef `json:"agentPool,omitempty"`
79+
// List of the agent tokens managed by the controller.
80+
//
81+
//+optional
82+
AgentTokens []*AgentAPIToken `json:"agentTokens,omitempty"`
83+
}
84+
85+
// +kubebuilder:object:root=true
86+
// +kubebuilder:subresource:status
87+
//+kubebuilder:printcolumn:name="Agent Pool Name",type=string,JSONPath=`.status.agentPool.name`
88+
//+kubebuilder:printcolumn:name="Agent Pool ID",type=string,JSONPath=`.status.agentPool.id`
89+
//+kubebuilder:metadata:labels="app.terraform.io/crd-schema-version=v25.9.0"
90+
91+
// AgentToken manages HCP Terraform Agent Tokens.
92+
// More information:
93+
// - https://developer.hashicorp.com/terraform/cloud-docs/users-teams-organizations/api-tokens#agent-api-tokens
94+
type AgentToken struct {
95+
metav1.TypeMeta `json:",inline"`
96+
metav1.ObjectMeta `json:"metadata,omitempty"`
97+
98+
Spec AgentTokenSpec `json:"spec"`
99+
Status AgentTokenStatus `json:"status,omitempty"`
100+
}
101+
102+
// +kubebuilder:object:root=true
103+
104+
// AgentTokenList contains a list of AgentToken.
105+
type AgentTokenList struct {
106+
metav1.TypeMeta `json:",inline"`
107+
metav1.ListMeta `json:"metadata,omitempty"`
108+
Items []AgentToken `json:"items"`
109+
}
110+
111+
func init() {
112+
SchemeBuilder.Register(&AgentToken{}, &AgentTokenList{})
113+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package v1alpha2
5+
6+
import (
7+
"fmt"
8+
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
11+
"k8s.io/apimachinery/pkg/util/validation/field"
12+
)
13+
14+
func (t *AgentToken) ValidateSpec() error {
15+
var allErrs field.ErrorList
16+
17+
allErrs = append(allErrs, t.validateSpecAgentTokens()...)
18+
19+
if len(allErrs) == 0 {
20+
return nil
21+
}
22+
23+
return apierrors.NewInvalid(
24+
schema.GroupKind{Group: "", Kind: "AgentToken"},
25+
t.Name,
26+
allErrs,
27+
)
28+
}
29+
30+
func (t *AgentToken) validateSpecAgentTokens() field.ErrorList {
31+
allErrs := field.ErrorList{}
32+
atn := make(map[string]int)
33+
34+
for i, at := range t.Spec.AgentTokens {
35+
f := field.NewPath("spec").Child(fmt.Sprintf("agentTokens[%d]", i))
36+
37+
if at.ID != "" {
38+
allErrs = append(allErrs, field.Forbidden(
39+
f.Child("id"),
40+
"id is not allowed in the spec"),
41+
)
42+
}
43+
if at.CreatedAt != nil {
44+
allErrs = append(allErrs, field.Forbidden(
45+
f.Child("createdAt"),
46+
"createdAt is not allowed in the spec"),
47+
)
48+
}
49+
if at.LastUsedAt != nil {
50+
allErrs = append(allErrs, field.Forbidden(
51+
f.Child("lastUsedAt"),
52+
"lastUsedAt is not allowed in the spec"),
53+
)
54+
}
55+
56+
if _, ok := atn[at.Name]; ok {
57+
allErrs = append(allErrs, field.Duplicate(f.Child("name"), at.Name))
58+
}
59+
atn[at.Name] = i
60+
}
61+
62+
return allErrs
63+
}

0 commit comments

Comments
 (0)