Skip to content

Commit 177176a

Browse files
authored
Merge pull request #736 from knottnt/feat/allow-adoption-with-no-primary-key
Add mutually_exclusive_identifiers config for resources with no single primary key
2 parents 399b047 + 8499c38 commit 177176a

11 files changed

Lines changed: 18573 additions & 18 deletions

File tree

pkg/config/resource.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ type ResourceConfig struct {
126126
// IsARNPrimaryKey determines whether the CRD uses the ARN as the primary
127127
// identifier in the ReadOne operations.
128128
IsARNPrimaryKey bool `json:"is_arn_primary_key"`
129+
// MutuallyExclusiveIdentifiers lists the resource's identifier fields (by
130+
// their configuration/original names, e.g. PolicyName, ResourceArn) when a
131+
// resource has no single mandatory identifier but is instead identified by
132+
// exactly one of several mutually-exclusive fields. When set, adoption
133+
// treats each listed field as optional (populating whichever the user
134+
// supplies) but requires that exactly one of them is present:
135+
// PopulateResourceFromAnnotation returns a terminal error if none or more
136+
// than one is supplied. This prevents an empty or misspelled adoption
137+
// annotation from silently matching an arbitrary resource. Requires at
138+
// least two fields and is incompatible with is_arn_primary_key.
139+
MutuallyExclusiveIdentifiers []string `json:"mutually_exclusive_identifiers,omitempty"`
129140
// TagConfig contains instructions for the code generator to generate
130141
// custom code for ensuring tags
131142
TagConfig *TagConfig `json:"tags,omitempty"`
@@ -512,6 +523,20 @@ func (c *Config) ResourceIsAdoptable(resourceName string) bool {
512523
return *rConfig.IsAdoptable
513524
}
514525

526+
// ResourceMutuallyExclusiveIdentifiers returns the list of mutually-exclusive
527+
// identifier field names configured for the resource (see
528+
// mutually_exclusive_identifiers), or nil if none are configured.
529+
func (c *Config) ResourceMutuallyExclusiveIdentifiers(resourceName string) []string {
530+
if c == nil {
531+
return nil
532+
}
533+
rConfig, ok := c.Resources[resourceName]
534+
if !ok {
535+
return nil
536+
}
537+
return rConfig.MutuallyExclusiveIdentifiers
538+
}
539+
515540
// ResourceContainsAttributesMap returns true if the underlying API has
516541
// Get{Resource}Attributes/Set{Resource}Attributes API calls that map real,
517542
// schema'd fields to a raw `map[string]*string` for a given resource name (see SNS and

pkg/config/validate.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"fmt"
1818
"sort"
1919
"strings"
20+
21+
"github.com/aws-controllers-k8s/pkg/names"
2022
)
2123

2224
// ValidateConfig checks that generator.yaml references to SDK operations are
@@ -44,10 +46,57 @@ func ValidateConfig(
4446

4547
errs = append(errs, validateRenameOperations(cfg, sdkOperations)...)
4648
errs = append(errs, validateIgnoredOperations(cfg, sdkOperations)...)
49+
errs = append(errs, validateMutuallyExclusiveIdentifiers(cfg)...)
4750

4851
return errs
4952
}
5053

