Skip to content

Commit 4536101

Browse files
pedjakclaude
andcommitted
feat: add ValidatingAdmissionPolicy for serviceAccount deprecation warning
Create VAP and VAPB resources that emit a warning when spec.serviceAccount is set on a ClusterExtension. Add integration test infrastructure for warning collection. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 635d602 commit 4536101

4 files changed

Lines changed: 137 additions & 44 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{{- if .Values.options.operatorController.enabled }}
2+
apiVersion: admissionregistration.k8s.io/v1
3+
kind: ValidatingAdmissionPolicy
4+
metadata:
5+
name: clusterextension-serviceaccount-deprecated
6+
labels:
7+
app.kubernetes.io/name: operator-controller
8+
{{- include "olmv1.labels" . | nindent 4 }}
9+
annotations:
10+
{{- include "olmv1.annotations" . | nindent 4 }}
11+
spec:
12+
matchConstraints:
13+
resourceRules:
14+
- apiGroups:
15+
- olm.operatorframework.io
16+
apiVersions:
17+
- v1
18+
operations:
19+
- CREATE
20+
- UPDATE
21+
resources:
22+
- clusterextensions
23+
validations:
24+
- expression: "!has(object.spec.serviceAccount) || !has(object.spec.serviceAccount.name) || object.spec.serviceAccount.name == ''"
25+
message: "spec.serviceAccount is deprecated, ignored, and will be removed in a future release. The operator-controller's cluster-admin service account is used for all cluster interactions."
26+
{{- end }}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{{- if .Values.options.operatorController.enabled }}
2+
apiVersion: admissionregistration.k8s.io/v1
3+
kind: ValidatingAdmissionPolicyBinding
4+
metadata:
5+
name: clusterextension-serviceaccount-deprecated
6+
labels:
7+
app.kubernetes.io/name: operator-controller
8+
{{- include "olmv1.labels" . | nindent 4 }}
9+
annotations:
10+
{{- include "olmv1.annotations" . | nindent 4 }}
11+
spec:
12+
policyName: clusterextension-serviceaccount-deprecated
13+
validationActions:
14+
- Warn
15+
{{- end }}

internal/operator-controller/controllers/clusterextension_admission_test.go

Lines changed: 28 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,6 @@ func TestClusterExtensionSourceConfig(t *testing.T) {
4545
},
4646
},
4747
Namespace: "default",
48-
ServiceAccount: ocv1.ServiceAccountReference{
49-
Name: "default",
50-
},
5148
}))
5249
}
5350
if tc.unionField == "" {
@@ -56,9 +53,6 @@ func TestClusterExtensionSourceConfig(t *testing.T) {
5653
SourceType: tc.sourceType,
5754
},
5855
Namespace: "default",
59-
ServiceAccount: ocv1.ServiceAccountReference{
60-
Name: "default",
61-
},
6256
}))
6357
}
6458

