Skip to content

Commit 3a818f9

Browse files
authored
Merge pull request #1571 from fluxcd/ssa-custom-stage
Introduce custom SSA stage
2 parents 25095db + 084420f commit 3a818f9

File tree

5 files changed

+263
-10
lines changed

5 files changed

+263
-10
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ require (
2929
github.com/fluxcd/pkg/http/fetch v0.21.0
3030
github.com/fluxcd/pkg/kustomize v1.24.0
3131
github.com/fluxcd/pkg/runtime v0.94.0
32-
github.com/fluxcd/pkg/ssa v0.61.0
32+
github.com/fluxcd/pkg/ssa v0.63.0
3333
github.com/fluxcd/pkg/tar v0.16.0
3434
github.com/fluxcd/pkg/testserver v0.13.0
3535
github.com/fluxcd/source-controller/api v1.7.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ github.com/fluxcd/pkg/runtime v0.94.0 h1:z33lG+albHTmmcpZgV7DY5VVUZXFFAErnBBATDI
210210
github.com/fluxcd/pkg/runtime v0.94.0/go.mod h1:/E4dT1pdSkidyRTR5ghSzoyHEUcEJw3ipvJt597ArOA=
211211
github.com/fluxcd/pkg/sourceignore v0.15.0 h1:tB30fuk4jlB3UGlR7ppJguZ3zaJh1iwuTCEufs91jSM=
212212
github.com/fluxcd/pkg/sourceignore v0.15.0/go.mod h1:mZ9X6gNtNkq9ZsD35LebEYjePc7DRvB2JdowMNoj6IU=
213-
github.com/fluxcd/pkg/ssa v0.61.0 h1:GeueQfZVrjPLEzmEkq6gpFTBr1MDcqUihCQDf6AaIo8=
214-
github.com/fluxcd/pkg/ssa v0.61.0/go.mod h1:PNRlgihYbmlQU5gzsB14nrsNMbtACNanBnKhLCWmeX8=
213+
github.com/fluxcd/pkg/ssa v0.63.0 h1:uLKN7dlchHEQA2qc1tmAJWXnfZCC0AJ44KLuGUBHHjQ=
214+
github.com/fluxcd/pkg/ssa v0.63.0/go.mod h1:PNRlgihYbmlQU5gzsB14nrsNMbtACNanBnKhLCWmeX8=
215215
github.com/fluxcd/pkg/tar v0.16.0 h1:P7hR2FjLBuI9AIndRqrZaO7VYFbbBzbYMBsLe2hh7fI=
216216
github.com/fluxcd/pkg/tar v0.16.0/go.mod h1:Bz1DmQ5vTY3/HLWw9LM0kHRL1vtgF4eVs5QmeRAD8UM=
217217
github.com/fluxcd/pkg/testserver v0.13.0 h1:xEpBcEYtD7bwvZ+i0ZmChxKkDo/wfQEV3xmnzVybSSg=

internal/controller/kustomization_controller.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3535
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3636
"k8s.io/apimachinery/pkg/runtime"
37+
"k8s.io/apimachinery/pkg/runtime/schema"
3738
"k8s.io/apimachinery/pkg/types"
3839
kerrors "k8s.io/apimachinery/pkg/util/errors"
3940
kuberecorder "k8s.io/client-go/tools/record"
@@ -90,13 +91,14 @@ type KustomizationReconciler struct {
9091

9192
// Kubernetes options
9293

93-
APIReader client.Reader
94-
ClusterReader engine.ClusterReaderFactory
95-
ConcurrentSSA int
96-
ControllerName string
97-
KubeConfigOpts runtimeClient.KubeConfigOptions
98-
Mapper apimeta.RESTMapper
99-
StatusManager string
94+
APIReader client.Reader
95+
ClusterReader engine.ClusterReaderFactory
96+
ConcurrentSSA int
97+
ControllerName string
98+
KubeConfigOpts runtimeClient.KubeConfigOptions
99+
Mapper apimeta.RESTMapper
100+
StatusManager string
101+
CustomStageKinds map[schema.GroupKind]struct{}
100102

101103
// Multi-tenancy and security options
102104

@@ -852,6 +854,7 @@ func (r *KustomizationReconciler) apply(ctx context.Context,
852854
applyOpts.ForceSelector = map[string]string{
853855
fmt.Sprintf("%s/force", kustomizev1.GroupVersion.Group): kustomizev1.EnabledValue,
854856
}
857+
applyOpts.CustomStageKinds = r.CustomStageKinds
855858

856859
fieldManagers := []ssa.FieldManager{
857860
{
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"testing"
23+
"time"
24+
25+
"github.com/fluxcd/pkg/apis/meta"
26+
"github.com/fluxcd/pkg/testserver"
27+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
28+
. "github.com/onsi/gomega"
29+
corev1 "k8s.io/api/core/v1"
30+
rbacv1 "k8s.io/api/rbac/v1"
31+
apimeta "k8s.io/apimachinery/pkg/api/meta"
32+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/apimachinery/pkg/runtime/schema"
34+
"k8s.io/apimachinery/pkg/types"
35+
"sigs.k8s.io/controller-runtime/pkg/client"
36+
37+
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
38+
)
39+
40+
func TestKustomizationReconciler_AppliesRoleAndRoleBinding(t *testing.T) {
41+
g := NewWithT(t)
42+
id := "custom-stage-" + randStringRunes(5)
43+
revision := "v1.0.0"
44+
45+
err := createNamespace(id)
46+
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
47+
48+
err = createKubeConfigSecret(id)
49+
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
50+
51+
// Create a ServiceAccount with limited RBAC (not cluster-admin).
52+
// The SA can create Roles and RoleBindings, and has the permissions
53+
// that the test Role will grant (configmaps get/list).
54+
sa := corev1.ServiceAccount{
55+
ObjectMeta: metav1.ObjectMeta{
56+
Name: id,
57+
Namespace: id,
58+
},
59+
}
60+
g.Expect(k8sClient.Create(context.Background(), &sa)).To(Succeed())
61+
62+
cr := rbacv1.ClusterRole{
63+
ObjectMeta: metav1.ObjectMeta{
64+
Name: id,
65+
},
66+
Rules: []rbacv1.PolicyRule{
67+
{
68+
APIGroups: []string{"rbac.authorization.k8s.io"},
69+
Resources: []string{"roles", "rolebindings"},
70+
Verbs: []string{"create", "update", "delete", "get", "list", "watch", "patch"},
71+
},
72+
// Grant the same permissions that the test Role will grant,
73+
// so RBAC escalation prevention allows creating the Role and
74+
// RoleBinding.
75+
{
76+
APIGroups: []string{""},
77+
Resources: []string{"configmaps"},
78+
Verbs: []string{"get", "list"},
79+
},
80+
},
81+
}
82+
g.Expect(k8sClient.Create(context.Background(), &cr)).To(Succeed())
83+
84+
crb := rbacv1.ClusterRoleBinding{
85+
ObjectMeta: metav1.ObjectMeta{
86+
Name: id,
87+
},
88+
Subjects: []rbacv1.Subject{
89+
{
90+
Kind: "ServiceAccount",
91+
Name: sa.Name,
92+
Namespace: sa.Namespace,
93+
},
94+
},
95+
RoleRef: rbacv1.RoleRef{
96+
APIGroup: "rbac.authorization.k8s.io",
97+
Kind: "ClusterRole",
98+
Name: cr.Name,
99+
},
100+
}
101+
g.Expect(k8sClient.Create(context.Background(), &crb)).To(Succeed())
102+
103+
// Manifests contain a Role and RoleBinding that references the Role.
104+
// Without CustomStageKinds, the RoleBinding will fail to apply because
105+
// the Role doesn't exist yet (both are sorted in the same stage).
106+
manifests := func(name string) []testserver.File {
107+
return []testserver.File{
108+
{
109+
Name: "role.yaml",
110+
Body: fmt.Sprintf(`---
111+
apiVersion: rbac.authorization.k8s.io/v1
112+
kind: Role
113+
metadata:
114+
name: %[1]s-role
115+
namespace: %[1]s
116+
rules:
117+
- apiGroups: [""]
118+
resources: ["configmaps"]
119+
verbs: ["get", "list"]
120+
`, name),
121+
},
122+
{
123+
Name: "rolebinding.yaml",
124+
Body: fmt.Sprintf(`---
125+
apiVersion: rbac.authorization.k8s.io/v1
126+
kind: RoleBinding
127+
metadata:
128+
name: %[1]s-rolebinding
129+
namespace: %[1]s
130+
roleRef:
131+
apiGroup: rbac.authorization.k8s.io
132+
kind: Role
133+
name: %[1]s-role
134+
subjects:
135+
- kind: ServiceAccount
136+
name: default
137+
namespace: %[1]s
138+
`, name),
139+
},
140+
}
141+
}
142+
143+
artifact, err := testServer.ArtifactFromFiles(manifests(id))
144+
g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files")
145+
146+
repositoryName := types.NamespacedName{
147+
Name: randStringRunes(5),
148+
Namespace: id,
149+
}
150+
151+
err = applyGitRepository(repositoryName, artifact, revision)
152+
g.Expect(err).NotTo(HaveOccurred())
153+
154+
kustomizationKey := types.NamespacedName{
155+
Name: randStringRunes(5),
156+
Namespace: id,
157+
}
158+
kustomization := &kustomizev1.Kustomization{
159+
ObjectMeta: metav1.ObjectMeta{
160+
Name: kustomizationKey.Name,
161+
Namespace: kustomizationKey.Namespace,
162+
},
163+
Spec: kustomizev1.KustomizationSpec{
164+
Interval: metav1.Duration{Duration: reconciliationInterval},
165+
Path: "./",
166+
KubeConfig: &meta.KubeConfigReference{
167+
SecretRef: &meta.SecretKeyReference{
168+
Name: "kubeconfig",
169+
},
170+
},
171+
SourceRef: kustomizev1.CrossNamespaceSourceReference{
172+
Name: repositoryName.Name,
173+
Namespace: repositoryName.Namespace,
174+
Kind: sourcev1.GitRepositoryKind,
175+
},
176+
TargetNamespace: id,
177+
ServiceAccountName: id, // Impersonate the limited SA
178+
Prune: true,
179+
},
180+
}
181+
182+
g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed())
183+
184+
resultK := &kustomizev1.Kustomization{}
185+
readyCondition := &metav1.Condition{}
186+
187+
t.Run("fails to reconcile Role and RoleBinding without custom stage", func(t *testing.T) {
188+
g.Eventually(func() bool {
189+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
190+
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
191+
return readyCondition != nil && readyCondition.Reason == meta.ReconciliationFailedReason
192+
}, timeout, time.Second).Should(BeTrue())
193+
194+
// The error message format is:
195+
// "RoleBinding/<namespace>/<name>-rolebinding not found: rolebindings.rbac.authorization.k8s.io \"<name>-role\" not found"
196+
// This proves that:
197+
// 1. The involved object is the RoleBinding (dry-run failed on it)
198+
// 2. The referenced Role was not found during dry-run validation
199+
expectedMsg := fmt.Sprintf(
200+
"RoleBinding/%[1]s/%[1]s-rolebinding not found: rolebindings.rbac.authorization.k8s.io %q not found",
201+
id, id+"-role")
202+
g.Expect(readyCondition.Message).To(HavePrefix(expectedMsg))
203+
})
204+
205+
t.Run("reconciles Role and RoleBinding with custom stage", func(t *testing.T) {
206+
// Capture the current CustomStageKinds
207+
originalCustomStageKinds := reconciler.CustomStageKinds
208+
t.Cleanup(func() {
209+
reconciler.CustomStageKinds = originalCustomStageKinds
210+
})
211+
212+
// Set CustomStageKinds to include Role
213+
reconciler.CustomStageKinds = map[schema.GroupKind]struct{}{
214+
{Group: "rbac.authorization.k8s.io", Kind: "Role"}: {},
215+
}
216+
217+
// Trigger reconciliation by updating the revision
218+
revision = "v2.0.0"
219+
err = applyGitRepository(repositoryName, artifact, revision)
220+
g.Expect(err).NotTo(HaveOccurred())
221+
222+
g.Eventually(func() bool {
223+
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
224+
readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition)
225+
return resultK.Status.LastAppliedRevision == revision
226+
}, timeout, time.Second).Should(BeTrue())
227+
228+
g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason))
229+
230+
// Verify the Role and RoleBinding were created
231+
role := &rbacv1.Role{}
232+
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: id + "-role", Namespace: id}, role)
233+
g.Expect(err).NotTo(HaveOccurred())
234+
235+
roleBinding := &rbacv1.RoleBinding{}
236+
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: id + "-rolebinding", Namespace: id}, roleBinding)
237+
g.Expect(err).NotTo(HaveOccurred())
238+
})
239+
}

