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

opendefensecloud / solution-arsenal / 29563664913

17 Jul 2026 07:37AM UTC coverage: 76.185% (-5.1%) from 81.303%
29563664913

Pull #708

github

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

51 of 101 new or added lines in 1 file covered. (50.5%)

9 existing lines in 4 files now uncovered.

4389 of 5761 relevant lines covered (76.18%)

31.8 hits per line

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

13.01
/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
        newClient    func(r *http.Request) (dynamic.Interface, error)
55
}
56

57
// NewHandler creates a new API handler.
58
func NewHandler(kubeconfig string, store *session.Store, provider auth.Provider, log logr.Logger) (*Handler, error) {
×
59
        var cfg *rest.Config
×
60
        var err error
×
61

×
62
        if kubeconfig != "" {
×
63
                cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
×
64
        } else {
×
65
                cfg, err = rest.InClusterConfig()
×
66
        }
×
67
        if err != nil {
×
68
                return nil, fmt.Errorf("failed to create kubernetes config: %w", err)
×
69
        }
×
70

NEW
71
        h := &Handler{
×
72
                baseConfig:   cfg,
×
73
                sessionStore: store,
×
74
                authProvider: provider,
×
75
                log:          log.WithName("api"),
×
NEW
76
        }
×
NEW
77
        h.newClient = h.clientFor
×
NEW
78

×
NEW
79
        return h, nil
×
80
}
81

82
// clientFor returns a dynamic client for the given session.
83
func (h *Handler) clientFor(r *http.Request) (dynamic.Interface, error) {
×
84
        sess := h.sessionStore.Get(r)
×
85
        if sess == nil {
×
86
                return nil, fmt.Errorf("unauthorized: no session")
×
87
        }
×
88
        cfg := h.authProvider.WrapConfig(h.baseConfig, sess)
×
89

×
90
        return dynamic.NewForConfig(cfg)
×
91
}
92

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

116
        // Always evaluate against the real identity, not the active preview.
117
        realSess := *sess
×
118
        realSess.ImpersonatingAs = ""
×
119
        realSess.ImpersonatingGroups = nil
×
120

×
121
        cfg := h.authProvider.WrapConfig(h.baseConfig, &realSess)
×
122
        cs, err := kubernetes.NewForConfig(cfg)
×
123
        if err != nil {
×
124
                return false, fmt.Errorf("build clientset: %w", err)
×
125
        }
×
126
        res, err := cs.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{
×
127
                Spec: authzv1.SelfSubjectAccessReviewSpec{
×
128
                        ResourceAttributes: &authzv1.ResourceAttributes{
×
129
                                Verb:     "impersonate",
×
130
                                Resource: "users",
×
131
                        },
×
132
                },
×
133
        }, metav1.CreateOptions{})
×
134
        if err != nil {
×
135
                return false, fmt.Errorf("SelfSubjectAccessReview: %w", err)
×
136
        }
×
137

138
        h.sessionStore.SetCanImpersonate(r, res.Status.Allowed)
×
139

×
140
        return res.Status.Allowed, nil
×
141
}
142

143
// CanListAllNamespaces returns true if the current identity (real or
144
// impersonated) is permitted to list namespaces at cluster scope. The
145
// frontend uses this to decide whether to offer "All namespaces" in the
146
// selector — without it, the cluster-wide list endpoints would just 403.
147
//
148
// Cached per session and invalidated when impersonation changes.
149
func (h *Handler) CanListAllNamespaces(ctx context.Context, r *http.Request) (bool, error) {
×
150
        sess := h.sessionStore.Get(r)
×
151
        if sess == nil {
×
152
                return false, nil
×
153
        }
×
154
        if sess.CanListAllNamespaces != nil {
×
155
                return *sess.CanListAllNamespaces, nil
×
156
        }
×
157

158
        cs, err := kubernetes.NewForConfig(h.authProvider.WrapConfig(h.baseConfig, sess))
×
159
        if err != nil {
×
160
                return false, fmt.Errorf("build clientset: %w", err)
×
161
        }
×
162
        res, err := cs.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{
×
163
                Spec: authzv1.SelfSubjectAccessReviewSpec{
×
164
                        ResourceAttributes: &authzv1.ResourceAttributes{
×
165
                                Verb:     "list",
×
166
                                Resource: "namespaces",
×
167
                        },
×
168
                },
×
169
        }, metav1.CreateOptions{})
