Skip to content

Commit 7dde8f9

Browse files
committed
feat(controllers)!: add ServiceAccount and Namespace controllers
Add controllers to handle WIF ConfigMap lifecycle management: **ServiceAccountReconciler:** - Watches ServiceAccounts with iam.gke.io/gcp-service-account annotation - Reconciles impersonation ConfigMaps on annotation changes (CREL-91) - Sets owner references for automatic cleanup **NamespaceReconciler:** - Watches all Namespaces - Manages wif-credentials-direct ConfigMaps - Self-heals on WORKLOAD_IDENTITY_PROVIDER changes **Webhook (Fast Path):** - Atomic ConfigMap creation on first pod admission (proven, optimized) - No race condition - ConfigMap exists before pod starts - Delegates all updates to controllers **Architecture:** This hybrid approach maintains transparent, foolproof atomic creation while supporting annotation changes and self-healing via controllers. **Security:** Both controllers respect namespace opt-out labels (workload-identity.io/injection: disabled) **Metrics:** - wif_webhook_configmap_operations_total (webhook creates) - wif_configmap_reconcile_operations_total (SA controller) - wif_direct_configmap_operations_total (namespace controller) BREAKING CHANGE: ConfigMap updates now handled by controllers instead of webhook. Requires running both ServiceAccount and Namespace controllers. CREL-91
1 parent 44303ec commit 7dde8f9

File tree

10 files changed

+2101
-117
lines changed

10 files changed

+2101
-117
lines changed

cmd/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
3838
"sigs.k8s.io/controller-runtime/pkg/webhook"
3939

40+
"github.com/groq/k8s-wif-webhook/internal/controller"
4041
"github.com/groq/k8s-wif-webhook/internal/version"
4142
webhookv1 "github.com/groq/k8s-wif-webhook/internal/webhook/v1"
4243
// +kubebuilder:scaffold:imports
@@ -203,6 +204,24 @@ func main() {
203204
os.Exit(1)
204205
}
205206

