mirror of
https://github.com/vee1e/runtimeclass-debugger.git
synced 2026-09-01 10:18:42 +00:00
360 lines
12 KiB
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) }
|