• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

opendefensecloud / solution-arsenal / 29580701377

17 Jul 2026 12:34PM UTC coverage: 81.841% (+0.5%) from 81.303%
29580701377

Pull #708

github

web-flow
Merge 5ec1ee642 into 756528edc
Pull Request #708: feat: crud for k8s ressources in web ui

106 of 123 new or added lines in 2 files covered. (86.18%)

7 existing lines in 4 files now uncovered.

5156 of 6300 relevant lines covered (81.84%)

30.19 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.54
/pkg/ui/api/handler.go
1
// Copyright 2026 BWI GmbH and Solution Arsenal contributors
2
// SPDX-License-Identifier: Apache-2.0
3

4
package api
5

6
import (
7
        "context"
8
        "encoding/json"
9
        "fmt"
10
        "io"
11
        "net/http"
12
        "sort"
13
        "strings"
14
        "sync"
15

16
        "github.com/go-logr/logr"
17
        authzv1 "k8s.io/api/authorization/v1"
18
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19
        "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
20
        "k8s.io/apimachinery/pkg/runtime/schema"
21
        "k8s.io/apimachinery/pkg/types"
22
        "k8s.io/client-go/dynamic"
23
        "k8s.io/client-go/kubernetes"
24
        "k8s.io/client-go/rest"
25
        "k8s.io/client-go/tools/clientcmd"
26

27
        "go.opendefense.cloud/solar/pkg/ui/auth"
28
        "go.opendefense.cloud/solar/pkg/ui/session"
29
)
30

31
const solarAPIGroup = "solar.opendefense.cloud"
32

33
const maxRequestBodyBytes = 1 << 20 // 1 MiB
34

35
// resourceMap maps resource names to their GVR.
36
var resourceMap = map[string]schema.GroupVersionResource{
37
        "targets":           {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "targets"},
38
        "releases":          {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "releases"},
39
        "releasebindings":   {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "releasebindings"},
40
        "components":        {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "components"},
41
        "componentversions": {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "componentversions"},
42
        "registries":        {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "registries"},
43
        "registrybindings":  {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "registrybindings"},
44
        "profiles":          {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "profiles"},
45
        "rendertasks":       {Group: "solar.opendefense.cloud", Version: "v1alpha1", Resource: "rendertasks"},
46
}
47

48
// Handler serves the K8s API proxy routes.
49
type Handler struct {
50
        baseConfig   *rest.Config
51
        sessionStore *session.Store
52
        authProvider auth.Provider
53
        log          logr.Logger
54
        // Client-construction seams. Defaults are wired in NewHandler; tests
55
        // override them with fakes. newClient builds a per-request dynamic client
56
        // for the user, newClientset a typed clientset for a given identity, and
57
        // newDiscovery the BFF's own (self-credentialed) dynamic client.
58
        newClient    func(r *http.Request) (dynamic.Interface, error)
59
        newClientset func(sess *session.Data) (kubernetes.Interface, error)
60
        newDiscovery func() (dynamic.Interface, error)
61
}
62

63
// NewHandler creates a new API handler.
64
func NewHandler(kubeconfig string, store *session.Store, provider auth.Provider, log logr.Logger) (*Handler, error) {
2✔
65
        var cfg *rest.Config
2✔
66
        var err error
2✔
67

2✔
68
        if kubeconfig != "" {
3✔
69
                cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
1✔
70
        } else {
2✔
71
                cfg, err = rest.InClusterConfig()
1✔
72
        }
1✔
73
        if err != nil {
3✔
74
                return nil, fmt.Errorf("failed to create kubernetes config: %w", err)
1✔
75
        }
1✔
76

77
        h := &Handler{
1✔
78
                baseConfig:   cfg,
1✔
79
                sessionStore: store,
1✔
80
                authProvider: provider,
1✔
81
                log:          log.WithName("api"),
1✔
82
        }
1✔
83
        h.newClient = h.clientFor
1✔
84
        h.newClientset = func(sess *session.Data) (kubernetes.Interface, error) {
1✔
NEW
85
                return kubernetes.NewForConfig(h.authProvider.WrapConfig(h.baseConfig, sess))
×
NEW
86
        }
×
87
        h.newDiscovery = func() (dynamic.Interface, error) {
1✔
NEW
88
                return dynamic.NewForConfig(h.baseConfig)
×
NEW
89
        }
×
90

91
        return h, nil
1✔
92
}
93