×
170
        if err != nil {
×
171
                return false, fmt.Errorf("SelfSubjectAccessReview: %w", err)
×
172
        }
×
173

174
        h.sessionStore.SetCanListAllNamespaces(r, res.Status.Allowed)
×
175

×
176
        return res.Status.Allowed, nil
×
177
}
178

179
// HandleMe returns the current user info, including canImpersonate which the
180
// frontend uses to decide whether to show the "Preview as" dropdown.
181
func (h *Handler) HandleMe() http.HandlerFunc {
×
182
        return func(w http.ResponseWriter, r *http.Request) {
×
183
                w.Header().Set("Content-Type", "application/json")
×
184

×
185
                sess := h.sessionStore.Get(r)
×
186
                if sess == nil {
×
187
                        _, _ = w.Write([]byte(`{"authenticated":false}`))
×
188
                        return
×
189
                }
×
190

191
                resp := map[string]any{
×
192
                        "authenticated": true,
×
193
                        "username":      sess.Username,
×
194
                        "groups":        sess.Groups,
×
195
                }
×
196
                if sess.ImpersonatingAs != "" {
×
197
                        resp["impersonating"] = map[string]any{
×
198
                                "username": sess.ImpersonatingAs,
×
199
                                "groups":   sess.ImpersonatingGroups,
×
200
                        }
×
201
                }
×
202

203
                // Best-effort SSAR — if the apiserver call fails, fall back to
204
                // canImpersonate=false rather than failing the whole /me response.
205
                canImpersonate, err := h.CanImpersonate(r.Context(), r)
×
206
                if err != nil {
×
207
                        h.log.Error(err, "SelfSubjectAccessReview (impersonate) failed; assuming non-admin")
×
208
                }
×
209
                resp["canImpersonate"] = canImpersonate
×
210

×
211
                canListAll, err := h.CanListAllNamespaces(r.Context(), r)
×
212
                if err != nil {
×
213
                        h.log.Error(err, "SelfSubjectAccessReview (list namespaces) failed; assuming false")
×
214
                }
×
215
                resp["canListAllNamespaces"] = canListAll
×
216

×
217
                if err := json.NewEncoder(w).Encode(resp); err != nil {
×
218
                        h.log.Error(err, "failed to encode /me response")
×
219
                }
×
220
        }
221
}
222

223
// HandleList returns a handler that lists resources of the given type.
224
// If the route has no {namespace} path value (cluster-wide route), the
225
// dynamic client is called with an empty namespace, which K8s interprets
226
// as "across all namespaces". K8s RBAC determines whether the user is
227
// permitted to do that cluster-wide list.
228
func (h *Handler) HandleList(resource string) http.HandlerFunc {
×
229
        gvr, ok := resourceMap[resource]
×
230
        if !ok {
×
231
                return func(w http.ResponseWriter, _ *http.Request) {
×
232
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
×
233
                }
×
234
        }
235

236
        return func(w http.ResponseWriter, r *http.Request) {
×
237
                namespace := r.PathValue("namespace")
×
238

×
239
                client, err := h.clientFor(r)
×
240
                if err != nil {
×
241
                        h.log.Error(err, "failed to create client")
×
242
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
243

×
244
                        return
×
245
                }
×
246

247
                list, err := client.Resource(gvr).Namespace(namespace).List(r.Context(), listOptions())
×
248
                if err != nil {
×
249
                        h.log.Error(err, "failed to list resources", "resource", resource, "namespace", namespace)
×
250
                        writeK8sError(w, err)
×
251

×
252
                        return
×
253
                }
×
254

255
                writeJSON(w, list)
×
256
        }
257
}
258

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

