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 1a38c76..c1d37cc 100644 --- a/cmd/macvz-kubelet/main.go +++ b/cmd/macvz-kubelet/main.go @@ -9,11 +9,19 @@ import ( "context" "flag" "fmt" + "net" "os" + "os/signal" + "runtime" + "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" + "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) @@ -40,7 +48,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 +60,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 +85,156 @@ 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) + + // 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() + } + nodeSpec := 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, + } + if cfg.Node.ServingTLSCertFile != "" && cfg.Node.ServingTLSKeyFile != "" { + nodeSpec.KubeletPort = cfg.Node.KubeletPort + } + 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, + ) + + // 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( + nodeProvider, + node, + clientset.CoreV1().Nodes(), + opts..., + ) + 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 + } + + // 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 run loops. + <-ctx.Done() + 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) + } + klog.InfoS("macvz-kubelet stopped cleanly") return nil } + +// 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 func() { _ = conn.Close() }() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { + return addr.IP.String() + } + return "" +} 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 ab077e9..652772a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,3 +17,47 @@ 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 + + # 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 + + # 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 fa9ec85..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`. +- **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. @@ -37,13 +37,13 @@ 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 | -| #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 | -| #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 | +| #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 | 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 | 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 1180756..97c3b39 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,109 @@ module github.com/chimerakang/macvz -go 1.25.0 +go 1.25.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 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 ) 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 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/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/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 - k8s.io/apimachinery v0.35.0 // indirect - k8s.io/client-go v0.35.0 // 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/utils v0.0.0-20251002143259-bc988d571ff4 // 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 dfd5bce..4a6d1e6 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,61 @@ +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= 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/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= +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/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= @@ -29,21 +66,71 @@ 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= +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/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= +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/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= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -51,8 +138,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 +152,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 +165,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= @@ -80,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= @@ -95,36 +197,157 @@ 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/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/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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.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.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.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.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.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.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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.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= +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.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= @@ -132,25 +355,39 @@ 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= 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/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 ff18967..fae1698 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,8 +5,13 @@ package config import ( "fmt" "os" + "time" "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" ) // Config is the on-disk configuration for a macvz-kubelet node process. @@ -30,8 +35,87 @@ 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"` + + // 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"` + + // 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. +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() @@ -40,7 +124,85 @@ 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), + }}, + // Heartbeat defaults mirror Virtual Kubelet's own defaults. + EnableLease: true, + LeaseDurationSeconds: 40, + PingInterval: "10s", + StatusUpdateInterval: "1m", + // Standard kubelet API port. + KubeletPort: 10250, + }, + } +} + +// 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 +} + +// 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. @@ -70,10 +232,67 @@ 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 == "" { 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 + } + 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) + } + 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 1f11639..5f89d33 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + "time" + + corev1 "k8s.io/api/core/v1" ) func TestLoadMissingPathReturnsDefaults(t *testing.T) { @@ -58,3 +61,214 @@ func TestRuntimeSocketIsOptional(t *testing.T) { t.Fatalf("RuntimeSocket is reserved for future service API use and should be optional: %v", err) } } + +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 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 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") + 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) + } +} 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..39465ca --- /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 func() { _ = 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/node.go b/pkg/provider/node.go new file mode 100644 index 0000000..8fb26d6 --- /dev/null +++ b/pkg/provider/node.go @@ -0,0 +1,139 @@ +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 + // 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 +// 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.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}, + {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_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): + } +} diff --git a/pkg/provider/node_test.go b/pkg/provider/node_test.go new file mode 100644 index 0000000..c848d52 --- /dev/null +++ b/pkg/provider/node_test.go @@ -0,0 +1,157 @@ +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) + } + } +} + +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 new file mode 100644 index 0000000..19dfa70 --- /dev/null +++ b/pkg/provider/pod.go @@ -0,0 +1,190 @@ +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 + } + + // 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 + + 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, "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 +} + +// 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.Labels = pod.Labels + st.pod.Annotations = pod.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..a38a7f1 --- /dev/null +++ b/pkg/provider/pod_test.go @@ -0,0 +1,400 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "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 + + // log/exec behavior + logData string + lastLogOpts runtime.LogOptions + logErr error + execStdout string + execStderr string + execErr error + lastExecCmd []string +} + +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, 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) 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 TestCreatePodLaunchesContainer(t *testing.T) { + p, rt := newTestProvider() + if err := p.CreatePod(context.Background(), testPod("web")); err != nil { + t.Fatalf("CreatePod: %v", err) + } + pulls, creates, starts, _, _ := rt.counts() + 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 { + 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) != 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) + } +} + +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 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() + 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..0aaefac 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -1,64 +1,74 @@ // 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 + // 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 } -// 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..c80ebca --- /dev/null +++ b/pkg/provider/status.go @@ -0,0 +1,215 @@ +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 { + // 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, + } + + 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 + } + // 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 + } + 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) + 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{ + 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 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 +// 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..1a0d852 --- /dev/null +++ b/pkg/provider/translate.go @@ -0,0 +1,219 @@ +package provider + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/chimerakang/macvz/internal/types" + corev1 "k8s.io/api/core/v1" +) + +// 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)) + } + 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) { + 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, + 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 { + if e.ValueFrom == nil { // valueFrom is rejected in unsupportedReasons + spec.Env[e.Name] = e.Value + } + } + } + + // 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() + } + if mem := resourceQuantity(c, corev1.ResourceMemory); mem != nil { + spec.MemoryBytes = mem.Value() + } + 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 { + 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 } diff --git a/pkg/provider/translate_test.go b/pkg/provider/translate_test.go new file mode 100644 index 0000000..5b8ade2 --- /dev/null +++ b/pkg/provider/translate_test.go @@ -0,0 +1,250 @@ +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", + }, + { + 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) { + _, 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 +} 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)}}