54+
// validateMutuallyExclusiveIdentifiers checks that a resource's
55+
// mutually_exclusive_identifiers configuration is internally consistent: it
56+
// must list at least two fields (a single identifier is not mutually exclusive
57+
// with anything), the fields must be distinct after name normalization (two
58+
// entries that resolve to the same CR field can never be "exactly one"), and it
59+
// cannot be combined with is_arn_primary_key, since an ARN-primary resource
60+
// always requires its ARN.
61+
func validateMutuallyExclusiveIdentifiers(cfg *Config) []error {
62+
var errs []error
63+
for resName, resCfg := range cfg.Resources {
64+
identifiers := resCfg.MutuallyExclusiveIdentifiers
65+
if len(identifiers) == 0 {
66+
continue
67+
}
68+
if len(identifiers) < 2 {
69+
errs = append(errs, fmt.Errorf(
70+
"resources.%s.mutually_exclusive_identifiers: must list at least two fields, got %d",
71+
resName, len(identifiers),
72+
))
73+
}
74+
// Entries are resolved to CR fields by camel-casing the name (see
75+
// CRD.GetMutuallyExclusiveIdentifierFields), so e.g. "PolicyName" and
76+
// "policyName" collapse to the same field. Reject duplicates after
77+
// normalization: the generated exactly-one guard would otherwise count
78+
// the same field twice and adoption could never satisfy it.
79+
seen := make(map[string]bool, len(identifiers))
80+
for _, id := range identifiers {
81+
norm := names.New(id).Camel
82+
if seen[norm] {
83+
errs = append(errs, fmt.Errorf(
84+
"resources.%s.mutually_exclusive_identifiers: %q resolves to the same field as another entry",
85+
resName, id,
86+
))
87+
}
88+
seen[norm] = true
89+
}
90+
if resCfg.IsARNPrimaryKey {
91+
errs = append(errs, fmt.Errorf(
92+
"resources.%s.mutually_exclusive_identifiers: cannot be combined with is_arn_primary_key",
93+
resName,
94+
))
95+
}
96+
}
97+
return errs
98+
}
99+
51100
// validateRenameOperations checks that operation names referenced in
52101
// resources[R].renames.operations[OpName] exist in the SDK.
53102
func validateRenameOperations(

pkg/config/validate_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,64 @@ func TestValidateConfig_ErrorMessageIncludesAvailable(t *testing.T) {
238238
}
239239
}
240240

241+
func TestValidateMutuallyExclusiveIdentifiers(t *testing.T) {
242+
testCases := []struct {
243+
name string
244+
resourceCfg ResourceConfig
245+
expectedErrs int
246+
}{
247+
{
248+
name: "valid two identifiers",
249+
resourceCfg: ResourceConfig{
250+
MutuallyExclusiveIdentifiers: []string{"PolicyName", "ResourceArn"},
251+
},
252+
expectedErrs: 0,
253+
},
254+
{
255+
name: "unset is allowed",
256+
resourceCfg: ResourceConfig{},
257+
expectedErrs: 0,
258+
},
259+
{
260+
name: "single identifier is invalid",
261+
resourceCfg: ResourceConfig{
262+
MutuallyExclusiveIdentifiers: []string{"PolicyName"},
263+
},
264+
expectedErrs: 1,
265+
},
266+
{
267+
name: "incompatible with is_arn_primary_key",
268+
resourceCfg: ResourceConfig{
269+
MutuallyExclusiveIdentifiers: []string{"PolicyName", "ResourceArn"},
270+
IsARNPrimaryKey: true,
271+
},
272+
expectedErrs: 1,
273+
},
274+
{
275+
name: "single identifier and arn primary key reports both",
276+
resourceCfg: ResourceConfig{
277+
MutuallyExclusiveIdentifiers: []string{"PolicyName"},
278+
IsARNPrimaryKey: true,
279+
},
280+
expectedErrs: 2,
281+
},
282+
}
283+
284+
for _, tc := range testCases {
285+
t.Run(tc.name, func(t *testing.T) {
286+
cfg := &Config{
287+
Resources: map[string]ResourceConfig{
288+
"ResourcePolicy": tc.resourceCfg,
289+
},
290+
}
291+
errs := validateMutuallyExclusiveIdentifiers(cfg)
292+
if len(errs) != tc.expectedErrs {
293+
t.Errorf("expected %d errors, got %d: %v", tc.expectedErrs, len(errs), errs)
294+
}
295+
})
296+
}
297+
}
298+
241299
func TestFormatAvailableTruncated(t *testing.T) {
242300
items := []string{"A", "B", "C", "D", "E"}
243301
got := formatAvailableTruncated(items, 3)

pkg/generate/code/check.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func CheckRequiredFieldsMissingFromShape(
7777
case model.OpTypeList:
7878
op = r.Ops.ReadMany
7979
return checkRequiredFieldsMissingFromShapeReadMany(
80-
r, koVarName, indentLevel, op, op.InputRef.Shape), nil
80+
r, koVarName, indentLevel, op, op.InputRef.Shape)
8181
case model.OpTypeGetAttributes:
8282
op = r.Ops.GetAttributes
8383
case model.OpTypeSetAttributes:
@@ -96,6 +96,42 @@ func CheckRequiredFieldsMissingFromShape(
9696
)
9797
}
9898