94
// clientFor returns a dynamic client for the given session.
95
func (h *Handler) clientFor(r *http.Request) (dynamic.Interface, error) {
2✔
96
        sess := h.sessionStore.Get(r)
2✔
97
        if sess == nil {
3✔
98
                return nil, fmt.Errorf("unauthorized: no session")
1✔
99
        }
1✔
100
        cfg := h.authProvider.WrapConfig(h.baseConfig, sess)
1✔
101

1✔
102
        return dynamic.NewForConfig(cfg)
1✔
103
}
104

105
// CanImpersonate runs a SelfSubjectAccessReview against the K8s API to ask
106
// whether the request's user is allowed to impersonate other users. This is
107
// the canonical "is admin" check: anyone permitted to impersonate users at
108
// the cluster level may also use the BFF's "preview as" feature.
109
//
110
// The result is cached on the session for the session's lifetime — RBAC
111
// doesn't change mid-session in practice, and this avoids a SSAR round-trip
112
// on every /auth/me call.
113
//
114
// The SSAR is always evaluated against the *real* identity even when the
115
// admin is currently previewing as another user; otherwise the cached answer
116
// would describe the previewed user's permissions, not the admin's.
117
//
118
// A missing session returns false without an error.
119
func (h *Handler) CanImpersonate(ctx context.Context, r *http.Request) (bool, error) {
3✔
120
        sess := h.sessionStore.Get(r)
3✔
121
        if sess == nil {
3✔
122
                return false, nil
×
123
        }
×
124
        if sess.CanImpersonate != nil {
4✔
125
                return *sess.CanImpersonate, nil
1✔
126
        }
1✔
127

128
        // Always evaluate against the real identity, not the active preview.
129
        realSess := *sess
2✔
130
        realSess.ImpersonatingAs = ""
2✔
131
        realSess.ImpersonatingGroups = nil
2✔
132

2✔
133
        cs, err := h.newClientset(&realSess)
2✔
134
        if err != nil {
2✔
135
                return false, fmt.Errorf("build clientset: %w", err)
×
136
        }
×
137
        res, err := cs.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{
2✔
138
                Spec: authzv1.SelfSubjectAccessReviewSpec{
2✔
139
                        ResourceAttributes: &authzv1.ResourceAttributes{
2✔
140
                                Verb:     "impersonate",
2✔
141
                                Resource: "users",
2✔
142
                        },
2✔
143
                },
2✔
144
        }, metav1.CreateOptions{})
2✔
145
        if err != nil {
2✔
146
                return false, fmt.Errorf("SelfSubjectAccessReview: %w", err)
×
147
        }
×
148

149
        h.sessionStore.SetCanImpersonate(r, res.Status.Allowed)
2✔
150

2✔
151
        return res.Status.Allowed, nil
2✔
152
}
153

154
// CanListAllNamespaces returns true if the current identity (real or
155
// impersonated) is permitted to list namespaces at cluster scope. The
156
// frontend uses this to decide whether to offer "All namespaces" in the
157
// selector — without it, the cluster-wide list endpoints would just 403.
158
//
159
// Cached per session and invalidated when impersonation changes.
160
func (h *Handler) CanListAllNamespaces(ctx context.Context, r *http.Request) (bool, error) {
5✔
161
        sess := h.sessionStore.Get(r)
5✔
162
        if sess == nil {
6✔
163
                return false, nil
1✔
164
        }
1✔
165
        if sess.CanListAllNamespaces != nil {
5✔
166
                return *sess.CanListAllNamespaces, nil
1✔
167
        }
1✔
168

169
        cs, err := h.newClientset(sess)
3✔
170
        if err != nil {
3✔
171
                return false, fmt.Errorf("build clientset: %w", err)
×
172
        }
×
173
        res, err := cs.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{
3✔
174
                Spec: authzv1.SelfSubjectAccessReviewSpec{
3✔
175
                        ResourceAttributes: &authzv1.ResourceAttributes{
3✔
176
                                Verb:     "list",
3✔
177
                                Resource: "namespaces",
3✔
178
                        },
3✔
179
                },
3✔
180
        }, metav1.CreateOptions{})
3✔
181
        if err != nil {
3✔
182
                return false, fmt.Errorf("SelfSubjectAccessReview: %w", err)
×
183
        }
×
184

185
        h.sessionStore.SetCanListAllNamespaces(r, res.Status.Allowed)
3✔
186

3✔
187
        return res.Status.Allowed, nil