main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import (
5050
"github.com/fluxcd/pkg/runtime/metrics"
5151
"github.com/fluxcd/pkg/runtime/pprof"
5252
"github.com/fluxcd/pkg/runtime/probes"
53+
ssautils "github.com/fluxcd/pkg/ssa/utils"
5354
sourcev1 "github.com/fluxcd/source-controller/api/v1"
5455

5556
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
@@ -102,6 +103,7 @@ func main() {
102103
featureGates feathelper.FeatureGates
103104
disallowedFieldManagers []string
104105
tokenCacheOptions pkgcache.TokenFlags
106+
customApplyStageKinds string
105107
)
106108

107109
flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
@@ -118,6 +120,8 @@ func main() {
118120
flag.StringVar(&defaultKubeConfigServiceAccount, auth.ControllerFlagDefaultKubeConfigServiceAccount, "", "Default service account used for kubeconfig.")
119121
flag.StringVar(&sopsAgeSecret, "sops-age-secret", "", "The name of a Kubernetes secret in the RUNTIME_NAMESPACE containing a SOPS age decryption key for fallback usage.")
120122
flag.StringArrayVar(&disallowedFieldManagers, "override-manager", []string{}, "Field manager disallowed to perform changes on managed resources.")
123+
flag.StringVar(&customApplyStageKinds, "custom-apply-stage-kinds", "", "A comma-separated list of GroupKind (e.g., 'rbac.authorization.k8s.io/Role,some.group.io/SomeResource') "+
124+
"resources to be applied in a custom stage during server-side apply running after CRDs and before all namespaced resources not in this list.")
121125

122126
clientOptions.BindFlags(flag.CommandLine)
123127
logOptions.BindFlags(flag.CommandLine)
@@ -319,6 +323,12 @@ func main() {
319323
}
320324
watchConfigs := !disableConfigWatchers
321325

326+
customStageKinds, err := ssautils.ParseGroupKindSet(customApplyStageKinds)
327+
if err != nil {
328+
setupLog.Error(err, "unable to parse --custom-apply-stage-kinds")
329+
os.Exit(1)
330+
}
331+
322332
if err = (&controller.KustomizationReconciler{
323333
AdditiveCELDependencyCheck: additiveCELDependencyCheck,
324334
AllowExternalArtifact: allowExternalArtifact,
@@ -343,6 +353,7 @@ func main() {
343353
StatusManager: fmt.Sprintf("gotk-%s", controllerName),
344354
StrictSubstitutions: strictSubstitutions,
345355
TokenCache: tokenCache,
356+
CustomStageKinds: customStageKinds,
346357
}).SetupWithManager(ctx, mgr, controller.KustomizationReconcilerOptions{
347358
RateLimiter: runtimeCtrl.GetRateLimiter(rateLimiterOptions),
348359
WatchConfigs: watchConfigs,

0 commit comments

Comments
 (0)