@@ -123,9 +117,6 @@ func TestClusterExtensionAdmissionPackageName(t *testing.T) {
123117
},
124118
},
125119
Namespace: "default",
126-
ServiceAccount: ocv1.ServiceAccountReference{
127-
Name: "default",
128-
},
129120
}))
130121
if tc.errMsg == "" {
131122
require.NoError(t, err, "unexpected error for package name %q: %w", tc.pkgName, err)
@@ -221,9 +212,6 @@ func TestClusterExtensionAdmissionVersion(t *testing.T) {
221212
},
222213
},
223214
Namespace: "default",
224-
ServiceAccount: ocv1.ServiceAccountReference{
225-
Name: "default",
226-
},
227215
}))
228216
if tc.errMsg == "" {
229217
require.NoError(t, err, "unexpected error for version %q: %w", tc.version, err)
@@ -245,7 +233,7 @@ func TestClusterExtensionAdmissionChannel(t *testing.T) {
245233
errMsg string
246234
}{
247235
{"no channel name", []string{""}, regexMismatchError},
248-
{"hypen-separated", []string{"hyphenated-name"}, ""},
236+
{"hyphen-separated", []string{"hyphenated-name"}, ""},
249237
{"dot-separated", []string{"dotted.name"}, ""},
250238
{"includes version", []string{"channel-has-version-1.0.1"}, ""},
251239
{"long channel name", []string{strings.Repeat("x", 254)}, tooLongError},
@@ -276,9 +264,6 @@ func TestClusterExtensionAdmissionChannel(t *testing.T) {
276264
},
277265
},
278266
Namespace: "default",
279-
ServiceAccount: ocv1.ServiceAccountReference{
280-
Name: "default",
281-
},
282267
}))
283268
if tc.errMsg == "" {
284269
require.NoError(t, err, "unexpected error for channel %q: %w", tc.channels, err)
@@ -300,7 +285,7 @@ func TestClusterExtensionAdmissionInstallNamespace(t *testing.T) {
300285
errMsg string
301286
}{
302287
{"just alphanumeric", "justalphanumberic1", ""},
303-
{"hypen-separated", "hyphenated-name", ""},
288+
{"hyphen-separated", "hyphenated-name", ""},
304289
{"no install namespace", "", regexMismatchError},
305290
{"dot-separated", "dotted.name", regexMismatchError},
306291
{"longest valid install namespace", strings.Repeat("x", 63), ""},
@@ -329,9 +314,6 @@ func TestClusterExtensionAdmissionInstallNamespace(t *testing.T) {
329314
},
330315
},
331316
Namespace: tc.namespace,
332-
ServiceAccount: ocv1.ServiceAccountReference{
333-
Name: "default",
334-
},
335317
}))
336318
if tc.errMsg == "" {
337319
require.NoError(t, err, "unexpected error for namespace %q: %w", tc.namespace, err)
@@ -343,38 +325,43 @@ func TestClusterExtensionAdmissionInstallNamespace(t *testing.T) {
343325
}
344326
}
345327