3✔
188
}
189

190
// HandleMe returns the current user info, including canImpersonate which the
191
// frontend uses to decide whether to show the "Preview as" dropdown.
192
func (h *Handler) HandleMe() http.HandlerFunc {
3✔
193
        return func(w http.ResponseWriter, r *http.Request) {
6✔
194
                w.Header().Set("Content-Type", "application/json")
3✔
195

3✔
196
                sess := h.sessionStore.Get(r)
3✔
197
                if sess == nil {
4✔
198
                        _, _ = w.Write([]byte(`{"authenticated":false}`))
1✔
199
                        return
1✔
200
                }
1✔
201

202
                resp := map[string]any{
2✔
203
                        "authenticated": true,
2✔
204
                        "username":      sess.Username,
2✔
205
                        "groups":        sess.Groups,
2✔
206
                }
2✔
207
                if sess.ImpersonatingAs != "" {
3✔
208
                        resp["impersonating"] = map[string]any{
1✔
209
                                "username": sess.ImpersonatingAs,
1✔
210
                                "groups":   sess.ImpersonatingGroups,
1✔
211
                        }
1✔
212
                }
1✔
213

214
                // Best-effort SSAR — if the apiserver call fails, fall back to
215
                // canImpersonate=false rather than failing the whole /me response.
216
                canImpersonate, err := h.CanImpersonate(r.Context(), r)
2✔
217
                if err != nil {
2✔
218
                        h.log.Error(err, "SelfSubjectAccessReview (impersonate) failed; assuming non-admin")
×
219
                }
×
220
                resp["canImpersonate"] = canImpersonate
2✔
221

2✔
222
                canListAll, err := h.CanListAllNamespaces(r.Context(), r)
2✔
223
                if err != nil {
2✔
224
                        h.log.Error(err, "SelfSubjectAccessReview (list namespaces) failed; assuming false")
×
225
                }
×
226
                resp["canListAllNamespaces"] = canListAll
2✔
227

2✔
228
                if err := json.NewEncoder(w).Encode(resp); err != nil {
2✔
229
                        h.log.Error(err, "failed to encode /me response")
×
230
                }
×
231
        }
232
}
233

234
// HandleList returns a handler that lists resources of the given type.
235
// If the route has no {namespace} path value (cluster-wide route), the
236
// dynamic client is called with an empty namespace, which K8s interprets
237
// as "across all namespaces". K8s RBAC determines whether the user is
238
// permitted to do that cluster-wide list.
239
func (h *Handler) HandleList(resource string) http.HandlerFunc {
3✔
240
        gvr, ok := resourceMap[resource]
3✔
241
        if !ok {
4✔
242
                return func(w http.ResponseWriter, _ *http.Request) {
2✔
243
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
1✔
244
                }
1✔
245
        }
246

247
        return func(w http.ResponseWriter, r *http.Request) {
4✔
248
                namespace := r.PathValue("namespace")
2✔
249

2✔
250
                client, err := h.newClient(r)
2✔
251
                if err != nil {
3✔
252
                        h.log.Error(err, "failed to create client")
1✔
253
                        http.Error(w, "internal error", http.StatusInternalServerError)
1✔
254

1✔
255
                        return
1✔
256
                }
1✔
257

258
                list, err := client.Resource(gvr).Namespace(namespace).List(r.Context(), listOptions())
1✔
259
                if err != nil {
1✔
260
                        h.log.Error(err, "failed to list resources", "resource", resource, "namespace", namespace)
×
261
                        writeK8sError(w, err)
×
262

×
263
                        return
×
264
                }
×
265

266
                writeJSON(w, list)
1✔
267
        }
268
}
269

