runtimeclass-debugger/pkg/diag/diag_test.go

360 lines
12 KiB
Go

package diag
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
nodev1 "k8s.io/api/node/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/kubeedge/runtimeclass-debugger/pkg/bridge"
"github.com/kubeedge/runtimeclass-debugger/pkg/metaserver"
)
type fakeMeta struct {
classes []nodev1.RuntimeClass
err error
}
func (f *fakeMeta) ListRuntimeClasses(context.Context) ([]nodev1.RuntimeClass, error) {
return f.classes, f.err
}
type fakeStore struct {
classes []nodev1.RuntimeClass
err error
}
func (f *fakeStore) ListRuntimeClasses() ([]nodev1.RuntimeClass, error) {
return f.classes, f.err
}
// fakeResolver models a wired runtimeclass manager with a fixed handler
// table; unknown classes resolve to NotFound like the kubelet does.
type fakeResolver struct {
handlers map[string]string
}
func (f *fakeResolver) LookupRuntimeHandler(name *string) (string, error) {
if name == nil || *name == "" {
return "", nil
}
if h, ok := f.handlers[*name]; ok {
return h, nil
}
return "", apierrors.NewNotFound(nodev1.Resource("runtimeclasses"), *name)
}
func runtimeClass(name, handler string) nodev1.RuntimeClass {
return nodev1.RuntimeClass{
ObjectMeta: metav1.ObjectMeta{Name: name},
Handler: handler,
}
}
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func withPaths(t *testing.T, d *Diag) *Diag {
dir := t.TempDir()
d.ContainerdPath = writeFile(t, dir, "config.toml", `version = 2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata]
runtime_type = "io.containerd.kata.v2"
`)
d.EdgeCorePath = writeFile(t, dir, "edgecore.yaml", "edged:\n reportEvent: true\n")
return d
}
func TestCheckTransport(t *testing.T) {
t.Run("pass with classes", func(t *testing.T) {
d := &Diag{MetaServer: &fakeMeta{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}}}
res, classes := d.CheckTransport(context.Background())
if res.Status != StatusPass || len(classes) != 1 {
t.Fatalf("expected pass with 1 class, got %+v", res)
}
})
t.Run("fail on empty list", func(t *testing.T) {
d := &Diag{MetaServer: &fakeMeta{}}
res, classes := d.CheckTransport(context.Background())
if res.Status != StatusFail || len(classes) != 0 {
t.Fatalf("expected fail with 0 classes, got %+v", res)
}
if !strings.Contains(res.Detail, "transport gap") {
t.Fatalf("expected transport gap reason, got %q", res.Detail)
}
})
t.Run("warn on auth required", func(t *testing.T) {
d := &Diag{MetaServer: &fakeMeta{err: &metaserver.AuthRequiredError{Reason: "requires certs"}}}
res, _ := d.CheckTransport(context.Background())
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
t.Run("warn on unreachable", func(t *testing.T) {
d := &Diag{MetaServer: &fakeMeta{err: &metaserver.UnreachableError{Reason: "down"}}}
res, _ := d.CheckTransport(context.Background())
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
t.Run("warn on unexpected error", func(t *testing.T) {
d := &Diag{MetaServer: &fakeMeta{err: errBoom}}
res, _ := d.CheckTransport(context.Background())
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
}
func TestCheckBridge(t *testing.T) {
t.Run("skip with no scope", func(t *testing.T) {
d := &Diag{Store: &fakeStore{}}
res := d.CheckBridge(nil)
if res.Status != StatusSkip {
t.Fatalf("expected skip, got %+v", res)
}
})
t.Run("pass when both lookups resolve", func(t *testing.T) {
d := &Diag{
Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}},
NewNaive: func() (bridge.Resolver, error) {
return &fakeResolver{handlers: map[string]string{"kata": "kata"}}, nil
},
}
res := d.CheckBridge([]string{"kata"})
if res.Status != StatusPass {
t.Fatalf("expected pass, got %+v", res)
}
if !strings.Contains(res.Detail, "wired lookup") {
t.Fatalf("expected wired lookup in detail, got %q", res.Detail)
}
})
t.Run("fail when class in store but kubelet bridge cannot resolve it", func(t *testing.T) {
d := &Diag{Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}}}
res := d.CheckBridge([]string{"kata"})
if res.Status != StatusFail {
t.Fatalf("expected fail, got %+v", res)
}
if !strings.Contains(res.Detail, "edge bridge gap") {
t.Fatalf("expected edge bridge gap reason, got %q", res.Detail)
}
})
t.Run("fail when class is missing from local store", func(t *testing.T) {
d := &Diag{Store: &fakeStore{}}
res := d.CheckBridge([]string{"kata"})
if res.Status != StatusFail {
t.Fatalf("expected fail, got %+v", res)
}
if !strings.Contains(res.Detail, "transport check") {
t.Fatalf("expected transport check reference, got %q", res.Detail)
}
})
t.Run("warn when local store unreadable", func(t *testing.T) {
d := &Diag{Store: &fakeStore{err: errBoom}}
res := d.CheckBridge([]string{"kata"})
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
}
func TestCheckCRI(t *testing.T) {
t.Run("pass when handler configured", func(t *testing.T) {
d := withPaths(t, &Diag{Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}}})
res := d.CheckCRI([]string{"kata"})
if res.Status != StatusPass {
t.Fatalf("expected pass, got %+v", res)
}
})
t.Run("pass with no scope", func(t *testing.T) {
d := withPaths(t, &Diag{})
res := d.CheckCRI(nil)
if res.Status != StatusPass {
t.Fatalf("expected pass, got %+v", res)
}
})
t.Run("fail when handler missing", func(t *testing.T) {
d := withPaths(t, &Diag{Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}}})
res := d.CheckCRI([]string{"runsc"})
if res.Status != StatusFail {
t.Fatalf("expected fail, got %+v", res)
}
if !strings.Contains(res.Detail, "not configured in containerd") {
t.Fatalf("expected not-configured reason, got %q", res.Detail)
}
})
t.Run("warn when config missing", func(t *testing.T) {
d := &Diag{Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}}, ContainerdPath: filepath.Join(t.TempDir(), "nope.toml")}
res := d.CheckCRI([]string{"kata"})
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
t.Run("warn when store unreadable", func(t *testing.T) {
d := withPaths(t, &Diag{Store: &fakeStore{err: errBoom}})
res := d.CheckCRI([]string{"kata"})
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
if !strings.Contains(res.Detail, "local store") {
t.Fatalf("expected store error in detail, got %q", res.Detail)
}
})
}
func TestCheckEvents(t *testing.T) {
t.Run("pass when reportEvent true", func(t *testing.T) {
d := withPaths(t, &Diag{})
if res := d.CheckEvents(); res.Status != StatusPass {
t.Fatalf("expected pass, got %+v", res)
}
})
t.Run("warn when reportEvent false", func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "edgecore.yaml", "edged:\n reportEvent: false\n")
d := &Diag{EdgeCorePath: path}
res := d.CheckEvents()
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
t.Run("warn when reportEvent absent", func(t *testing.T) {
dir := t.TempDir()
path := writeFile(t, dir, "edgecore.yaml", "edged:\n nodeStatusUpdateFrequency: 10\n")
d := &Diag{EdgeCorePath: path}
res := d.CheckEvents()
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
t.Run("warn when config missing", func(t *testing.T) {
d := &Diag{EdgeCorePath: filepath.Join(t.TempDir(), "nope.yaml")}
res := d.CheckEvents()
if res.Status != StatusWarn {
t.Fatalf("expected warn, got %+v", res)
}
})
}
func TestRunHealthy(t *testing.T) {
d := &Diag{
MetaServer: &fakeMeta{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}},
Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}},
NewNaive: func() (bridge.Resolver, error) {
return &fakeResolver{handlers: map[string]string{"kata": "kata"}}, nil
},
}
d = withPaths(t, d)
report := d.Run(context.Background(), nil)
want := map[string]Status{"transport": StatusPass, "bridge": StatusPass, "cri": StatusPass, "events": StatusPass}
if len(report.Checks) != 4 {
t.Fatalf("expected 4 checks, got %d", len(report.Checks))
}
for _, c := range report.Checks {
if c.Status != want[c.ID] {
t.Fatalf("check %s: expected %s, got %+v", c.ID, want[c.ID], c)
}
}
if report.ExitCode != ExitOK {
t.Fatalf("expected exit 0, got %d", report.ExitCode)
}
}
// TestRunClassicSymptom reproduces the classic KubeEdge symptom: the class
// reached the edge and the handler is configured (transport and cri are
// fine) but Edged cannot resolve it (bridge gap). The report must exit
// non-zero.
func TestRunClassicSymptom(t *testing.T) {
dir := t.TempDir()
d := withPaths(t, &Diag{
MetaServer: &fakeMeta{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}},
Store: &fakeStore{classes: []nodev1.RuntimeClass{runtimeClass("kata", "kata")}},
})
d.EdgeCorePath = writeFile(t, dir, "edgecore.yaml", "edged:\n reportEvent: false\n")
report := d.Run(context.Background(), nil)
want := map[string]Status{"transport": StatusPass, "bridge": StatusFail, "cri": StatusPass, "events": StatusWarn}
for _, c := range report.Checks {
if c.Status != want[c.ID] {
t.Fatalf("check %s: expected %s, got %+v", c.ID, want[c.ID], c)
}
}
if report.ExitCode != ExitFail {
t.Fatalf("expected exit 1, got %d", report.ExitCode)
}
}
func TestRunStockNode(t *testing.T) {
dir := t.TempDir()
d := &Diag{
MetaServer: &fakeMeta{},
Store: &fakeStore{},
ContainerdPath: filepath.Join(dir, "missing.toml"),
EdgeCorePath: writeFile(t, dir, "edgecore.yaml", "edged:\n reportEvent: false\n"),
}
report := d.Run(context.Background(), nil)
want := map[string]Status{"transport": StatusFail, "bridge": StatusSkip, "cri": StatusWarn, "events": StatusWarn}
for _, c := range report.Checks {
if c.Status != want[c.ID] {
t.Fatalf("check %s: expected %s, got %+v", c.ID, want[c.ID], c)
}
}
if report.ExitCode != ExitFail {
t.Fatalf("expected exit 1, got %d", report.ExitCode)
}
}
func TestRunWarnOnly(t *testing.T) {
dir := t.TempDir()
d := &Diag{
MetaServer: &fakeMeta{err: &metaserver.UnreachableError{Reason: "down"}},
Store: &fakeStore{},
ContainerdPath: filepath.Join(dir, "missing.toml"),
EdgeCorePath: writeFile(t, dir, "edgecore.yaml", "edged:\n reportEvent: false\n"),
}
report := d.Run(context.Background(), nil)
if report.ExitCode != ExitWarn {
t.Fatalf("expected exit 2, got %d", report.ExitCode)
}
}
func TestReportRendering(t *testing.T) {
r := &Report{
Checks: []CheckResult{
{ID: "transport", Status: StatusPass, Detail: "1 RuntimeClass object(s) served by MetaServer (kata)"},
{ID: "bridge", Status: StatusFail, Detail: "class \"kata\" not resolvable: edge bridge gap (NodeV1 not wired to metaclient)"},
{ID: "events", Status: StatusWarn, Detail: "reportEvent is false"},
},
}
r.ExitCode = r.ComputeExitCode()
if r.ExitCode != ExitFail {
t.Fatalf("expected exit 1, got %d", r.ExitCode)
}
table := r.Table()
if !strings.Contains(table, "[PASS] transport ") || !strings.Contains(table, "Result: 1 pass, 1 fail, 1 warn") {
t.Fatalf("unexpected table:\n%s", table)
}
jsonOut, err := r.JSON()
if err != nil {
t.Fatalf("JSON: %v", err)
}
if !strings.Contains(jsonOut, "\"exitCode\": 1") || !strings.Contains(jsonOut, "\"status\": \"fail\"") {
t.Fatalf("unexpected json:\n%s", jsonOut)
}
}
var errBoom = contextError("boom")
type contextError string
func (e contextError) Error() string { return string(e) }