Skip to content

Commit 5dbac40

Browse files
committed
review comments, unit & e2e tests for comments, and support for standard/experimental .spec.serviceaccount handling
Signed-off-by: grokspawn <jordan@nimblewidget.com>
1 parent 435dc41 commit 5dbac40

10 files changed

Lines changed: 278 additions & 46 deletions

File tree

api/v1/clusterextension_types.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,20 @@ type ClusterExtensionSpec struct {
6767
// +kubebuilder:validation:Required
6868
Namespace string `json:"namespace"`
6969

70+
// <opcon:standard:description>
71+
// serviceAccount is a required field that references a ServiceAccount used to
72+
// perform all interactions with the cluster that are required to manage the extension.
73+
// </opcon:standard:description>
74+
// <opcon:standard:validation:Required>
75+
//
76+
// <opcon:experimental:description>
7077
// serviceAccount is an optional field that references a ServiceAccount used to
7178
// perform all interactions with the cluster that are required to manage the extension.
7279
// If not set, operator-controller will use its own ServiceAccount for extension management.
7380
// The ServiceAccount must be configured with the necessary permissions to perform these interactions.
7481
// The ServiceAccount must exist in the namespace referenced in the spec.
75-
//
76-
// +optional
82+
// </opcon:experimental:description>
83+
// <opcon:experimental:validation:Optional>
7784
ServiceAccount ServiceAccountReference `json:"serviceAccount,omitzero"`
7885

7986
// source is a required field which selects the installation source of content
@@ -403,8 +410,8 @@ type ServiceAccountReference struct {
403410
// +kubebuilder:validation:MaxLength:=253
404411
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="name is immutable"
405412
// +kubebuilder:validation:XValidation:rule="self.matches(\"^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$\")",message="name must be a valid DNS1123 subdomain. It must contain only lowercase alphanumeric characters, hyphens (-) or periods (.), start and end with an alphanumeric character, and be no longer than 253 characters"
406-
// +kubebuilder:validation:Optional
407-
Name string `json:"name"`
413+
// +optional
414+
Name string `json:"name,omitempty"`
408415
}
409416

410417
// PreflightConfig holds the configuration for the preflight checks. If used, at least one preflight check must be non-nil.

api/v1/clusterextension_types_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package v1_test
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"go/ast"
67
"go/parser"
@@ -11,6 +12,7 @@ import (
1112

1213
"golang.org/x/exp/slices" // replace with "slices" in go 1.21
1314

15+
v1 "github.com/operator-framework/operator-controller/api/v1"
1416
"github.com/operator-framework/operator-controller/internal/operator-controller/conditionsets"
1517
)
1618

@@ -100,3 +102,62 @@ func parseConstants(prefix string) ([]string, error) {
100102
}
101103
return constValues, nil
102104
}
105+
106+
func TestServiceAccountMarshaling(t *testing.T) {
107+
tests := []struct {
108+
name string
109+
spec v1.ClusterExtensionSpec
110+
expectField string
111+
unexpectField string
112+
}{
113+
{
114+
name: "ServiceAccount with name is marshaled",
115+
spec: v1.ClusterExtensionSpec{
116+
Namespace: "test-ns",
117+
ServiceAccount: v1.ServiceAccountReference{
118+
Name: "test-sa",
119+
},
120+
Source: v1.SourceConfig{
121+
SourceType: "Catalog",
122+
Catalog: &v1.CatalogFilter{
123+
PackageName: "test-package",
124+
},
125+
},
126+
},
127+
expectField: "serviceAccount",
128+
},
129+
{
130+
name: "ServiceAccount with empty name is omitted",
131+
spec: v1.ClusterExtensionSpec{
132+
Namespace: "test-ns",
133+
ServiceAccount: v1.ServiceAccountReference{},
134+
Source: v1.SourceConfig{
135+
SourceType: "Catalog",
136+
Catalog: &v1.CatalogFilter{
137+
PackageName: "test-package",
138+
},
139+
},
140+
},
141+
unexpectField: "serviceAccount",
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
data, err := json.Marshal(tt.spec)
148+
if err != nil {
149+
t.Fatalf("failed to marshal spec: %v", err)
150+
}
151+
152+
jsonStr := string(data)
153+
154+
if tt.expectField != "" && !strings.Contains(jsonStr, tt.expectField) {
155+
t.Errorf("expected field %q to be present in JSON output, got: %s", tt.expectField, jsonStr)
156+
}
157+
158+
if tt.unexpectField != "" && strings.Contains(jsonStr, tt.unexpectField) {
159+
t.Errorf("expected field %q to be omitted from JSON output, got: %s", tt.unexpectField, jsonStr)
160+
}
161+
})
162+
}
163+
}