328+
// TestClusterExtensionAdmissionServiceAccount validates the deprecated spec.serviceAccount field:
329+
// - CRD-level validation (format, length) still works
330+
// - ValidatingAdmissionPolicy emits a deprecation warning for valid non-empty values
346331
func TestClusterExtensionAdmissionServiceAccount(t *testing.T) {
347332
tooLongError := "spec.serviceAccount.name: Too long: may not be more than 253"
348333
regexMismatchError := "name must be a valid DNS1123 subdomain"
334+
deprecationWarning := "spec.serviceAccount is deprecated"
349335

350336
testCases := []struct {
351337
name string
352338
serviceAccount string
353339
errMsg string
340+
warnMsg string
354341
}{
355-
{"just alphanumeric", "justalphanumeric1", ""},
356-
{"hypen-separated", "hyphenated-name", ""},
357-
{"dot-separated", "dotted.name", ""},
358-
{"longest valid service account name", strings.Repeat("x", 253), ""},
359-
{"too long service account name", strings.Repeat("x", 254), tooLongError},
360-
{"no service account name", "", regexMismatchError},
361-
{"spaces", "spaces spaces", regexMismatchError},
362-
{"capitalized", "Capitalized", regexMismatchError},
363-
{"camel case", "camelCase", regexMismatchError},
364-
{"invalid characters", "many/invalid$characters+in_name", regexMismatchError},
365-
{"starts with hyphen", "-start-with-hyphen", regexMismatchError},
366-
{"ends with hyphen", "end-with-hyphen-", regexMismatchError},
367-
{"starts with period", ".start-with-period", regexMismatchError},
368-
{"ends with period", "end-with-period.", regexMismatchError},
369-
{"multiple sequential separators", "a.-b", regexMismatchError},
342+
{"just alphanumeric", "justalphanumeric1", "", deprecationWarning},
343+
{"hyphen-separated", "hyphenated-name", "", deprecationWarning},
344+
{"dot-separated", "dotted.name", "", deprecationWarning},
345+
{"longest valid service account name", strings.Repeat("x", 253), "", deprecationWarning},
346+
{"too long service account name", strings.Repeat("x", 254), tooLongError, ""},
347+
{"no service account name", "", "", ""},
348+
{"spaces", "spaces spaces", regexMismatchError, ""},
349+
{"capitalized", "Capitalized", regexMismatchError, ""},
350+
{"camel case", "camelCase", regexMismatchError, ""},
351+
{"invalid characters", "many/invalid$characters+in_name", regexMismatchError, ""},
352+
{"starts with hyphen", "-start-with-hyphen", regexMismatchError, ""},
353+
{"ends with hyphen", "end-with-hyphen-", regexMismatchError, ""},
354+
{"starts with period", ".start-with-period", regexMismatchError, ""},
355+
{"ends with period", "end-with-period.", regexMismatchError, ""},
356+
{"multiple sequential separators", "a.-b", regexMismatchError, ""},
370357
}
371358

372359
t.Parallel()
373360
for _, tc := range testCases {
374361
tc := tc
375362
t.Run(tc.name, func(t *testing.T) {
376363
t.Parallel()
377-
cl := newClient(t)
364+
cl, collector := newWarningCapturingClient(t)
378365
err := cl.Create(context.Background(), buildClusterExtension(ocv1.ClusterExtensionSpec{
379366
Source: ocv1.SourceConfig{
380367
SourceType: "Catalog",
@@ -383,7 +370,7 @@ func TestClusterExtensionAdmissionServiceAccount(t *testing.T) {
383370
},
384371
},
385372
Namespace: "default",
386-
ServiceAccount: ocv1.ServiceAccountReference{
373+
ServiceAccount: ocv1.ServiceAccountReference{ //nolint:staticcheck // testing deprecated field
387374
Name: tc.serviceAccount,
388375
},
389376
}))
@@ -393,6 +380,9 @@ func TestClusterExtensionAdmissionServiceAccount(t *testing.T) {
393380
require.Error(t, err)
394381
require.Contains(t, err.Error(), tc.errMsg)
395382
}
383+
if tc.warnMsg != "" {
384+
require.True(t, collector.hasWarning(tc.warnMsg), "expected deprecation warning containing %q", tc.warnMsg)
385+
}
396386
})
397387
}
398388
}
@@ -442,10 +432,7 @@ func TestClusterExtensionAdmissionInstall(t *testing.T) {
442432
},
443433
},
444434
Namespace: "default",
445-
ServiceAccount: ocv1.ServiceAccountReference{
446-
Name: "default",
447-
},
448-
Install: tc.installConfig,
435+
Install: tc.installConfig,
449436
}))
450437
if tc.errMsg == "" {
451438
require.NoError(t, err, "unexpected error for install configuration %v: %w", tc.installConfig, err)
@@ -494,9 +481,6 @@ func Test_ClusterExtensionAdmissionInlineConfig(t *testing.T) {
494481
},
495482
},
496483
Namespace: "default",
497-
ServiceAccount: ocv1.ServiceAccountReference{
498-
Name: "default",
499-
},
500484
Config: &ocv1.ClusterExtensionConfig{
501485
ConfigType: ocv1.ClusterExtensionConfigTypeInline,
502486
Inline: &apiextensionsv1.JSON{

internal/operator-controller/controllers/suite_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,18 @@ limitations under the License.
1717
package controllers_test
1818

1919
import (
20+
"context"
2021
"log"
2122
"os"
23+
"strings"
24+
"sync"
2225
"testing"
2326
"time"
2427

2528
"github.com/stretchr/testify/require"
2629
"go.uber.org/mock/gomock"
30+
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
31+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2732
apimachineryruntime "k8s.io/apimachinery/pkg/runtime"
2833
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2934
"k8s.io/client-go/rest"
@@ -53,6 +58,37 @@ func newClient(t *testing.T) client.Client {
5358
return cl
5459
}
5560

61+
type warningCollector struct {
62+
mu sync.Mutex
63+
items []string
64+
}
65+
66+
func (w *warningCollector) HandleWarningHeader(code int, agent string, text string) {
67+
w.mu.Lock()
68+
defer w.mu.Unlock()
69+
w.items = append(w.items, text)
70+
}
71+
72+
func (w *warningCollector) hasWarning(substr string) bool {
73+
w.mu.Lock()
74+
defer w.mu.Unlock()
75+
for _, item := range w.items {
76+
if strings.Contains(item, substr) {
77+
return true
78+
}
79+
}
80+
return false
81+
}
82+
83+
func newWarningCapturingClient(t *testing.T) (client.Client, *warningCollector) {
84+
collector := &warningCollector{}
85+
cfg := rest.CopyConfig(config)
86+
cfg.WarningHandler = collector
87+
cl, err := client.New(cfg, client.Options{Scheme: newScheme(t)})
88+
require.NoError(t, err)
89+
return cl, collector
90+
}
91+
5692
// newMockRevisionStatesGetter creates a gomock-based RevisionStatesGetter
5793
// that returns fixed values, replacing the hand-written MockRevisionStatesGetter.
5894
func newMockRevisionStatesGetter(ctrl *gomock.Controller, revisionStates *controllers.RevisionStates, err error) *mockcontrollers.MockRevisionStatesGetter {
@@ -128,6 +164,38 @@ func TestMain(m *testing.M) {
128164
log.Panic("expected cfg to not be nil")
129165
}
130166

167+
cl, err := client.New(config, client.Options{})
168+
utilruntime.Must(err)
169+
ctx := context.Background()
170+
utilruntime.Must(cl.Create(ctx, &admissionregistrationv1.ValidatingAdmissionPolicy{
171+
ObjectMeta: metav1.ObjectMeta{Name: "clusterextension-serviceaccount-deprecated"},
172+
Spec: admissionregistrationv1.ValidatingAdmissionPolicySpec{
173+
MatchConstraints: &admissionregistrationv1.MatchResources{
174+
ResourceRules: []admissionregistrationv1.NamedRuleWithOperations{{
175+
RuleWithOperations: admissionregistrationv1.RuleWithOperations{
176+
Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create, admissionregistrationv1.Update},
177+
Rule: admissionregistrationv1.Rule{
178+
APIGroups: []string{"olm.operatorframework.io"},
179+
APIVersions: []string{"v1"},
180+
Resources: []string{"clusterextensions"},
181+
},
182+
},
183+
}},
184+
},
185+
Validations: []admissionregistrationv1.Validation{{
186+
Expression: `!has(object.spec.serviceAccount) || !has(object.spec.serviceAccount.name) || object.spec.serviceAccount.name == ''`,
187+
Message: "spec.serviceAccount is deprecated, ignored, and will be removed in a future release. The operator-controller's cluster-admin service account is used for all cluster interactions.",
188+
}},
189+
},
190+
}))
191+
utilruntime.Must(cl.Create(ctx, &admissionregistrationv1.ValidatingAdmissionPolicyBinding{
192+
ObjectMeta: metav1.ObjectMeta{Name: "clusterextension-serviceaccount-deprecated"},
193+
Spec: admissionregistrationv1.ValidatingAdmissionPolicyBindingSpec{
194+
PolicyName: "clusterextension-serviceaccount-deprecated",
195+
ValidationActions: []admissionregistrationv1.ValidationAction{admissionregistrationv1.Warn},
196+
},
197+
}))
198+
131199
code := m.Run()
132200
// Use Eventually wrapper for graceful test environment teardown
133201
// controller-runtime v0.23.0+ requires this to prevent timing-related errors

0 commit comments

Comments
 (0)