270
// HandleListNamespaces enumerates namespaces and returns only the ones in
271
// which the request's user has any SolAr API permission.
272
//
273
// Discovery uses the BFF's own credentials (not the user's), so the user
274
// does not need cluster-scoped `list namespaces` RBAC. Per-user filtering
275
// uses SelfSubjectRulesReview against each candidate namespace — every
276
// authenticated user may run this on themselves regardless of bindings.
277
//
278
// "Has access" is defined as: the user has at least one resource rule whose
279
// APIGroups includes `solar.opendefense.cloud` (or `*`). That excludes
280
// namespaces where the user can only see kube objects.
281
func (h *Handler) HandleListNamespaces() http.HandlerFunc {
2✔
282
        gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
2✔
283

2✔
284
        return func(w http.ResponseWriter, r *http.Request) {
4✔
285
                sess := h.sessionStore.Get(r)
2✔
286
                if sess == nil {
3✔
287
                        http.Error(w, "unauthorized", http.StatusUnauthorized)
1✔
288
                        return
1✔
289
                }
1✔
290

291
                // Discovery client = BFF's own creds (kubeconfig / SA).
292
                discovery, err := h.newDiscovery()
1✔
293
                if err != nil {
1✔
294
                        h.log.Error(err, "failed to build discovery client")
×
295
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
296

×
297
                        return
×
298
                }
×
299
                nsList, err := discovery.Resource(gvr).List(r.Context(), listOptions())
1✔
300
                if err != nil {
1✔
301
                        h.log.Error(err, "failed to list namespaces (discovery)")
×
302
                        writeK8sError(w, err)
×
303

×
304
                        return
×
305
                }
×
306

307
                // Review client = user identity (or admin previewing as persona).
308
                userCS, err := h.newClientset(sess)
1✔
309
                if err != nil {
1✔
310
                        h.log.Error(err, "failed to build user clientset")
×
311
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
312

×
313
                        return
×
314
                }
×
315

316
                // Parallel SSRR per namespace.
317
                ctx := r.Context()
1✔
318
                var wg sync.WaitGroup
1✔
319
                var mu sync.Mutex
1✔
320
                filtered := make([]unstructured.Unstructured, 0, len(nsList.Items))
1✔
321
                for i := range nsList.Items {
3✔
322
                        ns := nsList.Items[i]
2✔
323
                        wg.Add(1)
2✔
324
                        go func(ctx context.Context) {
4✔
325
                                defer wg.Done()
2✔
326
                                if hasSolarAccess(ctx, userCS, ns.GetName(), h.log) {
3✔
327
                                        mu.Lock()
1✔
328
                                        filtered = append(filtered, ns)
1✔
329
                                        mu.Unlock()
1✔
330
                                }
1✔
331
                        }(ctx)
332
                }
333
                wg.Wait()
1✔
334

1✔
335
                sort.Slice(filtered, func(i, j int) bool {
1✔
336
                        return filtered[i].GetName() < filtered[j].GetName()
×
337
                })
×
338
                nsList.Items = filtered
1✔
339

1✔
340
                writeJSON(w, nsList)
1✔
341
        }
342
}
343

344
// hasSolarAccess runs SelfSubjectRulesReview in `namespace` and returns
345
// true if any of the returned resource rules covers the SolAr API group.
346
func hasSolarAccess(ctx context.Context, cs kubernetes.Interface, namespace string, log logr.Logger) bool {
4✔
347
        review, err := cs.AuthorizationV1().SelfSubjectRulesReviews().Create(ctx, &authzv1.SelfSubjectRulesReview{
4✔
348
                Spec: authzv1.SelfSubjectRulesReviewSpec{Namespace: namespace},
4✔
349
        }, metav1.CreateOptions{})
4✔
350
        if err != nil {
5✔
351
                log.V(1).Info("SSRR failed; excluding namespace", "namespace", namespace, "error", err.Error())
1✔
352
                return false
1✔
353
        }
1✔
354
        for _, rule := range review.Status.ResourceRules {
5✔
355
                for _, group := range rule.APIGroups {
4✔
356
                        if group == solarAPIGroup || group == "*" {
4✔
357
                                return true
2✔
358
                        }
2✔
359
                }
360
        }
361

362
        return false
1✔
363
}
364

365
// HandleGet returns a handler that gets a single resource.
366
func (h *Handler) HandleGet(resource string) http.HandlerFunc {
3✔
367
        gvr, ok := resourceMap[resource]
3✔
368
        if !ok {
4✔
369
                return func(w http.ResponseWriter, _ *http.Request) {
2✔
370
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
1✔
371
                }
1✔
372
        }
373

374
        return func(w http.ResponseWriter, r *http.Request) {
4✔
375
                namespace := r.PathValue("namespace")
2✔
376
                name := r.PathValue("name")
2✔
377

2✔
378
                client, err := h.newClient(r)
2✔
379
                if err != nil {
2✔
380
                        h.log.Error(err, "failed to create client")
×
381
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
382

×
383
                        return
×
384
                }
×
385

386
                obj, err := client.Resource(gvr).Namespace(namespace).Get(r.Context(), name, getOptions())
2✔
387
                if err != nil {
3✔
388
                        h.log.Error(err, "failed to get resource", "resource", resource, "namespace", namespace, "name", name)
1✔
389
                        writeK8sError(w, err)
1✔
390

1✔
391
                        return
1✔
392
                }
1✔
393

394
                writeJSON(w, obj)
1✔
395
        }
396
}
397