99+
// mutuallyExclusiveIdentifierNilConditions returns, for each of the resource's
100+
// configured mutually-exclusive identifier fields, a "<path> == nil" condition
101+
// string, along with the set of CR paths so callers can exclude them from
102+
// per-field required checks. The resource is uniquely identified by exactly one
103+
// of these fields, so it is considered incomplete only when all of them are
104+
// nil. Returns nil slices when the resource has no mutually-exclusive
105+
// identifiers.
106+
func mutuallyExclusiveIdentifierNilConditions(
107+
r *model.CRD,
108+
koVarName string,
109+
) ([]string, map[string]bool, error) {
110+
if !r.HasMutuallyExclusiveIdentifiers() {
111+
return nil, nil, nil
112+
}
113+
identifierFields, err := r.GetMutuallyExclusiveIdentifierFields()
114+
if err != nil {
115+
return nil, nil, err
116+
}
117+
cfg := r.Config()
118+
conditions := make([]string, 0, len(identifierFields))
119+
paths := make(map[string]bool, len(identifierFields))
120+
for _, identifierField := range identifierFields {
121+
memberPath, targetField := findFieldInCR(cfg, r, identifierField.Names.Original)
122+
if targetField == nil {
123+
return nil, nil, fmt.Errorf(
124+
"resource %q: mutually_exclusive_identifiers field %q is not in the CR's Spec or Status",
125+
r.Names.Original, identifierField.Names.Original,
126+
)
127+
}
128+
path := fmt.Sprintf("%s%s.%s", koVarName, memberPath, targetField.Path)
129+
conditions = append(conditions, fmt.Sprintf("%s == nil", path))
130+
paths[path] = true
131+
}
132+
return conditions, paths, nil
133+
}
134+
99135
func checkRequiredFieldsMissingFromShape(
100136
r *model.CRD,
101137
koVarName string,
@@ -104,7 +140,26 @@ func checkRequiredFieldsMissingFromShape(
104140
shape *awssdkmodel.Shape,
105141
) (string, error) {
106142
indent := strings.Repeat("\t", indentLevel)
143+
144+
// When the resource declares mutually-exclusive identifiers, the resource is
145+
// uniquely identified by exactly one of the declared fields. Build a single
146+
// grouped condition that is true only when none of them are set, and collect
147+
// their CR paths so they are not required individually below. This makes the
148+
// generated check treat the input as incomplete unless at least one
149+
// identifier is present, mirroring the ReadMany handling.
150+
exclusiveConditions, exclusivePaths, err := mutuallyExclusiveIdentifierNilConditions(r, koVarName)
151+
if err != nil {
152+
return "", err
153+
}
154+
exclusiveGroupCondition := ""
155+
if len(exclusiveConditions) > 0 {
156+
exclusiveGroupCondition = fmt.Sprintf("(%s)", strings.Join(exclusiveConditions, " && "))
157+
}
158+
107159
if shape == nil || len(shape.Required) == 0 {
160+
if exclusiveGroupCondition != "" {
161+
return fmt.Sprintf("%sreturn %s\n", indent, exclusiveGroupCondition), nil
162+
}
108163
return fmt.Sprintf("%sreturn false", indent), nil
109164
}
110165

@@ -144,8 +199,16 @@ func checkRequiredFieldsMissingFromShape(
144199
r.Names.Original, memberName, shape.ShapeName,
145200
)
146201
}
202+
// Mutually-exclusive identifiers are not required individually; they are
203+
// covered by the grouped condition appended below.
204+
if exclusivePaths[resVarPath] {
205+
continue
206+
}
147207
missing = append(missing, fmt.Sprintf("%s == nil", resVarPath))
148208
}
209+
if exclusiveGroupCondition != "" {
210+
missing = append(missing, exclusiveGroupCondition)
211+
}
149212
// Use '||' because if any of the required fields are missing the object
150213
// is not created yet
151214
missingCondition := strings.Join(missing, " || ")
@@ -174,18 +237,34 @@ func checkRequiredFieldsMissingFromShapeReadMany(
174237
indentLevel int,
175238
op *awssdkmodel.Operation,
176239
shape *awssdkmodel.Shape,
177-
) string {
240+
) (string, error) {
178241
indent := strings.Repeat("\t", indentLevel)
179242
result := fmt.Sprintf("%sreturn false", indent)
180243

244+
// When the resource declares mutually-exclusive identifiers, the ReadMany
245+
// input typically has no required members, so the default `return false`
246+
// would let sdkFind list every resource and match an arbitrary one. Instead,
247+
// treat the read input as incomplete (returning true so sdkFind bails out
248+
// with NotFound) unless at least one of the declared identifiers is set on
249+
// the resource.
250+
if r.HasMutuallyExclusiveIdentifiers() {
251+
exclusiveConditions, _, err := mutuallyExclusiveIdentifierNilConditions(r, koVarName)
252+
if err != nil {
253+
return "", err
254+
}
255+
// Parenthesize the grouped condition to mirror the ReadOne handling and
256+
// stay correct if a future term is ever joined here with `||`.
257+
return fmt.Sprintf("%sreturn (%s)\n", indent, strings.Join(exclusiveConditions, " && ")), nil
258+
}
259+
181260
reqIdentifier, _ := FindPluralizedIdentifiersInShape(r, shape, op)
182261
resVarPath, err := r.GetSanitizedMemberPath(reqIdentifier, op, koVarName)
183262
if err != nil {
184-
return result
263+
return result, nil
185264
}
186265

187266
result = fmt.Sprintf("%s == nil", resVarPath)
188-
return fmt.Sprintf("%sreturn %s\n", indent, result)
267+
return fmt.Sprintf("%sreturn %s\n", indent, result), nil
189268
}
190269

191270
// CheckNilFieldPath returns the condition statement for Nil check

pkg/generate/code/check_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,65 @@ func TestCheckRequiredFields_StatusField_ReadMany_EgressOnlyIGW(t *testing.T) {
165165
)
166166
}
167167

168+
func TestCheckRequiredFields_MutuallyExclusiveIdentifiers_ReadOne(t *testing.T) {
169+
assert := assert.New(t)
170+
require := require.New(t)
171+
172+
g := testutil.NewModelForServiceWithOptions(t, "opensearchserverless", &testutil.TestingModelOptions{
173+
GeneratorConfigFile: "generator-with-mutually-exclusive-identifiers.yaml",
174+
})
175+
176+
crd := testutil.GetCRDByName(t, g, "SecurityPolicy")
177+
require.NotNil(crd)
178+
require.True(crd.HasMutuallyExclusiveIdentifiers())
179+
180+
// GetSecurityPolicy (ReadOne) marks both `name` and `type` required.
181+
// Declaring them mutually exclusive drops each from the per-field `||` list
182+
// and collapses them into a single grouped condition that is true only when
183+
// neither identifier is set.
184+
expRequiredFieldsCode := `
185+
return (r.ko.Spec.Name == nil && r.ko.Spec.Type == nil)
186+
`
187+
gotCode, err := code.CheckRequiredFieldsMissingFromShape(
188+
crd, model.OpTypeGet, "r.ko", 1,
189+
)
190+
require.NoError(err)
191+
assert.Equal(
192+
strings.TrimSpace(expRequiredFieldsCode),
193+
strings.TrimSpace(gotCode),
194+
)
195+
}
196+
197+
func TestCheckRequiredFields_MutuallyExclusiveIdentifiers_ReadMany(t *testing.T) {
198+
assert := assert.New(t)
199+
require := require.New(t)
200+
201+
g := testutil.NewModelForServiceWithOptions(t, "cloudwatch-logs", &testutil.TestingModelOptions{
202+
GeneratorConfigFile: "generator.yaml",
203+
})
204+
205+
crd := testutil.GetCRDByName(t, g, "ResourcePolicy")
206+
require.NotNil(crd)
207+
require.True(crd.HasMutuallyExclusiveIdentifiers())
208+
209+
// DescribeResourcePolicies has no required input members, so without
210+
// mutually_exclusive_identifiers this would generate `return false` and let
211+
// sdkFind match an arbitrary policy. Instead, the read input is treated as
212+
// incomplete unless at least one of the declared identifiers is set, which
213+
// is what the controller previously had to supply via a custom method.
214+
expRequiredFieldsCode := `
215+
return (r.ko.Spec.PolicyName == nil && r.ko.Spec.ResourceARN == nil)
216+
`
217+
gotCode, err := code.CheckRequiredFieldsMissingFromShape(
218+
crd, model.OpTypeList, "r.ko", 1,
219+
)
220+
require.NoError(err)
221+
assert.Equal(
222+
strings.TrimSpace(expRequiredFieldsCode),
223+
strings.TrimSpace(gotCode),
224+
)
225+
}
226+
168227
func TestCheckNilFieldPath(t *testing.T) {
169228
// Empty FieldPath
170229
field := model.Field{Path: ""}

0 commit comments

Comments
 (0)