Skip to content

Commit b4813af

Browse files
Add mocks and unit test
1 parent b71321d commit b4813af

File tree

5 files changed

+178
-15
lines changed

5 files changed

+178
-15
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ go 1.25.4
55
require (
66
github.com/go-logr/logr v1.4.3
77
github.com/go-logr/zapr v1.3.0
8+
github.com/golang/mock v1.6.0
89
github.com/google/go-cmp v0.7.0
910
github.com/hashicorp/go-slug v0.16.7
1011
github.com/hashicorp/go-tfe v1.93.0
1112
github.com/onsi/ginkgo/v2 v2.23.4
1213
github.com/onsi/gomega v1.36.3
1314
github.com/prometheus/client_golang v1.22.0
1415
github.com/stretchr/testify v1.11.1
16+
go.uber.org/mock v0.4.0
1517
go.uber.org/zap v1.27.0
1618
k8s.io/api v0.34.1
1719
k8s.io/apimachinery v0.34.1

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
3535
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
3636
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
3737
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
38+
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
39+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
3840
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
3941
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
4042
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
@@ -136,10 +138,13 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
136138
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
137139
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
138140
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
141+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
139142
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
140143
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
141144
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
142145
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
146+
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
147+
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
143148
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
144149
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
145150
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@@ -153,24 +158,31 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
153158
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
154159
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
155160
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
161+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
156162
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
157163
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
158164
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
159165
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
166+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
160167
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
161168
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
162169
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
163170
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
164171
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
165172
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
166173
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
174+
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
167175
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
168176
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
169177
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
170178
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
171179
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
180+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
181+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
182+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
172183
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
173184
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
185+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
174186
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
175187
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
176188
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -183,6 +195,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
183195
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
184196
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
185197
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
198+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
186199
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
187200
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
188201
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

internal/controller/agentpool_controller_autoscaling.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818
appv1alpha2 "github.com/hashicorp/hcp-terraform-operator/api/v1alpha2"
1919
)
2020

21+
type AgentPoolControllerAutoscaling interface {
22+
pendingWorkspaceRuns(ctx context.Context, ap *agentPoolInstance) (int32, error)
23+
}
24+
2125
// userInteractionRunStatuses contains run statuses that require user interaction.
2226
var userInteractionRunStatuses = map[tfc.RunStatus]struct{}{
2327
tfc.RunCostEstimated: {},
@@ -61,9 +65,10 @@ func matchWildcardName(wildcard string, str string) bool {
6165
}
6266
}
6367