398
// HandleCreate creates a resource in the namespace taken from the path.
399
func (h *Handler) HandleCreate(resource string) http.HandlerFunc {
6✔
400
        gvr, ok := resourceMap[resource]
6✔
401
        if !ok {
7✔
402
                return func(w http.ResponseWriter, _ *http.Request) {
2✔
403
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
1✔
404
                }
1✔
405
        }
406

407
        return func(w http.ResponseWriter, r *http.Request) {
10✔
408
                namespace := r.PathValue("namespace")
5✔
409
                r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
5✔
410

5✔
411
                var obj unstructured.Unstructured
5✔
412
                if err := json.NewDecoder(r.Body).Decode(&obj.Object); err != nil {
6✔
413
                        http.Error(w, "invalid request body", http.StatusBadRequest)
1✔
414
                        return
1✔
415
                }
1✔
416
                // A JSON null/scalar decodes without error but leaves a nil object;
417
                // reject it rather than sending a bodyless create to the apiserver.
418
                if obj.Object == nil {
5✔
419
                        http.Error(w, "request body must be a JSON object", http.StatusBadRequest)
1✔
420
                        return
1✔
421
                }
1✔
422
                obj.SetNamespace(namespace)
3✔
423

3✔
424
                client, err := h.newClient(r)
3✔
425
                if err != nil {
4✔
426
                        h.log.Error(err, "failed to create client")
1✔
427
                        http.Error(w, "internal error", http.StatusInternalServerError)
1✔
428

1✔
429
                        return
1✔
430
                }
1✔
431

432
                created, err := client.Resource(gvr).Namespace(namespace).Create(r.Context(), &obj, metav1.CreateOptions{})
2✔
433
                if err != nil {
3✔
434
                        h.log.Error(err, "failed to create resource", "resource", resource, "namespace", namespace)
1✔
435
                        writeK8sError(w, err)
1✔
436

1✔
437
                        return
1✔
438
                }
1✔
439

440
                writeJSON(w, created)
1✔
441
        }
442
}
443

444
// HandlePatch applies a patch to a resource.
445
func (h *Handler) HandlePatch(resource string) http.HandlerFunc {
4✔
446
        gvr, ok := resourceMap[resource]
4✔
447
        if !ok {
5✔
448
                return func(w http.ResponseWriter, _ *http.Request) {
2✔
449
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
1✔
450
                }
1✔
451
        }
452

453
        return func(w http.ResponseWriter, r *http.Request) {
6✔
454
                namespace := r.PathValue("namespace")
3✔
455
                name := r.PathValue("name")
3✔
456
                r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
3✔
457

3✔
458
                patch, err := io.ReadAll(r.Body)
3✔
459
                if err != nil {
3✔
NEW
460
                        http.Error(w, "invalid request body", http.StatusBadRequest)
×
NEW
461
                        return
×
NEW
462
                }
×
463

464
                patchType := types.MergePatchType
3✔
465
                if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json-patch+json") {
4✔
466
                        patchType = types.JSONPatchType
1✔
467
                }
1✔
468

469
                client, err := h.newClient(r)
3✔
470
                if err != nil {
3✔
NEW
471
                        h.log.Error(err, "failed to create client")
×
NEW
472
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
NEW
473

×
NEW
474
                        return
×
NEW
475
                }
×
476

477
                updated, err := client.Resource(gvr).Namespace(namespace).
3✔
478
                        Patch(r.Context(), name, patchType, patch, metav1.PatchOptions{})
3✔
479
                if err != nil {
4✔
480
                        h.log.Error(err, "failed to patch resource", "resource", resource, "namespace", namespace, "name", name)
1✔
481
                        writeK8sError(w, err)
1✔
482

1✔
483
                        return
1✔
484
                }
1✔
485

486
                writeJSON(w, updated)
2✔
487
        }
488
}
489

