Skip to content

Commit 44d2214

Browse files
ROSAENG-60113 | test(unit): add clusterrosa/hcp validator coverage
Cover HCP update validators, auto_node role ARN validation, and small pure helpers used during cluster updates without exercising CRUD paths. Signed-off-by: Amanda Hager Lopes de Andrade Katz <amanda.katz@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Amanda Hager Lopes de Andrade Katz <amanda.katz@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8247260 commit 44d2214

1 file changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// Copyright Red Hat
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package hcp
5+
6+
import (
7+
"context"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/attr"
10+
"github.com/hashicorp/terraform-plugin-framework/path"
11+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
. "github.com/onsi/ginkgo/v2" // nolint
14+
. "github.com/onsi/gomega" // nolint
15+
16+
rosa "github.com/terraform-redhat/terraform-provider-rhcs/provider/clusterrosa/common"
17+
"github.com/terraform-redhat/terraform-provider-rhcs/provider/common"
18+
)
19+
20+
func cloneBasicState() *ClusterRosaHcpState {
21+
state := generateBasicRosaHcpClusterState()
22+
state.Channel = types.StringNull()
23+
state.Version = types.StringValue("4.14.0")
24+
state.ExternalID = types.StringNull()
25+
state.Tags = types.MapNull(types.StringType)
26+
state.EtcdEncryption = types.BoolNull()
27+
state.FIPS = types.BoolNull()
28+
state.EtcdKmsKeyArn = types.StringNull()
29+
state.Private = types.BoolNull()
30+
state.MachineCIDR = types.StringNull()
31+
state.ServiceCIDR = types.StringNull()
32+
state.PodCIDR = types.StringNull()
33+
state.HostPrefix = types.Int64Null()
34+
state.AWSAdditionalComputeSecurityGroupIds = types.ListNull(types.StringType)
35+
state.AutoScalingEnabled = types.BoolNull()
36+
state.MinReplicas = types.Int64Null()
37+
state.MaxReplicas = types.Int64Null()
38+
state.ComputeMachineType = types.StringNull()
39+
state.Ec2MetadataHttpTokens = types.StringNull()
40+
state.WorkerDiskSize = types.Int64Null()
41+
state.CreateAdminUser = types.BoolNull()
42+
state.BaseDNSDomain = types.StringNull()
43+
state.ExternalAuthProvidersEnabled = types.BoolNull()
44+
state.LogForwardersAtClusterCreation = types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}})
45+
return state
46+
}
47+
48+
var _ = Describe("Channel and channel_group update validation", func() {
49+
DescribeTable("validateChannelAndChannelGroupChanges",
50+
func(mutate func(state, plan *ClusterRosaHcpState), expectedErr bool) {
51+
state := cloneBasicState()
52+
plan := cloneBasicState()
53+
mutate(state, plan)
54+
55+
diags := validateChannelAndChannelGroupChanges(state, plan)
56+
Expect(diags.HasError()).To(Equal(expectedErr))
57+
},
58+
Entry("unchanged -> ok",
59+
func(_, _ *ClusterRosaHcpState) {},
60+
false,
61+
),
62+
Entry("channel only -> ok",
63+
func(state, plan *ClusterRosaHcpState) {
64+
state.Channel = types.StringValue("stable")
65+
plan.Channel = types.StringValue("candidate")
66+
},
67+
false,
68+
),
69+
Entry("channel_group only -> ok",
70+
func(state, plan *ClusterRosaHcpState) {
71+
state.ChannelGroup = types.StringValue("stable")
72+
plan.ChannelGroup = types.StringValue("fast")
73+
},
74+
false,
75+
),
76+
Entry("channel and channel_group together -> error",
77+
func(state, plan *ClusterRosaHcpState) {
78+
state.Channel = types.StringValue("stable")
79+
state.ChannelGroup = types.StringValue("stable")
80+
plan.Channel = types.StringValue("candidate")
81+
plan.ChannelGroup = types.StringValue("fast")
82+
},
83+
true,
84+
),
85+
)
86+
})
87+
88+
var _ = Describe("Channel group and version update validation", func() {
89+
DescribeTable("validateChannelGroupAndVersionChanges",
90+
func(mutate func(state, plan *ClusterRosaHcpState), expectedErr bool) {
91+
state := cloneBasicState()
92+
plan := cloneBasicState()
93+
mutate(state, plan)
94+
95+
diags := validateChannelGroupAndVersionChanges(state, plan)
96+
Expect(diags.HasError()).To(Equal(expectedErr))
97+
},
98+
Entry("unchanged -> ok",
99+
func(_, _ *ClusterRosaHcpState) {},
100+
false,
101+
),
102+
Entry("channel_group only -> ok",
103+
func(state, plan *ClusterRosaHcpState) {
104+
state.ChannelGroup = types.StringValue("stable")
105+
plan.ChannelGroup = types.StringValue("fast")
106+
},
107+
false,
108+
),
109+
Entry("version only -> ok",
110+
func(state, plan *ClusterRosaHcpState) {
111+
state.Version = types.StringValue("4.14.0")
112+
plan.Version = types.StringValue("4.15.0")
113+
},
114+
false,
115+
),
116+
Entry("channel_group and version together -> error",
117+
func(state, plan *ClusterRosaHcpState) {
118+
state.ChannelGroup = types.StringValue("stable")
119+
state.Version = types.StringValue("4.14.0")
120+
plan.ChannelGroup = types.StringValue("fast")
121+
plan.Version = types.StringValue("4.15.0")
122+
},
123+
true,
124+
),
125+
Entry("version set when state version is null -> counts as version change with channel_group",
126+
func(state, plan *ClusterRosaHcpState) {
127+
state.ChannelGroup = types.StringValue("stable")
128+
state.Version = types.StringNull()
129+
plan.ChannelGroup = types.StringValue("fast")
130+
plan.Version = types.StringValue("4.15.0")
131+
},
132+
true,
133+
),
134+
)
135+
})
136+
137+
var _ = Describe("Channel and version update validation", func() {
138+
DescribeTable("validateChannelAndVersionChanges",
139+
func(mutate func(state, plan *ClusterRosaHcpState), expectedErr bool) {
140+
state := cloneBasicState()
141+
plan := cloneBasicState()
142+
mutate(state, plan)
143+
144+
diags := validateChannelAndVersionChanges(state, plan)
145+
Expect(diags.HasError()).To(Equal(expectedErr))
146+
},
147+
Entry("unchanged -> ok",
148+
func(_, _ *ClusterRosaHcpState) {},
149+
false,
150+
),
151+
Entry("channel only -> ok",
152+
func(state, plan *ClusterRosaHcpState) {
153+
state.Channel = types.StringValue("stable")
154+
plan.Channel = types.StringValue("candidate")
155+
},
156+
false,
157+
),
158+
Entry("version only -> ok",
159+
func(state, plan *ClusterRosaHcpState) {
160+
state.Version = types.StringValue("4.14.0")
161+
plan.Version = types.StringValue("4.15.0")
162+
},
163+
false,
164+
),
165+
Entry("channel and version together -> error",
166+
func(state, plan *ClusterRosaHcpState) {
167+
state.Channel = types.StringValue("stable")
168+
state.Version = types.StringValue("4.14.0")
169+
plan.Channel = types.StringValue("candidate")
170+
plan.Version = types.StringValue("4.15.0")
171+
},
172+
true,
173+
),
174+
Entry("version set when state version is null -> counts as version change with channel",
175+
func(state, plan *ClusterRosaHcpState) {
176+
state.Channel = types.StringValue("stable")
177+
state.Version = types.StringNull()
178+
plan.Channel = types.StringValue("candidate")
179+
plan.Version = types.StringValue("4.15.0")
180+
},
181+
true,
182+
),
183+
)
184+
})
185+
186+
var _ = Describe("Immutable attribute update validation", func() {
187+
It("returns no diagnostics when state and plan match", func() {
188+
state := cloneBasicState()
189+
plan := cloneBasicState()
190+
191+
diags := validateNoImmutableAttChange(state, plan)
192+
Expect(diags.HasError()).To(BeFalse())
193+
})
194+
195+
It("errors when an immutable attribute changes", func() {
196+
state := cloneBasicState()
197+
plan := cloneBasicState()
198+
plan.Name = types.StringValue("renamed-cluster")
199+
200+
diags := validateNoImmutableAttChange(state, plan)
201+
Expect(diags.HasError()).To(BeTrue())
202+
Expect(diags.Errors()[0].Summary()).To(Equal(common.AssertionErrorSummaryMessage))
203+
})
204+
})
205+
206+
var _ = Describe("Auto node role ARN validator", func() {
207+
DescribeTable("should validate correctly",
208+
func(request validator.StringRequest, expectedErr bool) {
209+
response := validator.StringResponse{}
210+
validateAutoNodeRoleARN().ValidateString(context.Background(), request, &response)
211+
Expect(response.Diagnostics.HasError()).To(Equal(expectedErr))
212+
},
213+
Entry("null -> ok",
214+
validator.StringRequest{
215+
Path: path.Root("auto_node").AtName("role_arn"),
216+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
217+
ConfigValue: types.StringNull(),
218+
},
219+
false,
220+
),
221+
Entry("unknown -> ok",
222+
validator.StringRequest{
223+
Path: path.Root("auto_node").AtName("role_arn"),
224+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
225+
ConfigValue: types.StringUnknown(),
226+
},
227+
false,
228+
),
229+
Entry("valid IAM role ARN -> ok",
230+
validator.StringRequest{
231+
Path: path.Root("auto_node").AtName("role_arn"),
232+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
233+
ConfigValue: types.StringValue(autoNodeRoleArn),
234+
},
235+
false,
236+
),
237+
Entry("empty string -> error",
238+
validator.StringRequest{
239+
Path: path.Root("auto_node").AtName("role_arn"),
240+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
241+
ConfigValue: types.StringValue(""),
242+
},
243+
true,
244+
),
245+
Entry("single quote -> error",
246+
validator.StringRequest{
247+
Path: path.Root("auto_node").AtName("role_arn"),
248+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
249+
ConfigValue: types.StringValue("arn:aws:iam::123456789012:role/karpenter'"),
250+
},
251+
true,
252+
),
253+
Entry("non-IAM ARN -> error",
254+
validator.StringRequest{
255+
Path: path.Root("auto_node").AtName("role_arn"),
256+
PathExpression: path.MatchRoot("auto_node").AtName("role_arn"),
257+
ConfigValue: types.StringValue("arn:aws:s3:::my-bucket"),
258+
},
259+
true,
260+
),
261+
)
262+
})
263+
264+
var _ = Describe("Auto node helpers", func() {
265+
It("getAutoNodeMode returns null when auto_node is nil", func() {
266+
Expect(getAutoNodeMode(nil)).To(Equal(types.StringNull()))
267+
})
268+
269+
It("getAutoNodeMode returns mode from auto_node", func() {
270+
autoNode := &AutoNode{Mode: types.StringValue(autoNodeModeEnabled)}
271+
Expect(getAutoNodeMode(autoNode)).To(Equal(types.StringValue(autoNodeModeEnabled)))
272+
})
273+
274+
It("getAutoNodeRoleARN returns null when auto_node is nil", func() {
275+
Expect(getAutoNodeRoleARN(nil)).To(Equal(types.StringNull()))
276+
})
277+
278+
It("getAutoNodeRoleARN returns role ARN from auto_node", func() {
279+
autoNode := &AutoNode{RoleARN: types.StringValue(autoNodeRoleArn)}
280+
Expect(getAutoNodeRoleARN(autoNode)).To(Equal(types.StringValue(autoNodeRoleArn)))
281+
})
282+
})
283+
284+
var _ = Describe("getOcmVersionMinor", func() {
285+
DescribeTable("extracts major.minor",
286+
func(input, expected string) {
287+
Expect(getOcmVersionMinor(input)).To(Equal(expected))
288+
},
289+
Entry("semver version", "4.14.5", "4.14"),
290+
Entry("two-segment version", "4.14", "4.14"),
291+
Entry("prefixed version falls back to split", "openshift-v4.14.5", "openshift-v4.14"),
292+
)
293+
})
294+
295+
var _ = Describe("shouldPatchProperties", func() {
296+
It("returns true when user properties change", func() {
297+
state := cloneBasicState()
298+
plan := cloneBasicState()
299+
plan.Properties = types.MapValueMust(types.StringType, map[string]attr.Value{
300+
"rosa_creator_arn": types.StringValue("arn:aws:iam::123456789012:user/other"),
301+
})
302+
303+
Expect(shouldPatchProperties(state, plan)).To(BeTrue())
304+
})
305+
306+
It("returns false when properties and OCM defaults are unchanged", func() {
307+
state := cloneBasicState()
308+
plan := cloneBasicState()
309+
state.OCMProperties = types.MapValueMust(types.StringType, map[string]attr.Value{
310+
rosa.PropertyRosaTfVersion: types.StringValue(rosa.OCMProperties[rosa.PropertyRosaTfVersion]),
311+
rosa.PropertyRosaTfCommit: types.StringValue(rosa.OCMProperties[rosa.PropertyRosaTfCommit]),
312+
})
313+
plan.OCMProperties = state.OCMProperties
314+
315+
Expect(shouldPatchProperties(state, plan)).To(BeFalse())
316+
})
317+
318+
It("returns true when OCM default property values drift", func() {
319+
state := cloneBasicState()
320+
plan := cloneBasicState()
321+
state.OCMProperties = types.MapValueMust(types.StringType, map[string]attr.Value{
322+
rosa.PropertyRosaTfVersion: types.StringValue("stale-version"),
323+
rosa.PropertyRosaTfCommit: types.StringValue(rosa.OCMProperties[rosa.PropertyRosaTfCommit]),
324+
})
325+
plan.OCMProperties = state.OCMProperties
326+
327+
Expect(shouldPatchProperties(state, plan)).To(BeTrue())
328+
})
329+
})

0 commit comments

Comments
 (0)