From 5c2c85150cf8274db056ff21050cc57236c42e47 Mon Sep 17 00:00:00 2001 From: Chimera Date: Thu, 18 Jun 2026 23:38:05 +0800 Subject: [PATCH 1/8] P2: wire Virtual Kubelet node controller into macvz-kubelet (#13) Start the Virtual Kubelet node controller from cmd/macvz-kubelet around the existing apple/container runtime driver, without registering Pods yet. - pkg/config: add Config.RestConfig to build a Kubernetes REST config from an explicit kubeconfig path, KUBECONFIG/default rules, or in-cluster config. A missing or unparseable kubeconfig fails loudly at startup rather than silently falling back. - cmd/macvz-kubelet: build the kube client, construct the provider over the runtime driver, and run a node.NewNodeController with NaiveNodeProvider on a minimal Node object. Capacity/conditions (#14), lease/heartbeat (#15), and the Pod lifecycle (#16) build on this loop. - Graceful shutdown via signal.NotifyContext (SIGINT/SIGTERM); runtime readiness remains visible on startup. - Tests cover missing, invalid, and valid kubeconfig wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/macvz-kubelet/main.go | 107 +++++++++++++++++++++++++++++++++++--- docs/MASTER_TASKS.md | 2 +- go.mod | 5 +- pkg/config/config.go | 41 +++++++++++++++ pkg/config/config_test.go | 56 ++++++++++++++++++++ 5 files changed, 201 insertions(+), 10 deletions(-) diff --git a/cmd/macvz-kubelet/main.go b/cmd/macvz-kubelet/main.go index 1a38c76..f366762 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -10,10 +10,17 @@ import ( "flag" "fmt" "os" + "os/signal" + "syscall" "github.com/chimerakang/macvz/internal/version" "github.com/chimerakang/macvz/pkg/config" + "github.com/chimerakang/macvz/pkg/provider" "github.com/chimerakang/macvz/pkg/runtime/container" + vknode "github.com/virtual-kubelet/virtual-kubelet/node" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) @@ -40,7 +47,11 @@ func main() { return } - if err := run(configPath, runtimeBinary); err != nil { + // Cancel the root context on SIGINT/SIGTERM for graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := run(ctx, configPath, runtimeBinary); err != nil { klog.ErrorS(err, "macvz-kubelet exited with error") klog.Flush() os.Exit(1) @@ -48,7 +59,7 @@ func main() { klog.Flush() } -func run(configPath, runtimeBinary string) error { +func run(ctx context.Context, configPath, runtimeBinary string) error { cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) @@ -73,16 +84,98 @@ func run(configPath, runtimeBinary string) error { // Build the apple/container driver and report runtime readiness (P1). driver := container.New(container.Config{Binary: bin}) - if err := driver.Ready(context.Background()); err != nil { + if err := driver.Ready(ctx); err != nil { klog.ErrorS(err, "apple/container runtime is not ready", "hint", "ensure the binary is installed and `container system start` has been run") } else { klog.InfoS("apple/container runtime is ready") } - _ = driver // handed to the provider in P2 - // TODO(P2): construct the provider from driver and start the Virtual Kubelet - // node controller here. - klog.InfoS("node registration not yet implemented (lands in P2); exiting cleanly") + // Build the Kubernetes client from kubeconfig / in-cluster config. + restCfg, err := cfg.RestConfig() + if err != nil { + return fmt.Errorf("kubernetes client config: %w", err) + } + clientset, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return fmt.Errorf("build kubernetes client: %w", err) + } + + // Construct the provider over the runtime driver. Pod lifecycle methods are + // stubbed until #16; this issue only wires the node controller loop. + p := provider.New(cfg.NodeName, driver) + _ = p // pod controller wiring lands with the Pod lifecycle (#16). + + // Start the Virtual Kubelet node controller. NaiveNodeProvider keeps the node + // marked ready via Ping; capacity/conditions (#14) and lease/heartbeat (#15) + // build on this loop. + nc, err := vknode.NewNodeController( + vknode.NaiveNodeProvider{}, + buildNode(cfg.NodeName), + clientset.CoreV1().Nodes(), + ) + if err != nil { + return fmt.Errorf("create node controller: %w", err) + } + + klog.InfoS("starting virtual-kubelet node controller", "node", cfg.NodeName) + go func() { + if err := nc.Run(ctx); err != nil && ctx.Err() == nil { + klog.ErrorS(err, "node controller stopped unexpectedly") + } + }() + + select { + case <-nc.Ready(): + klog.InfoS("virtual node registered and ready", "node", cfg.NodeName) + case <-nc.Done(): + return fmt.Errorf("node controller exited before becoming ready: %w", nc.Err()) + case <-ctx.Done(): + klog.InfoS("shutdown requested before node became ready") + return nil + } + + // Block until shutdown is requested, then let the cancelled context unwind + // the controller's run loop. + <-ctx.Done() + klog.InfoS("shutdown signal received; stopping node controller", "node", cfg.NodeName) + <-nc.Done() + if err := nc.Err(); err != nil { + return fmt.Errorf("node controller shutdown: %w", err) + } + klog.InfoS("macvz-kubelet stopped cleanly") return nil } + +// buildNode returns the minimal Node object the controller registers. Capacity, +// addresses, taints, and detailed conditions are filled in by #14; here we only +// establish identity and a Ready condition so the control loop can run. +func buildNode(name string) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "type": "virtual-kubelet", + "kubernetes.io/role": "agent", + "kubernetes.io/hostname": name, + "kubernetes.io/os": "linux", + "kubernetes.io/arch": "arm64", + }, + }, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: "linux", + Architecture: "arm64", + KubeletVersion: version.Version, + }, + Conditions: []corev1.NodeCondition{{ + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + Reason: "KubeletReady", + Message: "macvz-kubelet is ready", + LastHeartbeatTime: metav1.Now(), + LastTransitionTime: metav1.Now(), + }}, + }, + } +} diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index fa9ec85..53ce4e5 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -37,7 +37,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #10 | Exec into running micro-VMs | P1 | open | | #11 | Density & per-VM RAM-overhead benchmark | P1 | open | | #12 | arm64 image pull verification | P1 | open | -| #13 | Wire Virtual Kubelet controller into macvz-kubelet | P2 | open | +| #13 | Wire Virtual Kubelet controller into macvz-kubelet | P2 | in progress | | #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | open | | #15 | Implement node heartbeat and lease updates | P2 | open | | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | open | diff --git a/go.mod b/go.mod index 1180756..1729820 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/virtual-kubelet/virtual-kubelet v1.12.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 k8s.io/klog/v2 v2.130.1 ) @@ -28,6 +30,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -41,8 +44,6 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apimachinery v0.35.0 // indirect - k8s.io/client-go v0.35.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index ff18967..d00d617 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,8 @@ import ( "os" "gopkg.in/yaml.v3" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" ) // Config is the on-disk configuration for a macvz-kubelet node process. @@ -70,6 +72,45 @@ func Load(path string) (Config, error) { return cfg, nil } +// RestConfig builds the Kubernetes client REST config used to reach the API +// server, resolving sources in this order: +// +// 1. KubeconfigPath, when set. The file must exist and parse, otherwise a clear +// error is returned (no silent fallback — a misconfigured node should fail +// loudly at startup). +// 2. The KUBECONFIG env var and the default ~/.kube/config loading rules. +// 3. In-cluster config, when running inside a Pod. +func (c Config) RestConfig() (*rest.Config, error) { + if c.KubeconfigPath != "" { + if _, err := os.Stat(c.KubeconfigPath); err != nil { + return nil, fmt.Errorf("kubeconfig %q: %w", c.KubeconfigPath, err) + } + cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: c.KubeconfigPath}, + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return nil, fmt.Errorf("load kubeconfig %q: %w", c.KubeconfigPath, err) + } + return cfg, nil + } + + // No explicit path: honor KUBECONFIG / default rules, then in-cluster. + rules := clientcmd.NewDefaultClientConfigLoadingRules() + cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + rules, &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err == nil { + return cfg, nil + } + + inCluster, inErr := rest.InClusterConfig() + if inErr != nil { + return nil, fmt.Errorf("no kubeconfig found (%v) and not running in-cluster (%w)", err, inErr) + } + return inCluster, nil +} + // Validate checks that required fields are present and coherent. func (c Config) Validate() error { if c.NodeName == "" { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1f11639..3460567 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -58,3 +58,59 @@ func TestRuntimeSocketIsOptional(t *testing.T) { t.Fatalf("RuntimeSocket is reserved for future service API use and should be optional: %v", err) } } + +func TestRestConfigMissingKubeconfigErrors(t *testing.T) { + c := Default() + c.KubeconfigPath = filepath.Join(t.TempDir(), "absent.kubeconfig") + if _, err := c.RestConfig(); err == nil { + t.Fatal("expected a clear error for a missing kubeconfig path") + } +} + +func TestRestConfigInvalidKubeconfigErrors(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.kubeconfig") + if err := os.WriteFile(p, []byte("not: [valid: yaml"), 0o600); err != nil { + t.Fatal(err) + } + c := Default() + c.KubeconfigPath = p + if _, err := c.RestConfig(); err == nil { + t.Fatal("expected an error for an unparseable kubeconfig") + } +} + +func TestRestConfigLoadsValidKubeconfig(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "kubeconfig") + const body = `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://127.0.0.1:6443 + name: test +contexts: +- context: + cluster: test + user: test + name: test +current-context: test +users: +- name: test + user: + token: abc123 +` + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + c := Default() + c.KubeconfigPath = p + + rc, err := c.RestConfig() + if err != nil { + t.Fatalf("RestConfig: %v", err) + } + if rc.Host != "https://127.0.0.1:6443" { + t.Errorf("Host = %q, want https://127.0.0.1:6443", rc.Host) + } +} From aac7ae2e56c2f4f384ea07b0ed510eac75b4535d Mon Sep 17 00:00:00 2001 From: Chimera Date: Thu, 18 Jun 2026 23:46:09 +0800 Subject: [PATCH 2/8] P2: register virtual node with capacity, addresses, taints, conditions (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advertise a coherent, configurable node shape through Virtual Kubelet so the scheduler can target the Mac intentionally. - pkg/config: add a `node:` block (cpu/memory/pods, os/arch, internalIP, labels, annotations, taints). Capacity defaults stay conservative (2 CPU / 4Gi / 20 pods) until P1 density data is recorded. Capacity() and Taints() parse to k8s core types and are validated at load time (bad quantity or taint effect fails fast). Default taint is the well-known virtual-kubelet.io/provider=macvz:NoSchedule so only tolerating Pods land. - pkg/provider: BuildNode assembles the corev1.Node — capacity == allocatable (no MVP reservation), workload-platform OS/arch labels and node info, merged user labels/annotations, taints, addresses, and a coherent condition set. The Ready condition reflects live runtime readiness via the optional runtime.Pinger; pressure conditions report False. - cmd/macvz-kubelet: resolve the configured node shape, auto-detect the host InternalIP when unset, and register the assembled node. Ongoing heartbeat/lease updates remain #15. - config.example.yaml documents the new node block; tests cover capacity/taint parsing, validation, label override/merge, readiness mapping, and address handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/macvz-kubelet/main.go | 81 +++++++++++---------- config.example.yaml | 28 ++++++++ docs/MASTER_TASKS.md | 2 +- pkg/config/config.go | 108 ++++++++++++++++++++++++++++ pkg/config/config_test.go | 75 ++++++++++++++++++++ pkg/provider/node.go | 130 ++++++++++++++++++++++++++++++++++ pkg/provider/node_test.go | 143 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 530 insertions(+), 37 deletions(-) create mode 100644 pkg/provider/node.go create mode 100644 pkg/provider/node_test.go diff --git a/cmd/macvz-kubelet/main.go b/cmd/macvz-kubelet/main.go index f366762..e0b476d 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -9,6 +9,7 @@ import ( "context" "flag" "fmt" + "net" "os" "os/signal" "syscall" @@ -18,8 +19,6 @@ import ( "github.com/chimerakang/macvz/pkg/provider" "github.com/chimerakang/macvz/pkg/runtime/container" vknode "github.com/virtual-kubelet/virtual-kubelet/node" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) @@ -104,14 +103,41 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { // Construct the provider over the runtime driver. Pod lifecycle methods are // stubbed until #16; this issue only wires the node controller loop. p := provider.New(cfg.NodeName, driver) - _ = p // pod controller wiring lands with the Pod lifecycle (#16). + + // Resolve the configured node shape (capacity/taints validated at load). + capacity, err := cfg.Capacity() + if err != nil { + return fmt.Errorf("node capacity: %w", err) + } + taints, err := cfg.Taints() + if err != nil { + return fmt.Errorf("node taints: %w", err) + } + internalIP := cfg.Node.InternalIP + if internalIP == "" { + internalIP = detectInternalIP() + } + node := p.BuildNode(ctx, provider.NodeSpec{ + KubeletVersion: version.Version, + OS: cfg.Node.OS, + Arch: cfg.Node.Arch, + InternalIP: internalIP, + Capacity: capacity, + Labels: cfg.Node.Labels, + Annotations: cfg.Node.Annotations, + Taints: taints, + }) + klog.InfoS("registering virtual node", + "node", cfg.NodeName, + "cpu", cfg.Node.CPU, "memory", cfg.Node.Memory, "pods", cfg.Node.Pods, + "internalIP", internalIP, "taints", len(taints), + ) // Start the Virtual Kubelet node controller. NaiveNodeProvider keeps the node - // marked ready via Ping; capacity/conditions (#14) and lease/heartbeat (#15) - // build on this loop. + // marked ready via Ping; lease/heartbeat updates (#15) build on this loop. nc, err := vknode.NewNodeController( vknode.NaiveNodeProvider{}, - buildNode(cfg.NodeName), + node, clientset.CoreV1().Nodes(), ) if err != nil { @@ -147,35 +173,18 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { return nil } -// buildNode returns the minimal Node object the controller registers. Capacity, -// addresses, taints, and detailed conditions are filled in by #14; here we only -// establish identity and a Ready condition so the control loop can run. -func buildNode(name string) *corev1.Node { - return &corev1.Node{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Labels: map[string]string{ - "type": "virtual-kubelet", - "kubernetes.io/role": "agent", - "kubernetes.io/hostname": name, - "kubernetes.io/os": "linux", - "kubernetes.io/arch": "arm64", - }, - }, - Status: corev1.NodeStatus{ - NodeInfo: corev1.NodeSystemInfo{ - OperatingSystem: "linux", - Architecture: "arm64", - KubeletVersion: version.Version, - }, - Conditions: []corev1.NodeCondition{{ - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - Reason: "KubeletReady", - Message: "macvz-kubelet is ready", - LastHeartbeatTime: metav1.Now(), - LastTransitionTime: metav1.Now(), - }}, - }, +// detectInternalIP returns the host's primary outbound IPv4 address, used as +// the node's InternalIP when not set in config. It opens no connection (UDP +// dial just selects a route), and returns "" if detection fails — the node then +// registers without an InternalIP. +func detectInternalIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "" + } + defer conn.Close() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { + return addr.IP.String() } + return "" } diff --git a/config.example.yaml b/config.example.yaml index ab077e9..1aa526e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,3 +17,31 @@ runtimeBinary: container # Log verbosity: "info" or "debug" (klog -v levels also honored via flags). logLevel: info + +# Node shape advertised to Kubernetes. All fields are optional. +node: + # Capacity / allocatable resources. Kept conservative until P1 density data is + # recorded; raise once per-VM RAM overhead is measured. + cpu: "2" + memory: 4Gi + pods: "20" + + # Workload platform reported in node info and kubernetes.io/{os,arch} labels. + # MacVz runs Linux micro-VMs on Apple Silicon, so this is linux/arm64 — not + # the Darwin host. + os: linux + arch: arm64 + + # Node InternalIP. Leave empty to auto-detect the host's primary outbound IPv4. + internalIP: "" + + # Extra labels/annotations merged onto the node (override built-ins on clash). + labels: {} + annotations: {} + + # Scheduling taints. Defaults to the well-known Virtual Kubelet provider taint + # so only Pods that tolerate MacVz are scheduled here. Set to [] to disable. + taints: + - key: virtual-kubelet.io/provider + value: macvz + effect: NoSchedule diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index 53ce4e5..5eba2bd 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -38,7 +38,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #11 | Density & per-VM RAM-overhead benchmark | P1 | open | | #12 | arm64 image pull verification | P1 | open | | #13 | Wire Virtual Kubelet controller into macvz-kubelet | P2 | in progress | -| #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | open | +| #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | in progress | | #15 | Implement node heartbeat and lease updates | P2 | open | | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | open | | #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | open | diff --git a/pkg/config/config.go b/pkg/config/config.go index d00d617..3239718 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,8 @@ import ( "os" "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -32,8 +34,56 @@ type Config struct { // LogLevel is the klog verbosity ("info", "debug") or a numeric V level. LogLevel string `yaml:"logLevel"` + + // Node describes the shape this Mac advertises as a Kubernetes node: + // capacity, addresses, labels, and scheduling taints. + Node NodeConfig `yaml:"node"` +} + +// NodeConfig is the configurable shape of the virtual node registered with +// Kubernetes. Capacity is intentionally conservative until P1 density data is +// recorded (see docs/MASTER_TASKS.md), and everything here can be overridden +// per host via the YAML config rather than hardcoded in the binary. +type NodeConfig struct { + // CPU is the node's CPU capacity as a Kubernetes quantity (e.g. "4"). + CPU string `yaml:"cpu"` + // Memory is the node's memory capacity as a quantity (e.g. "8Gi"). + Memory string `yaml:"memory"` + // Pods is the maximum number of Pods schedulable to this node (e.g. "32"). + Pods string `yaml:"pods"` + + // OS and Arch are the operating system and CPU architecture of the workloads + // this node runs. MacVz runs Linux micro-VMs on Apple Silicon, so these + // default to linux/arm64 (the workload platform), not the Darwin host. + OS string `yaml:"os"` + Arch string `yaml:"arch"` + + // InternalIP is the node's reachable address. When empty, the kubelet + // detects the host's primary outbound IPv4 at startup. + InternalIP string `yaml:"internalIP"` + + // Labels and Annotations are merged onto the built-in node metadata. User + // entries override built-ins on key collision. + Labels map[string]string `yaml:"labels"` + Annotations map[string]string `yaml:"annotations"` + + // Taints are applied to the node so workloads only land here when they + // explicitly tolerate MacVz. Defaults to the well-known Virtual Kubelet + // provider taint with NoSchedule. + Taints []TaintConfig `yaml:"taints"` +} + +// TaintConfig is a YAML-friendly node taint. +type TaintConfig struct { + Key string `yaml:"key"` + Value string `yaml:"value"` + Effect string `yaml:"effect"` // NoSchedule | PreferNoSchedule | NoExecute } +// DefaultProviderTaintKey is the well-known Virtual Kubelet taint applied so +// only Pods that explicitly tolerate MacVz are scheduled here. +const DefaultProviderTaintKey = "virtual-kubelet.io/provider" + // Default returns a Config populated with built-in defaults. func Default() Config { host, _ := os.Hostname() @@ -42,7 +92,59 @@ func Default() Config { RuntimeSocket: "/var/run/com.apple.container.sock", RuntimeBinary: "container", LogLevel: "info", + Node: NodeConfig{ + // Conservative capacity until P1 density data is recorded. + CPU: "2", + Memory: "4Gi", + Pods: "20", + OS: "linux", + Arch: "arm64", + Taints: []TaintConfig{{ + Key: DefaultProviderTaintKey, + Value: "macvz", + Effect: string(corev1.TaintEffectNoSchedule), + }}, + }, + } +} + +// Capacity returns the node capacity/allocatable resource list parsed from the +// configured quantities. +func (c Config) Capacity() (corev1.ResourceList, error) { + out := corev1.ResourceList{} + for name, raw := range map[corev1.ResourceName]string{ + corev1.ResourceCPU: c.Node.CPU, + corev1.ResourceMemory: c.Node.Memory, + corev1.ResourcePods: c.Node.Pods, + } { + q, err := resource.ParseQuantity(raw) + if err != nil { + return nil, fmt.Errorf("node.%s quantity %q: %w", name, raw, err) + } + out[name] = q } + return out, nil +} + +// Taints returns the configured taints as Kubernetes core types, validating +// each effect. +func (c Config) Taints() ([]corev1.Taint, error) { + out := make([]corev1.Taint, 0, len(c.Node.Taints)) + for _, t := range c.Node.Taints { + if t.Key == "" { + return nil, fmt.Errorf("node taint key must not be empty") + } + effect := corev1.TaintEffect(t.Effect) + switch effect { + case corev1.TaintEffectNoSchedule, + corev1.TaintEffectPreferNoSchedule, + corev1.TaintEffectNoExecute: + default: + return nil, fmt.Errorf("node taint %q: invalid effect %q", t.Key, t.Effect) + } + out = append(out, corev1.Taint{Key: t.Key, Value: t.Value, Effect: effect}) + } + return out, nil } // Load reads and parses the YAML config at path, layering it over defaults. @@ -116,5 +218,11 @@ func (c Config) Validate() error { if c.NodeName == "" { return fmt.Errorf("nodeName must be set (hostname lookup failed)") } + if _, err := c.Capacity(); err != nil { + return err + } + if _, err := c.Taints(); err != nil { + return err + } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3460567..f3ae819 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + corev1 "k8s.io/api/core/v1" ) func TestLoadMissingPathReturnsDefaults(t *testing.T) { @@ -59,6 +61,79 @@ func TestRuntimeSocketIsOptional(t *testing.T) { } } +func TestDefaultNodeCapacity(t *testing.T) { + c := Default() + cap, err := c.Capacity() + if err != nil { + t.Fatalf("Capacity: %v", err) + } + if cap.Cpu().String() != "2" { + t.Errorf("default cpu = %q, want 2", cap.Cpu().String()) + } + if _, ok := cap[corev1.ResourcePods]; !ok { + t.Error("pods capacity missing") + } +} + +func TestDefaultNodeTaint(t *testing.T) { + c := Default() + taints, err := c.Taints() + if err != nil { + t.Fatalf("Taints: %v", err) + } + if len(taints) != 1 || taints[0].Key != DefaultProviderTaintKey { + t.Fatalf("expected default provider taint, got %v", taints) + } + if string(taints[0].Effect) != "NoSchedule" { + t.Errorf("default taint effect = %q, want NoSchedule", taints[0].Effect) + } +} + +func TestCapacityRejectsBadQuantity(t *testing.T) { + c := Default() + c.Node.Memory = "not-a-quantity" + if _, err := c.Capacity(); err == nil { + t.Fatal("expected error for invalid memory quantity") + } + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject an invalid capacity quantity") + } +} + +func TestTaintsRejectsBadEffect(t *testing.T) { + c := Default() + c.Node.Taints = []TaintConfig{{Key: "k", Effect: "Nonsense"}} + if _, err := c.Taints(); err == nil { + t.Fatal("expected error for invalid taint effect") + } + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject an invalid taint effect") + } +} + +func TestLoadOverridesNodeCapacity(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.yaml") + const body = "node:\n cpu: \"8\"\n memory: 16Gi\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Node.CPU != "8" || cfg.Node.Memory != "16Gi" { + t.Errorf("node overrides not applied: %+v", cfg.Node) + } + // Unspecified node fields keep their defaults. + if cfg.Node.Pods != "20" { + t.Errorf("Pods = %q, want default 20", cfg.Node.Pods) + } + if cfg.Node.OS != "linux" { + t.Errorf("OS = %q, want default linux", cfg.Node.OS) + } +} + func TestRestConfigMissingKubeconfigErrors(t *testing.T) { c := Default() c.KubeconfigPath = filepath.Join(t.TempDir(), "absent.kubeconfig") diff --git a/pkg/provider/node.go b/pkg/provider/node.go new file mode 100644 index 0000000..54fe16e --- /dev/null +++ b/pkg/provider/node.go @@ -0,0 +1,130 @@ +package provider + +import ( + "context" + + "github.com/chimerakang/macvz/pkg/runtime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodeSpec is the resolved shape advertised to Kubernetes when the node is +// registered. The config package translates user YAML into this struct so the +// provider stays free of parsing and YAML concerns. +type NodeSpec struct { + // KubeletVersion is reported in node info (the macvz-kubelet build version). + KubeletVersion string + // OS and Arch describe the workload platform (linux/arm64), not the host. + OS, Arch string + // InternalIP is the node's reachable address; omitted when empty. + InternalIP string + // Capacity is used for both capacity and allocatable (no reservation in MVP). + Capacity corev1.ResourceList + // Labels and Annotations are merged over the built-in node metadata. + Labels, Annotations map[string]string + // Taints gate scheduling so only Pods that tolerate MacVz land here. + Taints []corev1.Taint +} + +// BuildNode assembles the corev1.Node this provider registers. The Ready +// condition reflects live runtime readiness when the runtime supports +// runtime.Pinger; ongoing heartbeat/lease updates are handled by the node +// controller (#15). +func (p *Provider) BuildNode(ctx context.Context, spec NodeSpec) *corev1.Node { + ready, readyMsg := p.runtimeReady(ctx) + + labels := map[string]string{ + "type": "virtual-kubelet", + "kubernetes.io/role": "agent", + "kubernetes.io/hostname": p.nodeName, + "kubernetes.io/os": spec.OS, + "kubernetes.io/arch": spec.Arch, + } + for k, v := range spec.Labels { + labels[k] = v + } + + annotations := map[string]string{} + for k, v := range spec.Annotations { + annotations[k] = v + } + + node := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: p.nodeName, + Labels: labels, + Annotations: annotations, + }, + Spec: corev1.NodeSpec{ + Taints: spec.Taints, + }, + Status: corev1.NodeStatus{ + Capacity: spec.Capacity, + Allocatable: spec.Capacity, + NodeInfo: corev1.NodeSystemInfo{ + OperatingSystem: spec.OS, + Architecture: spec.Arch, + KubeletVersion: spec.KubeletVersion, + }, + Conditions: nodeConditions(ready, readyMsg), + }, + } + + if spec.InternalIP != "" { + node.Status.Addresses = []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: spec.InternalIP}, + {Type: corev1.NodeHostName, Address: p.nodeName}, + } + } else { + node.Status.Addresses = []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: p.nodeName}, + } + } + + return node +} + +// runtimeReady probes the runtime for readiness when it implements +// runtime.Pinger. Runtimes without the capability are assumed ready. +func (p *Provider) runtimeReady(ctx context.Context) (ready bool, message string) { + pinger, ok := p.rt.(runtime.Pinger) + if !ok { + return true, "runtime readiness probe unavailable; assuming ready" + } + if err := pinger.Ready(ctx); err != nil { + return false, err.Error() + } + return true, "macvz-kubelet and apple/container runtime are ready" +} + +// nodeConditions builds a coherent condition set for `kubectl describe node`. +// Ready tracks runtime readiness; the pressure conditions are reported False +// because micro-VM workloads do not consume the host kubelet's resources. +func nodeConditions(ready bool, readyMsg string) []corev1.NodeCondition { + now := metav1.Now() + readyStatus := corev1.ConditionFalse + readyReason := "RuntimeNotReady" + if ready { + readyStatus = corev1.ConditionTrue + readyReason = "KubeletReady" + } + + cond := func(t corev1.NodeConditionType, s corev1.ConditionStatus, reason, msg string) corev1.NodeCondition { + return corev1.NodeCondition{ + Type: t, + Status: s, + Reason: reason, + Message: msg, + LastHeartbeatTime: now, + LastTransitionTime: now, + } + } + + return []corev1.NodeCondition{ + cond(corev1.NodeReady, readyStatus, readyReason, readyMsg), + cond(corev1.NodeMemoryPressure, corev1.ConditionFalse, "KubeletHasSufficientMemory", "kubelet has sufficient memory available"), + cond(corev1.NodeDiskPressure, corev1.ConditionFalse, "KubeletHasNoDiskPressure", "kubelet has no disk pressure"), + cond(corev1.NodePIDPressure, corev1.ConditionFalse, "KubeletHasSufficientPID", "kubelet has sufficient PID available"), + cond(corev1.NodeNetworkUnavailable, corev1.ConditionFalse, "RouteCreated", "node network is configured"), + } +} diff --git a/pkg/provider/node_test.go b/pkg/provider/node_test.go new file mode 100644 index 0000000..5bcd119 --- /dev/null +++ b/pkg/provider/node_test.go @@ -0,0 +1,143 @@ +package provider + +import ( + "context" + "io" + "testing" + "time" + + "github.com/chimerakang/macvz/internal/types" + "github.com/chimerakang/macvz/pkg/runtime" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// fakeRuntime satisfies runtime.Runtime but not runtime.Pinger, so the provider +// treats it as readiness-unknown (assumed ready). +type fakeRuntime struct{} + +func (fakeRuntime) Pull(context.Context, string) error { return nil } +func (fakeRuntime) Create(context.Context, types.ContainerSpec) (string, error) { return "", nil } +func (fakeRuntime) Start(context.Context, string) error { return nil } +func (fakeRuntime) Stop(context.Context, string, time.Duration) error { return nil } +func (fakeRuntime) Destroy(context.Context, string) error { return nil } +func (fakeRuntime) Status(context.Context, string) (runtime.Status, error) { + return runtime.Status{}, nil +} +func (fakeRuntime) Logs(context.Context, string, runtime.LogOptions) (io.ReadCloser, error) { + return nil, nil +} +func (fakeRuntime) Exec(context.Context, string, []string, runtime.ExecIO) error { return nil } + +// pingableRuntime adds the optional runtime.Pinger capability. +type pingableRuntime struct { + fakeRuntime + readyErr error +} + +func (p pingableRuntime) Ready(context.Context) error { return p.readyErr } + +func testSpec() NodeSpec { + return NodeSpec{ + KubeletVersion: "test", + OS: "linux", + Arch: "arm64", + InternalIP: "10.0.0.5", + Capacity: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + corev1.ResourcePods: resource.MustParse("32"), + }, + Labels: map[string]string{"custom": "yes", "kubernetes.io/os": "override"}, + Taints: []corev1.Taint{{Key: "virtual-kubelet.io/provider", Value: "macvz", Effect: corev1.TaintEffectNoSchedule}}, + } +} + +func findCondition(node *corev1.Node, t corev1.NodeConditionType) (corev1.NodeCondition, bool) { + for _, c := range node.Status.Conditions { + if c.Type == t { + return c, true + } + } + return corev1.NodeCondition{}, false +} + +func TestBuildNodeShape(t *testing.T) { + p := New("mac-01", pingableRuntime{}) + node := p.BuildNode(context.Background(), testSpec()) + + if node.Name != "mac-01" { + t.Errorf("Name = %q, want mac-01", node.Name) + } + if got := node.Status.Capacity.Cpu().String(); got != "4" { + t.Errorf("capacity cpu = %q, want 4", got) + } + // Allocatable mirrors capacity in the MVP (no reservation). + if node.Status.Allocatable.Memory().String() != node.Status.Capacity.Memory().String() { + t.Error("allocatable should mirror capacity") + } + if node.Status.NodeInfo.OperatingSystem != "linux" || node.Status.NodeInfo.Architecture != "arm64" { + t.Error("node info OS/arch not set to workload platform") + } + // User labels override built-ins; built-ins still present for unset keys. + if node.Labels["kubernetes.io/os"] != "override" { + t.Errorf("user label override not applied: %v", node.Labels["kubernetes.io/os"]) + } + if node.Labels["custom"] != "yes" { + t.Error("custom label missing") + } + if node.Labels["type"] != "virtual-kubelet" { + t.Error("built-in type label missing") + } + if len(node.Spec.Taints) != 1 || node.Spec.Taints[0].Key != "virtual-kubelet.io/provider" { + t.Errorf("taints = %v, want the provider taint", node.Spec.Taints) + } + + var foundIP bool + for _, a := range node.Status.Addresses { + if a.Type == corev1.NodeInternalIP && a.Address == "10.0.0.5" { + foundIP = true + } + } + if !foundIP { + t.Errorf("InternalIP address missing: %v", node.Status.Addresses) + } +} + +func TestBuildNodeReadyReflectsRuntime(t *testing.T) { + ready := New("n", pingableRuntime{}).BuildNode(context.Background(), testSpec()) + if c, ok := findCondition(ready, corev1.NodeReady); !ok || c.Status != corev1.ConditionTrue { + t.Fatalf("expected Ready=True, got %+v (found=%v)", c, ok) + } + + notReady := New("n", pingableRuntime{readyErr: runtime.ErrNotReady}). + BuildNode(context.Background(), testSpec()) + c, ok := findCondition(notReady, corev1.NodeReady) + if !ok { + t.Fatal("Ready condition missing") + } + if c.Status != corev1.ConditionFalse { + t.Fatalf("expected Ready=False when runtime not ready, got %+v", c) + } + if c.Reason != "RuntimeNotReady" { + t.Errorf("Ready reason = %q, want RuntimeNotReady", c.Reason) + } +} + +func TestBuildNodeWithoutPingerAssumesReady(t *testing.T) { + node := New("n", fakeRuntime{}).BuildNode(context.Background(), testSpec()) + if c, ok := findCondition(node, corev1.NodeReady); !ok || c.Status != corev1.ConditionTrue { + t.Fatalf("runtime without Pinger should be assumed Ready, got %+v", c) + } +} + +func TestBuildNodeOmitsEmptyInternalIP(t *testing.T) { + spec := testSpec() + spec.InternalIP = "" + node := New("n", fakeRuntime{}).BuildNode(context.Background(), spec) + for _, a := range node.Status.Addresses { + if a.Type == corev1.NodeInternalIP { + t.Errorf("did not expect an InternalIP address, got %q", a.Address) + } + } +} From a655ceeb31b2c1692fcabcfce8bd5142798d1764 Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:13:44 +0800 Subject: [PATCH 3/8] P2: implement node heartbeat and lease updates (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the MacVz virtual node alive and surface runtime readiness over time. - pkg/config: add heartbeat tuning to the node block — enableLease (default true), leaseDurationSeconds (40), pingInterval (10s), statusUpdateInterval (1m), mirroring Virtual Kubelet's defaults. HeartbeatIntervals() parses and validates the durations; Validate() rejects bad intervals and a non-positive lease duration when leases are enabled. - pkg/provider: add NodeStatusProvider implementing node.NodeProvider. Ping reports process liveness (driving the lease/heartbeat); NotifyNodeStatus runs a watch loop that re-probes the runtime each status interval and pushes a refreshed node only when the Ready condition flips, so runtime failures show up as NotReady without removing the node. The loop exits on context cancel. - cmd/macvz-kubelet: wire the status provider plus WithNodePingInterval, WithNodeStatusUpdateInterval, WithNodeEnableLeaseV1 (lease in kube-node-lease), and a status-update error handler that logs and returns nil so the control loop and client-go retry/backoff handle transient API errors. - config.example.yaml documents the heartbeat block; tests cover interval parsing/validation, lease-duration validation, Ping semantics, push-on-change (both directions), and clean shutdown (race-clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/macvz-kubelet/main.go | 42 +++++++++++-- config.example.yaml | 9 +++ docs/MASTER_TASKS.md | 2 +- pkg/config/config.go | 51 ++++++++++++++++ pkg/config/config_test.go | 53 ++++++++++++++++ pkg/provider/node_status.go | 82 +++++++++++++++++++++++++ pkg/provider/node_status_test.go | 101 +++++++++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 pkg/provider/node_status.go create mode 100644 pkg/provider/node_status_test.go diff --git a/cmd/macvz-kubelet/main.go b/cmd/macvz-kubelet/main.go index e0b476d..f510ed9 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -19,6 +19,7 @@ import ( "github.com/chimerakang/macvz/pkg/provider" "github.com/chimerakang/macvz/pkg/runtime/container" vknode "github.com/virtual-kubelet/virtual-kubelet/node" + corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) @@ -117,7 +118,7 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { if internalIP == "" { internalIP = detectInternalIP() } - node := p.BuildNode(ctx, provider.NodeSpec{ + nodeSpec := provider.NodeSpec{ KubeletVersion: version.Version, OS: cfg.Node.OS, Arch: cfg.Node.Arch, @@ -126,19 +127,50 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { Labels: cfg.Node.Labels, Annotations: cfg.Node.Annotations, Taints: taints, - }) + } + node := p.BuildNode(ctx, nodeSpec) + + pingInterval, statusInterval, err := cfg.HeartbeatIntervals() + if err != nil { + return fmt.Errorf("heartbeat intervals: %w", err) + } + klog.InfoS("registering virtual node", "node", cfg.NodeName, "cpu", cfg.Node.CPU, "memory", cfg.Node.Memory, "pods", cfg.Node.Pods, "internalIP", internalIP, "taints", len(taints), + "enableLease", cfg.Node.EnableLease, "pingInterval", pingInterval, "statusInterval", statusInterval, ) - // Start the Virtual Kubelet node controller. NaiveNodeProvider keeps the node - // marked ready via Ping; lease/heartbeat updates (#15) build on this loop. + // Node provider re-probes runtime readiness on the status interval and + // surfaces changes as node conditions. + nodeProvider := p.NewNodeStatusProvider(nodeSpec, statusInterval) + + // Log transient status-update errors and keep going: the control loop retries + // on the next interval, and client-go applies its own backoff. Returning nil + // signals "handled, retry possible" to Virtual Kubelet. + statusErrHandler := func(_ context.Context, err error) error { + klog.ErrorS(err, "transient node status update error; will retry", "node", cfg.NodeName) + return nil + } + + opts := []vknode.NodeControllerOpt{ + vknode.WithNodePingInterval(pingInterval), + vknode.WithNodeStatusUpdateInterval(statusInterval), + vknode.WithNodeStatusUpdateErrorHandler(statusErrHandler), + } + if cfg.Node.EnableLease { + // The Lease in kube-node-lease is the modern node heartbeat; Kubernetes + // marks the node NotReady if it is not renewed within the lease duration. + leaseClient := clientset.CoordinationV1().Leases(corev1.NamespaceNodeLease) + opts = append(opts, vknode.WithNodeEnableLeaseV1(leaseClient, cfg.Node.LeaseDurationSeconds)) + } + nc, err := vknode.NewNodeController( - vknode.NaiveNodeProvider{}, + nodeProvider, node, clientset.CoreV1().Nodes(), + opts..., ) if err != nil { return fmt.Errorf("create node controller: %w", err) diff --git a/config.example.yaml b/config.example.yaml index 1aa526e..2f3eb63 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,3 +45,12 @@ node: - key: virtual-kubelet.io/provider value: macvz effect: NoSchedule + + # Heartbeat / lease tuning. The coordination.k8s.io Lease is the node + # heartbeat: Kubernetes marks the node NotReady if it is not renewed within + # leaseDurationSeconds. Disable enableLease only on clusters without lease + # support (then node status updates double as the heartbeat). + enableLease: true + leaseDurationSeconds: 40 + pingInterval: 10s # node liveness Ping cadence + statusUpdateInterval: 1m # node status push + runtime readiness re-probe diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index 5eba2bd..1a88104 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -39,7 +39,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #12 | arm64 image pull verification | P1 | open | | #13 | Wire Virtual Kubelet controller into macvz-kubelet | P2 | in progress | | #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | in progress | -| #15 | Implement node heartbeat and lease updates | P2 | open | +| #15 | Implement node heartbeat and lease updates | P2 | in progress | | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | open | | #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | open | | #18 | Wire kubectl logs and exec through the runtime | P2 | open | diff --git a/pkg/config/config.go b/pkg/config/config.go index 3239718..5f7468f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,6 +5,7 @@ package config import ( "fmt" "os" + "time" "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" @@ -71,6 +72,26 @@ type NodeConfig struct { // explicitly tolerate MacVz. Defaults to the well-known Virtual Kubelet // provider taint with NoSchedule. Taints []TaintConfig `yaml:"taints"` + + // EnableLease toggles the coordination.k8s.io/v1 Lease used as the node + // heartbeat. Enabled by default; disable only on clusters without lease + // support, where the node status update doubles as the heartbeat. + EnableLease bool `yaml:"enableLease"` + + // LeaseDurationSeconds is the node lease duration. Kubernetes marks the node + // NotReady when the lease is not renewed within this window. The renew + // interval is derived by Virtual Kubelet (a fraction of the duration). + LeaseDurationSeconds int32 `yaml:"leaseDurationSeconds"` + + // PingInterval is how often the node liveness Ping runs. When leases are + // disabled this is also the node status update cadence. Parsed as a Go + // duration (e.g. "10s"). + PingInterval string `yaml:"pingInterval"` + + // StatusUpdateInterval is how often node status is pushed to the API server + // when leases are enabled. It is also the cadence at which MacVz re-probes + // runtime readiness. Parsed as a Go duration (e.g. "1m"). + StatusUpdateInterval string `yaml:"statusUpdateInterval"` } // TaintConfig is a YAML-friendly node taint. @@ -104,6 +125,11 @@ func Default() Config { Value: "macvz", Effect: string(corev1.TaintEffectNoSchedule), }}, + // Heartbeat defaults mirror Virtual Kubelet's own defaults. + EnableLease: true, + LeaseDurationSeconds: 40, + PingInterval: "10s", + StatusUpdateInterval: "1m", }, } } @@ -147,6 +173,25 @@ func (c Config) Taints() ([]corev1.Taint, error) { return out, nil } +// HeartbeatIntervals parses the configured ping and status-update intervals. +func (c Config) HeartbeatIntervals() (ping, status time.Duration, err error) { + ping, err = time.ParseDuration(c.Node.PingInterval) + if err != nil { + return 0, 0, fmt.Errorf("node.pingInterval %q: %w", c.Node.PingInterval, err) + } + if ping <= 0 { + return 0, 0, fmt.Errorf("node.pingInterval must be positive, got %q", c.Node.PingInterval) + } + status, err = time.ParseDuration(c.Node.StatusUpdateInterval) + if err != nil { + return 0, 0, fmt.Errorf("node.statusUpdateInterval %q: %w", c.Node.StatusUpdateInterval, err) + } + if status <= 0 { + return 0, 0, fmt.Errorf("node.statusUpdateInterval must be positive, got %q", c.Node.StatusUpdateInterval) + } + return ping, status, nil +} + // Load reads and parses the YAML config at path, layering it over defaults. // A non-existent path is not an error: defaults are returned. This lets the // binary run with zero config during early development. @@ -224,5 +269,11 @@ func (c Config) Validate() error { if _, err := c.Taints(); err != nil { return err } + if _, _, err := c.HeartbeatIntervals(); err != nil { + return err + } + if c.Node.EnableLease && c.Node.LeaseDurationSeconds <= 0 { + return fmt.Errorf("node.leaseDurationSeconds must be positive when leases are enabled, got %d", c.Node.LeaseDurationSeconds) + } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f3ae819..abbb644 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" corev1 "k8s.io/api/core/v1" ) @@ -134,6 +135,58 @@ func TestLoadOverridesNodeCapacity(t *testing.T) { } } +func TestDefaultHeartbeat(t *testing.T) { + c := Default() + if !c.Node.EnableLease { + t.Error("lease should be enabled by default") + } + if c.Node.LeaseDurationSeconds != 40 { + t.Errorf("LeaseDurationSeconds = %d, want 40", c.Node.LeaseDurationSeconds) + } + ping, status, err := c.HeartbeatIntervals() + if err != nil { + t.Fatalf("HeartbeatIntervals: %v", err) + } + if ping != 10*time.Second { + t.Errorf("ping = %v, want 10s", ping) + } + if status != time.Minute { + t.Errorf("status = %v, want 1m", status) + } +} + +func TestHeartbeatIntervalsRejectsBadDuration(t *testing.T) { + c := Default() + c.Node.PingInterval = "soon" + if _, _, err := c.HeartbeatIntervals(); err == nil { + t.Fatal("expected error for invalid ping interval") + } + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject an invalid interval") + } +} + +func TestHeartbeatIntervalsRejectsNonPositive(t *testing.T) { + c := Default() + c.Node.StatusUpdateInterval = "0s" + if _, _, err := c.HeartbeatIntervals(); err == nil { + t.Fatal("expected error for non-positive status interval") + } +} + +func TestValidateRejectsBadLeaseDuration(t *testing.T) { + c := Default() + c.Node.LeaseDurationSeconds = 0 + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject a non-positive lease duration when leases enabled") + } + // Disabling leases makes the duration irrelevant. + c.Node.EnableLease = false + if err := c.Validate(); err != nil { + t.Fatalf("lease duration should be ignored when leases disabled: %v", err) + } +} + func TestRestConfigMissingKubeconfigErrors(t *testing.T) { c := Default() c.KubeconfigPath = filepath.Join(t.TempDir(), "absent.kubeconfig") diff --git a/pkg/provider/node_status.go b/pkg/provider/node_status.go new file mode 100644 index 0000000..4cf417b --- /dev/null +++ b/pkg/provider/node_status.go @@ -0,0 +1,82 @@ +package provider + +import ( + "context" + "time" + + "github.com/virtual-kubelet/virtual-kubelet/node" + corev1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" +) + +// NodeStatusProvider implements the Virtual Kubelet node.NodeProvider contract. +// +// Ping reports process liveness (it drives the node heartbeat / lease, keeping +// the node present in Kubernetes). NotifyNodeStatus runs a loop that re-probes +// the runtime on an interval and pushes a refreshed node whenever readiness +// changes, so runtime failures surface in the node's Ready condition without +// removing the node. +type NodeStatusProvider struct { + provider *Provider + spec NodeSpec + interval time.Duration +} + +// Compile-time assertion that NodeStatusProvider satisfies the contract. +var _ node.NodeProvider = (*NodeStatusProvider)(nil) + +// NewNodeStatusProvider builds a NodeProvider that re-probes runtime readiness +// every interval and reports changes through the node controller. +func (p *Provider) NewNodeStatusProvider(spec NodeSpec, interval time.Duration) *NodeStatusProvider { + return &NodeStatusProvider{provider: p, spec: spec, interval: interval} +} + +// Ping reports whether this kubelet process is alive. Runtime health is carried +// in node conditions (via NotifyNodeStatus), not here: a down runtime should +// make the node NotReady, not make it disappear. Returning the context error +// keeps the heartbeat lightweight and lets shutdown propagate. +func (np *NodeStatusProvider) Ping(ctx context.Context) error { + return ctx.Err() +} + +// NotifyNodeStatus registers the controller callback and starts the readiness +// watch loop. It must not block, so the loop runs in its own goroutine and +// exits when ctx is cancelled. +func (np *NodeStatusProvider) NotifyNodeStatus(ctx context.Context, cb func(*corev1.Node)) { + go np.watch(ctx, cb) +} + +func (np *NodeStatusProvider) watch(ctx context.Context, cb func(*corev1.Node)) { + ticker := time.NewTicker(np.interval) + defer ticker.Stop() + + // The node registered by the controller already reflects the readiness + // observed at startup; only push when it subsequently changes. + last := nodeReady(np.provider.BuildNode(ctx, np.spec)) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + node := np.provider.BuildNode(ctx, np.spec) + ready := nodeReady(node) + if ready == last { + continue + } + last = ready + klog.InfoS("node runtime readiness changed", "node", np.provider.nodeName, "ready", ready) + cb(node) + } + } +} + +// nodeReady reports whether the node's Ready condition is True. +func nodeReady(n *corev1.Node) bool { + for _, c := range n.Status.Conditions { + if c.Type == corev1.NodeReady { + return c.Status == corev1.ConditionTrue + } + } + return false +} diff --git a/pkg/provider/node_status_test.go b/pkg/provider/node_status_test.go new file mode 100644 index 0000000..f819715 --- /dev/null +++ b/pkg/provider/node_status_test.go @@ -0,0 +1,101 @@ +package provider + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/chimerakang/macvz/pkg/runtime" + corev1 "k8s.io/api/core/v1" +) + +// togglableRuntime is a fakeRuntime whose Pinger readiness can flip at runtime. +type togglableRuntime struct { + fakeRuntime + mu sync.Mutex + err error +} + +func (r *togglableRuntime) Ready(context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.err +} + +func (r *togglableRuntime) set(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.err = err +} + +func TestNodeStatusProviderPingReturnsContextError(t *testing.T) { + np := New("n", &togglableRuntime{}).NewNodeStatusProvider(testSpec(), time.Second) + if err := np.Ping(context.Background()); err != nil { + t.Errorf("Ping with live context should be nil, got %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := np.Ping(ctx); err == nil { + t.Error("Ping with cancelled context should return its error") + } +} + +func TestNodeStatusProviderPushesOnReadinessChange(t *testing.T) { + rt := &togglableRuntime{} // starts ready (nil error) + np := New("mac-01", rt).NewNodeStatusProvider(testSpec(), 5*time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + updates := make(chan *corev1.Node, 4) + np.NotifyNodeStatus(ctx, func(n *corev1.Node) { updates <- n }) + + // No change yet: the loop should not push while readiness is stable. + select { + case n := <-updates: + t.Fatalf("unexpected status push while readiness unchanged: %+v", n.Status.Conditions) + case <-time.After(40 * time.Millisecond): + } + + // Flip to not-ready: expect a push with Ready=False. + rt.set(runtime.ErrNotReady) + select { + case n := <-updates: + if nodeReady(n) { + t.Errorf("expected Ready=False after runtime failure, got Ready=True") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for a not-ready status push") + } + + // Recover: expect a push with Ready=True. + rt.set(nil) + select { + case n := <-updates: + if !nodeReady(n) { + t.Errorf("expected Ready=True after runtime recovery, got Ready=False") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for a recovery status push") + } +} + +func TestNodeStatusProviderStopsOnContextCancel(t *testing.T) { + rt := &togglableRuntime{} + np := New("n", rt).NewNodeStatusProvider(testSpec(), time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) + + pushed := make(chan *corev1.Node, 1) + np.NotifyNodeStatus(ctx, func(n *corev1.Node) { pushed <- n }) + cancel() + + // After cancellation, a readiness change must not produce further pushes. + time.Sleep(10 * time.Millisecond) + rt.set(runtime.ErrNotReady) + select { + case <-pushed: + t.Error("status push after context cancellation") + case <-time.After(30 * time.Millisecond): + } +} From dd99d7f15fbb35f272320634efadb9d98ee39c34 Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:19:55 +0800 Subject: [PATCH 4/8] P2: implement Provider PodLifecycleHandler state and CRUD (#16) Replace the P0 not-implemented skeleton with a real PodLifecycleHandler backed by the runtime abstraction and an in-memory pod/workload state store. - pkg/provider/provider.go: add the podState store mapping namespace/name to the runtime workload IDs backing each container, with a sync.RWMutex for concurrent reconcile/CRUD access. - pkg/provider/pod.go: implement CreatePod/UpdatePod/DeletePod/GetPod/ GetPodStatus/GetPods. CreatePod pulls+creates+starts a workload per container, is a no-op for an already-tracked Pod, and rolls back started workloads on partial failure so retries start clean. DeletePod stops+destroys and is idempotent (unknown Pod -> errdefs.NotFound). Missing Pods return errdefs.NotFound so Virtual Kubelet reconciliation behaves correctly. - pkg/provider/status.go: reconcile observed runtime state into container statuses and aggregate the Pod phase (Pending/Running/Succeeded/Failed); a runtime ErrNotFound maps to a terminated "Lost" container rather than failing the whole status read. - pkg/provider/translate.go: minimal Pod->ContainerSpec translation (image, command/args, literal env, CPU/memory). Richer translation is #17. - Tests with a recording fake runtime cover create/get/list/status/delete, idempotent create+delete retries, rollback, phase mapping, lost workloads, and ErrNotReady propagation (race-clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MASTER_TASKS.md | 2 +- pkg/provider/pod.go | 154 ++++++++++++++++++ pkg/provider/pod_test.go | 317 ++++++++++++++++++++++++++++++++++++++ pkg/provider/provider.go | 75 ++++----- pkg/provider/status.go | 178 +++++++++++++++++++++ pkg/provider/translate.go | 62 ++++++++ 6 files changed, 752 insertions(+), 36 deletions(-) create mode 100644 pkg/provider/pod.go create mode 100644 pkg/provider/pod_test.go create mode 100644 pkg/provider/status.go create mode 100644 pkg/provider/translate.go diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index 1a88104..ded56c7 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -40,7 +40,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #13 | Wire Virtual Kubelet controller into macvz-kubelet | P2 | in progress | | #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | in progress | | #15 | Implement node heartbeat and lease updates | P2 | in progress | -| #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | open | +| #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | in progress | | #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | open | | #18 | Wire kubectl logs and exec through the runtime | P2 | open | | #19 | Add RBAC, manifests, and P2 MVP smoke test docs | P2 | open | diff --git a/pkg/provider/pod.go b/pkg/provider/pod.go new file mode 100644 index 0000000..c0519ec --- /dev/null +++ b/pkg/provider/pod.go @@ -0,0 +1,154 @@ +package provider + +import ( + "context" + "errors" + "fmt" + + "github.com/chimerakang/macvz/pkg/runtime" + "github.com/virtual-kubelet/virtual-kubelet/errdefs" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" +) + +// CreatePod launches each of a Pod's containers as a runtime workload. +// +// It is idempotent: a Pod already tracked by the provider is treated as a +// successful no-op (Virtual Kubelet may re-issue CreatePod on reconcile). On a +// partial failure the started workloads are rolled back and nothing is stored, +// so a later retry starts from a clean slate. +func (p *Provider) CreatePod(ctx context.Context, pod *corev1.Pod) error { + key := podKey(pod.Namespace, pod.Name) + + p.mu.RLock() + _, exists := p.pods[key] + p.mu.RUnlock() + if exists { + klog.InfoS("CreatePod is a no-op for an already-tracked Pod", "pod", key) + return nil + } + + st := &podState{pod: pod.DeepCopy()} + now := metav1.Now() + st.pod.Status.StartTime = &now + + for _, c := range pod.Spec.Containers { + spec := translateContainer(pod, c) + if err := p.rt.Pull(ctx, spec.Image); err != nil { + p.rollback(ctx, st) + return fmt.Errorf("pull image %q for container %q: %w", spec.Image, c.Name, err) + } + id, err := p.rt.Create(ctx, spec) + if err != nil { + p.rollback(ctx, st) + return fmt.Errorf("create container %q: %w", c.Name, err) + } + st.workloads = append(st.workloads, workload{container: c.Name, id: id}) + if err := p.rt.Start(ctx, id); err != nil { + p.rollback(ctx, st) + return fmt.Errorf("start container %q: %w", c.Name, err) + } + } + + st.pod.Status = p.reconcileStatus(ctx, st) + p.mu.Lock() + p.pods[key] = st + p.mu.Unlock() + klog.InfoS("created Pod", "pod", key, "containers", len(st.workloads)) + return nil +} + +// rollback destroys any workloads already started for a failed CreatePod. +func (p *Provider) rollback(ctx context.Context, st *podState) { + for _, w := range st.workloads { + if err := p.rt.Destroy(ctx, w.id); err != nil && !errors.Is(err, runtime.ErrNotFound) { + klog.ErrorS(err, "rollback: failed to destroy workload", "id", w.id, "container", w.container) + } + } +} + +// UpdatePod reconciles metadata for a tracked Pod. Container specs are treated +// as immutable in the MVP: only the stored Pod's labels/annotations are +// refreshed; in-place spec changes are out of scope (see #17). +func (p *Provider) UpdatePod(ctx context.Context, pod *corev1.Pod) error { + key := podKey(pod.Namespace, pod.Name) + p.mu.Lock() + defer p.mu.Unlock() + st, ok := p.pods[key] + if !ok { + return errdefs.NotFoundf("pod %q is not known to this node", key) + } + st.pod.ObjectMeta.Labels = pod.ObjectMeta.Labels + st.pod.ObjectMeta.Annotations = pod.ObjectMeta.Annotations + klog.InfoS("updated Pod metadata", "pod", key) + return nil +} + +// DeletePod tears down the workloads backing a Pod and forgets it. +// +// It is idempotent: deleting an unknown Pod returns an errdefs.NotFound error, +// which Virtual Kubelet treats as already-deleted. Workloads that the runtime +// already lost (ErrNotFound) are tolerated. +func (p *Provider) DeletePod(ctx context.Context, pod *corev1.Pod) error { + key := podKey(pod.Namespace, pod.Name) + p.mu.Lock() + st, ok := p.pods[key] + if ok { + delete(p.pods, key) + } + p.mu.Unlock() + if !ok { + return errdefs.NotFoundf("pod %q is not known to this node", key) + } + + timeout := stopTimeout(pod) + for _, w := range st.workloads { + if err := p.rt.Stop(ctx, w.id, timeout); err != nil && !errors.Is(err, runtime.ErrNotFound) { + klog.ErrorS(err, "DeletePod: failed to stop workload", "id", w.id, "container", w.container) + } + if err := p.rt.Destroy(ctx, w.id); err != nil && !errors.Is(err, runtime.ErrNotFound) { + klog.ErrorS(err, "DeletePod: failed to destroy workload", "id", w.id, "container", w.container) + } + } + klog.InfoS("deleted Pod", "pod", key, "containers", len(st.workloads)) + return nil +} + +// GetPod returns a copy of the tracked Pod, or an errdefs.NotFound error. +func (p *Provider) GetPod(ctx context.Context, namespace, name string) (*corev1.Pod, error) { + key := podKey(namespace, name) + p.mu.RLock() + st, ok := p.pods[key] + p.mu.RUnlock() + if !ok { + return nil, errdefs.NotFoundf("pod %q is not known to this node", key) + } + return st.pod.DeepCopy(), nil +} + +// GetPodStatus reconciles the Pod's status from observed runtime state and +// returns a copy. Returns an errdefs.NotFound error for an unknown Pod. +func (p *Provider) GetPodStatus(ctx context.Context, namespace, name string) (*corev1.PodStatus, error) { + key := podKey(namespace, name) + p.mu.Lock() + defer p.mu.Unlock() + st, ok := p.pods[key] + if !ok { + return nil, errdefs.NotFoundf("pod %q is not known to this node", key) + } + st.pod.Status = p.reconcileStatus(ctx, st) + return st.pod.Status.DeepCopy(), nil +} + +// GetPods returns copies of all tracked Pods with freshly reconciled status. +func (p *Provider) GetPods(ctx context.Context) ([]*corev1.Pod, error) { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]*corev1.Pod, 0, len(p.pods)) + for _, st := range p.pods { + st.pod.Status = p.reconcileStatus(ctx, st) + out = append(out, st.pod.DeepCopy()) + } + return out, nil +} diff --git a/pkg/provider/pod_test.go b/pkg/provider/pod_test.go new file mode 100644 index 0000000..3bb4197 --- /dev/null +++ b/pkg/provider/pod_test.go @@ -0,0 +1,317 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "testing" + "time" + + "github.com/chimerakang/macvz/internal/types" + "github.com/chimerakang/macvz/pkg/runtime" + "github.com/virtual-kubelet/virtual-kubelet/errdefs" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// recordingRuntime is a runtime.Runtime fake that records calls, allows error +// injection per method, and tracks per-workload status. +type recordingRuntime struct { + mu sync.Mutex + nextID int + exists map[string]bool // id -> created and not destroyed + statuses map[string]runtime.Status // id -> status to report + + pulls []string + createdSpecs []types.ContainerSpec + startedIDs []string + stoppedIDs []string + destroyedIDs []string + + pullErr, createErr, startErr, stopErr, destroyErr, statusErr error +} + +func newRecordingRuntime() *recordingRuntime { + return &recordingRuntime{ + exists: map[string]bool{}, + statuses: map[string]runtime.Status{}, + } +} + +func (r *recordingRuntime) Pull(_ context.Context, image string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.pulls = append(r.pulls, image) + return r.pullErr +} + +func (r *recordingRuntime) Create(_ context.Context, spec types.ContainerSpec) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.createErr != nil { + return "", r.createErr + } + r.nextID++ + id := fmt.Sprintf("wl-%d", r.nextID) + r.createdSpecs = append(r.createdSpecs, spec) + r.exists[id] = true + r.statuses[id] = runtime.Status{ID: id, Phase: runtime.PhaseCreated} + return id, nil +} + +func (r *recordingRuntime) Start(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.startErr != nil { + return r.startErr + } + r.startedIDs = append(r.startedIDs, id) + r.statuses[id] = runtime.Status{ID: id, Phase: runtime.PhaseRunning, StartedAt: time.Now()} + return nil +} + +func (r *recordingRuntime) Stop(_ context.Context, id string, _ time.Duration) error { + r.mu.Lock() + defer r.mu.Unlock() + r.stoppedIDs = append(r.stoppedIDs, id) + return r.stopErr +} + +func (r *recordingRuntime) Destroy(_ context.Context, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.destroyedIDs = append(r.destroyedIDs, id) + if r.destroyErr != nil { + return r.destroyErr + } + delete(r.exists, id) + delete(r.statuses, id) + return nil +} + +func (r *recordingRuntime) Status(_ context.Context, id string) (runtime.Status, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.statusErr != nil { + return runtime.Status{}, r.statusErr + } + st, ok := r.statuses[id] + if !ok { + return runtime.Status{}, runtime.ErrNotFound + } + return st, nil +} + +func (r *recordingRuntime) Logs(context.Context, string, runtime.LogOptions) (io.ReadCloser, error) { + return nil, nil +} +func (r *recordingRuntime) Exec(context.Context, string, []string, runtime.ExecIO) error { return nil } + +func (r *recordingRuntime) setStatus(id string, st runtime.Status) { + r.mu.Lock() + defer r.mu.Unlock() + r.statuses[id] = st +} + +func (r *recordingRuntime) counts() (pulls, creates, starts, stops, destroys int) { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.pulls), len(r.createdSpecs), len(r.startedIDs), len(r.stoppedIDs), len(r.destroyedIDs) +} + +func testPod(containers ...string) *corev1.Pod { + cs := make([]corev1.Container, 0, len(containers)) + for _, name := range containers { + cs = append(cs, corev1.Container{Name: name, Image: "alpine:3.20"}) + } + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "p1"}, + Spec: corev1.PodSpec{Containers: cs}, + } +} + +func newTestProvider() (*Provider, *recordingRuntime) { + rt := newRecordingRuntime() + return New("mac-01", rt), rt +} + +func TestCreatePodLaunchesEachContainer(t *testing.T) { + p, rt := newTestProvider() + if err := p.CreatePod(context.Background(), testPod("web", "side")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + pulls, creates, starts, _, _ := rt.counts() + if pulls != 2 || creates != 2 || starts != 2 { + t.Errorf("pulls=%d creates=%d starts=%d, want 2/2/2", pulls, creates, starts) + } + got, err := p.GetPod(context.Background(), "default", "p1") + if err != nil { + t.Fatalf("GetPod: %v", err) + } + if got.Status.Phase != corev1.PodRunning { + t.Errorf("phase = %q, want Running", got.Status.Phase) + } + if len(got.Status.ContainerStatuses) != 2 { + t.Errorf("container statuses = %d, want 2", len(got.Status.ContainerStatuses)) + } +} + +func TestCreatePodIdempotent(t *testing.T) { + p, rt := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod (retry): %v", err) + } + _, creates, _, _, _ := rt.counts() + if creates != 1 { + t.Errorf("duplicate CreatePod created %d workloads, want 1", creates) + } +} + +func TestCreatePodRollsBackOnStartError(t *testing.T) { + p, rt := newTestProvider() + rt.startErr = fmt.Errorf("boom") + err := p.CreatePod(context.Background(), testPod("web")) + if err == nil { + t.Fatal("expected CreatePod to fail when Start fails") + } + _, creates, _, _, destroys := rt.counts() + if creates != 1 || destroys != 1 { + t.Errorf("expected 1 create and 1 rollback destroy, got creates=%d destroys=%d", creates, destroys) + } + // Nothing should be tracked, so a retry can start clean. + if _, gerr := p.GetPod(context.Background(), "default", "p1"); !errdefs.IsNotFound(gerr) { + t.Errorf("expected NotFound after failed create, got %v", gerr) + } +} + +func TestCreatePodPropagatesErrNotReady(t *testing.T) { + p, rt := newTestProvider() + rt.pullErr = runtime.ErrNotReady + err := p.CreatePod(context.Background(), testPod("web")) + if err == nil || !errors.Is(err, runtime.ErrNotReady) { + t.Fatalf("expected error wrapping ErrNotReady, got %v", err) + } +} + +func TestGetPodNotFound(t *testing.T) { + p, _ := newTestProvider() + _, err := p.GetPod(context.Background(), "default", "missing") + if !errdefs.IsNotFound(err) { + t.Errorf("expected errdefs NotFound, got %v", err) + } +} + +func TestGetPodStatusReconcilesPhases(t *testing.T) { + p, rt := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + + // Running by default. + st, err := p.GetPodStatus(ctx, "default", "p1") + if err != nil { + t.Fatalf("GetPodStatus: %v", err) + } + if st.Phase != corev1.PodRunning { + t.Errorf("phase = %q, want Running", st.Phase) + } + + // Flip workload to stopped exit 0 -> Succeeded. + rt.setStatus("wl-1", runtime.Status{ID: "wl-1", Phase: runtime.PhaseStopped, ExitCode: 0}) + st, _ = p.GetPodStatus(ctx, "default", "p1") + if st.Phase != corev1.PodSucceeded { + t.Errorf("phase = %q, want Succeeded", st.Phase) + } + + // Flip to failed -> Failed with non-zero exit. + rt.setStatus("wl-1", runtime.Status{ID: "wl-1", Phase: runtime.PhaseFailed, ExitCode: 0, Message: "crash"}) + st, _ = p.GetPodStatus(ctx, "default", "p1") + if st.Phase != corev1.PodFailed { + t.Errorf("phase = %q, want Failed", st.Phase) + } + if term := st.ContainerStatuses[0].State.Terminated; term == nil || term.ExitCode == 0 { + t.Errorf("failed container should report non-zero exit, got %+v", term) + } +} + +func TestGetPodStatusMapsLostWorkload(t *testing.T) { + p, rt := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + // Simulate the runtime losing the workload. + rt.mu.Lock() + delete(rt.statuses, "wl-1") + rt.mu.Unlock() + + st, err := p.GetPodStatus(ctx, "default", "p1") + if err != nil { + t.Fatalf("GetPodStatus: %v", err) + } + term := st.ContainerStatuses[0].State.Terminated + if term == nil || term.Reason != "Lost" { + t.Errorf("lost workload should map to terminated/Lost, got %+v", st.ContainerStatuses[0].State) + } +} + +func TestDeletePodIsIdempotent(t *testing.T) { + p, rt := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + if err := p.DeletePod(ctx, testPod("web")); err != nil { + t.Fatalf("DeletePod: %v", err) + } + _, _, _, stops, destroys := rt.counts() + if stops != 1 || destroys != 1 { + t.Errorf("expected 1 stop and 1 destroy, got stops=%d destroys=%d", stops, destroys) + } + // Second delete: already gone -> NotFound (treated as success by VK). + if err := p.DeletePod(ctx, testPod("web")); !errdefs.IsNotFound(err) { + t.Errorf("duplicate DeletePod should return NotFound, got %v", err) + } +} + +func TestUpdatePod(t *testing.T) { + p, _ := newTestProvider() + ctx := context.Background() + if err := p.UpdatePod(ctx, testPod("web")); !errdefs.IsNotFound(err) { + t.Errorf("UpdatePod on unknown pod should return NotFound, got %v", err) + } + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + updated := testPod("web") + updated.Labels = map[string]string{"team": "infra"} + if err := p.UpdatePod(ctx, updated); err != nil { + t.Fatalf("UpdatePod: %v", err) + } + got, _ := p.GetPod(ctx, "default", "p1") + if got.Labels["team"] != "infra" { + t.Errorf("UpdatePod did not refresh labels: %v", got.Labels) + } +} + +func TestGetPodsListsAll(t *testing.T) { + p, _ := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + pods, err := p.GetPods(ctx) + if err != nil { + t.Fatalf("GetPods: %v", err) + } + if len(pods) != 1 { + t.Errorf("GetPods returned %d, want 1", len(pods)) + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index b99c60b..9b3396f 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -1,64 +1,69 @@ // Package provider implements the Virtual Kubelet PodLifecycleHandler, turning -// an Apple Silicon Mac into a Kubernetes node. Each Pod is realized as a -// micro-VM through the runtime.Runtime abstraction. +// an Apple Silicon Mac into a Kubernetes node. Each Pod is realized as one or +// more micro-VMs through the runtime.Runtime abstraction. // -// This is the P0 skeleton: the type satisfies the interface and depends only on -// runtime.Runtime (never a concrete driver). Real translation and lifecycle -// logic land in P2. +// The provider keeps an in-memory store mapping each Pod (by namespace/name) to +// the runtime workload IDs backing its containers, and reconciles observed +// runtime state into Kubernetes Pod status on demand. Pod-spec translation is +// intentionally minimal here and is extended in #17. package provider import ( - "context" - "errors" + "sync" + "time" "github.com/chimerakang/macvz/pkg/runtime" "github.com/virtual-kubelet/virtual-kubelet/node" corev1 "k8s.io/api/core/v1" ) -// errNotImplemented is returned by every method until P2 fills them in. -var errNotImplemented = errors.New("macvz provider: not implemented yet") +// defaultStopTimeout is used when a Pod has no terminationGracePeriodSeconds. +const defaultStopTimeout = 30 * time.Second // Provider realizes Kubernetes Pods as micro-VMs via a runtime.Runtime. type Provider struct { nodeName string rt runtime.Runtime -} -// New constructs a Provider bound to a node name and runtime driver. -func New(nodeName string, rt runtime.Runtime) *Provider { - return &Provider{nodeName: nodeName, rt: rt} + mu sync.RWMutex + pods map[string]*podState } -// Compile-time assertion that Provider satisfies the Virtual Kubelet contract. -var _ node.PodLifecycleHandler = (*Provider)(nil) - -// CreatePod takes a Kubernetes Pod and launches it as a micro-VM. -func (p *Provider) CreatePod(ctx context.Context, pod *corev1.Pod) error { - return errNotImplemented +// podState tracks one Pod and the runtime workloads backing its containers. +type podState struct { + // pod is the tracked Pod, including the status the provider maintains. + pod *corev1.Pod + // workloads maps each container to its runtime workload ID, in spec order. + workloads []workload } -// UpdatePod reconciles an existing Pod's desired state. -func (p *Provider) UpdatePod(ctx context.Context, pod *corev1.Pod) error { - return errNotImplemented +// workload binds a Pod container to a runtime workload ID. +type workload struct { + container string + id string } -// DeletePod tears down the micro-VM backing a Pod. -func (p *Provider) DeletePod(ctx context.Context, pod *corev1.Pod) error { - return errNotImplemented +// New constructs a Provider bound to a node name and runtime driver. +func New(nodeName string, rt runtime.Runtime) *Provider { + return &Provider{ + nodeName: nodeName, + rt: rt, + pods: make(map[string]*podState), + } } -// GetPod returns the Pod for namespace/name, or nil if not found. -func (p *Provider) GetPod(ctx context.Context, namespace, name string) (*corev1.Pod, error) { - return nil, errNotImplemented -} +// Compile-time assertion that Provider satisfies the Virtual Kubelet contract. +var _ node.PodLifecycleHandler = (*Provider)(nil) -// GetPodStatus returns the status of the Pod identified by namespace/name. -func (p *Provider) GetPodStatus(ctx context.Context, namespace, name string) (*corev1.PodStatus, error) { - return nil, errNotImplemented +// podKey is the store key for a Pod. +func podKey(namespace, name string) string { + return namespace + "/" + name } -// GetPods lists all Pods known to this provider. -func (p *Provider) GetPods(ctx context.Context) ([]*corev1.Pod, error) { - return nil, errNotImplemented +// stopTimeout returns the graceful-stop timeout for a Pod. +func stopTimeout(pod *corev1.Pod) time.Duration { + if pod.Spec.TerminationGracePeriodSeconds != nil { + return time.Duration(*pod.Spec.TerminationGracePeriodSeconds) * time.Second + } + return defaultStopTimeout } diff --git a/pkg/provider/status.go b/pkg/provider/status.go new file mode 100644 index 0000000..3309b69 --- /dev/null +++ b/pkg/provider/status.go @@ -0,0 +1,178 @@ +package provider + +import ( + "context" + "errors" + + "github.com/chimerakang/macvz/pkg/runtime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// reconcileStatus rebuilds a Pod's status from observed runtime state. It +// preserves the original StartTime and PodIP, queries each backing workload, +// maps runtime phases to container statuses, and aggregates the Pod phase. +func (p *Provider) reconcileStatus(ctx context.Context, st *podState) corev1.PodStatus { + status := corev1.PodStatus{ + StartTime: st.pod.Status.StartTime, + PodIP: st.pod.Status.PodIP, + } + + byName := make(map[string]workload, len(st.workloads)) + for _, w := range st.workloads { + byName[w.container] = w + } + + statuses := make([]corev1.ContainerStatus, 0, len(st.pod.Spec.Containers)) + for _, c := range st.pod.Spec.Containers { + w, ok := byName[c.Name] + if !ok { + statuses = append(statuses, waitingStatus(c, "NotCreated", "container has no backing workload")) + continue + } + rs, err := p.rt.Status(ctx, w.id) + if err != nil { + if errors.Is(err, runtime.ErrNotFound) { + // The workload vanished from the runtime; report it terminated. + statuses = append(statuses, terminatedStatus(c, w.id, 0, "Lost", "runtime workload not found")) + continue + } + statuses = append(statuses, waitingStatus(c, "RuntimeError", err.Error())) + continue + } + statuses = append(statuses, containerStatus(c, w.id, rs)) + if status.PodIP == "" && rs.IP != "" { + status.PodIP = rs.IP + } + } + + status.ContainerStatuses = statuses + status.Phase = aggregatePhase(statuses) + status.Conditions = podConditions(status.Phase) + return status +} + +// containerStatus maps a single runtime status to a Kubernetes container status. +func containerStatus(c corev1.Container, id string, rs runtime.Status) corev1.ContainerStatus { + cs := corev1.ContainerStatus{ + Name: c.Name, + Image: c.Image, + ImageID: c.Image, + ContainerID: "macvz://" + id, + } + switch rs.Phase { + case runtime.PhaseRunning: + cs.State.Running = &corev1.ContainerStateRunning{ + StartedAt: metav1.NewTime(rs.StartedAt), + } + cs.Ready = true + cs.Started = boolPtr(true) + case runtime.PhaseStopped: + cs.State.Terminated = terminated(int32(rs.ExitCode), "Completed", rs.Message, id) + case runtime.PhaseFailed: + cs.State.Terminated = terminated(nonZero(int32(rs.ExitCode)), "Error", rs.Message, id) + case runtime.PhaseCreated: + cs.State.Waiting = &corev1.ContainerStateWaiting{Reason: "Created", Message: rs.Message} + default: // PhaseUnknown + cs.State.Waiting = &corev1.ContainerStateWaiting{Reason: "Unknown", Message: rs.Message} + } + return cs +} + +func waitingStatus(c corev1.Container, reason, msg string) corev1.ContainerStatus { + return corev1.ContainerStatus{ + Name: c.Name, + Image: c.Image, + ImageID: c.Image, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: reason, Message: msg}}, + } +} + +func terminatedStatus(c corev1.Container, id string, code int32, reason, msg string) corev1.ContainerStatus { + return corev1.ContainerStatus{ + Name: c.Name, + Image: c.Image, + ImageID: c.Image, + ContainerID: "macvz://" + id, + State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: code, Reason: reason, Message: msg}}, + } +} + +func terminated(code int32, reason, msg, id string) *corev1.ContainerStateTerminated { + return &corev1.ContainerStateTerminated{ + ExitCode: code, + Reason: reason, + Message: msg, + ContainerID: "macvz://" + id, + } +} + +// aggregatePhase derives the Pod phase from its container statuses, following +// the kubelet's rules closely enough for the MVP: +// - any container still waiting -> Pending +// - any container terminated with a non-zero exit -> Failed +// - all containers terminated with exit 0 -> Succeeded +// - otherwise (at least one running) -> Running +func aggregatePhase(statuses []corev1.ContainerStatus) corev1.PodPhase { + if len(statuses) == 0 { + return corev1.PodPending + } + var running, succeeded, failed, waiting int + for _, s := range statuses { + switch { + case s.State.Waiting != nil: + waiting++ + case s.State.Running != nil: + running++ + case s.State.Terminated != nil: + if s.State.Terminated.ExitCode == 0 { + succeeded++ + } else { + failed++ + } + } + } + switch { + case failed > 0: + return corev1.PodFailed + case waiting > 0: + return corev1.PodPending + case running > 0: + return corev1.PodRunning + case succeeded == len(statuses): + return corev1.PodSucceeded + default: + return corev1.PodPending + } +} + +// podConditions returns the standard Pod conditions for a phase. +func podConditions(phase corev1.PodPhase) []corev1.PodCondition { + ready := phase == corev1.PodRunning + cond := func(t corev1.PodConditionType, status bool) corev1.PodCondition { + s := corev1.ConditionFalse + if status { + s = corev1.ConditionTrue + } + return corev1.PodCondition{Type: t, Status: s} + } + // Initialized stays true once the Pod has been started; ContainersReady and + // Ready track the running state. + initialized := phase != corev1.PodPending + return []corev1.PodCondition{ + cond(corev1.PodInitialized, initialized), + cond(corev1.ContainersReady, ready), + cond(corev1.PodReady, ready), + } +} + +func boolPtr(b bool) *bool { return &b } + +// nonZero ensures a failed container reports a non-zero exit code even when the +// runtime did not supply one. +func nonZero(code int32) int32 { + if code == 0 { + return 1 + } + return code +} diff --git a/pkg/provider/translate.go b/pkg/provider/translate.go new file mode 100644 index 0000000..c0c556e --- /dev/null +++ b/pkg/provider/translate.go @@ -0,0 +1,62 @@ +package provider + +import ( + "github.com/chimerakang/macvz/internal/types" + corev1 "k8s.io/api/core/v1" +) + +// translateContainer maps a Kubernetes container into the runtime's +// ContainerSpec. This is the minimal translation needed for the lifecycle CRUD +// (#16): image, command/args, literal env, and CPU/memory requests. Richer +// translation — env valueFrom, volumes, probes, downward API — is the subject +// of #17. +func translateContainer(pod *corev1.Pod, c corev1.Container) types.ContainerSpec { + spec := types.ContainerSpec{ + Name: c.Name, + Image: c.Image, + Command: c.Command, + Args: c.Args, + } + + if len(c.Env) > 0 { + spec.Env = make(map[string]string, len(c.Env)) + for _, e := range c.Env { + // valueFrom (configmap/secret/fieldRef) is deferred to #17; only + // literal values are carried here. + if e.ValueFrom == nil { + spec.Env[e.Name] = e.Value + } + } + } + + // Prefer limits, fall back to requests, for the VM sizing hints. + if cpu := resourceQuantity(c, corev1.ResourceCPU); cpu != nil { + spec.CPUMillis = cpu.MilliValue() + } + if mem := resourceQuantity(c, corev1.ResourceMemory); mem != nil { + spec.MemoryBytes = mem.Value() + } + return spec +} + +// resourceQuantity returns the limit for a resource, falling back to the +// request, or nil when neither is set. +func resourceQuantity(c corev1.Container, name corev1.ResourceName) *resourceValue { + if q, ok := c.Resources.Limits[name]; ok { + return &resourceValue{q.MilliValue(), q.Value()} + } + if q, ok := c.Resources.Requests[name]; ok { + return &resourceValue{q.MilliValue(), q.Value()} + } + return nil +} + +// resourceValue carries both the milli and whole-unit views of a quantity so +// callers can pick the appropriate one without re-parsing. +type resourceValue struct { + milli int64 + value int64 +} + +func (r resourceValue) MilliValue() int64 { return r.milli } +func (r resourceValue) Value() int64 { return r.value } From 530b495c0c42e774f31988d41cdc2ebe49e9ac6c Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:25:35 +0800 Subject: [PATCH 5/8] P2: translate Pod specs into runtime workload specs (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build out the MVP translation layer from Kubernetes Pod/container fields to types.ContainerSpec, with clear rejection of unsupported shapes. - pkg/provider/translate.go: translatePod validates a Pod is a supported shape and translates its single container. workloadID derives a stable, DNS-safe (RFC 1123) name from namespace/name/container, hash-truncated when it would exceed 63 chars, so the driver gets a deterministic --name. Image, command, args, literal env, and CPU/memory (limit, falling back to request) are translated. unsupportedReasons reports multi-container, init/ephemeral containers, user volumes (the auto-mounted service-account token is tolerated), pod/container securityContext, host namespaces, envFrom, and env valueFrom — each with an actionable message. - pkg/provider/pod.go: CreatePod now translates a single container. Unsupported specs and arch-incompatible images (the P1 ErrIncompatibleArch signal) are recorded as a sticky terminal Failed status with a clear reason/message and return nil, so Virtual Kubelet surfaces it to Kubernetes instead of retrying forever. provider/status.go honors the sticky status during reconciliation. - Table-driven tests cover the kubectl-run shape, command/args/env/resource sizing, request fallback, every unsupported feature, SA-token tolerance, and workload-ID stability/DNS-safety/truncation. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MASTER_TASKS.md | 2 +- pkg/provider/pod.go | 68 ++++++--- pkg/provider/pod_test.go | 34 ++++- pkg/provider/provider.go | 5 + pkg/provider/status.go | 5 + pkg/provider/translate.go | 172 +++++++++++++++++++++-- pkg/provider/translate_test.go | 242 +++++++++++++++++++++++++++++++++ 7 files changed, 496 insertions(+), 32 deletions(-) create mode 100644 pkg/provider/translate_test.go diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index ded56c7..538b4f0 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -41,7 +41,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #14 | Register virtual node with capacity, addresses, taints, and conditions | P2 | in progress | | #15 | Implement node heartbeat and lease updates | P2 | in progress | | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | in progress | -| #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | open | +| #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | in progress | | #18 | Wire kubectl logs and exec through the runtime | P2 | open | | #19 | Add RBAC, manifests, and P2 MVP smoke test docs | P2 | open | | #20 | Implement Kubernetes-coordinated Pod IPAM | P3 | open | diff --git a/pkg/provider/pod.go b/pkg/provider/pod.go index c0519ec..6c47585 100644 --- a/pkg/provider/pod.go +++ b/pkg/provider/pod.go @@ -29,33 +29,69 @@ func (p *Provider) CreatePod(ctx context.Context, pod *corev1.Pod) error { return nil } + // Translate the Pod into a single workload spec. An unsupported shape is + // terminal: record a clear Failed status instead of retrying forever. + spec, err := translatePod(pod) + if err != nil { + return p.markTerminalFailure(key, pod, "UnsupportedPodSpec", err) + } + c := pod.Spec.Containers[0] + st := &podState{pod: pod.DeepCopy()} now := metav1.Now() st.pod.Status.StartTime = &now - for _, c := range pod.Spec.Containers { - spec := translateContainer(pod, c) - if err := p.rt.Pull(ctx, spec.Image); err != nil { - p.rollback(ctx, st) - return fmt.Errorf("pull image %q for container %q: %w", spec.Image, c.Name, err) - } - id, err := p.rt.Create(ctx, spec) - if err != nil { - p.rollback(ctx, st) - return fmt.Errorf("create container %q: %w", c.Name, err) - } - st.workloads = append(st.workloads, workload{container: c.Name, id: id}) - if err := p.rt.Start(ctx, id); err != nil { - p.rollback(ctx, st) - return fmt.Errorf("start container %q: %w", c.Name, err) + if err := p.rt.Pull(ctx, spec.Image); err != nil { + // An image with no arm64 variant can never run here (P1 surfaces this + // signal); make it a terminal Failed status. Other pull errors (e.g. + // runtime not ready) are transient and worth retrying. + if errors.Is(err, runtime.ErrIncompatibleArch) { + return p.markTerminalFailure(key, pod, "ImageArchitectureMismatch", + fmt.Errorf("pull image %q for container %q: %w", spec.Image, c.Name, err)) } + return fmt.Errorf("pull image %q for container %q: %w", spec.Image, c.Name, err) + } + id, err := p.rt.Create(ctx, spec) + if err != nil { + p.rollback(ctx, st) + return fmt.Errorf("create container %q: %w", c.Name, err) + } + st.workloads = append(st.workloads, workload{container: c.Name, id: id}) + if err := p.rt.Start(ctx, id); err != nil { + p.rollback(ctx, st) + return fmt.Errorf("start container %q: %w", c.Name, err) } st.pod.Status = p.reconcileStatus(ctx, st) p.mu.Lock() p.pods[key] = st p.mu.Unlock() - klog.InfoS("created Pod", "pod", key, "containers", len(st.workloads)) + klog.InfoS("created Pod", "pod", key, "workloadID", spec.Name) + return nil +} + +// markTerminalFailure records a sticky Failed status for a Pod that can never +// run on this node, then returns nil so Virtual Kubelet stops retrying and +// surfaces the status/message to Kubernetes (no silent partial behavior). +func (p *Provider) markTerminalFailure(key string, pod *corev1.Pod, reason string, cause error) error { + now := metav1.Now() + status := corev1.PodStatus{ + Phase: corev1.PodFailed, + Reason: reason, + Message: cause.Error(), + StartTime: &now, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodInitialized, Status: corev1.ConditionFalse, Reason: reason}, + {Type: corev1.PodReady, Status: corev1.ConditionFalse, Reason: reason}, + {Type: corev1.ContainersReady, Status: corev1.ConditionFalse, Reason: reason}, + }, + } + st := &podState{pod: pod.DeepCopy(), terminalStatus: &status} + st.pod.Status = status + p.mu.Lock() + p.pods[key] = st + p.mu.Unlock() + klog.ErrorS(cause, "Pod cannot run on this node", "pod", key, "reason", reason) return nil } diff --git a/pkg/provider/pod_test.go b/pkg/provider/pod_test.go index 3bb4197..ead68c8 100644 --- a/pkg/provider/pod_test.go +++ b/pkg/provider/pod_test.go @@ -137,14 +137,14 @@ func newTestProvider() (*Provider, *recordingRuntime) { return New("mac-01", rt), rt } -func TestCreatePodLaunchesEachContainer(t *testing.T) { +func TestCreatePodLaunchesContainer(t *testing.T) { p, rt := newTestProvider() - if err := p.CreatePod(context.Background(), testPod("web", "side")); err != nil { + if err := p.CreatePod(context.Background(), testPod("web")); err != nil { t.Fatalf("CreatePod: %v", err) } pulls, creates, starts, _, _ := rt.counts() - if pulls != 2 || creates != 2 || starts != 2 { - t.Errorf("pulls=%d creates=%d starts=%d, want 2/2/2", pulls, creates, starts) + if pulls != 1 || creates != 1 || starts != 1 { + t.Errorf("pulls=%d creates=%d starts=%d, want 1/1/1", pulls, creates, starts) } got, err := p.GetPod(context.Background(), "default", "p1") if err != nil { @@ -153,8 +153,30 @@ func TestCreatePodLaunchesEachContainer(t *testing.T) { if got.Status.Phase != corev1.PodRunning { t.Errorf("phase = %q, want Running", got.Status.Phase) } - if len(got.Status.ContainerStatuses) != 2 { - t.Errorf("container statuses = %d, want 2", len(got.Status.ContainerStatuses)) + if len(got.Status.ContainerStatuses) != 1 { + t.Errorf("container statuses = %d, want 1", len(got.Status.ContainerStatuses)) + } +} + +func TestCreatePodMarksUnsupportedShapeFailed(t *testing.T) { + p, rt := newTestProvider() + // A multi-container Pod is unsupported in the MVP. + if err := p.CreatePod(context.Background(), testPod("web", "side")); err != nil { + t.Fatalf("CreatePod should record a terminal failure, not return an error: %v", err) + } + pulls, creates, _, _, _ := rt.counts() + if pulls != 0 || creates != 0 { + t.Errorf("unsupported Pod should not touch the runtime, got pulls=%d creates=%d", pulls, creates) + } + st, err := p.GetPodStatus(context.Background(), "default", "p1") + if err != nil { + t.Fatalf("GetPodStatus: %v", err) + } + if st.Phase != corev1.PodFailed { + t.Errorf("phase = %q, want Failed", st.Phase) + } + if st.Reason != "UnsupportedPodSpec" || st.Message == "" { + t.Errorf("expected a clear UnsupportedPodSpec reason/message, got reason=%q msg=%q", st.Reason, st.Message) } } diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 9b3396f..0aaefac 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -35,6 +35,11 @@ type podState struct { pod *corev1.Pod // workloads maps each container to its runtime workload ID, in spec order. workloads []workload + // terminalStatus, when set, is a sticky status that overrides live + // reconciliation. It is used for Pods that can never run on this node (an + // unsupported spec, or an image with no arm64 variant), so they surface a + // clear, stable Failed status instead of being re-derived as Pending. + terminalStatus *corev1.PodStatus } // workload binds a Pod container to a runtime workload ID. diff --git a/pkg/provider/status.go b/pkg/provider/status.go index 3309b69..d918745 100644 --- a/pkg/provider/status.go +++ b/pkg/provider/status.go @@ -13,6 +13,11 @@ import ( // preserves the original StartTime and PodIP, queries each backing workload, // maps runtime phases to container statuses, and aggregates the Pod phase. func (p *Provider) reconcileStatus(ctx context.Context, st *podState) corev1.PodStatus { + // A Pod that can never run keeps its sticky Failed status. + if st.terminalStatus != nil { + return *st.terminalStatus + } + status := corev1.PodStatus{ StartTime: st.pod.Status.StartTime, PodIP: st.pod.Status.PodIP, diff --git a/pkg/provider/translate.go b/pkg/provider/translate.go index c0c556e..7a2b56d 100644 --- a/pkg/provider/translate.go +++ b/pkg/provider/translate.go @@ -1,15 +1,95 @@ package provider import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "github.com/chimerakang/macvz/internal/types" corev1 "k8s.io/api/core/v1" ) -// translateContainer maps a Kubernetes container into the runtime's -// ContainerSpec. This is the minimal translation needed for the lifecycle CRUD -// (#16): image, command/args, literal env, and CPU/memory requests. Richer -// translation — env valueFrom, volumes, probes, downward API — is the subject -// of #17. +// maxWorkloadIDLen bounds the derived workload name to a DNS label (RFC 1123), +// which is the strictest constraint apple/container and Kubernetes share. +const maxWorkloadIDLen = 63 + +// workloadIDPrefix namespaces every workload this node creates, so MacVz +// workloads are easy to distinguish from anything else on the host. +const workloadIDPrefix = "macvz" + +// translatePod validates that a Pod is a shape MacVz can run in the MVP and +// translates its single container into a runtime ContainerSpec. Unsupported +// shapes return a clear error so the caller can surface a Kubernetes-facing +// Failed status rather than silently running a partial workload. +func translatePod(pod *corev1.Pod) (types.ContainerSpec, error) { + if reasons := unsupportedReasons(pod); len(reasons) > 0 { + return types.ContainerSpec{}, fmt.Errorf("unsupported Pod spec: %s", strings.Join(reasons, "; ")) + } + c := pod.Spec.Containers[0] + spec := translateContainer(pod, c) + spec.Name = workloadID(pod.Namespace, pod.Name, c.Name) + return spec, nil +} + +// unsupportedReasons returns a human-readable reason for every feature in the +// Pod that the MVP cannot honor. An empty result means the Pod is supported. +func unsupportedReasons(pod *corev1.Pod) []string { + var reasons []string + + switch len(pod.Spec.Containers) { + case 0: + reasons = append(reasons, "Pod has no containers") + case 1: + default: + reasons = append(reasons, fmt.Sprintf("multi-container Pods are not supported yet (found %d containers; the MVP runs a single container)", len(pod.Spec.Containers))) + } + if n := len(pod.Spec.InitContainers); n > 0 { + reasons = append(reasons, fmt.Sprintf("init containers are not supported yet (found %d)", n)) + } + if n := len(pod.Spec.EphemeralContainers); n > 0 { + reasons = append(reasons, fmt.Sprintf("ephemeral containers are not supported yet (found %d)", n)) + } + + for _, v := range pod.Spec.Volumes { + if isDefaultProjectedToken(v) { + continue // the auto-mounted service-account token is tolerated (ignored) + } + reasons = append(reasons, fmt.Sprintf("volume %q is not supported yet (only the default service-account token is tolerated)", v.Name)) + } + + if pod.Spec.SecurityContext != nil && !isEmptyPodSecurityContext(pod.Spec.SecurityContext) { + reasons = append(reasons, "pod-level securityContext is not supported yet") + } + if pod.Spec.HostNetwork { + reasons = append(reasons, "hostNetwork is not supported") + } + if pod.Spec.HostPID { + reasons = append(reasons, "hostPID is not supported") + } + if pod.Spec.HostIPC { + reasons = append(reasons, "hostIPC is not supported") + } + + for _, c := range pod.Spec.Containers { + if c.SecurityContext != nil { + reasons = append(reasons, fmt.Sprintf("container %q securityContext is not supported yet", c.Name)) + } + if len(c.EnvFrom) > 0 { + reasons = append(reasons, fmt.Sprintf("container %q envFrom is not supported yet", c.Name)) + } + for _, e := range c.Env { + if e.ValueFrom != nil { + reasons = append(reasons, fmt.Sprintf("container %q env %q uses valueFrom, which is not supported yet", c.Name, e.Name)) + } + } + } + + return reasons +} + +// translateContainer maps a single Kubernetes container into a ContainerSpec. +// The caller is responsible for setting spec.Name to the stable workload ID. func translateContainer(pod *corev1.Pod, c corev1.Container) types.ContainerSpec { spec := types.ContainerSpec{ Name: c.Name, @@ -21,15 +101,13 @@ func translateContainer(pod *corev1.Pod, c corev1.Container) types.ContainerSpec if len(c.Env) > 0 { spec.Env = make(map[string]string, len(c.Env)) for _, e := range c.Env { - // valueFrom (configmap/secret/fieldRef) is deferred to #17; only - // literal values are carried here. - if e.ValueFrom == nil { + if e.ValueFrom == nil { // valueFrom is rejected in unsupportedReasons spec.Env[e.Name] = e.Value } } } - // Prefer limits, fall back to requests, for the VM sizing hints. + // CPU/memory: prefer the limit, fall back to the request, as the VM sizing. if cpu := resourceQuantity(c, corev1.ResourceCPU); cpu != nil { spec.CPUMillis = cpu.MilliValue() } @@ -39,6 +117,82 @@ func translateContainer(pod *corev1.Pod, c corev1.Container) types.ContainerSpec return spec } +// workloadID derives a stable, DNS-safe (RFC 1123 label) workload name from the +// Pod's identity. The same namespace/name/container always yields the same ID; +// when the joined name would exceed the label limit it is truncated and made +// unique with a short hash of the full identity. +func workloadID(namespace, podName, containerName string) string { + id := sanitizeDNSLabel(strings.Join([]string{workloadIDPrefix, namespace, podName, containerName}, "-")) + if len(id) <= maxWorkloadIDLen { + return id + } + sum := sha256.Sum256([]byte(namespace + "/" + podName + "/" + containerName)) + suffix := hex.EncodeToString(sum[:])[:8] + keep := maxWorkloadIDLen - len(suffix) - 1 + return strings.TrimRight(id[:keep], "-") + "-" + suffix +} + +// sanitizeDNSLabel lowercases s and replaces any run of characters outside +// [a-z0-9-] with a single hyphen, trimming leading/trailing hyphens so the +// result is a valid DNS label. +func sanitizeDNSLabel(s string) string { + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + lastDash := false + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + lastDash = false + default: + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "wl" + } + return out +} + +// isDefaultProjectedToken reports whether v is the service-account access token +// volume that Kubernetes auto-injects (so it can be tolerated, not rejected). +func isDefaultProjectedToken(v corev1.Volume) bool { + if strings.HasPrefix(v.Name, "kube-api-access-") { + return true + } + if v.Projected != nil { + for _, s := range v.Projected.Sources { + if s.ServiceAccountToken != nil { + return true + } + } + } + return false +} + +// isEmptyPodSecurityContext reports whether a pod securityContext carries no +// meaningful settings. kubectl run leaves it nil, but other tooling may attach +// an empty struct that should not trip the unsupported check. +func isEmptyPodSecurityContext(sc *corev1.PodSecurityContext) bool { + if sc == nil { + return true + } + return sc.RunAsUser == nil && + sc.RunAsGroup == nil && + sc.RunAsNonRoot == nil && + sc.FSGroup == nil && + sc.SELinuxOptions == nil && + sc.WindowsOptions == nil && + len(sc.SupplementalGroups) == 0 && + len(sc.Sysctls) == 0 && + sc.SeccompProfile == nil +} + // resourceQuantity returns the limit for a resource, falling back to the // request, or nil when neither is set. func resourceQuantity(c corev1.Container, name corev1.ResourceName) *resourceValue { diff --git a/pkg/provider/translate_test.go b/pkg/provider/translate_test.go new file mode 100644 index 0000000..495a72d --- /dev/null +++ b/pkg/provider/translate_test.go @@ -0,0 +1,242 @@ +package provider + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func pod(ns, name string, spec corev1.PodSpec) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, + Spec: spec, + } +} + +func oneContainer(c corev1.Container) corev1.PodSpec { + return corev1.PodSpec{Containers: []corev1.Container{c}} +} + +func TestTranslatePodSupported(t *testing.T) { + // Mirror `kubectl run alpine --image=alpine -- sleep 3600`. + p := pod("default", "alpine", oneContainer(corev1.Container{ + Name: "alpine", + Image: "alpine", + Command: []string{"sleep"}, + Args: []string{"3600"}, + })) + spec, err := translatePod(p) + if err != nil { + t.Fatalf("translatePod: %v", err) + } + if spec.Image != "alpine" { + t.Errorf("image = %q, want alpine", spec.Image) + } + if strings.Join(spec.Command, " ") != "sleep" || strings.Join(spec.Args, " ") != "3600" { + t.Errorf("command/args = %v / %v, want [sleep] / [3600]", spec.Command, spec.Args) + } + if spec.Name != "macvz-default-alpine-alpine" { + t.Errorf("workload ID = %q, want macvz-default-alpine-alpine", spec.Name) + } +} + +func TestTranslateContainerFields(t *testing.T) { + c := corev1.Container{ + Name: "app", + Image: "nginx:1.27", + Env: []corev1.EnvVar{ + {Name: "FOO", Value: "bar"}, + {Name: "BAZ", Value: "qux"}, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + } + spec := translateContainer(pod("ns", "n", oneContainer(c)), c) + + if spec.Env["FOO"] != "bar" || spec.Env["BAZ"] != "qux" { + t.Errorf("env not translated: %v", spec.Env) + } + // Limits win over requests. + if spec.CPUMillis != 500 { + t.Errorf("CPUMillis = %d, want 500 (limit)", spec.CPUMillis) + } + if spec.MemoryBytes != 128*1024*1024 { + t.Errorf("MemoryBytes = %d, want %d (limit)", spec.MemoryBytes, 128*1024*1024) + } +} + +func TestTranslateContainerFallsBackToRequests(t *testing.T) { + c := corev1.Container{ + Name: "app", + Image: "x", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + }, + } + spec := translateContainer(pod("ns", "n", oneContainer(c)), c) + if spec.CPUMillis != 1000 { + t.Errorf("CPUMillis = %d, want 1000 (request)", spec.CPUMillis) + } + if spec.MemoryBytes != 32*1024*1024 { + t.Errorf("MemoryBytes = %d, want %d (request)", spec.MemoryBytes, 32*1024*1024) + } +} + +func TestTranslatePodUnsupported(t *testing.T) { + tests := []struct { + name string + spec corev1.PodSpec + wantSub string + }{ + { + name: "no containers", + spec: corev1.PodSpec{}, + wantSub: "no containers", + }, + { + name: "multi-container", + spec: corev1.PodSpec{Containers: []corev1.Container{ + {Name: "a", Image: "x"}, {Name: "b", Image: "y"}, + }}, + wantSub: "multi-container", + }, + { + name: "init container", + spec: corev1.PodSpec{ + InitContainers: []corev1.Container{{Name: "init", Image: "x"}}, + Containers: []corev1.Container{{Name: "a", Image: "x"}}, + }, + wantSub: "init containers", + }, + { + name: "user volume", + spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a", Image: "x"}}, + Volumes: []corev1.Volume{{Name: "data"}}, + }, + wantSub: `volume "data"`, + }, + { + name: "container securityContext", + spec: corev1.PodSpec{Containers: []corev1.Container{ + {Name: "a", Image: "x", SecurityContext: &corev1.SecurityContext{}}, + }}, + wantSub: "securityContext", + }, + { + name: "env valueFrom", + spec: corev1.PodSpec{Containers: []corev1.Container{{ + Name: "a", + Image: "x", + Env: []corev1.EnvVar{{Name: "K", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}}}}, + }}}, + wantSub: "valueFrom", + }, + { + name: "hostNetwork", + spec: corev1.PodSpec{ + HostNetwork: true, + Containers: []corev1.Container{{Name: "a", Image: "x"}}, + }, + wantSub: "hostNetwork", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := translatePod(pod("ns", "n", tt.spec)) + if err == nil { + t.Fatal("expected an unsupported error") + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not mention %q", err.Error(), tt.wantSub) + } + }) + } +} + +func TestTranslatePodToleratesServiceAccountToken(t *testing.T) { + spec := corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a", Image: "x"}}, + Volumes: []corev1.Volume{{ + Name: "kube-api-access-abcde", + VolumeSource: corev1.VolumeSource{Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ServiceAccountToken: &corev1.ServiceAccountTokenProjection{}}}, + }}, + }}, + } + if _, err := translatePod(pod("ns", "n", spec)); err != nil { + t.Errorf("auto-mounted service-account token should be tolerated, got %v", err) + } +} + +func TestWorkloadIDIsStableAndDNSSafe(t *testing.T) { + id1 := workloadID("default", "alpine", "alpine") + id2 := workloadID("default", "alpine", "alpine") + if id1 != id2 { + t.Errorf("workloadID not stable: %q != %q", id1, id2) + } + if !isDNSLabel(id1) { + t.Errorf("workloadID %q is not a valid DNS label", id1) + } + + // Uppercase and illegal characters are sanitized. + id := workloadID("Team_A", "My.Pod", "Web/Server") + if !isDNSLabel(id) { + t.Errorf("sanitized workloadID %q is not a valid DNS label", id) + } +} + +func TestWorkloadIDTruncatesLongNames(t *testing.T) { + long := strings.Repeat("x", 100) + id := workloadID("ns", long, "container") + if len(id) > maxWorkloadIDLen { + t.Errorf("workloadID length = %d, want <= %d", len(id), maxWorkloadIDLen) + } + if !isDNSLabel(id) { + t.Errorf("truncated workloadID %q is not a valid DNS label", id) + } + // Still stable after truncation. + if id != workloadID("ns", long, "container") { + t.Error("truncated workloadID is not stable") + } + // Distinct inputs produce distinct IDs even when truncated. + other := workloadID("ns", long+"y", "container") + if id == other { + t.Error("truncated workloadIDs collide for distinct inputs") + } +} + +// isDNSLabel reports whether s is a valid RFC 1123 DNS label: 1-63 chars, +// lowercase alphanumeric or '-', starting and ending alphanumeric. +func isDNSLabel(s string) bool { + if len(s) == 0 || len(s) > 63 { + return false + } + for i, r := range s { + alnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if i == 0 || i == len(s)-1 { + if !alnum { + return false + } + continue + } + if !alnum && r != '-' { + return false + } + } + return true +} From f53497a2ef88dbd9068ae71d3cc8c43829f80c3b Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:37:16 +0800 Subject: [PATCH 6/8] P2: wire kubectl logs and exec through the runtime (#18) Expose the Virtual Kubelet log and exec paths on the provider, delegating to runtime.Logs and runtime.Exec for running micro-VM workloads. - pkg/provider/logs_exec.go: GetContainerLogs maps Virtual Kubelet ContainerLogOpts (follow/tail) onto runtime.LogOptions; RunInContainer wires AttachIO stdin/stdout/stderr and TTY into runtime.ExecIO, drains the resize channel for TTY sessions (the runtime has no resize), and converts a clean non-zero command exit into a utilexec.ExitError so kubectl reports the exit status. A workloadID helper resolves container -> runtime workload ID, and runtime ErrNotFound/ErrNotRunning map to clear Kubernetes-facing errors (errdefs.NotFound / "not running"). Compile-time assertions pin the method signatures to the VK handler function types. - Fake-runtime tests cover log streaming + tail propagation, exec stream wiring, non-zero exit-code mapping, and unknown pod/container errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MASTER_TASKS.md | 2 +- go.mod | 20 ++++- go.sum | 108 ++++++++++++++++++++++++++ pkg/provider/logs_exec.go | 137 ++++++++++++++++++++++++++++++++ pkg/provider/logs_exec_test.go | 138 +++++++++++++++++++++++++++++++++ pkg/provider/pod_test.go | 35 ++++++++- 6 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 pkg/provider/logs_exec.go create mode 100644 pkg/provider/logs_exec_test.go diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index 538b4f0..d9965f3 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -42,7 +42,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #15 | Implement node heartbeat and lease updates | P2 | in progress | | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | in progress | | #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | in progress | -| #18 | Wire kubectl logs and exec through the runtime | P2 | open | +| #18 | Wire kubectl logs and exec through the runtime | P2 | in progress | | #19 | Add RBAC, manifests, and P2 MVP smoke test docs | P2 | open | | #20 | Implement Kubernetes-coordinated Pod IPAM | P3 | open | | #21 | Bring up WireGuard mesh between MacVz nodes | P3 | open | diff --git a/go.mod b/go.mod index 1729820..e06cbca 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,13 @@ require ( k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 k8s.io/klog/v2 v2.130.1 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -19,19 +23,31 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect @@ -44,8 +60,10 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiserver v0.35.0 // indirect + k8s.io/component-base v0.35.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/kubelet v0.35.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index dfd5bce..13ac95b 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ= github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,6 +22,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -31,19 +44,46 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -51,8 +91,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -61,6 +105,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= @@ -72,6 +118,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= @@ -99,30 +146,83 @@ github.com/virtual-kubelet/virtual-kubelet v1.12.0 h1:6RnRE3egGnqw3BDL9PBbP5DPV6 github.com/virtual-kubelet/virtual-kubelet v1.12.0/go.mod h1:dVlVEsFfrrwAcj/v0eDGgkTF5r+eAsBnn9gDxx3au2s= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -137,18 +237,26 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c= +k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= diff --git a/pkg/provider/logs_exec.go b/pkg/provider/logs_exec.go new file mode 100644 index 0000000..a3cb0b0 --- /dev/null +++ b/pkg/provider/logs_exec.go @@ -0,0 +1,137 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/chimerakang/macvz/pkg/runtime" + "github.com/virtual-kubelet/virtual-kubelet/errdefs" + vkapi "github.com/virtual-kubelet/virtual-kubelet/node/api" + "k8s.io/klog/v2" + utilexec "k8s.io/utils/exec" +) + +// Compile-time assertions that the provider methods satisfy the Virtual Kubelet +// log/exec handler function types wired into its kubelet API server. +var ( + _ vkapi.ContainerLogsHandlerFunc = (*Provider)(nil).GetContainerLogs + _ vkapi.ContainerExecHandlerFunc = (*Provider)(nil).RunInContainer +) + +// GetContainerLogs streams a container's output for `kubectl logs`, mapping the +// Virtual Kubelet log options onto runtime.LogOptions. The caller closes the +// returned reader. +func (p *Provider) GetContainerLogs(ctx context.Context, namespace, podName, containerName string, opts vkapi.ContainerLogOpts) (io.ReadCloser, error) { + id, err := p.workloadID(namespace, podName, containerName) + if err != nil { + return nil, err + } + rc, err := p.rt.Logs(ctx, id, runtime.LogOptions{ + Follow: opts.Follow, + Tail: opts.Tail, + }) + if err != nil { + return nil, mapExecLogError(namespace, podName, containerName, err) + } + klog.InfoS("streaming container logs", "pod", podKey(namespace, podName), "container", containerName, "follow", opts.Follow, "tail", opts.Tail) + return rc, nil +} + +// RunInContainer executes a command inside a running workload for +// `kubectl exec`, wiring stdin/stdout/stderr and TTY. A non-zero command exit +// is returned as a utilexec.ExitError so the API surfaces the exit status. +func (p *Provider) RunInContainer(ctx context.Context, namespace, podName, containerName string, cmd []string, attach vkapi.AttachIO) error { + id, err := p.workloadID(namespace, podName, containerName) + if err != nil { + return err + } + + // The runtime does not support terminal resize; drain the resize channel so + // the API server's producer never blocks. + if attach.TTY() { + go drainResize(ctx, attach.Resize()) + } + + sio := runtime.ExecIO{ + Stdin: attach.Stdin(), + Stdout: attach.Stdout(), + Stderr: attach.Stderr(), + TTY: attach.TTY(), + } + klog.InfoS("exec in container", "pod", podKey(namespace, podName), "container", containerName, "tty", attach.TTY()) + + err = p.rt.Exec(ctx, id, cmd, sio) + if err == nil { + return nil + } + // Convert a clean non-zero exit into an ExitError carrying the code, so + // kubectl reports "command terminated with exit code N" rather than a + // generic failure. + var exit *runtime.ExitError + if errors.As(err, &exit) { + return execCodeError{code: exit.Code} + } + return mapExecLogError(namespace, podName, containerName, err) +} + +// workloadID resolves the runtime workload ID backing a Pod's container, +// returning an errdefs.NotFound error when the Pod, the container, or its +// backing workload is unknown (e.g. the Pod never ran). +func (p *Provider) workloadID(namespace, podName, containerName string) (string, error) { + key := podKey(namespace, podName) + p.mu.RLock() + st, ok := p.pods[key] + p.mu.RUnlock() + if !ok { + return "", errdefs.NotFoundf("pod %q is not known to this node", key) + } + for _, w := range st.workloads { + if w.container == containerName { + return w.id, nil + } + } + return "", errdefs.NotFoundf("container %q has no running workload in pod %q", containerName, key) +} + +// mapExecLogError turns runtime sentinels into Kubernetes-facing errors. +func mapExecLogError(namespace, podName, containerName string, err error) error { + key := podKey(namespace, podName) + switch { + case errors.Is(err, runtime.ErrNotFound): + return errdefs.NotFoundf("container %q workload not found for pod %q", containerName, key) + case errors.Is(err, runtime.ErrNotRunning): + return fmt.Errorf("container %q in pod %q is not running", containerName, key) + default: + return err + } +} + +// drainResize discards terminal resize events until the channel closes or the +// context is done. +func drainResize(ctx context.Context, resize <-chan vkapi.TermSize) { + for { + select { + case <-ctx.Done(): + return + case _, ok := <-resize: + if !ok { + return + } + } + } +} + +// execCodeError adapts a command exit code to utilexec.ExitError, the interface +// the Virtual Kubelet exec handler inspects to report the exit status. +type execCodeError struct{ code int } + +var _ utilexec.ExitError = execCodeError{} + +func (e execCodeError) Error() string { + return fmt.Sprintf("command terminated with exit code %d", e.code) +} +func (e execCodeError) String() string { return e.Error() } +func (e execCodeError) Exited() bool { return true } +func (e execCodeError) ExitStatus() int { return e.code } diff --git a/pkg/provider/logs_exec_test.go b/pkg/provider/logs_exec_test.go new file mode 100644 index 0000000..3e9a0e4 --- /dev/null +++ b/pkg/provider/logs_exec_test.go @@ -0,0 +1,138 @@ +package provider + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/chimerakang/macvz/pkg/runtime" + "github.com/virtual-kubelet/virtual-kubelet/errdefs" + vkapi "github.com/virtual-kubelet/virtual-kubelet/node/api" + utilexec "k8s.io/utils/exec" +) + +// --- log/exec hooks on the recording runtime --- + +// nopWriteCloser adapts a Writer to io.WriteCloser for AttachIO streams. +type nopWriteCloser struct{ io.Writer } + +func (nopWriteCloser) Close() error { return nil } + +// fakeAttachIO implements vkapi.AttachIO for exec tests. +type fakeAttachIO struct { + stdin io.Reader + stdout io.WriteCloser + stderr io.WriteCloser + tty bool + resize chan vkapi.TermSize +} + +func newAttachIO(stdin string, tty bool) (*fakeAttachIO, *bytes.Buffer, *bytes.Buffer) { + out, errb := &bytes.Buffer{}, &bytes.Buffer{} + return &fakeAttachIO{ + stdin: strings.NewReader(stdin), + stdout: nopWriteCloser{out}, + stderr: nopWriteCloser{errb}, + tty: tty, + resize: make(chan vkapi.TermSize), + }, out, errb +} + +func (a *fakeAttachIO) Stdin() io.Reader { return a.stdin } +func (a *fakeAttachIO) Stdout() io.WriteCloser { return a.stdout } +func (a *fakeAttachIO) Stderr() io.WriteCloser { return a.stderr } +func (a *fakeAttachIO) TTY() bool { return a.tty } +func (a *fakeAttachIO) Resize() <-chan vkapi.TermSize { return a.resize } + +func runningProvider(t *testing.T) (*Provider, *recordingRuntime) { + t.Helper() + p, rt := newTestProvider() + if err := p.CreatePod(context.Background(), testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + return p, rt +} + +func TestGetContainerLogsStreams(t *testing.T) { + p, rt := runningProvider(t) + rt.logData = "hello from alpine\n" + + rc, err := p.GetContainerLogs(context.Background(), "default", "p1", "web", vkapi.ContainerLogOpts{Tail: 10}) + if err != nil { + t.Fatalf("GetContainerLogs: %v", err) + } + defer rc.Close() + got, _ := io.ReadAll(rc) + if string(got) != "hello from alpine\n" { + t.Errorf("logs = %q, want greeting", string(got)) + } + if rt.lastLogOpts.Tail != 10 { + t.Errorf("tail not propagated: %+v", rt.lastLogOpts) + } +} + +func TestGetContainerLogsUnknownPod(t *testing.T) { + p, _ := newTestProvider() + _, err := p.GetContainerLogs(context.Background(), "default", "missing", "web", vkapi.ContainerLogOpts{}) + if !errdefs.IsNotFound(err) { + t.Errorf("expected NotFound for unknown pod, got %v", err) + } +} + +func TestGetContainerLogsUnknownContainer(t *testing.T) { + p, _ := runningProvider(t) + _, err := p.GetContainerLogs(context.Background(), "default", "p1", "nope", vkapi.ContainerLogOpts{}) + if !errdefs.IsNotFound(err) { + t.Errorf("expected NotFound for unknown container, got %v", err) + } +} + +func TestRunInContainerWiresStreams(t *testing.T) { + p, rt := runningProvider(t) + rt.execStdout = "stdout text" + rt.execStderr = "stderr text" + + attach, out, errb := newAttachIO("input", false) + err := p.RunInContainer(context.Background(), "default", "p1", "web", []string{"sh", "-c", "echo hi"}, attach) + if err != nil { + t.Fatalf("RunInContainer: %v", err) + } + if out.String() != "stdout text" { + t.Errorf("stdout = %q, want %q", out.String(), "stdout text") + } + if errb.String() != "stderr text" { + t.Errorf("stderr = %q, want %q", errb.String(), "stderr text") + } + if strings.Join(rt.lastExecCmd, " ") != "sh -c echo hi" { + t.Errorf("cmd = %v, want [sh -c echo hi]", rt.lastExecCmd) + } +} + +func TestRunInContainerNonZeroExit(t *testing.T) { + p, rt := runningProvider(t) + rt.execErr = &runtime.ExitError{Code: 7} + + attach, _, _ := newAttachIO("", false) + err := p.RunInContainer(context.Background(), "default", "p1", "web", []string{"false"}, attach) + if err == nil { + t.Fatal("expected an error for non-zero exit") + } + exitErr, ok := err.(utilexec.ExitError) + if !ok { + t.Fatalf("error %T does not implement utilexec.ExitError", err) + } + if !exitErr.Exited() || exitErr.ExitStatus() != 7 { + t.Errorf("exit status = %d (exited=%v), want 7/true", exitErr.ExitStatus(), exitErr.Exited()) + } +} + +func TestRunInContainerUnknownPod(t *testing.T) { + p, _ := newTestProvider() + attach, _, _ := newAttachIO("", false) + err := p.RunInContainer(context.Background(), "default", "missing", "web", []string{"true"}, attach) + if !errdefs.IsNotFound(err) { + t.Errorf("expected NotFound for unknown pod, got %v", err) + } +} diff --git a/pkg/provider/pod_test.go b/pkg/provider/pod_test.go index ead68c8..384e837 100644 --- a/pkg/provider/pod_test.go +++ b/pkg/provider/pod_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "strings" "sync" "testing" "time" @@ -31,6 +32,15 @@ type recordingRuntime struct { destroyedIDs []string pullErr, createErr, startErr, stopErr, destroyErr, statusErr error + + // log/exec behavior + logData string + lastLogOpts runtime.LogOptions + logErr error + execStdout string + execStderr string + execErr error + lastExecCmd []string } func newRecordingRuntime() *recordingRuntime { @@ -104,10 +114,29 @@ func (r *recordingRuntime) Status(_ context.Context, id string) (runtime.Status, return st, nil } -func (r *recordingRuntime) Logs(context.Context, string, runtime.LogOptions) (io.ReadCloser, error) { - return nil, nil +func (r *recordingRuntime) Logs(_ context.Context, _ string, opts runtime.LogOptions) (io.ReadCloser, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.lastLogOpts = opts + if r.logErr != nil { + return nil, r.logErr + } + return io.NopCloser(strings.NewReader(r.logData)), nil +} + +func (r *recordingRuntime) Exec(_ context.Context, _ string, cmd []string, sio runtime.ExecIO) error { + r.mu.Lock() + stdout, stderr, execErr := r.execStdout, r.execStderr, r.execErr + r.lastExecCmd = cmd + r.mu.Unlock() + if sio.Stdout != nil && stdout != "" { + io.WriteString(sio.Stdout, stdout) + } + if sio.Stderr != nil && stderr != "" { + io.WriteString(sio.Stderr, stderr) + } + return execErr } -func (r *recordingRuntime) Exec(context.Context, string, []string, runtime.ExecIO) error { return nil } func (r *recordingRuntime) setStatus(id string, st runtime.Status) { r.mu.Lock() From 7ea7175d5f778697d427efd57556acf4d665218a Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:48:01 +0800 Subject: [PATCH 7/8] P2: wire pod controller + kubelet server, add RBAC/manifests/smoke test (#19) Tie the virtual node together end-to-end and document the P2 acceptance path. Serving (the code needed for the smoke test to actually work): - cmd/macvz-kubelet/serve.go: start the Virtual Kubelet pod controller with pod (node-filtered) / configmap / secret / service informers and an event recorder, so scheduled Pods become micro-VMs via the provider. Start an HTTPS kubelet API server (api.PodHandler) routing kubectl logs/exec to the provider; it is a no-op with a clear warning when no serving cert is configured, so Pods still run without logs/exec. - main.go starts both after the node becomes Ready and tears them down on shutdown. - pkg/config: add node.kubeletPort (default 10250) and servingTLSCertFile/ servingTLSKeyFile, validated (port range; cert+key set together). - pkg/provider: advertise the kubelet endpoint port in node DaemonEndpoints so the API server knows where to reach logs/exec. Manifests and docs: - deployments/rbac.yaml: ServiceAccount + ClusterRole/Binding scoped to the MVP (nodes/status, pods/status, configmaps/secrets/services read, events, leases). - deployments/alpine-smoke.yaml: single-container Alpine Pod tolerating the provider taint and targeting the virtual node. - docs/P2_SMOKE_TEST.md: build/configure/run, kubectl get nodes + describe, run Alpine, verify logs/exec (incl. non-zero exit and error cases), cleanup, and troubleshooting. Linked as P2 acceptance evidence from MASTER_TASKS. Dependency: bump google.golang.org/genproto to resolve an ambiguous-import conflict pulled in by the kubelet API server packages. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/macvz-kubelet/main.go | 20 +++- cmd/macvz-kubelet/serve.go | 123 ++++++++++++++++++++++++ config.example.yaml | 7 ++ deployments/alpine-smoke.yaml | 34 +++++++ deployments/rbac.yaml | 62 ++++++++++++ docs/MASTER_TASKS.md | 4 +- docs/P2_SMOKE_TEST.md | 149 +++++++++++++++++++++++++++++ go.mod | 60 ++++++++++-- go.sum | 173 +++++++++++++++++++++++++++++----- pkg/config/config.go | 19 ++++ pkg/config/config_test.go | 30 ++++++ pkg/provider/node.go | 9 ++ 12 files changed, 654 insertions(+), 36 deletions(-) create mode 100644 cmd/macvz-kubelet/serve.go create mode 100644 deployments/alpine-smoke.yaml create mode 100644 deployments/rbac.yaml create mode 100644 docs/P2_SMOKE_TEST.md diff --git a/cmd/macvz-kubelet/main.go b/cmd/macvz-kubelet/main.go index f510ed9..ac2daa4 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -12,6 +12,7 @@ import ( "net" "os" "os/signal" + "runtime" "syscall" "github.com/chimerakang/macvz/internal/version" @@ -127,6 +128,7 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { Labels: cfg.Node.Labels, Annotations: cfg.Node.Annotations, Taints: taints, + KubeletPort: cfg.Node.KubeletPort, } node := p.BuildNode(ctx, nodeSpec) @@ -193,10 +195,24 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { return nil } + // Start the Pod lifecycle controller so scheduled Pods become micro-VMs. + stopPods, err := startPodController(ctx, cfg, clientset, p, runtime.NumCPU()) + if err != nil { + return fmt.Errorf("start pod controller: %w", err) + } + defer stopPods() + + // Start the kubelet API server for kubectl logs/exec (no-op without certs). + stopServer, err := startKubeletServer(ctx, cfg, p) + if err != nil { + return fmt.Errorf("start kubelet server: %w", err) + } + defer stopServer() + // Block until shutdown is requested, then let the cancelled context unwind - // the controller's run loop. + // the controller run loops. <-ctx.Done() - klog.InfoS("shutdown signal received; stopping node controller", "node", cfg.NodeName) + klog.InfoS("shutdown signal received; stopping controllers", "node", cfg.NodeName) <-nc.Done() if err := nc.Err(); err != nil { return fmt.Errorf("node controller shutdown: %w", err) diff --git a/cmd/macvz-kubelet/serve.go b/cmd/macvz-kubelet/serve.go new file mode 100644 index 0000000..8c12df4 --- /dev/null +++ b/cmd/macvz-kubelet/serve.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "path" + "time" + + "github.com/chimerakang/macvz/pkg/config" + "github.com/chimerakang/macvz/pkg/provider" + vknode "github.com/virtual-kubelet/virtual-kubelet/node" + vkapi "github.com/virtual-kubelet/virtual-kubelet/node/api" + "github.com/virtual-kubelet/virtual-kubelet/node/nodeutil" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" +) + +// informerResync is how often the shared informers do a full relist. +const informerResync = time.Minute + +// startPodController wires the Virtual Kubelet pod controller: pod/configmap/ +// secret/service informers (pods filtered to this node), an event recorder, and +// the controller itself driving the provider's Pod lifecycle. It returns a +// cleanup func to release the event broadcaster. +func startPodController(ctx context.Context, cfg config.Config, clientset *kubernetes.Clientset, p *provider.Provider, workers int) (func(), error) { + podFactory := informers.NewSharedInformerFactoryWithOptions(clientset, informerResync, nodeutil.PodInformerFilter(cfg.NodeName)) + scmFactory := informers.NewSharedInformerFactoryWithOptions(clientset, informerResync) + + podInformer := podFactory.Core().V1().Pods() + secretInformer := scmFactory.Core().V1().Secrets() + configMapInformer := scmFactory.Core().V1().ConfigMaps() + serviceInformer := scmFactory.Core().V1().Services() + + eb := record.NewBroadcaster() + eb.StartRecordingToSink(&corev1client.EventSinkImpl{Interface: clientset.CoreV1().Events(corev1.NamespaceAll)}) + recorder := eb.NewRecorder(scheme.Scheme, corev1.EventSource{Component: path.Join(cfg.NodeName, "pod-controller")}) + + pc, err := vknode.NewPodController(vknode.PodControllerConfig{ + PodClient: clientset.CoreV1(), + EventRecorder: recorder, + Provider: p, + PodInformer: podInformer, + SecretInformer: secretInformer, + ConfigMapInformer: configMapInformer, + ServiceInformer: serviceInformer, + }) + if err != nil { + eb.Shutdown() + return nil, fmt.Errorf("create pod controller: %w", err) + } + + podFactory.Start(ctx.Done()) + scmFactory.Start(ctx.Done()) + + go func() { + if err := pc.Run(ctx, workers); err != nil && ctx.Err() == nil { + klog.ErrorS(err, "pod controller stopped unexpectedly") + } + }() + + select { + case <-pc.Ready(): + klog.InfoS("pod controller ready", "node", cfg.NodeName, "workers", workers) + case <-pc.Done(): + eb.Shutdown() + return nil, fmt.Errorf("pod controller exited before becoming ready: %w", pc.Err()) + case <-ctx.Done(): + } + + return eb.Shutdown, nil +} + +// startKubeletServer starts the HTTPS kubelet API used by `kubectl logs`/`exec`, +// routing to the provider. It is a no-op (returning a no-op cleanup) when no +// serving certificate is configured, mirroring upstream Virtual Kubelet: Pods +// still run, but logs/exec are unavailable until certs are provided. +func startKubeletServer(ctx context.Context, cfg config.Config, p *provider.Provider) (func(), error) { + if cfg.Node.ServingTLSCertFile == "" || cfg.Node.ServingTLSKeyFile == "" { + klog.InfoS("kubelet TLS serving disabled (no servingTLSCertFile/KeyFile); kubectl logs/exec will be unavailable") + return func() {}, nil + } + + cert, err := tls.LoadX509KeyPair(cfg.Node.ServingTLSCertFile, cfg.Node.ServingTLSKeyFile) + if err != nil { + return nil, fmt.Errorf("load serving TLS keypair: %w", err) + } + + handler := vkapi.PodHandler(vkapi.PodHandlerConfig{ + RunInContainer: p.RunInContainer, + GetContainerLogs: p.GetContainerLogs, + GetPods: p.GetPods, + GetPodsFromKubernetes: p.GetPods, + }, false) + + addr := fmt.Sprintf(":%d", cfg.Node.KubeletPort) + listener, err := tls.Listen("tcp", addr, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", addr, err) + } + + srv := &http.Server{Handler: handler, ReadHeaderTimeout: 30 * time.Second} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed && ctx.Err() == nil { + klog.ErrorS(err, "kubelet API server stopped unexpectedly") + } + }() + klog.InfoS("kubelet API server listening", "addr", addr) + + return func() { + _ = srv.Close() + _ = listener.Close() + }, nil +} diff --git a/config.example.yaml b/config.example.yaml index 2f3eb63..652772a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -54,3 +54,10 @@ node: leaseDurationSeconds: 40 pingInterval: 10s # node liveness Ping cadence statusUpdateInterval: 1m # node status push + runtime readiness re-probe + + # Kubelet API (kubectl logs/exec). The Kubernetes API server connects to the + # node's InternalIP on kubeletPort over HTTPS. Provide a serving cert+key to + # enable logs/exec; leave them empty to run Pods without logs/exec support. + kubeletPort: 10250 + servingTLSCertFile: "" # e.g. /etc/macvz/pki/kubelet.crt + servingTLSKeyFile: "" # e.g. /etc/macvz/pki/kubelet.key diff --git a/deployments/alpine-smoke.yaml b/deployments/alpine-smoke.yaml new file mode 100644 index 0000000..c9e8481 --- /dev/null +++ b/deployments/alpine-smoke.yaml @@ -0,0 +1,34 @@ +# Smoke-test Pod for the P2 MVP: a single Alpine container that sleeps. +# +# It tolerates the MacVz provider taint and pins itself to a MacVz node so the +# scheduler places it on the virtual node rather than a real one. Replace the +# nodeSelector hostname with your node name (the macvz-kubelet `nodeName`). +# +# Apply with: kubectl apply -f deployments/alpine-smoke.yaml +apiVersion: v1 +kind: Pod +metadata: + name: alpine-smoke + namespace: default +spec: + # Land on the MacVz node. Either keep the type label selector, or pin to a + # specific host via kubernetes.io/hostname: . + nodeSelector: + type: virtual-kubelet + tolerations: + - key: virtual-kubelet.io/provider + operator: Equal + value: macvz + effect: NoSchedule + restartPolicy: Never + containers: + - name: alpine + image: alpine:3.20 + command: ["sleep", "3600"] + resources: + requests: + cpu: "250m" + memory: 64Mi + limits: + cpu: "500m" + memory: 128Mi diff --git a/deployments/rbac.yaml b/deployments/rbac.yaml new file mode 100644 index 0000000..621477e --- /dev/null +++ b/deployments/rbac.yaml @@ -0,0 +1,62 @@ +# RBAC for a macvz-kubelet virtual node, scoped to the P2 MVP. +# +# macvz-kubelet runs as a host process on the Mac (it drives apple/container, a +# host service) and talks to the cluster with the identity in its kubeconfig. +# For production, point that kubeconfig at the ServiceAccount token below; for +# local development an admin kubeconfig already covers these permissions. +# +# Apply with: kubectl apply -f deployments/rbac.yaml +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: macvz-kubelet + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: macvz-kubelet +rules: + # Register and maintain the virtual node and its status. + - apiGroups: [""] + resources: ["nodes", "nodes/status"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + + # Reconcile Pods scheduled to this node and report their status. + - apiGroups: [""] + resources: ["pods", "pods/status"] + verbs: ["get", "list", "watch", "update", "patch", "delete"] + + # Resolve Pod references (env, projected volumes) via the pod controller's + # informers. + - apiGroups: [""] + resources: ["configmaps", "secrets", "services"] + verbs: ["get", "list", "watch"] + + # Emit Pod/node lifecycle events. + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch", "update"] + + # Node heartbeat lease in kube-node-lease. + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: macvz-kubelet +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: macvz-kubelet +subjects: + - kind: ServiceAccount + name: macvz-kubelet + namespace: kube-system +# Note on `kubectl logs`/`exec`: these do not need extra permissions for the +# macvz-kubelet identity (it *serves* them). The API server proxies the request +# to the node's kubelet endpoint; the requesting user needs the usual +# pods/log and pods/exec (nodes/proxy) permissions, which cluster-admin has. diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index d9965f3..fbcd8fb 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -18,7 +18,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas - **P0** — `go build ./...`, lint, and tests are green in CI. - **P1** — Go boots an Alpine micro-VM in seconds; `logs`/`exec` work; per-host concurrent-VM ceiling and per-VM RAM overhead are measured and recorded. -- **P2** — `kubectl run alpine --image=alpine -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work; node shows in `kubectl get nodes`. +- **P2** — `kubectl run alpine --image=alpine -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work; node shows in `kubectl get nodes`. Operator-facing run/verify/cleanup steps and expected output are documented in [docs/P2_SMOKE_TEST.md](P2_SMOKE_TEST.md); RBAC and manifests under [deployments/](../deployments/). - **P3** — A Service backed by Pods on two different Macs is reachable through normal Kubernetes networking. - **P4** — Multi-node e2e suite green; signed/notarized `macvz-kubelet` build; volumes + image-arch handling supported. @@ -43,7 +43,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas | #16 | Implement Provider PodLifecycleHandler state and CRUD methods | P2 | in progress | | #17 | Translate Kubernetes Pod specs into runtime workload specs | P2 | in progress | | #18 | Wire kubectl logs and exec through the runtime | P2 | in progress | -| #19 | Add RBAC, manifests, and P2 MVP smoke test docs | P2 | open | +| #19 | Add RBAC, manifests, and P2 MVP smoke test docs | P2 | in progress | | #20 | Implement Kubernetes-coordinated Pod IPAM | P3 | open | | #21 | Bring up WireGuard mesh between MacVz nodes | P3 | open | | #22 | Connect micro-VM networking to the controllable Pod network path | P3 | open | diff --git a/docs/P2_SMOKE_TEST.md b/docs/P2_SMOKE_TEST.md new file mode 100644 index 0000000..2a2bc06 --- /dev/null +++ b/docs/P2_SMOKE_TEST.md @@ -0,0 +1,149 @@ +# P2 MVP Smoke Test + +This guide runs `macvz-kubelet` as a Virtual Kubelet node, schedules an Alpine +Pod onto it as an `apple/container` micro-VM, and verifies `kubectl logs` and +`kubectl exec`. It is the operator-facing acceptance path for **P2 — Virtual +Kubelet Provider MVP**. + +## Prerequisites + +- An Apple Silicon Mac with [`apple/container`](https://github.com/apple/container) + installed and started: `container system start` (verify with + `container system status`). +- A reachable Kubernetes cluster and a kubeconfig with permissions to register + nodes and manage Pods (an admin kubeconfig is simplest for local dev; see + [deployments/rbac.yaml](../deployments/rbac.yaml) for the scoped permissions). +- The cluster's API server must be able to reach this Mac's IP on the kubelet + port (default `10250`) for `kubectl logs`/`exec` to work. + +## 1. Apply RBAC (optional for admin kubeconfig) + +```sh +kubectl apply -f deployments/rbac.yaml +``` + +## 2. Build macvz-kubelet + +```sh +make build # produces ./bin/macvz-kubelet +``` + +## 3. Configure + +Copy and edit the example config: + +```sh +cp config.example.yaml ./config.yaml +# Set nodeName, kubeconfigPath, and (for logs/exec) the serving TLS cert/key. +``` + +For `kubectl logs`/`exec`, generate a serving certificate whose SAN covers the +Mac's IP/hostname and is trusted by your cluster, then set `servingTLSCertFile` +and `servingTLSKeyFile` in `config.yaml`. A quick self-signed cert for local +testing: + +```sh +mkdir -p pki +openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout pki/kubelet.key -out pki/kubelet.crt \ + -subj "/CN=$(hostname)" \ + -addext "subjectAltName=IP:$(ipconfig getifaddr en0),DNS:$(hostname)" +``` + +> Without a serving cert the node still registers and runs Pods, but +> `kubectl logs`/`exec` are unavailable (macvz-kubelet logs a warning at start). + +## 4. Run the node + +```sh +./bin/macvz-kubelet --config ./config.yaml +``` + +Expected startup logs: + +``` +"starting macvz-kubelet" ... +"apple/container runtime is ready" +"registering virtual node" node="" cpu="2" memory="4Gi" pods="20" ... +"virtual node registered and ready" node="" +"pod controller ready" node="" +"kubelet API server listening" addr=":10250" # only with serving certs +``` + +In another terminal, confirm the node is present and Ready: + +```sh +kubectl get nodes +# NAME STATUS ROLES AGE VERSION +# Ready agent 10s + +kubectl describe node +# Shows Capacity/Allocatable (cpu/memory/pods), the +# virtual-kubelet.io/provider=macvz:NoSchedule taint, labels, and Ready=True. +``` + +## 5. Run Alpine and verify logs/exec + +```sh +# Pin the node name in the manifest's nodeSelector if you scheduled by hostname. +kubectl apply -f deployments/alpine-smoke.yaml + +kubectl get pod alpine-smoke -w # wait for Running +``` + +`kubectl logs` (Alpine sleeps quietly, so exec is the clearer check): + +```sh +kubectl exec alpine-smoke -- sh -c 'echo hello-from-macvz; uname -m' +# hello-from-macvz +# aarch64 + +kubectl logs alpine-smoke # streams the workload's output +``` + +A non-zero command surfaces its exit code coherently: + +```sh +kubectl exec alpine-smoke -- sh -c 'exit 7'; echo "exit=$?" +# command terminated with exit code 7 +# exit=7 +``` + +Error cases return clear messages: + +```sh +kubectl exec alpine-smoke -c nope -- true # unknown container -> NotFound +kubectl logs missing-pod # unknown pod -> NotFound +``` + +## 6. Cleanup + +```sh +# Remove the test Pod (tears down the micro-VM). +kubectl delete -f deployments/alpine-smoke.yaml + +# Stop macvz-kubelet (Ctrl-C). The node lease stops renewing and Kubernetes +# marks the node NotReady, then removes it after the node-monitor grace period. +# To remove it immediately: +kubectl delete node + +# Remove RBAC if applied. +kubectl delete -f deployments/rbac.yaml + +# Optional: confirm no micro-VMs remain on the host. +container ls --all +``` + +## Troubleshooting + +- **Node never becomes Ready**: check the kubeconfig and that the API server is + reachable; a missing/invalid kubeconfig fails loudly at startup. +- **Pod stuck Pending**: confirm the Pod tolerates `virtual-kubelet.io/provider` + and targets the node (nodeSelector/affinity). +- **Pod Failed with `UnsupportedPodSpec`**: the MVP runs single-container Pods + only; multi-container, init containers, user volumes, and securityContext are + rejected with a clear message (`kubectl describe pod`). +- **Pod Failed with `ImageArchitectureMismatch`**: the image has no linux/arm64 + variant; MacVz boots arm64 micro-VMs (amd64/Rosetta is deferred to P4). +- **`kubectl logs`/`exec` hang or error**: ensure serving certs are configured + and the API server can reach the Mac on `kubeletPort`. diff --git a/go.mod b/go.mod index e06cbca..97c3b39 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/chimerakang/macvz -go 1.25.0 +go 1.25.8 require ( github.com/virtual-kubelet/virtual-kubelet v1.12.0 @@ -13,24 +13,41 @@ require ( ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -43,27 +60,50 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.etcd.io/etcd/api/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/v3 v3.6.5 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect k8s.io/apiserver v0.35.0 // indirect k8s.io/component-base v0.35.0 // indirect + k8s.io/kms v0.35.0 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/kubelet v0.35.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 13ac95b..4a6d1e6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -10,16 +16,25 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ= github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -28,10 +43,19 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -42,8 +66,11 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -58,6 +85,12 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -78,10 +111,24 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -127,10 +174,18 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 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= @@ -142,58 +197,112 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/virtual-kubelet/virtual-kubelet v1.12.0 h1:6RnRE3egGnqw3BDL9PBbP5DPV6OaXC2h/nfq5c7VsF4= github.com/virtual-kubelet/virtual-kubelet v1.12.0/go.mod h1:dVlVEsFfrrwAcj/v0eDGgkTF5r+eAsBnn9gDxx3au2s= 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/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/pkg/v3 v3.6.5 h1:byxWB4AqIKI4SBmquZUG1WGtvMfMaorXFoCcFbVeoxM= +go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/server/v3 v3.6.5 h1:4RbUb1Bd4y1WkBHmuF+cZII83JNQMuNXzyjwigQ06y0= +go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -201,19 +310,33 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw= +google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -223,8 +346,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -232,6 +355,8 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -253,12 +378,16 @@ k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= +k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c= k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/pkg/config/config.go b/pkg/config/config.go index 5f7468f..fae1698 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -92,6 +92,17 @@ type NodeConfig struct { // when leases are enabled. It is also the cadence at which MacVz re-probes // runtime readiness. Parsed as a Go duration (e.g. "1m"). StatusUpdateInterval string `yaml:"statusUpdateInterval"` + + // KubeletPort is the port the node's kubelet API (logs/exec) listens on. + // The Kubernetes API server connects here to serve `kubectl logs`/`exec`. + KubeletPort int32 `yaml:"kubeletPort"` + + // ServingTLSCertFile and ServingTLSKeyFile enable the kubelet HTTPS server. + // When either is empty the server is not started: Pods still run, but + // `kubectl logs`/`exec` are unavailable. The API server must be able to + // reach the node's InternalIP on KubeletPort and trust the serving cert. + ServingTLSCertFile string `yaml:"servingTLSCertFile"` + ServingTLSKeyFile string `yaml:"servingTLSKeyFile"` } // TaintConfig is a YAML-friendly node taint. @@ -130,6 +141,8 @@ func Default() Config { LeaseDurationSeconds: 40, PingInterval: "10s", StatusUpdateInterval: "1m", + // Standard kubelet API port. + KubeletPort: 10250, }, } } @@ -275,5 +288,11 @@ func (c Config) Validate() error { if c.Node.EnableLease && c.Node.LeaseDurationSeconds <= 0 { return fmt.Errorf("node.leaseDurationSeconds must be positive when leases are enabled, got %d", c.Node.LeaseDurationSeconds) } + if c.Node.KubeletPort <= 0 || c.Node.KubeletPort > 65535 { + return fmt.Errorf("node.kubeletPort must be in 1..65535, got %d", c.Node.KubeletPort) + } + if (c.Node.ServingTLSCertFile == "") != (c.Node.ServingTLSKeyFile == "") { + return fmt.Errorf("node.servingTLSCertFile and node.servingTLSKeyFile must be set together") + } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index abbb644..5f89d33 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -187,6 +187,36 @@ func TestValidateRejectsBadLeaseDuration(t *testing.T) { } } +func TestDefaultKubeletPort(t *testing.T) { + if Default().Node.KubeletPort != 10250 { + t.Errorf("default KubeletPort = %d, want 10250", Default().Node.KubeletPort) + } +} + +func TestValidateRejectsBadKubeletPort(t *testing.T) { + c := Default() + c.Node.KubeletPort = 0 + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject KubeletPort 0") + } + c.Node.KubeletPort = 70000 + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject KubeletPort > 65535") + } +} + +func TestValidateRejectsUnpairedServingTLS(t *testing.T) { + c := Default() + c.Node.ServingTLSCertFile = "/tmp/cert.pem" + if err := c.Validate(); err == nil { + t.Fatal("Validate should reject cert without key") + } + c.Node.ServingTLSKeyFile = "/tmp/key.pem" + if err := c.Validate(); err != nil { + t.Fatalf("cert+key together should validate: %v", err) + } +} + func TestRestConfigMissingKubeconfigErrors(t *testing.T) { c := Default() c.KubeconfigPath = filepath.Join(t.TempDir(), "absent.kubeconfig") diff --git a/pkg/provider/node.go b/pkg/provider/node.go index 54fe16e..8fb26d6 100644 --- a/pkg/provider/node.go +++ b/pkg/provider/node.go @@ -24,6 +24,9 @@ type NodeSpec struct { Labels, Annotations map[string]string // Taints gate scheduling so only Pods that tolerate MacVz land here. Taints []corev1.Taint + // KubeletPort is advertised as the node's kubelet endpoint so the API + // server knows where to reach logs/exec. Zero leaves it unset. + KubeletPort int32 } // BuildNode assembles the corev1.Node this provider registers. The Ready @@ -70,6 +73,12 @@ func (p *Provider) BuildNode(ctx context.Context, spec NodeSpec) *corev1.Node { }, } + if spec.KubeletPort > 0 { + node.Status.DaemonEndpoints = corev1.NodeDaemonEndpoints{ + KubeletEndpoint: corev1.DaemonEndpoint{Port: spec.KubeletPort}, + } + } + if spec.InternalIP != "" { node.Status.Addresses = []corev1.NodeAddress{ {Type: corev1.NodeInternalIP, Address: spec.InternalIP}, From badb4bdfb7e364752403f67baab3cb5abf937b41 Mon Sep 17 00:00:00 2001 From: Chimera Date: Fri, 19 Jun 2026 00:59:24 +0800 Subject: [PATCH 8/8] Fix P2 provider status semantics --- README.md | 2 +- README.zh-TW.md | 2 +- cmd/macvz-kubelet/main.go | 6 +++-- docs/MASTER_TASKS.md | 2 +- pkg/provider/logs_exec_test.go | 2 +- pkg/provider/node_test.go | 14 +++++++++++ pkg/provider/pod.go | 4 ++-- pkg/provider/pod_test.go | 36 ++++++++++++++++++++++++++-- pkg/provider/status.go | 32 +++++++++++++++++++++++++ pkg/provider/translate.go | 3 +++ pkg/provider/translate_test.go | 8 +++++++ pkg/runtime/container/driver.go | 22 +++++++++++++++-- pkg/runtime/container/driver_test.go | 27 +++++++++++++++++++++ 13 files changed, 148 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 14c0c34..5995855 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ macvz/ - Register the Mac as a virtual node; advertise CPU/RAM capacity so the standard scheduler can place Pods. - Translate Pod spec → `pkg/runtime` calls (image, command/args, env, resource limits). - Wire up `kubectl logs` and `kubectl exec` through the provider. -- **Acceptance:** `kubectl run alpine --image=alpine -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work. +- **Acceptance:** `kubectl run alpine --image=alpine --restart=Never -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work. ### Phase 3 — Cross-host mesh networking diff --git a/README.zh-TW.md b/README.zh-TW.md index 1fc9fe2..54634f8 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -110,7 +110,7 @@ macvz/ - 將 Mac 註冊為虛擬節點;回報 CPU/RAM 容量,讓標準調度器能派 Pod。 - 將 Pod spec 翻譯成 `pkg/runtime` 呼叫(映像、command/args、env、資源限制)。 - 打通 `kubectl logs` 與 `kubectl exec`。 -- **驗收:** `kubectl run alpine --image=alpine -- sleep 3600` 能在 Mac 上落地一個 micro-VM,且 `kubectl logs`/`exec` 正常。 +- **驗收:** `kubectl run alpine --image=alpine --restart=Never -- sleep 3600` 能在 Mac 上落地一個 micro-VM,且 `kubectl logs`/`exec` 正常。 ### 階段三:跨主機網格網路 diff --git a/cmd/macvz-kubelet/main.go b/cmd/macvz-kubelet/main.go index ac2daa4..c1d37cc 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -128,7 +128,9 @@ func run(ctx context.Context, configPath, runtimeBinary string) error { Labels: cfg.Node.Labels, Annotations: cfg.Node.Annotations, Taints: taints, - KubeletPort: cfg.Node.KubeletPort, + } + if cfg.Node.ServingTLSCertFile != "" && cfg.Node.ServingTLSKeyFile != "" { + nodeSpec.KubeletPort = cfg.Node.KubeletPort } node := p.BuildNode(ctx, nodeSpec) @@ -230,7 +232,7 @@ func detectInternalIP() string { if err != nil { return "" } - defer conn.Close() + defer func() { _ = conn.Close() }() if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { return addr.IP.String() } diff --git a/docs/MASTER_TASKS.md b/docs/MASTER_TASKS.md index fbcd8fb..cf4a62b 100644 --- a/docs/MASTER_TASKS.md +++ b/docs/MASTER_TASKS.md @@ -18,7 +18,7 @@ Source of truth for the phased roadmap. Phases map to GitHub **Milestones**; tas - **P0** — `go build ./...`, lint, and tests are green in CI. - **P1** — Go boots an Alpine micro-VM in seconds; `logs`/`exec` work; per-host concurrent-VM ceiling and per-VM RAM overhead are measured and recorded. -- **P2** — `kubectl run alpine --image=alpine -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work; node shows in `kubectl get nodes`. Operator-facing run/verify/cleanup steps and expected output are documented in [docs/P2_SMOKE_TEST.md](P2_SMOKE_TEST.md); RBAC and manifests under [deployments/](../deployments/). +- **P2** — `kubectl run alpine --image=alpine --restart=Never -- sleep 3600` lands a micro-VM on the Mac; `kubectl logs`/`exec` work; node shows in `kubectl get nodes`. Operator-facing run/verify/cleanup steps and expected output are documented in [docs/P2_SMOKE_TEST.md](P2_SMOKE_TEST.md); RBAC and manifests under [deployments/](../deployments/). - **P3** — A Service backed by Pods on two different Macs is reachable through normal Kubernetes networking. - **P4** — Multi-node e2e suite green; signed/notarized `macvz-kubelet` build; volumes + image-arch handling supported. diff --git a/pkg/provider/logs_exec_test.go b/pkg/provider/logs_exec_test.go index 3e9a0e4..39465ca 100644 --- a/pkg/provider/logs_exec_test.go +++ b/pkg/provider/logs_exec_test.go @@ -63,7 +63,7 @@ func TestGetContainerLogsStreams(t *testing.T) { if err != nil { t.Fatalf("GetContainerLogs: %v", err) } - defer rc.Close() + defer func() { _ = rc.Close() }() got, _ := io.ReadAll(rc) if string(got) != "hello from alpine\n" { t.Errorf("logs = %q, want greeting", string(got)) diff --git a/pkg/provider/node_test.go b/pkg/provider/node_test.go index 5bcd119..c848d52 100644 --- a/pkg/provider/node_test.go +++ b/pkg/provider/node_test.go @@ -141,3 +141,17 @@ func TestBuildNodeOmitsEmptyInternalIP(t *testing.T) { } } } + +func TestBuildNodeAdvertisesKubeletPortOnlyWhenConfigured(t *testing.T) { + spec := testSpec() + node := New("n", fakeRuntime{}).BuildNode(context.Background(), spec) + if node.Status.DaemonEndpoints.KubeletEndpoint.Port != 0 { + t.Errorf("kubelet endpoint port = %d, want unset", node.Status.DaemonEndpoints.KubeletEndpoint.Port) + } + + spec.KubeletPort = 10250 + node = New("n", fakeRuntime{}).BuildNode(context.Background(), spec) + if node.Status.DaemonEndpoints.KubeletEndpoint.Port != 10250 { + t.Errorf("kubelet endpoint port = %d, want 10250", node.Status.DaemonEndpoints.KubeletEndpoint.Port) + } +} diff --git a/pkg/provider/pod.go b/pkg/provider/pod.go index 6c47585..19dfa70 100644 --- a/pkg/provider/pod.go +++ b/pkg/provider/pod.go @@ -115,8 +115,8 @@ func (p *Provider) UpdatePod(ctx context.Context, pod *corev1.Pod) error { if !ok { return errdefs.NotFoundf("pod %q is not known to this node", key) } - st.pod.ObjectMeta.Labels = pod.ObjectMeta.Labels - st.pod.ObjectMeta.Annotations = pod.ObjectMeta.Annotations + st.pod.Labels = pod.Labels + st.pod.Annotations = pod.Annotations klog.InfoS("updated Pod metadata", "pod", key) return nil } diff --git a/pkg/provider/pod_test.go b/pkg/provider/pod_test.go index 384e837..a38a7f1 100644 --- a/pkg/provider/pod_test.go +++ b/pkg/provider/pod_test.go @@ -130,10 +130,10 @@ func (r *recordingRuntime) Exec(_ context.Context, _ string, cmd []string, sio r r.lastExecCmd = cmd r.mu.Unlock() if sio.Stdout != nil && stdout != "" { - io.WriteString(sio.Stdout, stdout) + _, _ = io.WriteString(sio.Stdout, stdout) } if sio.Stderr != nil && stderr != "" { - io.WriteString(sio.Stderr, stderr) + _, _ = io.WriteString(sio.Stderr, stderr) } return execErr } @@ -313,6 +313,38 @@ func TestGetPodStatusMapsLostWorkload(t *testing.T) { } } +func TestGetPodStatusPreservesPreviousStatusOnRuntimeError(t *testing.T) { + p, rt := newTestProvider() + ctx := context.Background() + if err := p.CreatePod(ctx, testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + st, err := p.GetPodStatus(ctx, "default", "p1") + if err != nil { + t.Fatalf("GetPodStatus: %v", err) + } + if st.Phase != corev1.PodRunning { + t.Fatalf("phase = %q, want Running before injected error", st.Phase) + } + + rt.statusErr = runtime.ErrNotReady + st, err = p.GetPodStatus(ctx, "default", "p1") + if err != nil { + t.Fatalf("GetPodStatus with transient runtime error: %v", err) + } + if st.Phase != corev1.PodRunning { + t.Errorf("phase regressed to %q, want Running", st.Phase) + } + if st.Message == "" { + t.Error("expected runtime error message to be surfaced on Pod status") + } + for _, c := range st.Conditions { + if c.Type == corev1.PodReady && c.Reason != "RuntimeStatusError" { + t.Errorf("Ready condition reason = %q, want RuntimeStatusError", c.Reason) + } + } +} + func TestDeletePodIsIdempotent(t *testing.T) { p, rt := newTestProvider() ctx := context.Background() diff --git a/pkg/provider/status.go b/pkg/provider/status.go index d918745..c80ebca 100644 --- a/pkg/provider/status.go +++ b/pkg/provider/status.go @@ -42,6 +42,14 @@ func (p *Provider) reconcileStatus(ctx context.Context, st *podState) corev1.Pod statuses = append(statuses, terminatedStatus(c, w.id, 0, "Lost", "runtime workload not found")) continue } + // A transient inspect/runtime error should not regress an already + // running Pod back to Pending. Preserve the last observed state when + // available, and expose the runtime error through Pod conditions. + if prev, ok := previousContainerStatus(st.pod.Status, c.Name); ok { + statuses = append(statuses, prev) + status.Message = err.Error() + continue + } statuses = append(statuses, waitingStatus(c, "RuntimeError", err.Error())) continue } @@ -54,9 +62,21 @@ func (p *Provider) reconcileStatus(ctx context.Context, st *podState) corev1.Pod status.ContainerStatuses = statuses status.Phase = aggregatePhase(statuses) status.Conditions = podConditions(status.Phase) + if status.Message != "" { + status.Conditions = podConditionsWithRuntimeError(status.Conditions, status.Message) + } return status } +func previousContainerStatus(status corev1.PodStatus, name string) (corev1.ContainerStatus, bool) { + for _, s := range status.ContainerStatuses { + if s.Name == name { + return *s.DeepCopy(), true + } + } + return corev1.ContainerStatus{}, false +} + // containerStatus maps a single runtime status to a Kubernetes container status. func containerStatus(c corev1.Container, id string, rs runtime.Status) corev1.ContainerStatus { cs := corev1.ContainerStatus{ @@ -171,6 +191,18 @@ func podConditions(phase corev1.PodPhase) []corev1.PodCondition { } } +func podConditionsWithRuntimeError(conditions []corev1.PodCondition, msg string) []corev1.PodCondition { + out := append([]corev1.PodCondition(nil), conditions...) + for i := range out { + if out[i].Type == corev1.PodReady || out[i].Type == corev1.ContainersReady { + out[i].Status = corev1.ConditionFalse + out[i].Reason = "RuntimeStatusError" + out[i].Message = msg + } + } + return out +} + func boolPtr(b bool) *bool { return &b } // nonZero ensures a failed container reports a non-zero exit code even when the diff --git a/pkg/provider/translate.go b/pkg/provider/translate.go index 7a2b56d..1a0d852 100644 --- a/pkg/provider/translate.go +++ b/pkg/provider/translate.go @@ -50,6 +50,9 @@ func unsupportedReasons(pod *corev1.Pod) []string { if n := len(pod.Spec.EphemeralContainers); n > 0 { reasons = append(reasons, fmt.Sprintf("ephemeral containers are not supported yet (found %d)", n)) } + if pod.Spec.RestartPolicy != "" && pod.Spec.RestartPolicy != corev1.RestartPolicyNever { + reasons = append(reasons, fmt.Sprintf("restartPolicy %q is not supported yet (only Never is supported in the MVP)", pod.Spec.RestartPolicy)) + } for _, v := range pod.Spec.Volumes { if isDefaultProjectedToken(v) { diff --git a/pkg/provider/translate_test.go b/pkg/provider/translate_test.go index 495a72d..5b8ade2 100644 --- a/pkg/provider/translate_test.go +++ b/pkg/provider/translate_test.go @@ -154,6 +154,14 @@ func TestTranslatePodUnsupported(t *testing.T) { }, wantSub: "hostNetwork", }, + { + name: "restart policy always", + spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyAlways, + Containers: []corev1.Container{{Name: "a", Image: "x"}}, + }, + wantSub: "restartPolicy", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/runtime/container/driver.go b/pkg/runtime/container/driver.go index 8144a16..9fdaa7f 100644 --- a/pkg/runtime/container/driver.go +++ b/pkg/runtime/container/driver.go @@ -323,6 +323,8 @@ type inspectResult struct { Status struct { State string `json:"state"` StartedDate string `json:"startedDate"` + ExitCode *int `json:"exitCode"` + ExitStatus *int `json:"exitStatus"` Networks []struct { IPv4Address string `json:"ipv4Address"` } `json:"networks"` @@ -340,7 +342,11 @@ func parseStatus(id string, out []byte) (runtime.Status, error) { } r := results[0] - st := runtime.Status{ID: r.ID, Phase: phaseFor(r.Status.State, r.Status.StartedDate)} + exitCode := firstInt(r.Status.ExitCode, r.Status.ExitStatus) + st := runtime.Status{ID: r.ID, Phase: phaseFor(r.Status.State, r.Status.StartedDate, exitCode)} + if exitCode != nil { + st.ExitCode = *exitCode + } if r.Status.StartedDate != "" { if t, err := time.Parse(time.RFC3339, r.Status.StartedDate); err == nil { st.StartedAt = t @@ -359,7 +365,7 @@ func parseStatus(id string, out []byte) (runtime.Status, error) { // phaseFor translates an apple/container state string into a runtime.Phase. A // freshly created, never-started workload reports "stopped" with no start time, // which we surface as PhaseCreated to distinguish it from a stopped run. -func phaseFor(state, startedDate string) runtime.Phase { +func phaseFor(state, startedDate string, exitCode *int) runtime.Phase { switch strings.ToLower(state) { case "running": return runtime.PhaseRunning @@ -367,12 +373,24 @@ func phaseFor(state, startedDate string) runtime.Phase { if startedDate == "" { return runtime.PhaseCreated } + if exitCode != nil && *exitCode != 0 { + return runtime.PhaseFailed + } return runtime.PhaseStopped default: return runtime.PhaseUnknown } } +func firstInt(values ...*int) *int { + for _, v := range values { + if v != nil { + return v + } + } + return nil +} + // mapErr translates a CLI failure into a typed runtime sentinel where the // stderr text is recognizable, otherwise returns it unchanged. func mapErr(err error) error { diff --git a/pkg/runtime/container/driver_test.go b/pkg/runtime/container/driver_test.go index 68886c1..56defa4 100644 --- a/pkg/runtime/container/driver_test.go +++ b/pkg/runtime/container/driver_test.go @@ -293,6 +293,33 @@ func TestStatusCreatedVsStopped(t *testing.T) { } } +func TestStatusParsesNonZeroExitCode(t *testing.T) { + body := `[{"id":"x","status":{"state":"stopped","startedDate":"2026-06-18T13:55:56Z","exitCode":7,"networks":[]}}]` + f := &fakeRunner{outputs: map[string][]byte{"inspect": []byte(body)}} + st, err := driverWith(f).Status(context.Background(), "x") + if err != nil { + t.Fatalf("Status: %v", err) + } + if st.Phase != runtime.PhaseFailed { + t.Errorf("Phase = %q, want Failed", st.Phase) + } + if st.ExitCode != 7 { + t.Errorf("ExitCode = %d, want 7", st.ExitCode) + } +} + +func TestStatusParsesExitStatusAlias(t *testing.T) { + body := `[{"id":"x","status":{"state":"stopped","startedDate":"2026-06-18T13:55:56Z","exitStatus":3,"networks":[]}}]` + f := &fakeRunner{outputs: map[string][]byte{"inspect": []byte(body)}} + st, err := driverWith(f).Status(context.Background(), "x") + if err != nil { + t.Fatalf("Status: %v", err) + } + if st.Phase != runtime.PhaseFailed || st.ExitCode != 3 { + t.Errorf("status = phase %q exit %d, want Failed/3", st.Phase, st.ExitCode) + } +} + func mustPhase(t *testing.T, body string) runtime.Phase { t.Helper() f := &fakeRunner{outputs: map[string][]byte{"inspect": []byte(body)}}