490
// HandleDelete deletes a resource
491
func (h *Handler) HandleDelete(resource string) http.HandlerFunc {
3✔
492
        gvr, ok := resourceMap[resource]
3✔
493
        if !ok {
4✔
494
                return func(w http.ResponseWriter, _ *http.Request) {
2✔
495
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
1✔
496
                }
1✔
497
        }
498

499
        return func(w http.ResponseWriter, r *http.Request) {
4✔
500
                namespace := r.PathValue("namespace")
2✔
501
                name := r.PathValue("name")
2✔
502

2✔
503
                client, err := h.newClient(r)
2✔
504
                if err != nil {
3✔
505
                        h.log.Error(err, "failed to create client")
1✔
506
                        http.Error(w, "internal error", http.StatusInternalServerError)
1✔
507

1✔
508
                        return
1✔
509
                }
1✔
510

511
                if err := client.Resource(gvr).Namespace(namespace).Delete(r.Context(), name, metav1.DeleteOptions{}); err != nil {
1✔
NEW
512
                        h.log.Error(err, "failed to delete resource", "resource", resource, "namespace", namespace, "name", name)
×
NEW
513
                        writeK8sError(w, err)
×
NEW
514

×
NEW
515
                        return
×
NEW
516
                }
×
517

518
                w.WriteHeader(http.StatusNoContent)
1✔
519
        }
520
}
521

522
// HandleSSE returns a handler that streams resource watch events as SSE.
523
// An empty {namespace} path value (the cluster-wide /api/events route)
524
// watches across all namespaces; K8s RBAC decides per-resource whether the
525
// user is allowed to do that, and individual watches that 403 are skipped
526
// rather than failing the whole stream.
527
func (h *Handler) HandleSSE() http.HandlerFunc {
1✔
528
        return func(w http.ResponseWriter, r *http.Request) {
2✔
529
                namespace := r.PathValue("namespace")
1✔
530

1✔
531
                client, err := h.newClient(r)
1✔
532
                if err != nil {
1✔
533
                        h.log.Error(err, "failed to create client")
×
534
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
535

×
536
                        return
×
537
                }
×
538

539
                flusher, ok := w.(http.Flusher)
1✔
540
                if !ok {
1✔
541
                        http.Error(w, "streaming not supported", http.StatusInternalServerError)
×
542

×
543
                        return
×
544
                }
×
545

546
                w.Header().Set("Content-Type", "text/event-stream")
1✔
547
                w.Header().Set("Cache-Control", "no-cache")
1✔
548
                w.Header().Set("Connection", "keep-alive")
1✔
549
                flusher.Flush()
1✔
550

1✔
551
                // Watch all solar resources and multiplex into SSE via a channel.
1✔
552
                // Namespace on the event is read from the object metadata so
1✔
553
                // cluster-wide watches still deliver the originating namespace.
1✔
554
                type sseEvent struct {
1✔
555
                        Type      string `json:"type"`
1✔
556
                        Resource  string `json:"resource"`
1✔
557
                        Namespace string `json:"namespace"`
1✔
558
                }
1✔
559
                events := make(chan sseEvent, 64)
1✔
560

1✔
561
                ctx := r.Context()
1✔
562
                for resourceName, gvr := range resourceMap {
10✔
563
                        go func(ctx context.Context) {
18✔
564
                                watcher, err := client.Resource(gvr).Namespace(namespace).Watch(ctx, watchOptions())
9✔
565
                                if err != nil {
9✔
566
                                        h.log.Error(err, "failed to watch", "resource", resourceName)
×
567

×
568
                                        return
×
569
                                }
×
570
                                defer watcher.Stop()
9✔
571

9✔
572
                                for event := range watcher.ResultChan() {
9✔
573
                                        eventNs := namespace
×
574
                                        if obj, ok := event.Object.(metav1.Object); ok {
×
575
                                                eventNs = obj.GetNamespace()
×
576
                                        }
×
577
                                        select {
×
578
                                        case events <- sseEvent{
579
                                                Type:      string(event.Type),
580
                                                Resource:  resourceName,
581
                                                Namespace: eventNs,
582
                                        }:
×
583
                                        case <-ctx.Done():
×
584
                                                return
×
585
                                        }
586
                                }
587
                        }(ctx)
588
                }
589

590
                // Single writer goroutine — serializes all writes and respects client disconnect
591
                for {
2✔
592
                        select {
1✔
593
                        case evt := <-events:
×
594
                                b, _ := json.Marshal(evt)
×
595
                                fmt.Fprintf(w, "data: %s\n\n", b)
×
596
                                flusher.Flush()
×
597
                        case <-r.Context().Done():
1✔
598
                                return
1✔
599
                        }
600
                }
601
        }
602
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc