Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions backend/everest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
102 changes: 63 additions & 39 deletions backend/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -152,33 +174,35 @@ 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},
{"invalid follow", nil, "mydb-0", map[string]string{"follow": "maybe"}, http.StatusBadRequest},
}
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"))
}
})
}
Expand Down
68 changes: 42 additions & 26 deletions backend/pods.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"cmp"
"context"
"fmt"
"net/http"
Expand All @@ -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"}
Expand All @@ -43,55 +48,66 @@ 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
}

// 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
}
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 {
Expand Down Expand Up @@ -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,
Expand Down