207+
// Setup Namespace controller for direct identity ConfigMap management
208+
if err = (&controller.NamespaceReconciler{
209+
Client: mgr.GetClient(),
210+
Scheme: mgr.GetScheme(),
211+
}).SetupWithManager(mgr); err != nil {
212+
setupLog.Error(err, "unable to create controller", "controller", "Namespace")
213+
os.Exit(1)
214+
}
215+
216+
// Setup ServiceAccount controller for impersonation ConfigMap reconciliation
217+
if err = (&controller.ServiceAccountReconciler{
218+
Client: mgr.GetClient(),
219+
Scheme: mgr.GetScheme(),
220+
}).SetupWithManager(mgr); err != nil {
221+
setupLog.Error(err, "unable to create controller", "controller", "ServiceAccount")
222+
os.Exit(1)
223+
}
224+
206225
// nolint:goconst
207226
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
208227
if err := webhookv1.SetupPodWebhookWithManager(mgr); err != nil {

config/rbac/role.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ rules:
1010
- configmaps
1111
verbs:
1212
- create
13+
- delete
1314
- get
1415
- list
1516
- patch
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
Copyright 2025.
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+
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
20+
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch
21+
22+
import (
23+
"context"
24+
"fmt"
25+
26+
"github.com/prometheus/client_golang/prometheus"
27+
corev1 "k8s.io/api/core/v1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"k8s.io/apimachinery/pkg/runtime"
31+
"k8s.io/apimachinery/pkg/types"
32+
ctrl "sigs.k8s.io/controller-runtime"
33+
"sigs.k8s.io/controller-runtime/pkg/client"
34+
"sigs.k8s.io/controller-runtime/pkg/log"
35+
"sigs.k8s.io/controller-runtime/pkg/metrics"
36+
37+
"github.com/groq/k8s-wif-webhook/internal/wif"
38+
)
39+
40+
const (
41+
directIdentityConfigMapName = "wif-credentials-direct"
42+
)
43+
44+
var (
45+
// directConfigMapOperations tracks direct identity ConfigMap operations during reconciliation
46+
// This provides finer granularity than the built-in controller_runtime_reconcile_total metric
47+
directConfigMapOperations = prometheus.NewCounterVec(
48+
prometheus.CounterOpts{
49+
Name: "wif_direct_configmap_operations_total",
50+
Help: "Total number of direct identity ConfigMap operations",
51+
},
52+
[]string{"operation"}, // create, update, noop
53+
)
54+
)
55+
56+
func init() {
57+
// Note: controller-runtime automatically provides:
58+
// - controller_runtime_reconcile_total{controller="Namespace",result="success|error|requeue"}
59+
// - controller_runtime_reconcile_errors_total{controller="Namespace"}
60+
// - controller_runtime_reconcile_time_seconds{controller="Namespace"}
61+
metrics.Registry.MustRegister(directConfigMapOperations)
62+
}
63+
64+
// NamespaceReconciler reconciles Namespace objects and ensures direct identity ConfigMaps exist
65+
type NamespaceReconciler struct {
66+
client.Client
67+
Scheme *runtime.Scheme
68+
}
69+
70+
// SetupWithManager sets up the controller with the Manager
71+
func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
72+
return ctrl.NewControllerManagedBy(mgr).
73+
For(&corev1.Namespace{}).
74+
Owns(&corev1.ConfigMap{}).
75+
Complete(r)
76+
}
77+
78+
// Reconcile ensures the direct identity ConfigMap exists in each namespace
79+
func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
80+
log := log.FromContext(ctx)
81+
82+
// Fetch the Namespace
83+
namespace := &corev1.Namespace{}
84+
if err := r.Get(ctx, req.NamespacedName, namespace); err != nil {
85+
if apierrors.IsNotFound(err) {
86+
// Namespace deleted - ConfigMap will be cleaned up automatically
87+
log.V(1).Info("Namespace not found, likely deleted", "name", req.Name)
88+
return ctrl.Result{}, nil
89+
}
90+
return ctrl.Result{}, err
91+
}
92+
93+
// Skip terminating namespaces
94+
if namespace.Status.Phase == corev1.NamespaceTerminating {
95+
log.V(1).Info("Skipping terminating namespace", "namespace", namespace.Name)
96+
return ctrl.Result{}, nil
97+
}
98+
99+
// Skip namespaces that have explicitly opted out of WIF injection
100+
// This prevents credential access even via manual ConfigMap mounting (security requirement)
101+
if r.isWIFInjectionDisabled(namespace) {
102+
log.V(1).Info("Skipping namespace with WIF injection disabled", "namespace", namespace.Name)
103+
return ctrl.Result{}, nil
104+
}
105+
106+
log.Info("Reconciling direct identity ConfigMap for namespace", "namespace", namespace.Name)
107+
108+
// Reconcile the direct identity ConfigMap
109+
if err := r.reconcileDirectConfigMap(ctx, namespace); err != nil {
110+
return ctrl.Result{}, err
111+
}
112+
113+
return ctrl.Result{}, nil
114+
}
115+
116+
// isWIFInjectionDisabled checks if WIF injection is disabled at the namespace level
117+
// This matches the webhook's opt-out behavior to prevent credential access
118+
func (r *NamespaceReconciler) isWIFInjectionDisabled(namespace *corev1.Namespace) bool {
119+
if namespace.Labels == nil {
120+
return false
121+
}
122+
123+
// Check namespace-level opt-out label (matches webhook behavior)
124+
if inject, exists := namespace.Labels["workload-identity.io/injection"]; exists && inject == "disabled" {
125+
return true
126+
}
127+
128+
return false
129+
}
130+
131+
// reconcileDirectConfigMap ensures the direct identity ConfigMap exists and is up-to-date
132+
func (r *NamespaceReconciler) reconcileDirectConfigMap(ctx context.Context, namespace *corev1.Namespace) error {
133+
log := log.FromContext(ctx)
134+
135+
configMapKey := types.NamespacedName{
136+
Name: directIdentityConfigMapName,
137+
Namespace: namespace.Name,
138+
}
139+
140+
// Check if ConfigMap exists
141+
existingCM := &corev1.ConfigMap{}
142+
err := r.Get(ctx, configMapKey, existingCM)
143+
144+
// Generate expected ConfigMap
145+
desiredCM := r.buildDirectConfigMap(namespace)
146+
147+
if apierrors.IsNotFound(err) {
148+
// ConfigMap doesn't exist - create it
149+
log.Info("Creating direct identity ConfigMap",
150+
"configMap", directIdentityConfigMapName,
151+
"namespace", namespace.Name)
152+
153+
if err := r.Create(ctx, desiredCM); err != nil {
154+
if apierrors.IsAlreadyExists(err) {
155+
// Race condition - another reconciliation created it
156+
log.V(1).Info("ConfigMap already exists (race condition)", "configMap", directIdentityConfigMapName)
157+
directConfigMapOperations.WithLabelValues("noop").Inc()
158+
return nil
159+
}
160+
directConfigMapOperations.WithLabelValues("create").Inc()
161+
return fmt.Errorf("failed to create ConfigMap %s/%s: %w", namespace.Name, directIdentityConfigMapName, err)
162+
}
163+
164+
directConfigMapOperations.WithLabelValues("create").Inc()
165+
log.Info("Successfully created direct identity ConfigMap",
166+
"configMap", directIdentityConfigMapName,
167+
"namespace", namespace.Name)
168+
return nil
169+
}
170+
171+
if err != nil {
172+
// Some other error occurred
173+
return fmt.Errorf("failed to get ConfigMap %s/%s: %w", namespace.Name, directIdentityConfigMapName, err)
174+
}
175+
176+
// ConfigMap exists - check if it needs updating
177+
if r.configMapNeedsUpdate(existingCM, desiredCM) {
178+
log.Info("Updating direct identity ConfigMap",
179+
"configMap", directIdentityConfigMapName,
180+
"namespace", namespace.Name)
181+
182+
// Update the ConfigMap
183+
existingCM.Data = desiredCM.Data
184+
existingCM.Labels = desiredCM.Labels
185+
186+
if err := r.Update(ctx, existingCM); err != nil {
187+
directConfigMapOperations.WithLabelValues("update").Inc()
188+
return fmt.Errorf("failed to update ConfigMap %s/%s: %w", namespace.Name, directIdentityConfigMapName, err)
189+
}
190+
191+
directConfigMapOperations.WithLabelValues("update").Inc()
192+
log.Info("Successfully updated direct identity ConfigMap",
193+
"configMap", directIdentityConfigMapName,
194+
"namespace", namespace.Name)
195+
return nil
196+
}
197+
198+
// ConfigMap is up-to-date
199+
log.V(1).Info("ConfigMap is up-to-date, no changes needed",
200+
"configMap", directIdentityConfigMapName,
201+
"namespace", namespace.Name)
202+
directConfigMapOperations.WithLabelValues("noop").Inc()
203+
return nil
204+
}
205+
206+
// buildDirectConfigMap constructs the desired direct identity ConfigMap for the namespace
207+
func (r *NamespaceReconciler) buildDirectConfigMap(namespace *corev1.Namespace) *corev1.ConfigMap {
208+
// Direct identity configuration
209+
config := &wif.WIFConfig{
210+
UseDirectIdentity: true,
211+
CredentialsConfigMap: directIdentityConfigMapName,
212+
}
213+
214+
credentialsData := wif.GenerateCredentialsConfig(config)
215+
216+
cm := &corev1.ConfigMap{
217+
ObjectMeta: metav1.ObjectMeta{
218+
Name: directIdentityConfigMapName,
219+
Namespace: namespace.Name,
220+
Labels: map[string]string{
221+
"app.kubernetes.io/managed-by": "k8s-native-wif-webhook",
222+
"workload-identity.io/type": "direct",
223+
},
224+
},
225+
Data: map[string]string{
226+
"credentials.json": credentialsData,
227+
},
228+
}
229+
230+
// Note: No owner reference - ConfigMap is shared across all pods in namespace
231+
// It will be cleaned up when the namespace is deleted
232+
233+
return cm
234+
}
235+
236+
// configMapNeedsUpdate determines if the existing ConfigMap needs to be updated
237+
func (r *NamespaceReconciler) configMapNeedsUpdate(existing, desired *corev1.ConfigMap) bool {
238+
// Check credentials data
239+
if existing.Data == nil || existing.Data["credentials.json"] != desired.Data["credentials.json"] {
240+
return true
241+
}
242+
243+
// Check labels
244+
if existing.Labels == nil {
245+
return true
246+
}
247+
if existing.Labels["app.kubernetes.io/managed-by"] != desired.Labels["app.kubernetes.io/managed-by"] {
248+
return true
249+
}
250+
if existing.Labels["workload-identity.io/type"] != desired.Labels["workload-identity.io/type"] {
251+
return true
252+
}
253+
254+
return false
255+
}

0 commit comments

Comments
 (0)