docs/api-reference/olmv1-api-reference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ _Appears in:_
340340
| Field | Description | Default | Validation |
341341
| --- | --- | --- | --- |
342342
| `namespace` _string_ | namespace is a reference to a Kubernetes namespace.<br />This is the namespace in which the provided ServiceAccount must exist.<br />It also designates the default namespace where namespace-scoped resources<br />for the extension are applied to the cluster.<br />Some extensions may contain namespace-scoped resources to be applied in other namespaces.<br />This namespace must exist.<br />namespace is required, immutable, and follows the DNS label standard<br />as defined in [RFC 1123]. It must contain only lowercase alphanumeric characters or hyphens (-),<br />start and end with an alphanumeric character, and be no longer than 63 characters<br />[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 63 <br />Required: \{\} <br /> |
343-
| `serviceAccount` _[ServiceAccountReference](#serviceaccountreference)_ | serviceAccount is a reference to a ServiceAccount used to perform all interactions<br />with the cluster that are required to manage the extension.<br />The ServiceAccount must be configured with the necessary permissions to perform these interactions.<br />The ServiceAccount must exist in the namespace referenced in the spec.<br />serviceAccount is required. | | Required: \{\} <br /> |
343+
| `serviceAccount` _[ServiceAccountReference](#serviceaccountreference)_ | <opcon:standard:description><br />serviceAccount is a required field that references a ServiceAccount used to<br />perform all interactions with the cluster that are required to manage the extension.<br /></opcon:standard:description><br /><opcon:standard:validation:Required><br /><opcon:experimental:description><br />serviceAccount is an optional field that references a ServiceAccount used to<br />perform all interactions with the cluster that are required to manage the extension.<br />If not set, operator-controller will use its own ServiceAccount for extension management.<br />The ServiceAccount must be configured with the necessary permissions to perform these interactions.<br />The ServiceAccount must exist in the namespace referenced in the spec.<br /></opcon:experimental:description><br /><opcon:experimental:validation:Optional> | | |
344344
| `source` _[SourceConfig](#sourceconfig)_ | source is a required field which selects the installation source of content<br />for this ClusterExtension. Selection is performed by setting the sourceType.<br />Catalog is currently the only implemented sourceType, and setting the<br />sourcetype to "Catalog" requires the catalog field to also be defined.<br />Below is a minimal example of a source definition (in yaml):<br />source:<br /> sourceType: Catalog<br /> catalog:<br /> packageName: example-package | | Required: \{\} <br /> |
345345
| `install` _[ClusterExtensionInstallConfig](#clusterextensioninstallconfig)_ | install is an optional field used to configure the installation options<br />for the ClusterExtension such as the pre-flight check configuration. | | |
346346
| `config` _[ClusterExtensionConfig](#clusterextensionconfig)_ | config is an optional field used to specify bundle specific configuration<br />used to configure the bundle. Configuration is bundle specific and a bundle may provide<br />a configuration schema. When not specified, the default configuration of the resolved bundle will be used.<br />config is validated against a configuration schema provided by the resolved bundle. If the bundle does not provide<br />a configuration schema the final manifests will be derived on a best-effort basis. More information on how<br />to configure the bundle should be found in its end-user documentation.<br /><opcon:experimental> | | |
@@ -439,7 +439,7 @@ _Appears in:_
439439

440440

441441

442-
ServiceAccountReference identifies the serviceAccount used fo install a ClusterExtension.
442+
ServiceAccountReference identifies the serviceAccount used to install a ClusterExtension.
443443

444444

445445

@@ -448,7 +448,7 @@ _Appears in:_
448448

449449
| Field | Description | Default | Validation |
450450
| --- | --- | --- | --- |
451-
| `name` _string_ | name is a required, immutable reference to the name of the ServiceAccount<br />to be used for installation and management of the content for the package<br />specified in the packageName field.<br />This ServiceAccount must exist in the installNamespace.<br />name follows the DNS subdomain standard as defined in [RFC 1123].<br />It must contain only lowercase alphanumeric characters,<br />hyphens (-) or periods (.), start and end with an alphanumeric character,<br />and be no longer than 253 characters.<br />Some examples of valid values are:<br /> - some-serviceaccount<br /> - 123-serviceaccount<br /> - 1-serviceaccount-2<br /> - someserviceaccount<br /> - some.serviceaccount<br />Some examples of invalid values are:<br /> - -some-serviceaccount<br /> - some-serviceaccount-<br />[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 253 <br />Required: \{\} <br /> |
451+
| `name` _string_ | name is a required, immutable reference to the name of the ServiceAccount<br />to be used for installation and management of the content for the package<br />specified in the packageName field.<br />This ServiceAccount must exist in the installNamespace.<br />name follows the DNS subdomain standard as defined in [RFC 1123].<br />It must contain only lowercase alphanumeric characters,<br />hyphens (-) or periods (.), start and end with an alphanumeric character,<br />and be no longer than 253 characters.<br />Some examples of valid values are:<br /> - some-serviceaccount<br /> - 123-serviceaccount<br /> - 1-serviceaccount-2<br /> - someserviceaccount<br /> - some.serviceaccount<br />Some examples of invalid values are:<br /> - -some-serviceaccount<br /> - some-serviceaccount-<br />[RFC 1123]: https://tools.ietf.org/html/rfc1123 | | MaxLength: 253 <br /> |
452452

453453

454454
#### SourceConfig

hack/tools/crd-generator/main.go

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"log"
2424
"os"
2525
"regexp"
26+
"slices"
2627
"strings"
2728

2829
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -136,7 +137,7 @@ func runGenerator(args ...string) {
136137
if channel == StandardChannel && strings.Contains(version.Name, "alpha") {
137138
channelCrd.Spec.Versions[i].Served = false
138139
}
139-
version.Schema.OpenAPIV3Schema.Properties = opconTweaksMap(channel, version.Schema.OpenAPIV3Schema.Properties)
140+
channelCrd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties, channelCrd.Spec.Versions[i].Schema.OpenAPIV3Schema.Required = opconTweaksMap(channel, version.Schema.OpenAPIV3Schema.Properties, version.Schema.OpenAPIV3Schema.Required)
140141
}
141142

142143
conv, err := crd.AsVersion(*channelCrd, apiextensionsv1.SchemeGroupVersion)
@@ -179,25 +180,43 @@ func runGenerator(args ...string) {
179180
}
180181
}
181182

182-
func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps) map[string]apiextensionsv1.JSONSchemaProps {
183+
func opconTweaksMap(channel string, props map[string]apiextensionsv1.JSONSchemaProps, existingRequired []string) (map[string]apiextensionsv1.JSONSchemaProps, []string) {
184+
// Start with existing required fields (from kubebuilder markers)
185+
requiredFields := slices.Clone(existingRequired)
186+
183187
for name := range props {
184188
jsonProps := props[name]
185-
p := opconTweaks(channel, name, jsonProps)
189+
p, reqStatus := opconTweaks(channel, name, jsonProps)
186190
if p == nil {
187191
delete(props, name)
192+
// Remove from required list if present
193+
requiredFields = slices.DeleteFunc(requiredFields, func(s string) bool { return s == name })
188194
} else {
189195
props[name] = *p
196+
// Update required list based on tag
197+
switch reqStatus {
198+
case "required":
199+
if !slices.Contains(requiredFields, name) {
200+
requiredFields = append(requiredFields, name)
201+
}
202+
case "optional":
203+
requiredFields = slices.DeleteFunc(requiredFields, func(s string) bool { return s == name })
204+
// "" (unspecified) means keep existing status
205+
}
190206
}
191207
}
192-
return props
208+
return props, requiredFields
193209
}
194210

195211
// Custom Opcon API Tweaks for tags prefixed with `<opcon:` that get past
196212
// the limitations of Kubebuilder annotations.
197-
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaProps {
213+
// Returns the modified schema and a string indicating required status:
214+
// "required", "optional", or "" (unspecified - preserve existing)
215+
func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSchemaProps) (*apiextensionsv1.JSONSchemaProps, string) {
216+
requiredStatus := "" // "required", "optional", or "" (unspecified)
198217
if channel == StandardChannel {
199218
if strings.Contains(jsonProps.Description, "<opcon:experimental>") {
200-
return nil
219+
return nil, ""
201220
}
202221
}
203222

@@ -237,6 +256,22 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
237256
Rule: celMatch[2],
238257
})
239258
}
259+
260+
optReqRe := regexp.MustCompile(validationPrefix + "(Optional|Required)>")
261+
optReqMatches := optReqRe.FindAllStringSubmatch(jsonProps.Description, 64)
262+
for _, optReqMatch := range optReqMatches {
263+
if len(optReqMatch) != 2 {
264+
log.Fatalf("Invalid %s Optional/Required tag for %s", validationPrefix, name)
265+
}
266+
267+
numValid++
268+
switch optReqMatch[1] {
269+
case "Optional":
270+
requiredStatus = "optional"
271+
case "Required":
272+
requiredStatus = "required"
273+
}
274+
}
240275
}
241276

242277
if numValid < numExpressions {
@@ -246,34 +281,42 @@ func opconTweaks(channel string, name string, jsonProps apiextensionsv1.JSONSche
246281
jsonProps.Description = formatDescription(jsonProps.Description, channel, name)
247282

248283
if len(jsonProps.Properties) > 0 {
249-
jsonProps.Properties = opconTweaksMap(channel, jsonProps.Properties)
284+
jsonProps.Properties, jsonProps.Required = opconTweaksMap(channel, jsonProps.Properties, jsonProps.Required)
250285
} else if jsonProps.Items != nil && jsonProps.Items.Schema != nil {
251-
jsonProps.Items.Schema = opconTweaks(channel, name, *jsonProps.Items.Schema)
286+
jsonProps.Items.Schema, _ = opconTweaks(channel, name, *jsonProps.Items.Schema)
252287
}
253288

254-
return &jsonProps
289+
return &jsonProps, requiredStatus
255290
}
256291

257292
func formatDescription(description string, channel string, name string) string {
258-
startTag := "<opcon:experimental:description>"
259-
endTag := "</opcon:experimental:description>"
260-
if channel == StandardChannel && strings.Contains(description, startTag) {
261-
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
262-
re := regexp.MustCompile(regexPattern)
263-
match := re.FindStringSubmatch(description)
264-
if len(match) != 2 {
265-
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
293+
tagset := []struct {
294+
channel string
295+
start string
296+
end string
297+
}{
298+
{channel: ExperimentalChannel, start: "<opcon:standard:description>", end: "</opcon:standard:description>"},
299+
{channel: StandardChannel, start: "<opcon:experimental:description>", end: "</opcon:experimental:description>"},
300+
}
301+
for _, ts := range tagset {
302+
if channel == ts.channel && strings.Contains(description, ts.start) {
303+
regexPattern := `\n*` + regexp.QuoteMeta(ts.start) + `(?s:(.*?))` + regexp.QuoteMeta(ts.end) + `\n*`
304+
re := regexp.MustCompile(regexPattern)
305+
match := re.FindStringSubmatch(description)
306+
if len(match) != 2 {
307+
log.Fatalf("Invalid <opcon:experimental:description> tag for %s", name)
308+
}
309+
description = re.ReplaceAllString(description, "\n\n")
310+
} else {
311+
description = strings.ReplaceAll(description, ts.start, "")
312+
description = strings.ReplaceAll(description, ts.end, "")
266313
}
267-
description = re.ReplaceAllString(description, "\n\n")
268-
} else {
269-
description = strings.ReplaceAll(description, startTag, "")
270-
description = strings.ReplaceAll(description, endTag, "")
271314
}
272315

273316
// Comments within "opcon:util:excludeFromCRD" tag are not included in the generated CRD and all trailing \n operators before
274317
// and after the tags are removed and replaced with three \n operators.
275-
startTag = "<opcon:util:excludeFromCRD>"
276-
endTag = "</opcon:util:excludeFromCRD>"
318+
startTag := "<opcon:util:excludeFromCRD>"
319+
endTag := "</opcon:util:excludeFromCRD>"
277320
if strings.Contains(description, startTag) {
278321
regexPattern := `\n*` + regexp.QuoteMeta(startTag) + `(?s:(.*?))` + regexp.QuoteMeta(endTag) + `\n*`
279322
re := regexp.MustCompile(regexPattern)

helm/olmv1/base/operator-controller/crd/standard/olm.operatorframework.io_clusterextensions.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,8 @@ spec:
132132
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
133133
serviceAccount:
134134
description: |-
135-
serviceAccount is an optional field that references a ServiceAccount used to
135+
serviceAccount is a required field that references a ServiceAccount used to
136136
perform all interactions with the cluster that are required to manage the extension.
137-
If not set, operator-controller will use its own ServiceAccount for extension management.
138-
The ServiceAccount must be configured with the necessary permissions to perform these interactions.
139-
The ServiceAccount must exist in the namespace referenced in the spec.
140137
properties:
141138
name:
142139
description: |-
@@ -457,6 +454,7 @@ spec:
457454
has(self.catalog) : !has(self.catalog)'
458455
required:
459456
- namespace
457+
- serviceAccount
460458
- source
461459
type: object
462460
status:

manifests/experimental-e2e.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1699,7 +1699,7 @@ metadata:
16991699
labels:
17001700
app.kubernetes.io/name: operator-controller
17011701
app.kubernetes.io/part-of: olm
1702-
name: operator-controller-manager-admin-rolebinding
1702+
name: operator-controller-manager-rolebinding
17031703
roleRef:
17041704
apiGroup: rbac.authorization.k8s.io
17051705
kind: ClusterRole

manifests/experimental.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1664,7 +1664,7 @@ metadata:
16641664
labels:
16651665
app.kubernetes.io/name: operator-controller
16661666
app.kubernetes.io/part-of: olm
1667-
name: operator-controller-manager-admin-rolebinding
1667+
name: operator-controller-manager-rolebinding
16681668
roleRef:
16691669
apiGroup: rbac.authorization.k8s.io
16701670
kind: ClusterRole

manifests/standard-e2e.yaml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -723,11 +723,8 @@ spec:
723723
rule: self.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
724724
serviceAccount:
725725
description: |-
726-
serviceAccount is an optional field that references a ServiceAccount used to
726+
serviceAccount is a required field that references a ServiceAccount used to
727727
perform all interactions with the cluster that are required to manage the extension.
728-
If not set, operator-controller will use its own ServiceAccount for extension management.
729-
The ServiceAccount must be configured with the necessary permissions to perform these interactions.
730-
The ServiceAccount must exist in the namespace referenced in the spec.
731728
properties:
732729
name:
733730
description: |-
@@ -1048,6 +1045,7 @@ spec:
10481045
has(self.catalog) : !has(self.catalog)'
10491046
required:
10501047
- namespace
1048+
- serviceAccount
10511049
- source
10521050
type: object
10531051
status:
@@ -1450,7 +1448,7 @@ metadata:
14501448
roleRef:
14511449
apiGroup: rbac.authorization.k8s.io
14521450
kind: ClusterRole
1453-
name: operator-controller-manager-role
1451+
name: cluster-admin
14541452
subjects:
14551453
- kind: ServiceAccount
14561454
name: operator-controller-controller-manager

0 commit comments

Comments
 (0)