64-
// pendingWorkspaceRuns returns the number of workspaces with pending runs for a given agent pool.
68+
// pendingWorkspaceRuns returns the number of agents needed to execute current pending runs for a given agent pool.
69+
// If there are no plan-only runs in the list of current pending runs for a workspace this functoion returns the number of workspaces.
6570
// This function is compatible with HCP Terraform and TFE version v202409-1 and later.
66-
func pendingWorkspaceRuns(ctx context.Context, ap *agentPoolInstance) (int32, error) {
71+
func (ap *agentPoolInstance) pendingWorkspaceRuns(ctx context.Context) (int32, error) {
6772
runs := map[string]struct{}{}
6873
awaitingUserInteractionRuns := map[string]int{} // Track runs awaiting user interaction by status for future metrics
6974
listOpts := &tfc.RunListForOrganizationOptions{
@@ -74,8 +79,9 @@ func pendingWorkspaceRuns(ctx context.Context, ap *agentPoolInstance) (int32, er
7479
PageNumber: initPageNumber,
7580
},
7681
}
77-
runCount := 0
82+
planOnlyRunCount := 0
7883
for {
84+
ap.log.Info("Fetching runs for organization", "org", ap.instance.Spec.Organization, "page", listOpts.PageNumber)
7985
runsList, err := ap.tfClient.Client.Runs.ListForOrganization(ctx, ap.instance.Spec.Organization, listOpts)
8086
if err != nil {
8187
return 0, err
@@ -87,8 +93,9 @@ func pendingWorkspaceRuns(ctx context.Context, ap *agentPoolInstance) (int32, er
8793
awaitingUserInteractionRuns[string(run.Status)]++
8894
continue
8995
}
90-
if _, exists := runs[run.Workspace.ID]; exists && run.PlanOnly {
91-
runCount++
96+
// Count plan-only runs separately so agents can scale up and execute runs parallely
97+
if run.PlanOnly {
98+
planOnlyRunCount++
9299
continue
93100
}
94101
runs[run.Workspace.ID] = struct{}{}
@@ -101,15 +108,14 @@ func pendingWorkspaceRuns(ctx context.Context, ap *agentPoolInstance) (int32, er
101108

102109
// TODO:
103110
// Add metric(s) for runs awaiting user interaction
104-
ap.log.Info("Runs", "msg", fmt.Sprintf("Runs: %+v", runs))
105-
ap.log.Info("Run count", "msg", fmt.Sprintf("RunCount: %+v", runCount))
106-
runCount = len(runs) + runCount
107-
return int32(runCount), nil
111+
agentsCount := len(runs) + planOnlyRunCount
112+
ap.log.Info("Workspaces and plan-only runs count", "msg", fmt.Sprintf("Workspaces: %+v Plan-only runs: %d Total agents: %d", runs, planOnlyRunCount, agentsCount))
113+
return int32(agentsCount), nil
108114
}
109115

110116
// computeRequiredAgents is a legacy algorithm that is used to compute the number of agents needed.
111117
// It is used when the TFE version is less than v202409-1.
112-
func computeRequiredAgents(ctx context.Context, ap *agentPoolInstance) (int32, error) {
118+
func (ap *agentPoolInstance) computeRequiredAgents(ctx context.Context) (int32, error) {
113119
required := 0
114120
// NOTE:
115121
// - Two maps are used here to simplify target workspace searching by ID, name, and wildcard.
@@ -248,7 +254,7 @@ func (r *AgentPoolReconciler) reconcileAgentAutoscaling(ctx context.Context, ap
248254

249255
requiredAgents, err := func() (int32, error) {
250256
if ap.tfClient.Client.IsCloud() {
251-
return pendingWorkspaceRuns(ctx, ap)
257+
return ap.pendingWorkspaceRuns(ctx)
252258
}
253259
tfeVersion := ap.tfClient.Client.RemoteTFEVersion()
254260
useRunsEndpoint, err := validateTFEVersion(tfeVersion)
@@ -262,10 +268,10 @@ func (r *AgentPoolReconciler) reconcileAgentAutoscaling(ctx context.Context, ap
262268
// It now allows retrieving a list of runs for the organization.
263269
if useRunsEndpoint {
264270
ap.log.Info("Reconcile Agent Autoscaling", "msg", fmt.Sprintf("Proceeding with the new algorithm based on the detected TFE version %s", tfeVersion))
265-
return pendingWorkspaceRuns(ctx, ap)
271+
return ap.pendingWorkspaceRuns(ctx)
266272
}
267273
ap.log.Info("Reconcile Agent Autoscaling", "msg", fmt.Sprintf("Proceeding with the legacy algorithm based to the detected TFE version %s", tfeVersion))
268-
return computeRequiredAgents(ctx, ap)
274+
return ap.computeRequiredAgents(ctx)
269275
}()
270276
if err != nil {
271277
ap.log.Error(err, "Reconcile Agent Autoscaling", "msg", "Failed to get agents needed")

internal/controller/agentpool_controller_autoscaling_test.go

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,25 @@
44
package controller
55

66
import (
7+
"context"
8+
"errors"
79
"fmt"
10+
"testing"
811
"time"
912

13+
"github.com/go-logr/logr"
1014
tfc "github.com/hashicorp/go-tfe"
15+
"github.com/hashicorp/go-tfe/mocks"
1116
appv1alpha2 "github.com/hashicorp/hcp-terraform-operator/api/v1alpha2"
1217
"github.com/hashicorp/hcp-terraform-operator/internal/pointer"
1318
. "github.com/onsi/ginkgo/v2"
1419
. "github.com/onsi/gomega"
20+
gomock "go.uber.org/mock/gomock"
1521
corev1 "k8s.io/api/core/v1"
16-
"k8s.io/apimachinery/pkg/api/errors"
22+
k8sapierrors "k8s.io/apimachinery/pkg/api/errors"
1723
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
25+
"github.com/stretchr/testify/assert"
1826
)
1927

2028
var _ = Describe("Agent Pool controller", Ordered, func() {
@@ -77,7 +85,7 @@ var _ = Describe("Agent Pool controller", Ordered, func() {
7785
Expect(k8sClient.Delete(ctx, instance)).To(Succeed())
7886
Eventually(func() bool {
7987
err := k8sClient.Get(ctx, namespacedName, instance)
80-
return errors.IsNotFound(err)
88+
return k8sapierrors.IsNotFound(err)
8189
}).Should(BeTrue())
8290
})
8391

@@ -194,3 +202,87 @@ var _ = Describe("Agent Pool controller", Ordered, func() {
194202
})
195203
})
196204
})
205+
206+
func TestPendingWorkspaceRuns(t *testing.T) {
207+
tests := []struct {
208+
name string
209+
mockRuns []*tfc.Run
210+
mockErr error
211+
expectedCount int32
212+
expectError bool
213+
}{
214+
{
215+
name: "returns error from client",
216+
mockErr: errors.New("api error"),
217+
expectedCount: 0,
218+
expectError: true,
219+
},
220+
{
221+
name: "counts plan-only runs",
222+
mockRuns: []*tfc.Run{
223+
{ID: "run1", PlanOnly: true, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws1"}},
224+
{ID: "run2", PlanOnly: true, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws2"}},
225+
},
226+
expectedCount: 2,
227+
expectError: false,
228+
},
229+
{
230+
name: "skips user interaction runs",
231+
mockRuns: []*tfc.Run{
232+
{ID: "run1", PlanOnly: false, Status: tfc.RunPlanned, Workspace: &tfc.Workspace{ID: "ws1"}},
233+
{ID: "run2", PlanOnly: false, Status: tfc.RunPolicyOverride, Workspace: &tfc.Workspace{ID: "ws2"}},
234+
},
235+
expectedCount: 0,
236+
expectError: false,
237+
},
238+
{
239+
name: "counts normal pending runs",
240+
mockRuns: []*tfc.Run{
241+
{ID: "run1", PlanOnly: false, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws1"}},
242+
{ID: "run2", PlanOnly: false, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws2"}},
243+
},
244+
expectedCount: 2,
245+
expectError: false,
246+
},
247+
{
248+
name: "mix of plan-only and normal runs",
249+
mockRuns: []*tfc.Run{
250+
{ID: "run1", PlanOnly: true, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws1"}},
251+
{ID: "run2", PlanOnly: false, Status: tfc.RunPlanning, Workspace: &tfc.Workspace{ID: "ws2"}},
252+
},
253+
expectedCount: 2,
254+
expectError: false,
255+
},
256+
}
257+
258+
for _, tt := range tests {
259+
t.Run(tt.name, func(t *testing.T) {
260+
ctrl := gomock.NewController(t)
261+
defer ctrl.Finish()
262+
263+
mockRuns := mocks.NewMockRuns(ctrl)
264+
mockRuns.EXPECT().
265+
ListForOrganization(gomock.Any(), "test-org", gomock.Any()).
266+
Return(&tfc.RunList{Items: tt.mockRuns, Pagination: &tfc.Pagination{NextPage: 0}}, tt.mockErr)
267+
268+
ap := &agentPoolInstance{
269+
tfClient: HCPTerraformClient{Client: &tfc.Client{Runs: mockRuns}},
270+
instance: appv1alpha2.AgentPool{
271+
Spec: appv1alpha2.AgentPoolSpec{
272+
Name: "test-pool",
273+
Organization: "test-org",
274+
},
275+
},
276+
log: logr.Logger{},
277+
}
278+
279+
count, err := ap.pendingWorkspaceRuns(context.Background())
280+
if tt.expectError {
281+
assert.Error(t, err)
282+
} else {
283+
assert.NoError(t, err)
284+
assert.Equal(t, tt.expectedCount, count)
285+
}
286+
})
287+
}
288+
}

mocks/agentpool_controller_autoscaling_mocks.go

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)