×
273
        return func(w http.ResponseWriter, r *http.Request) {
×
274
                sess := h.sessionStore.Get(r)
×
275
                if sess == nil {
×
276
                        http.Error(w, "unauthorized", http.StatusUnauthorized)
×
277
                        return
×
278
                }
×
279

280
                // Discovery client = BFF's own creds (kubeconfig / SA).
281
                discovery, err := dynamic.NewForConfig(h.baseConfig)
×
282
                if err != nil {
×
283
                        h.log.Error(err, "failed to build discovery client")
×
284
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
285

×
286
                        return
×
287
                }
×
288
                nsList, err := discovery.Resource(gvr).List(r.Context(), listOptions())
×
289
                if err != nil {
×
290
                        h.log.Error(err, "failed to list namespaces (discovery)")
×
291
                        writeK8sError(w, err)
×
292

×
293
                        return
×
294
                }
×
295

296
                // Review client = user identity (or admin previewing as persona).
297
                userCS, err := kubernetes.NewForConfig(h.authProvider.WrapConfig(h.baseConfig, sess))
×
298
                if err != nil {
×
299
                        h.log.Error(err, "failed to build user clientset")
×
300
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
301

×
302
                        return
×
303
                }
×
304

305
                // Parallel SSRR per namespace.
306
                ctx := r.Context()
×
307
                var wg sync.WaitGroup
×
308
                var mu sync.Mutex
×
309
                filtered := make([]unstructured.Unstructured, 0, len(nsList.Items))
×
310
                for i := range nsList.Items {
×
311
                        ns := nsList.Items[i]
×
312
                        wg.Add(1)
×
313
                        go func(ctx context.Context) {
×
314
                                defer wg.Done()
×
315
                                if hasSolarAccess(ctx, userCS, ns.GetName(), h.log) {
×
316
                                        mu.Lock()
×
317
                                        filtered = append(filtered, ns)
×
318
                                        mu.Unlock()
×
319
                                }
×
320
                        }(ctx)
321
                }
322
                wg.Wait()
×
323

×
324
                sort.Slice(filtered, func(i, j int) bool {
×
325
                        return filtered[i].GetName() < filtered[j].GetName()
×
326
                })
×
327
                nsList.Items = filtered
×
328

×
329
                writeJSON(w, nsList)
×
330
        }
331
}
332

333
// hasSolarAccess runs SelfSubjectRulesReview in `namespace` and returns
334
// true if any of the returned resource rules covers the SolAr API group.
335
func hasSolarAccess(ctx context.Context, cs kubernetes.Interface, namespace string, log logr.Logger) bool {
×
336
        review, err := cs.AuthorizationV1().SelfSubjectRulesReviews().Create(ctx, &authzv1.SelfSubjectRulesReview{
×
337
                Spec: authzv1.SelfSubjectRulesReviewSpec{Namespace: namespace},
×
338
        }, metav1.CreateOptions{})
×
339
        if err != nil {
×
340
                log.V(1).Info("SSRR failed; excluding namespace", "namespace", namespace, "error", err.Error())
×
341
                return false
×
342
        }
×
343
        for _, rule := range review.Status.ResourceRules {
×
344
                for _, group := range rule.APIGroups {
×
345
                        if group == solarAPIGroup || group == "*" {
×
346
                                return true
×
347
                        }
×
348
                }
349
        }
350

351
        return false
×
352
}
353

354
// HandleGet returns a handler that gets a single resource.
355
func (h *Handler) HandleGet(resource string) http.HandlerFunc {
×
356
        gvr, ok := resourceMap[resource]
×
357
        if !ok {
×
358
                return func(w http.ResponseWriter, _ *http.Request) {
×
359
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
×
360
                }
×
361
        }
362

363
        return func(w http.ResponseWriter, r *http.Request) {
×
364
                namespace := r.PathValue("namespace")
×
365
                name := r.PathValue("name")
×
366

×
367
                client, err := h.clientFor(r)
×
368
                if err != nil {
×
369
                        h.log.Error(err, "failed to create client")
×
370
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
371

×
372
                        return
×
373
                }
×
374

375
                obj, err := client.Resource(gvr).Namespace(namespace).Get(r.Context(), name, getOptions())
×
376
                if err != nil {
×
377
                        h.log.Error(err, "failed to get resource", "resource", resource, "namespace", namespace, "name", name)
×
378
                        writeK8sError(w, err)
×
379

×
380
                        return
×
381
                }
×
382

383
                writeJSON(w, obj)
×
384
        }
385
}
386

