From 8675cc85e50a4b80441653a41f5801de4c72bb93 Mon Sep 17 00:00:00 2001 From: Diogo Recharte Date: Tue, 6 Oct 2026 20:02:52 +0100 Subject: [PATCH] feat: find component pods by the selectors in status.components status.components[].podRefs are gone from the Instance API; each component now reports a pod selector instead. Signed-off-by: Diogo Recharte --- backend/everest.go | 20 +++------ backend/go.mod | 3 +- backend/go.sum | 12 ++--- backend/main_test.go | 102 ++++++++++++++++++++++++++----------------- backend/pods.go | 68 ++++++++++++++++++----------- 5 files changed, 118 insertions(+), 87 deletions(-) diff --git a/backend/everest.go b/backend/everest.go index 0a5ec75..561ac93 100644 --- a/backend/everest.go +++ b/backend/everest.go @@ -38,24 +38,14 @@ func (e *statusError) Error() string { return e.message } // instance is the subset of the Instance CR the plugin needs. type instance struct { Status struct { - Components []struct { - PodRefs []struct { - Name string `json:"name"` - } `json:"podRefs"` - } `json:"components"` + Components []instanceComponent `json:"components"` } `json:"status"` } -func (in *instance) podRefNames() []string { - var names []string - for _, c := range in.Status.Components { - for _, ref := range c.PodRefs { - if ref.Name != "" { - names = append(names, ref.Name) - } - } - } - return names +// instanceComponent is a component whose pods the provider runtime reports. +type instanceComponent struct { + Name string `json:"name"` + Selector string `json:"selector"` } type everestClient struct { diff --git a/backend/go.mod b/backend/go.mod index 3ea3511..b1848a0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module github.com/openeverest/plugin-inspector/backend go 1.25.0 require ( + github.com/stretchr/testify v1.12.1 k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 @@ -29,7 +30,7 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0dfb1f8..7104477 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -67,22 +67,22 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/backend/main_test.go b/backend/main_test.go index b78e49a..ae00ae0 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -7,6 +7,8 @@ import ( "net/url" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -38,9 +40,22 @@ func testPod(name, instanceName string) *corev1.Pod { } } -// fakeEverest serves GET instance for "db/mydb" with the given podRefs and -// rejects any token other than validToken. -func fakeEverest(t *testing.T, podRefs []string) *httptest.Server { +// everestPod is a pod the provider labelled for the runtime's status.components. +func everestPod(name, instanceName, component string) *corev1.Pod { + pod := testPod(name, instanceName) + pod.Labels[everestInstanceLabel] = instanceName + pod.Labels[everestComponentLabel] = component + return pod +} + +var ( + engineComponent = instanceComponent{Name: "engine", Selector: "core.openeverest.io/component=engine,core.openeverest.io/instance=mydb"} + proxyComponent = instanceComponent{Name: "proxy", Selector: "core.openeverest.io/component=proxy,core.openeverest.io/instance=mydb"} +) + +// fakeEverest serves GET instance for "db/mydb" with the given status.components +// and rejects any token other than validToken. +func fakeEverest(t *testing.T, components []instanceComponent) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer "+validToken { @@ -51,22 +66,18 @@ func fakeEverest(t *testing.T, podRefs []string) *httptest.Server { w.WriteHeader(http.StatusNotFound) return } - refs := make([]map[string]string, 0, len(podRefs)) - for _, name := range podRefs { - refs = append(refs, map[string]string{"name": name}) - } _ = json.NewEncoder(w).Encode(map[string]any{ - "status": map[string]any{"components": []map[string]any{{"podRefs": refs}}}, + "status": map[string]any{"components": components}, }) })) t.Cleanup(srv.Close) return srv } -func newTestMux(t *testing.T, podRefs []string, objects ...runtime.Object) *http.ServeMux { +func newTestMux(t *testing.T, components []instanceComponent, objects ...runtime.Object) *http.ServeMux { t.Helper() kube := fake.NewClientset(objects...) - return newMux(&server{kube: kube, everest: newEverestClient(fakeEverest(t, podRefs).URL)}) + return newMux(&server{kube: kube, everest: newEverestClient(fakeEverest(t, components).URL)}) } func doRequest(mux *http.ServeMux, path string, query url.Values, token string) *httptest.ResponseRecorder { @@ -120,25 +131,36 @@ func TestComponentsAuthorization(t *testing.T) { } } -func TestComponentsUsesPodRefsWhenReported(t *testing.T) { - // mydb-1 carries the instance label but isn't in podRefs, so it must be ignored. - mux := newTestMux(t, []string{"mydb-0", "gone-0"}, testPod("mydb-0", "mydb"), testPod("mydb-1", "mydb")) +func TestComponentsUsesSelectorsWhenReported(t *testing.T) { + // mydb-9 only carries the operator label, so it belongs to no component. + mux := newTestMux(t, []instanceComponent{engineComponent, proxyComponent}, + everestPod("mydb-0", "mydb", "engine"), everestPod("mydb-proxy-0", "mydb", "proxy"), testPod("mydb-9", "mydb")) rec := doRequest(mux, "/api/components", instanceQuery(nil), validToken) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d: %s", rec.Code, rec.Body) - } + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) got := decodeComponents(t, rec) - if len(got) != 1 || got[0].Name != "mydb-0" { - t.Fatalf("components = %+v, want only mydb-0", got) - } - c := got[0] - if c.Ready != "1/2" || c.Restarts != 3 || c.Type != "engine" || c.NodeName != "node-a" { - t.Fatalf("unexpected mapping: %+v", c) - } - if c.Containers[0].Status != "Running" || c.Containers[1].Status != "Waiting" { - t.Fatalf("unexpected container statuses: %+v", c.Containers) - } + require.Len(t, got, 2) + assert.Equal(t, "mydb-0", got[0].Name) + assert.Equal(t, "engine", got[0].Type) + assert.Equal(t, "1/2", got[0].Ready) + assert.Equal(t, int32(3), got[0].Restarts) + assert.Equal(t, "node-a", got[0].NodeName) + assert.Equal(t, "Running", got[0].Containers[0].Status) + assert.Equal(t, "Waiting", got[0].Containers[1].Status) + assert.Equal(t, "mydb-proxy-0", got[1].Name) + assert.Equal(t, "proxy", got[1].Type) +} + +func TestComponentsNarrowsSelectorsToTheInstance(t *testing.T) { + broad := instanceComponent{Name: "engine", Selector: "core.openeverest.io/component=engine"} + mux := newTestMux(t, []instanceComponent{broad}, everestPod("mydb-0", "mydb", "engine"), everestPod("other-0", "other", "engine")) + + rec := doRequest(mux, "/api/components", instanceQuery(nil), validToken) + + got := decodeComponents(t, rec) + require.Len(t, got, 1) + assert.Equal(t, "mydb-0", got[0].Name) } func TestComponentsFallsBackToInstanceLabel(t *testing.T) { @@ -152,19 +174,21 @@ func TestComponentsFallsBackToInstanceLabel(t *testing.T) { } func TestLogs(t *testing.T) { - pods := []runtime.Object{testPod("mydb-0", "mydb"), testPod("mydb-1", "mydb"), testPod("other-0", "other")} + pods := []runtime.Object{testPod("mydb-0", "mydb"), testPod("mydb-1", "mydb"), testPod("other-0", "other"), everestPod("mydb-2", "mydb", "engine")} + engine := []instanceComponent{engineComponent} cases := []struct { - name string - podRefs []string - pod string - extra map[string]string - status int + name string + components []instanceComponent + pod string + extra map[string]string + status int }{ {"label fallback", nil, "mydb-0", nil, http.StatusOK}, {"explicit container", nil, "mydb-0", map[string]string{"container": "backup-agent", "follow": "true"}, http.StatusOK}, {"pod of another instance", nil, "other-0", nil, http.StatusNotFound}, - {"labelled pod outside podRefs", []string{"mydb-0"}, "mydb-1", nil, http.StatusNotFound}, + {"pod of a component", engine, "mydb-2", nil, http.StatusOK}, + {"labelled pod outside the components", engine, "mydb-1", nil, http.StatusNotFound}, {"missing pod", nil, "mydb-9", nil, http.StatusNotFound}, {"unknown container", nil, "mydb-0", map[string]string{"container": "nope"}, http.StatusBadRequest}, {"tailLines too large", nil, "mydb-0", map[string]string{"tailLines": "10001"}, http.StatusBadRequest}, @@ -172,13 +196,13 @@ func TestLogs(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - mux := newTestMux(t, tc.podRefs, pods...) + mux := newTestMux(t, tc.components, pods...) + rec := doRequest(mux, "/api/components/"+tc.pod+"/logs", instanceQuery(tc.extra), validToken) - if rec.Code != tc.status { - t.Fatalf("status = %d, want %d: %s", rec.Code, tc.status, rec.Body) - } - if tc.status == http.StatusOK && rec.Header().Get("Content-Type") != "text/plain; charset=utf-8" { - t.Fatalf("content type = %q", rec.Header().Get("Content-Type")) + + require.Equal(t, tc.status, rec.Code, rec.Body.String()) + if tc.status == http.StatusOK { + assert.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type")) } }) } diff --git a/backend/pods.go b/backend/pods.go index f0255ed..908e29b 100644 --- a/backend/pods.go +++ b/backend/pods.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "fmt" "net/http" @@ -12,12 +13,16 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" "k8s.io/client-go/kubernetes" ) const ( instanceLabel = "app.kubernetes.io/instance" componentLabel = "app.kubernetes.io/component" + + everestInstanceLabel = "core.openeverest.io/instance" + everestComponentLabel = "core.openeverest.io/component" ) var errPodNotInInstance = &statusError{status: http.StatusNotFound, message: "pod not found in instance"} @@ -43,31 +48,21 @@ type component struct { Containers []container `json:"containers"` } -// instancePods returns the pods backing an instance. Providers that report -// status.components[].podRefs are authoritative; otherwise fall back to the -// conventional operator label. +// instancePods returns the pods backing an instance: the pods of each component +// in status.components, or else the pods with the conventional operator label. func instancePods(ctx context.Context, kube kubernetes.Interface, namespace, name string, in *instance) ([]corev1.Pod, error) { - refs := in.podRefNames() - if len(refs) == 0 { - list, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.SelectorFromSet(labels.Set{instanceLabel: name}).String(), - }) - if err != nil { - return nil, fmt.Errorf("list pods: %w", err) - } - return list.Items, nil + selectors := componentSelectors(name, in) + if len(selectors) == 0 { + selectors = []labels.Selector{labels.SelectorFromSet(labels.Set{instanceLabel: name})} } - pods := make([]corev1.Pod, 0, len(refs)) - for _, ref := range refs { - pod, err := kube.CoreV1().Pods(namespace).Get(ctx, ref, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - continue - } + var pods []corev1.Pod + for _, selector := range selectors { + list, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector.String()}) if err != nil { - return nil, fmt.Errorf("get pod %s: %w", ref, err) + return nil, fmt.Errorf("list pods: %w", err) } - pods = append(pods, *pod) + pods = append(pods, list.Items...) } return pods, nil } @@ -75,10 +70,6 @@ func instancePods(ctx context.Context, kube kubernetes.Interface, namespace, nam // instancePod returns a single pod, but only if it belongs to the instance, so a // caller who can read one instance can't read logs of other pods in the namespace. func instancePod(ctx context.Context, kube kubernetes.Interface, namespace, name, podName string, in *instance) (*corev1.Pod, error) { - refs := in.podRefNames() - if len(refs) > 0 && !slices.Contains(refs, podName) { - return nil, errPodNotInInstance - } pod, err := kube.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) if apierrors.IsNotFound(err) { return nil, errPodNotInInstance @@ -86,12 +77,37 @@ func instancePod(ctx context.Context, kube kubernetes.Interface, namespace, name if err != nil { return nil, fmt.Errorf("get pod %s: %w", podName, err) } - if len(refs) == 0 && pod.Labels[instanceLabel] != name { + + selectors := componentSelectors(name, in) + inInstance := pod.Labels[instanceLabel] == name + if len(selectors) > 0 { + inInstance = slices.ContainsFunc(selectors, func(s labels.Selector) bool { return s.Matches(labels.Set(pod.Labels)) }) + } + if !inInstance { return nil, errPodNotInInstance } return pod, nil } +// componentSelectors parses the pod selectors in status.components, each +// narrowed to the instance's own pods so it can never reach another instance. +func componentSelectors(name string, in *instance) []labels.Selector { + own, err := labels.NewRequirement(everestInstanceLabel, selection.Equals, []string{name}) + if err != nil { + return nil + } + + var selectors []labels.Selector + for _, c := range in.Status.Components { + selector, err := labels.Parse(c.Selector) + if c.Selector == "" || err != nil { + continue + } + selectors = append(selectors, selector.Add(*own)) + } + return selectors +} + func toComponents(pods []corev1.Pod) []component { res := make([]component, 0, len(pods)) for _, pod := range pods { @@ -127,7 +143,7 @@ func toComponent(pod corev1.Pod) component { comp := component{ Name: pod.Name, - Type: pod.Labels[componentLabel], + Type: cmp.Or(pod.Labels[everestComponentLabel], pod.Labels[componentLabel]), Status: string(pod.Status.Phase), Reason: podReason(pod), NodeName: pod.Spec.NodeName,