387
// HandleCreate creates a resource in the namespace taken from the path.
388
func (h *Handler) HandleCreate(resource string) http.HandlerFunc {
2✔
389
        gvr, ok := resourceMap[resource]
2✔
390
        if !ok {
2✔
NEW
391
                return func(w http.ResponseWriter, _ *http.Request) {
×
NEW
392
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
×
NEW
393
                }
×
394
        }
395

396
        return func(w http.ResponseWriter, r *http.Request) {
4✔
397
                namespace := r.PathValue("namespace")
2✔
398
                r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
2✔
399

2✔
400
                var obj unstructured.Unstructured
2✔
401
                if err := json.NewDecoder(r.Body).Decode(&obj.Object); err != nil {
2✔
NEW
402
                        http.Error(w, "invalid request body", http.StatusBadRequest)
×
NEW
403
                        return
×
NEW
404
                }
×
405
                // A JSON null/scalar decodes without error but leaves a nil object;
406
                // reject it rather than sending a bodyless create to the apiserver.
407
                if obj.Object == nil {
3✔
408
                        http.Error(w, "request body must be a JSON object", http.StatusBadRequest)
1✔
409
                        return
1✔
410
                }
1✔
411
                obj.SetNamespace(namespace)
1✔
412

1✔
413
                client, err := h.newClient(r)
1✔
414
                if err != nil {
1✔
NEW
415
                        h.log.Error(err, "failed to create client")
×
NEW
416
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
NEW
417

×
NEW
418
                        return
×
NEW
419
                }
×
420

421
                created, err := client.Resource(gvr).Namespace(namespace).Create(r.Context(), &obj, metav1.CreateOptions{})
1✔
422
                if err != nil {
1✔
NEW
423
                        h.log.Error(err, "failed to create resource", "resource", resource, "namespace", namespace)
×
NEW
424
                        writeK8sError(w, err)
×
NEW
425

×
NEW
426
                        return
×
NEW
427
                }
×
428

429
                writeJSON(w, created)
1✔
430
        }
431
}
432

433
// HandlePatch applies a patch to a resource.
434
func (h *Handler) HandlePatch(resource string) http.HandlerFunc {
2✔
435
        gvr, ok := resourceMap[resource]
2✔
436
        if !ok {
2✔
NEW
437
                return func(w http.ResponseWriter, _ *http.Request) {
×
NEW
438
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
×
NEW
439
                }
×
440
        }
441

442
        return func(w http.ResponseWriter, r *http.Request) {
4✔
443
                namespace := r.PathValue("namespace")
2✔
444
                name := r.PathValue("name")
2✔
445
                r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
2✔
446

2✔
447
                patch, err := io.ReadAll(r.Body)
2✔
448
                if err != nil {
2✔
NEW
449
                        http.Error(w, "invalid request body", http.StatusBadRequest)
×
NEW
450
                        return
×
NEW
451
                }
×
452

453
                patchType := types.MergePatchType
2✔
454
                if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json-patch+json") {
3✔
455
                        patchType = types.JSONPatchType
1✔
456
                }
1✔
457

458
                client, err := h.newClient(r)
2✔
459
                if err != nil {
2✔
NEW
460
                        h.log.Error(err, "failed to create client")
×
NEW
461
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
NEW
462

×
NEW
463
                        return
×
NEW
464
                }
×
465

466
                updated, err := client.Resource(gvr).Namespace(namespace).
2✔
467
                        Patch(r.Context(), name, patchType, patch, metav1.PatchOptions{})
2✔
468
                if err != nil {
2✔
NEW
469
                        h.log.Error(err, "failed to patch resource", "resource", resource, "namespace", namespace, "name", name)
×
NEW
470
                        writeK8sError(w, err)
×
NEW
471

×
NEW
472
                        return
×
NEW
473
                }
×
474

475
                writeJSON(w, updated)
2✔
476
        }
477
}
478

479
// HandleDelete deletes a resource
480
func (h *Handler) HandleDelete(resource string) http.HandlerFunc {
1✔
481
        gvr, ok := resourceMap[resource]
1✔
482
        if !ok {
1✔
NEW
483
                return func(w http.ResponseWriter, _ *http.Request) {
×
NEW
484
                        http.Error(w, fmt.Sprintf("unknown resource: %s", resource), http.StatusNotFound)
×
NEW
485
                }
×
486
        }
487

488
        return func(w http.ResponseWriter, r *http.Request) {
2✔
489
                namespace := r.PathValue("namespace")
1✔
490
                name := r.PathValue("name")
1✔
491

1✔
492
                client, err := h.newClient(r)
1✔
493
                if err != nil {
1✔
NEW
494
                        h.log.Error(err, "failed to create client")
×
NEW
495
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
NEW
496

×
NEW
497
                        return
×
NEW
498
                }
×
499

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

×
NEW
504
                        return
×
NEW
505
                }
×
506

507
                w.WriteHeader(http.StatusNoContent)
1✔
508
        }
509
}
510

511
// HandleSSE returns a handler that streams resource watch events as SSE.
512
// An empty {namespace} path value (the cluster-wide /api/events route)
513
// watches across all namespaces; K8s RBAC decides per-resource whether the
514
// user is allowed to do that, and individual watches that 403 are skipped
515
// rather than failing the whole stream.
516
func (h *Handler) HandleSSE() http.HandlerFunc {
×
517
        return func(w http.ResponseWriter, r *http.Request) {
×
518
                namespace := r.PathValue("namespace")
×
519

×
520
                client, err := h.clientFor(r)
×
521
                if err != nil {
×
522
                        h.log.Error(err, "failed to create client")
×
523
                        http.Error(w, "internal error", http.StatusInternalServerError)
×
524

×
525
                        return
×
526
                }
×
527

528
                flusher, ok := w.(http.Flusher)
×
529
                if !ok {
×
530
                        http.Error(w, "streaming not supported", http.StatusInternalServerError)
×
531

×
532
                        return
×
533
                }
×
534

535
                w.Header().Set("Content-Type", "text/event-stream")
×
536
                w.Header().Set("Cache-Control", "no-cache")
×
537
                w.Header().Set("Connection", "keep-alive")
×
538
                flusher.Flush()
×
539

×
540
                // Watch all solar resources and multiplex into SSE via a channel.
×
541
                // Namespace on the event is read from the object metadata so
×
542
                // cluster-wide watches still deliver the originating namespace.
×
543
                type sseEvent struct {
×
544
                        Type      string `json:"type"`
×
545
                        Resource  string `json:"resource"`
×
546
                        Namespace string `json:"namespace"`
×
547
                }
×
548
                events := make(chan sseEvent, 64)
×
549

×
550
                ctx := r.Context()
×
551
                for resourceName, gvr := range resourceMap {
×
552
                        go func(ctx context.Context) {
×
553
                                watcher, err := client.Resource(gvr).Namespace(namespace).Watch(ctx, watchOptions())
×
554
                                if err != nil {
×
555
                                        h.log.Error(err, "failed to watch", "resource", resourceName)
×
556

×
557
                                        return
×
558
                                }
×
559
                                defer watcher.Stop()
×
560

×
561
                                for event := range watcher.ResultChan() {
×
562
                                        eventNs := namespace
×
563
                                        if obj, ok := event.Object.(metav1.Object); ok {
×
564
                                                eventNs = obj.GetNamespace()
×
565
                                        }
×
566
                                        select {
×
567
                                        case events <- sseEvent{
568
                                                Type:      string(event.Type),
569
                                                Resource:  resourceName,
570
                                                Namespace: eventNs,
571
                                        }:
×
572
                                        case <-ctx.Done():
×
573
                                                return
×
574
                                        }
575
                                }
576
                        }(ctx)
577
                }
578

579
                // Single writer goroutine — serializes all writes and respects client disconnect
580
                for {
×
581
                        select {
×
582
                        case evt := <-events:
×
583
                                b, _ := json.Marshal(evt)
×
584
                                fmt.Fprintf(w, "data: %s\n\n", b)
×
585
                                flusher.Flush()
×
586
                        case <-r.Context().Done():
×
587
                                return
×
588
                        }
589
                }
590
        }
591
}
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