From 0f930d408b4e246737cb7b307e7d2171ddb55630 Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti Date: Wed, 29 Apr 2026 10:23:15 +0530 Subject: [PATCH] service: add unit tests for LCOW v2 shim service layer Defines a narrow vmController interface in the service package and threads it through Service in place of the concrete *vm.Controller field. Production passes vm.New(); tests pass a generated gomock. The interface includes the methods pod.New requires (Guest, SCSIController, VPCIController, Plan9Controller, NetworkController) so the same field satisfies both Service's direct calls and pod.New's narrower interface via Go structural typing. Tests cover the production failure paths through the service RPC surface: Sandbox API (23 tests): - duplicate Create rejection, missing config.json, VM create / start failure - sandbox-id mismatch guards - stop success / failure / idempotency - wait clean / error / wait-failure / exit-status-failure exits - status mapping for every state, ping not-implemented - shutdown idempotency, running-VM termination, terminate-error swallowed - metrics success and stats failure Shimdiag API (10 tests): - pid passthrough, exec-in-host success / failure - tasks / share / stacks state guards - share missing host-path validation - stacks dump-failure and success Task API (16 tests): - consolidated state-guard and unknown-container guard tables - not-implemented surfaces, shutdown no-op - update validation: nil resources rejected, dispatch by resource type, per-resource failure surfaces - enrichNotFoundError pass-through and ErrNotFound preservation Mocks committed at mocks/mock_service.go with the standard build tag (windows && lcow). Standardising mock generation across controllers is tracked in #2707. Signed-off-by: Shreyansh Sancheti --- .../service/mocks/mock_service.go | 353 ++++++++++ .../service/service.go | 5 +- .../service/service_sandbox_internal_test.go | 656 ++++++++++++++++++ .../service/service_shimdiag_internal_test.go | 210 ++++++ .../service/service_task_internal_test.go | 564 +++++++++++++++ cmd/containerd-shim-lcow-v2/service/types.go | 92 +++ 6 files changed, 1878 insertions(+), 2 deletions(-) create mode 100644 cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go create mode 100644 cmd/containerd-shim-lcow-v2/service/service_sandbox_internal_test.go create mode 100644 cmd/containerd-shim-lcow-v2/service/service_shimdiag_internal_test.go create mode 100644 cmd/containerd-shim-lcow-v2/service/service_task_internal_test.go create mode 100644 cmd/containerd-shim-lcow-v2/service/types.go diff --git a/cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go b/cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go new file mode 100644 index 0000000000..7a2ed3711e --- /dev/null +++ b/cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go @@ -0,0 +1,353 @@ +//go:build windows && lcow + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/Microsoft/hcsshim/cmd/containerd-shim-lcow-v2/service (interfaces: vmController) +// +// Generated by this command: +// +// mockgen -build_flags=-tags=windows,lcow -build_constraint='windows && lcow' -package mocks -destination cmd/containerd-shim-lcow-v2/service/mocks/mock_service.go github.com/Microsoft/hcsshim/cmd/containerd-shim-lcow-v2/service vmController +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + time "time" + + stats "github.com/Microsoft/hcsshim/cmd/containerd-shim-runhcs-v1/stats" + lcow "github.com/Microsoft/hcsshim/internal/builder/vm/lcow" + plan9 "github.com/Microsoft/hcsshim/internal/controller/device/plan9" + scsi "github.com/Microsoft/hcsshim/internal/controller/device/scsi" + vpci "github.com/Microsoft/hcsshim/internal/controller/device/vpci" + network "github.com/Microsoft/hcsshim/internal/controller/network" + vm "github.com/Microsoft/hcsshim/internal/controller/vm" + schema2 "github.com/Microsoft/hcsshim/internal/hcs/schema2" + guestresource "github.com/Microsoft/hcsshim/internal/protocol/guestresource" + shimdiag "github.com/Microsoft/hcsshim/internal/shimdiag" + guestmanager "github.com/Microsoft/hcsshim/internal/vm/guestmanager" + gomock "go.uber.org/mock/gomock" +) + +// MockvmController is a mock of vmController interface. +type MockvmController struct { + ctrl *gomock.Controller + recorder *MockvmControllerMockRecorder + isgomock struct{} +} + +// MockvmControllerMockRecorder is the mock recorder for MockvmController. +type MockvmControllerMockRecorder struct { + mock *MockvmController +} + +// NewMockvmController creates a new mock instance. +func NewMockvmController(ctrl *gomock.Controller) *MockvmController { + mock := &MockvmController{ctrl: ctrl} + mock.recorder = &MockvmControllerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockvmController) EXPECT() *MockvmControllerMockRecorder { + return m.recorder +} + +// CreateVM mocks base method. +func (m *MockvmController) CreateVM(ctx context.Context, opts *vm.CreateOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateVM", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateVM indicates an expected call of CreateVM. +func (mr *MockvmControllerMockRecorder) CreateVM(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateVM", reflect.TypeOf((*MockvmController)(nil).CreateVM), ctx, opts) +} + +// DumpStacks mocks base method. +func (m *MockvmController) DumpStacks(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DumpStacks", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DumpStacks indicates an expected call of DumpStacks. +func (mr *MockvmControllerMockRecorder) DumpStacks(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DumpStacks", reflect.TypeOf((*MockvmController)(nil).DumpStacks), ctx) +} + +// ExecIntoHost mocks base method. +func (m *MockvmController) ExecIntoHost(ctx context.Context, request *shimdiag.ExecProcessRequest) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecIntoHost", ctx, request) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExecIntoHost indicates an expected call of ExecIntoHost. +func (mr *MockvmControllerMockRecorder) ExecIntoHost(ctx, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecIntoHost", reflect.TypeOf((*MockvmController)(nil).ExecIntoHost), ctx, request) +} + +// ExitStatus mocks base method. +func (m *MockvmController) ExitStatus() (*vm.ExitStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExitStatus") + ret0, _ := ret[0].(*vm.ExitStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExitStatus indicates an expected call of ExitStatus. +func (mr *MockvmControllerMockRecorder) ExitStatus() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExitStatus", reflect.TypeOf((*MockvmController)(nil).ExitStatus)) +} + +// Guest mocks base method. +func (m *MockvmController) Guest() *guestmanager.Guest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Guest") + ret0, _ := ret[0].(*guestmanager.Guest) + return ret0 +} + +// Guest indicates an expected call of Guest. +func (mr *MockvmControllerMockRecorder) Guest() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Guest", reflect.TypeOf((*MockvmController)(nil).Guest)) +} + +// NetworkController mocks base method. +func (m *MockvmController) NetworkController(networkNamespaceID string) *network.Controller { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NetworkController", networkNamespaceID) + ret0, _ := ret[0].(*network.Controller) + return ret0 +} + +// NetworkController indicates an expected call of NetworkController. +func (mr *MockvmControllerMockRecorder) NetworkController(networkNamespaceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NetworkController", reflect.TypeOf((*MockvmController)(nil).NetworkController), networkNamespaceID) +} + +// Plan9Controller mocks base method. +func (m *MockvmController) Plan9Controller() *plan9.Controller { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Plan9Controller") + ret0, _ := ret[0].(*plan9.Controller) + return ret0 +} + +// Plan9Controller indicates an expected call of Plan9Controller. +func (mr *MockvmControllerMockRecorder) Plan9Controller() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Plan9Controller", reflect.TypeOf((*MockvmController)(nil).Plan9Controller)) +} + +// RuntimeID mocks base method. +func (m *MockvmController) RuntimeID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RuntimeID") + ret0, _ := ret[0].(string) + return ret0 +} + +// RuntimeID indicates an expected call of RuntimeID. +func (mr *MockvmControllerMockRecorder) RuntimeID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RuntimeID", reflect.TypeOf((*MockvmController)(nil).RuntimeID)) +} + +// SCSIController mocks base method. +func (m *MockvmController) SCSIController() *scsi.Controller { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SCSIController") + ret0, _ := ret[0].(*scsi.Controller) + return ret0 +} + +// SCSIController indicates an expected call of SCSIController. +func (mr *MockvmControllerMockRecorder) SCSIController() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SCSIController", reflect.TypeOf((*MockvmController)(nil).SCSIController)) +} + +// SandboxOptions mocks base method. +func (m *MockvmController) SandboxOptions() *lcow.SandboxOptions { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SandboxOptions") + ret0, _ := ret[0].(*lcow.SandboxOptions) + return ret0 +} + +// SandboxOptions indicates an expected call of SandboxOptions. +func (mr *MockvmControllerMockRecorder) SandboxOptions() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SandboxOptions", reflect.TypeOf((*MockvmController)(nil).SandboxOptions)) +} + +// StartTime mocks base method. +func (m *MockvmController) StartTime() time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartTime") + ret0, _ := ret[0].(time.Time) + return ret0 +} + +// StartTime indicates an expected call of StartTime. +func (mr *MockvmControllerMockRecorder) StartTime() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartTime", reflect.TypeOf((*MockvmController)(nil).StartTime)) +} + +// StartVM mocks base method. +func (m *MockvmController) StartVM(ctx context.Context, opts *vm.StartOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartVM", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// StartVM indicates an expected call of StartVM. +func (mr *MockvmControllerMockRecorder) StartVM(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartVM", reflect.TypeOf((*MockvmController)(nil).StartVM), ctx, opts) +} + +// State mocks base method. +func (m *MockvmController) State() vm.State { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "State") + ret0, _ := ret[0].(vm.State) + return ret0 +} + +// State indicates an expected call of State. +func (mr *MockvmControllerMockRecorder) State() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "State", reflect.TypeOf((*MockvmController)(nil).State)) +} + +// Stats mocks base method. +func (m *MockvmController) Stats(ctx context.Context) (*stats.VirtualMachineStatistics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Stats", ctx) + ret0, _ := ret[0].(*stats.VirtualMachineStatistics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Stats indicates an expected call of Stats. +func (mr *MockvmControllerMockRecorder) Stats(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stats", reflect.TypeOf((*MockvmController)(nil).Stats), ctx) +} + +// TerminateVM mocks base method. +func (m *MockvmController) TerminateVM(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TerminateVM", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// TerminateVM indicates an expected call of TerminateVM. +func (mr *MockvmControllerMockRecorder) TerminateVM(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TerminateVM", reflect.TypeOf((*MockvmController)(nil).TerminateVM), ctx) +} + +// UpdateCPU mocks base method. +func (m *MockvmController) UpdateCPU(ctx context.Context, limits *schema2.ProcessorLimits) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCPU", ctx, limits) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCPU indicates an expected call of UpdateCPU. +func (mr *MockvmControllerMockRecorder) UpdateCPU(ctx, limits any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCPU", reflect.TypeOf((*MockvmController)(nil).UpdateCPU), ctx, limits) +} + +// UpdateCPUGroup mocks base method. +func (m *MockvmController) UpdateCPUGroup(ctx context.Context, cpuGroupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCPUGroup", ctx, cpuGroupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCPUGroup indicates an expected call of UpdateCPUGroup. +func (mr *MockvmControllerMockRecorder) UpdateCPUGroup(ctx, cpuGroupID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCPUGroup", reflect.TypeOf((*MockvmController)(nil).UpdateCPUGroup), ctx, cpuGroupID) +} + +// UpdateMemory mocks base method. +func (m *MockvmController) UpdateMemory(ctx context.Context, requestedSizeInMB uint64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateMemory", ctx, requestedSizeInMB) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateMemory indicates an expected call of UpdateMemory. +func (mr *MockvmControllerMockRecorder) UpdateMemory(ctx, requestedSizeInMB any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMemory", reflect.TypeOf((*MockvmController)(nil).UpdateMemory), ctx, requestedSizeInMB) +} + +// UpdatePolicyFragment mocks base method. +func (m *MockvmController) UpdatePolicyFragment(ctx context.Context, fragment guestresource.SecurityPolicyFragment) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePolicyFragment", ctx, fragment) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdatePolicyFragment indicates an expected call of UpdatePolicyFragment. +func (mr *MockvmControllerMockRecorder) UpdatePolicyFragment(ctx, fragment any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePolicyFragment", reflect.TypeOf((*MockvmController)(nil).UpdatePolicyFragment), ctx, fragment) +} + +// VPCIController mocks base method. +func (m *MockvmController) VPCIController() *vpci.Controller { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VPCIController") + ret0, _ := ret[0].(*vpci.Controller) + return ret0 +} + +// VPCIController indicates an expected call of VPCIController. +func (mr *MockvmControllerMockRecorder) VPCIController() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VPCIController", reflect.TypeOf((*MockvmController)(nil).VPCIController)) +} + +// Wait mocks base method. +func (m *MockvmController) Wait(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Wait", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Wait indicates an expected call of Wait. +func (mr *MockvmControllerMockRecorder) Wait(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wait", reflect.TypeOf((*MockvmController)(nil).Wait), ctx) +} diff --git a/cmd/containerd-shim-lcow-v2/service/service.go b/cmd/containerd-shim-lcow-v2/service/service.go index c44b4590db..b37c69437b 100644 --- a/cmd/containerd-shim-lcow-v2/service/service.go +++ b/cmd/containerd-shim-lcow-v2/service/service.go @@ -42,8 +42,9 @@ type Service struct { // For LCOW shim, sandboxID corresponds 1-1 with the UtilityVM managed by the shim. sandboxID string - // vmController is responsible for managing the lifecycle of the underlying utility VM and its associated resources. - vmController *vm.Controller + // vmController is responsible for managing the lifecycle of the underlying + // utility VM. Tests substitute a mock; production passes [*vm.Controller]. + vmController vmController // podControllers maps podID -> PodController for each active pod. podControllers map[string]*pod.Controller diff --git a/cmd/containerd-shim-lcow-v2/service/service_sandbox_internal_test.go b/cmd/containerd-shim-lcow-v2/service/service_sandbox_internal_test.go new file mode 100644 index 0000000000..d3789c252a --- /dev/null +++ b/cmd/containerd-shim-lcow-v2/service/service_sandbox_internal_test.go @@ -0,0 +1,656 @@ +//go:build windows && lcow + +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/mock/gomock" + "golang.org/x/sys/windows" + + "github.com/Microsoft/hcsshim/cmd/containerd-shim-lcow-v2/service/mocks" + "github.com/Microsoft/hcsshim/cmd/containerd-shim-runhcs-v1/stats" + "github.com/Microsoft/hcsshim/internal/builder/vm/lcow" + "github.com/Microsoft/hcsshim/internal/controller/vm" + + "github.com/Microsoft/hcsshim/internal/controller/pod" + sandboxsvc "github.com/containerd/containerd/api/runtime/sandbox/v1" + "github.com/containerd/containerd/v2/pkg/shutdown" +) + +// Sentinel errors used by the sandbox tests to assert that the service wraps +// and propagates errors from the underlying vm controller. +var ( + errVMCreate = errors.New("vm create failed") + errVMStart = errors.New("vm start failed") + errVMTerminate = errors.New("vm terminate failed") + errVMWait = errors.New("vm wait failed") + errVMExitStat = errors.New("vm exit status unavailable") + errVMStats = errors.New("vm stats unavailable") +) + +// newTestService builds a [Service] wired to a mock vm controller. +func newTestService(t *testing.T) (*Service, *mocks.MockvmController) { + t.Helper() + ctrl := gomock.NewController(t) + mockCtrl := mocks.NewMockvmController(ctrl) + _, sd := shutdown.WithShutdown(context.Background()) + t.Cleanup(sd.Shutdown) + return &Service{ + vmController: mockCtrl, + events: make(chan interface{}, 128), + podControllers: make(map[string]*pod.Controller), + containerPodMapping: make(map[string]string), + shutdown: sd, + }, mockCtrl +} + +// writeMiniConfigJSON drops a minimal config.json into dir so that +// createSandboxInternal gets past the file-read gate and reaches the +// duplicate-sandbox guard. +func writeMiniConfigJSON(t *testing.T, dir string) { + t.Helper() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"annotations":{}}`), 0o644); err != nil { + t.Fatalf("write config.json: %v", err) + } +} + +// ─── createSandboxInternal tests ────────────────────────────────────────── + +// TestCreateSandbox_DuplicateRejected verifies that a second CreateSandbox +// call against the same Service is rejected; the shim follows a +// one-sandbox-per-shim model and a duplicate would silently leak the first VM. +func TestCreateSandbox_DuplicateRejected(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + svc.sandboxID = "existing-sandbox" + + bundleDir := t.TempDir() + writeMiniConfigJSON(t, bundleDir) + + _, err := svc.createSandboxInternal(context.Background(), &sandboxsvc.CreateSandboxRequest{ + SandboxID: "new-sandbox", + BundlePath: bundleDir, + }) + if err == nil { + t.Fatal("expected error for duplicate sandbox, got nil") + } + if !strings.Contains(err.Error(), "sandbox already exists") { + t.Errorf("expected error to contain %q, got %q", "sandbox already exists", err.Error()) + } +} + +// TestCreateSandbox_MissingConfigJSON verifies that an empty bundle directory +// is rejected before any VM creation work happens; if this guard regresses, +// the shim crashes deeper in JSON decoding instead of returning a clean error. +func TestCreateSandbox_MissingConfigJSON(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + + _, err := svc.createSandboxInternal(context.Background(), &sandboxsvc.CreateSandboxRequest{ + SandboxID: "test-sandbox", + BundlePath: t.TempDir(), // empty dir, no config.json + }) + if err == nil { + t.Fatal("expected error for missing config.json, got nil") + } +} + +// TestCreateSandbox_VMCreateFailure verifies that a CreateVM failure is +// surfaced and that the sandboxID is NOT recorded on failure; recording it +// would lock the Service into an unusable state with no underlying VM. +func TestCreateSandbox_VMCreateFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + + bundleDir := t.TempDir() + writeMiniConfigJSON(t, bundleDir) + + mockCtrl.EXPECT().CreateVM(gomock.Any(), gomock.Any()).Return(errVMCreate) + + _, err := svc.createSandboxInternal(context.Background(), &sandboxsvc.CreateSandboxRequest{ + SandboxID: "test-sandbox", + BundlePath: bundleDir, + }) + if err == nil { + t.Fatal("expected error from CreateVM, got nil") + } + if !errors.Is(err, errVMCreate) { + t.Errorf("expected error to wrap errVMCreate, got %v", err) + } + if got := svc.sandboxID; got != "" { + t.Errorf("expected sandboxID to remain empty after failure, got %q", got) + } +} + +// ─── Sandbox-ID-mismatch guards ─────────────────────────────────────────── + +// TestSandboxIDMismatch verifies that every per-sandbox internal method +// rejects a request whose SandboxID does not match the one this Service owns. +// A regression here would let containerd talk to the wrong sandbox after a +// shim restart. +func TestSandboxIDMismatch(t *testing.T) { + tests := []struct { + name string + call func(*Service) error + }{ + { + name: "startSandboxInternal", + call: func(svc *Service) error { + _, err := svc.startSandboxInternal(context.Background(), &sandboxsvc.StartSandboxRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "platformInternal", + call: func(svc *Service) error { + _, err := svc.platformInternal(context.Background(), &sandboxsvc.PlatformRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "stopSandboxInternal", + call: func(svc *Service) error { + _, err := svc.stopSandboxInternal(context.Background(), &sandboxsvc.StopSandboxRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "waitSandboxInternal", + call: func(svc *Service) error { + _, err := svc.waitSandboxInternal(context.Background(), &sandboxsvc.WaitSandboxRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "sandboxStatusInternal", + call: func(svc *Service) error { + _, err := svc.sandboxStatusInternal(context.Background(), &sandboxsvc.SandboxStatusRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "shutdownSandboxInternal", + call: func(svc *Service) error { + _, err := svc.shutdownSandboxInternal(context.Background(), &sandboxsvc.ShutdownSandboxRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + { + name: "sandboxMetricsInternal", + call: func(svc *Service) error { + _, err := svc.sandboxMetricsInternal(context.Background(), &sandboxsvc.SandboxMetricsRequest{SandboxID: "wrong-sandbox"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run("reject/"+tt.name, func(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + svc.sandboxID = "test-sandbox" + + err := tt.call(svc) + if err == nil { + t.Fatal("expected error for sandbox ID mismatch, got nil") + } + if !strings.Contains(err.Error(), "sandbox ID mismatch") { + t.Errorf("expected error to contain %q, got %q", "sandbox ID mismatch", err.Error()) + } + }) + } +} + +// ─── startSandboxInternal tests ─────────────────────────────────────────── + +// TestStartSandbox_Success verifies that startSandboxInternal forwards to +// vmController.StartVM and returns the VM start time as CreatedAt. +func TestStartSandbox_Success(t *testing.T) { + t.Parallel() + startedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().StartVM(gomock.Any(), gomock.Any()).Return(nil) + mockCtrl.EXPECT().StartTime().Return(startedAt) + + resp, err := svc.startSandboxInternal(context.Background(), &sandboxsvc.StartSandboxRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resp.CreatedAt.AsTime(); !got.Equal(startedAt) { + t.Errorf("CreatedAt = %v, want %v", got, startedAt) + } +} + +// TestStartSandbox_StartVMFailure verifies that a StartVM error is wrapped +// and returned to the caller; if it were swallowed, containerd would think +// the sandbox is healthy when it is not. +func TestStartSandbox_StartVMFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().StartVM(gomock.Any(), gomock.Any()).Return(errVMStart) + + _, err := svc.startSandboxInternal(context.Background(), &sandboxsvc.StartSandboxRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from StartVM, got nil") + } + if !errors.Is(err, errVMStart) { + t.Errorf("expected error to wrap errVMStart, got %v", err) + } +} + +// ─── platformInternal tests ─────────────────────────────────────────────── + +// TestPlatform_Success verifies that platformInternal reports the architecture +// from SandboxOptions and the linux platform string for the LCOW shim. +func TestPlatform_Success(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().SandboxOptions().Return(&lcow.SandboxOptions{Architecture: "amd64"}) + + resp, err := svc.platformInternal(context.Background(), &sandboxsvc.PlatformRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := resp.Platform.OS, "linux"; got != want { + t.Errorf("Platform.OS = %q, want %q", got, want) + } + if got, want := resp.Platform.Architecture, "amd64"; got != want { + t.Errorf("Platform.Architecture = %q, want %q", got, want) + } +} + +// TestPlatform_NotCreatedRejected verifies that platformInternal refuses to +// answer when the VM has not yet been created; otherwise containerd would +// receive a default-zero Platform string and silently mis-route requests. +func TestPlatform_NotCreatedRejected(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + // State() is called twice in this branch: once for the guard check and + // once when formatting the error string. + mockCtrl.EXPECT().State().Return(vm.StateNotCreated).AnyTimes() + + _, err := svc.platformInternal(context.Background(), &sandboxsvc.PlatformRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error for not-created VM, got nil") + } + if !strings.Contains(err.Error(), "sandbox has not been created") { + t.Errorf("expected error to contain %q, got %q", "sandbox has not been created", err.Error()) + } +} + +// ─── stopSandboxInternal tests ──────────────────────────────────────────── + +// TestStopSandbox_Success verifies the happy-path forward to TerminateVM. +func TestStopSandbox_Success(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().TerminateVM(gomock.Any()).Return(nil) + + if _, err := svc.stopSandboxInternal(context.Background(), &sandboxsvc.StopSandboxRequest{SandboxID: "test-sandbox"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// TestStopSandbox_TerminateFailure verifies TerminateVM errors are wrapped. +func TestStopSandbox_TerminateFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().TerminateVM(gomock.Any()).Return(errVMTerminate) + + _, err := svc.stopSandboxInternal(context.Background(), &sandboxsvc.StopSandboxRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from TerminateVM, got nil") + } + if !errors.Is(err, errVMTerminate) { + t.Errorf("expected error to wrap errVMTerminate, got %v", err) + } +} + +// TestStopSandbox_Idempotent verifies that two consecutive Stop calls against +// the same Service both reach TerminateVM; the service does not short-circuit +// on prior state, leaving idempotency to the controller. A regression that +// added a state check would break containerd retry of Stop after a shim +// restart. +func TestStopSandbox_Idempotent(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().TerminateVM(gomock.Any()).Return(nil).Times(2) + + for i := 0; i < 2; i++ { + if _, err := svc.stopSandboxInternal(context.Background(), &sandboxsvc.StopSandboxRequest{SandboxID: "test-sandbox"}); err != nil { + t.Fatalf("Stop call %d returned error: %v", i+1, err) + } + } +} + +// ─── waitSandboxInternal tests ──────────────────────────────────────────── + +// TestWaitSandbox_CleanExit verifies that an exit with no error maps to +// ExitStatus = 0 and that the StoppedTime is propagated as ExitedAt. +func TestWaitSandbox_CleanExit(t *testing.T) { + t.Parallel() + stoppedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Wait(gomock.Any()).Return(nil) + mockCtrl.EXPECT().ExitStatus().Return(&vm.ExitStatus{StoppedTime: stoppedAt}, nil) + + resp, err := svc.waitSandboxInternal(context.Background(), &sandboxsvc.WaitSandboxRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.ExitStatus != 0 { + t.Errorf("ExitStatus = %d, want 0", resp.ExitStatus) + } + if got := resp.ExitedAt.AsTime(); !got.Equal(stoppedAt) { + t.Errorf("ExitedAt = %v, want %v", got, stoppedAt) + } +} + +// TestWaitSandbox_ErrorExit verifies that a non-clean exit maps to +// ERROR_INTERNAL_ERROR; this is the exit code containerd surfaces to the +// kubelet for sandbox failure events. +func TestWaitSandbox_ErrorExit(t *testing.T) { + t.Parallel() + stoppedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Wait(gomock.Any()).Return(nil) + mockCtrl.EXPECT().ExitStatus().Return(&vm.ExitStatus{ + StoppedTime: stoppedAt, + Err: errors.New("guest crashed"), + }, nil) + + resp, err := svc.waitSandboxInternal(context.Background(), &sandboxsvc.WaitSandboxRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := resp.ExitStatus, uint32(windows.ERROR_INTERNAL_ERROR); got != want { + t.Errorf("ExitStatus = %d, want %d", got, want) + } +} + +// TestWaitSandbox_WaitFailure verifies that a Wait error short-circuits +// before ExitStatus is consulted; the wrapped error preserves the cause. +func TestWaitSandbox_WaitFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Wait(gomock.Any()).Return(errVMWait) + + _, err := svc.waitSandboxInternal(context.Background(), &sandboxsvc.WaitSandboxRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from Wait, got nil") + } + if !errors.Is(err, errVMWait) { + t.Errorf("expected error to wrap errVMWait, got %v", err) + } +} + +// TestWaitSandbox_ExitStatusFailure verifies that a Wait succeeds but a +// subsequent ExitStatus failure is wrapped and returned; without this the +// shim would silently report ExitStatus = 0 for a VM that may have failed. +func TestWaitSandbox_ExitStatusFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Wait(gomock.Any()).Return(nil) + mockCtrl.EXPECT().ExitStatus().Return(nil, errVMExitStat) + + _, err := svc.waitSandboxInternal(context.Background(), &sandboxsvc.WaitSandboxRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from ExitStatus, got nil") + } + if !errors.Is(err, errVMExitStat) { + t.Errorf("expected error to wrap errVMExitStat, got %v", err) + } +} + +// ─── sandboxStatusInternal tests ────────────────────────────────────────── + +// TestSandboxStatus_StateMapping verifies that every VM lifecycle state maps +// to the correct CRI sandbox state and that timestamp fields are populated +// only for the states that have meaningful values for them. +func TestSandboxStatus_StateMapping(t *testing.T) { + startedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + stoppedAt := time.Date(2026, 1, 1, 1, 0, 0, 0, time.UTC) + + tests := []struct { + name string + state vm.State + wantState string + wantCreatedAt bool + wantExitedAt bool + }{ + { + name: "NotCreated", + state: vm.StateNotCreated, + wantState: SandboxStateNotReady, + }, + { + name: "Created", + state: vm.StateCreated, + wantState: SandboxStateNotReady, + }, + { + name: "Invalid", + state: vm.StateInvalid, + wantState: SandboxStateNotReady, + }, + { + name: "Running", + state: vm.StateRunning, + wantState: SandboxStateReady, + wantCreatedAt: true, + }, + { + name: "Terminated", + state: vm.StateTerminated, + wantState: SandboxStateNotReady, + wantCreatedAt: true, + wantExitedAt: true, + }, + } + + for _, tt := range tests { + t.Run("state/"+tt.name, func(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(tt.state) + if tt.wantCreatedAt { + mockCtrl.EXPECT().StartTime().Return(startedAt) + } + if tt.wantExitedAt { + mockCtrl.EXPECT().ExitStatus().Return(&vm.ExitStatus{StoppedTime: stoppedAt}, nil) + } + + resp, err := svc.sandboxStatusInternal(context.Background(), &sandboxsvc.SandboxStatusRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.State != tt.wantState { + t.Errorf("State = %q, want %q", resp.State, tt.wantState) + } + switch { + case tt.wantCreatedAt && resp.CreatedAt == nil: + t.Error("expected CreatedAt to be set") + case tt.wantCreatedAt: + if got := resp.CreatedAt.AsTime(); !got.Equal(startedAt) { + t.Errorf("CreatedAt = %v, want %v", got, startedAt) + } + case !tt.wantCreatedAt && resp.CreatedAt != nil: + t.Errorf("expected CreatedAt nil, got %v", resp.CreatedAt) + } + switch { + case tt.wantExitedAt && resp.ExitedAt == nil: + t.Error("expected ExitedAt to be set") + case tt.wantExitedAt: + if got := resp.ExitedAt.AsTime(); !got.Equal(stoppedAt) { + t.Errorf("ExitedAt = %v, want %v", got, stoppedAt) + } + case !tt.wantExitedAt && resp.ExitedAt != nil: + t.Errorf("expected ExitedAt nil, got %v", resp.ExitedAt) + } + }) + } +} + +// TestSandboxStatus_TerminatedExitStatusFailure verifies that an ExitStatus +// failure on a Terminated VM is wrapped and returned; otherwise the sandbox +// would appear "ready=false" with no diagnostic info about why. +func TestSandboxStatus_TerminatedExitStatusFailure(t *testing.T) { + t.Parallel() + startedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(vm.StateTerminated) + mockCtrl.EXPECT().StartTime().Return(startedAt) + mockCtrl.EXPECT().ExitStatus().Return(nil, errVMExitStat) + + _, err := svc.sandboxStatusInternal(context.Background(), &sandboxsvc.SandboxStatusRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from ExitStatus, got nil") + } + if !errors.Is(err, errVMExitStat) { + t.Errorf("expected error to wrap errVMExitStat, got %v", err) + } +} + +// ─── pingSandboxInternal tests ──────────────────────────────────────────── + +// TestPingSandbox_NotImplemented verifies that pingSandboxInternal returns +// errdefs.ErrNotImplemented; some callers depend on this code to detect +// sandboxes that do not support liveness probes. +func TestPingSandbox_NotImplemented(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + + _, err := svc.pingSandboxInternal(context.Background(), &sandboxsvc.PingRequest{}) + if err == nil { + t.Fatal("expected ErrNotImplemented, got nil") + } +} + +// ─── shutdownSandboxInternal tests ──────────────────────────────────────── + +// TestShutdownSandbox_AlreadyTerminated verifies that shutdownSandboxInternal +// does NOT call TerminateVM when the VM is already terminated; doing so +// would produce a misleading log line on every shim shutdown. +func TestShutdownSandbox_AlreadyTerminated(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(vm.StateTerminated) + // TerminateVM must NOT be called. + + if _, err := svc.shutdownSandboxInternal(context.Background(), &sandboxsvc.ShutdownSandboxRequest{SandboxID: "test-sandbox"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// TestShutdownSandbox_TerminatesRunningVM verifies that shutdownSandboxInternal +// terminates a running VM as part of best-effort cleanup. +func TestShutdownSandbox_TerminatesRunningVM(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().TerminateVM(gomock.Any()).Return(nil) + + if _, err := svc.shutdownSandboxInternal(context.Background(), &sandboxsvc.ShutdownSandboxRequest{SandboxID: "test-sandbox"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// TestShutdownSandbox_TerminateErrorSwallowed verifies that a TerminateVM +// failure during shutdown is logged but NOT returned to the caller; the +// shutdown handler is best-effort and a returned error would make containerd +// retry the request, leaking the goroutine that schedules the actual exit. +func TestShutdownSandbox_TerminateErrorSwallowed(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().TerminateVM(gomock.Any()).Return(errVMTerminate) + + if _, err := svc.shutdownSandboxInternal(context.Background(), &sandboxsvc.ShutdownSandboxRequest{SandboxID: "test-sandbox"}); err != nil { + t.Errorf("expected nil error (terminate failure must be swallowed), got: %v", err) + } +} + +// ─── sandboxMetricsInternal tests ───────────────────────────────────────── + +// TestSandboxMetrics_Success verifies the happy-path: Stats is fetched, +// marshalled, and returned with the SandboxID stamped on the metric. +func TestSandboxMetrics_Success(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Stats(gomock.Any()).Return(&stats.VirtualMachineStatistics{}, nil) + + resp, err := svc.sandboxMetricsInternal(context.Background(), &sandboxsvc.SandboxMetricsRequest{SandboxID: "test-sandbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Metrics == nil { + t.Fatal("expected Metrics to be non-nil") + } + if got, want := resp.Metrics.ID, "test-sandbox"; got != want { + t.Errorf("Metrics.ID = %q, want %q", got, want) + } +} + +// TestSandboxMetrics_StatsFailure verifies that Stats errors are wrapped. +func TestSandboxMetrics_StatsFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.sandboxID = "test-sandbox" + + mockCtrl.EXPECT().Stats(gomock.Any()).Return(nil, errVMStats) + + _, err := svc.sandboxMetricsInternal(context.Background(), &sandboxsvc.SandboxMetricsRequest{SandboxID: "test-sandbox"}) + if err == nil { + t.Fatal("expected error from Stats, got nil") + } + if !errors.Is(err, errVMStats) { + t.Errorf("expected error to wrap errVMStats, got %v", err) + } +} diff --git a/cmd/containerd-shim-lcow-v2/service/service_shimdiag_internal_test.go b/cmd/containerd-shim-lcow-v2/service/service_shimdiag_internal_test.go new file mode 100644 index 0000000000..1ced90976b --- /dev/null +++ b/cmd/containerd-shim-lcow-v2/service/service_shimdiag_internal_test.go @@ -0,0 +1,210 @@ +//go:build windows && lcow + +package service + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "go.uber.org/mock/gomock" + + "github.com/Microsoft/hcsshim/internal/controller/vm" + "github.com/Microsoft/hcsshim/internal/shimdiag" +) + +// Sentinel errors used by the shimdiag tests. +var ( + errExecHost = errors.New("exec into host failed") + errDumpStack = errors.New("dump stacks failed") +) + +// ─── DiagPid tests ──────────────────────────────────────────────────────── + +// TestDiagPid_ReturnsCurrentPid verifies that DiagPid returns the calling +// process PID. This is the simplest diagnostic and a regression here would +// silently break shim discovery in operator tooling. +func TestDiagPid_ReturnsCurrentPid(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + + resp, err := svc.DiagPid(context.Background(), &shimdiag.PidRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := resp.Pid, int32(os.Getpid()); got != want { + t.Errorf("Pid = %d, want %d", got, want) + } +} + +// ─── diagExecInHostInternal tests ───────────────────────────────────────── + +// TestDiagExecInHost_Success verifies happy-path delegation to +// vmController.ExecIntoHost: the request is forwarded unchanged and the +// exit code is plumbed back into the response. +func TestDiagExecInHost_Success(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + + req := &shimdiag.ExecProcessRequest{Args: []string{"/bin/ls"}, Workdir: "/"} + mockCtrl.EXPECT().ExecIntoHost(gomock.Any(), req).Return(42, nil) + + resp, err := svc.diagExecInHostInternal(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := resp.ExitCode, int32(42); got != want { + t.Errorf("ExitCode = %d, want %d", got, want) + } +} + +// TestDiagExecInHost_Failure verifies that ExecIntoHost errors are wrapped +// before being returned through the gRPC layer. +func TestDiagExecInHost_Failure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + + req := &shimdiag.ExecProcessRequest{Args: []string{"/bin/false"}} + mockCtrl.EXPECT().ExecIntoHost(gomock.Any(), req).Return(0, errExecHost) + + _, err := svc.diagExecInHostInternal(context.Background(), req) + if err == nil { + t.Fatal("expected error from ExecIntoHost, got nil") + } + if !errors.Is(err, errExecHost) { + t.Errorf("expected error to wrap errExecHost, got %v", err) + } +} + +// ─── diagTasksInternal tests ────────────────────────────────────────────── + +// TestDiagTasks_RejectVMNotRunning verifies that diagTasksInternal refuses +// to enumerate while the VM is not running; before VM start there are no +// real containers and a regression here would surface stale or empty data. +func TestDiagTasks_RejectVMNotRunning(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateNotCreated).AnyTimes() + + _, err := svc.diagTasksInternal(context.Background(), &shimdiag.TasksRequest{}) + if err == nil { + t.Fatal("expected error for VM not running, got nil") + } + if !strings.Contains(err.Error(), "vm is not running") { + t.Errorf("expected error to contain %q, got %q", "vm is not running", err.Error()) + } +} + +// TestDiagTasks_EmptyWhenNoPods verifies that a Running VM with no +// registered pods returns an empty task list rather than an error. +func TestDiagTasks_EmptyWhenNoPods(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning) + + resp, err := svc.diagTasksInternal(context.Background(), &shimdiag.TasksRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Tasks) != 0 { + t.Errorf("expected 0 tasks, got %d", len(resp.Tasks)) + } +} + +// ─── diagShareInternal tests ────────────────────────────────────────────── + +// TestDiagShare_RejectVMNotRunning verifies the VM-state guard for share +// requests; without it the plan9 controller call would dereference the +// underlying nil VM pointer. +func TestDiagShare_RejectVMNotRunning(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateNotCreated).AnyTimes() + + _, err := svc.diagShareInternal(context.Background(), &shimdiag.ShareRequest{HostPath: "C:\\nonexistent"}) + if err == nil { + t.Fatal("expected error for VM not running, got nil") + } + if !strings.Contains(err.Error(), "vm is not running") { + t.Errorf("expected error to contain %q, got %q", "vm is not running", err.Error()) + } +} + +// TestDiagShare_RejectMissingHostPath verifies that an unstattable host path +// is rejected before the plan9 reservation begins; this ensures the caller +// gets a clear error instead of a misleading reservation failure. +func TestDiagShare_RejectMissingHostPath(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning) + + missing := "Q:\\does-not-exist-" + t.Name() + _, err := svc.diagShareInternal(context.Background(), &shimdiag.ShareRequest{HostPath: missing}) + if err == nil { + t.Fatal("expected error for missing host path, got nil") + } + if !strings.Contains(err.Error(), "failed to open source path") { + t.Errorf("expected error to contain %q, got %q", "failed to open source path", err.Error()) + } +} + +// ─── diagStacksInternal tests ───────────────────────────────────────────── + +// TestDiagStacks_RejectVMNotRunning verifies the VM-state guard; +// diagStacksInternal must not attempt to dump guest stacks before the GCS +// connection is up. +func TestDiagStacks_RejectVMNotRunning(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateNotCreated).AnyTimes() + + _, err := svc.diagStacksInternal(context.Background()) + if err == nil { + t.Fatal("expected error for VM not running, got nil") + } + if !strings.Contains(err.Error(), "vm is not running") { + t.Errorf("expected error to contain %q, got %q", "vm is not running", err.Error()) + } +} + +// TestDiagStacks_DumpStacksFailure verifies that a guest-side dump failure +// is wrapped and returned; without this the operator would lose the cause +// when triaging a hung shim. +func TestDiagStacks_DumpStacksFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().DumpStacks(gomock.Any()).Return("", errDumpStack) + + _, err := svc.diagStacksInternal(context.Background()) + if err == nil { + t.Fatal("expected error from DumpStacks, got nil") + } + if !errors.Is(err, errDumpStack) { + t.Errorf("expected error to wrap errDumpStack, got %v", err) + } +} + +// TestDiagStacks_Success verifies that the response contains both the host +// stack dump (always populated by runtime.Stack) and the guest dump returned +// by the mock. A regression that drops either field would silently degrade +// debug output. +func TestDiagStacks_Success(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().DumpStacks(gomock.Any()).Return("guest-goroutine-1", nil) + + resp, err := svc.diagStacksInternal(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Stacks == "" { + t.Error("expected host Stacks to be populated") + } + if resp.GuestStacks != "guest-goroutine-1" { + t.Errorf("GuestStacks = %q, want %q", resp.GuestStacks, "guest-goroutine-1") + } +} diff --git a/cmd/containerd-shim-lcow-v2/service/service_task_internal_test.go b/cmd/containerd-shim-lcow-v2/service/service_task_internal_test.go new file mode 100644 index 0000000000..f1493709f5 --- /dev/null +++ b/cmd/containerd-shim-lcow-v2/service/service_task_internal_test.go @@ -0,0 +1,564 @@ +//go:build windows && lcow + +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/opencontainers/runtime-spec/specs-go" + "go.uber.org/mock/gomock" + + "github.com/Microsoft/hcsshim/internal/controller/vm" + hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" + "github.com/Microsoft/hcsshim/internal/protocol/guestresource" + "github.com/Microsoft/hcsshim/pkg/annotations" + "github.com/Microsoft/hcsshim/pkg/ctrdtaskapi" + + task "github.com/containerd/containerd/api/runtime/task/v2" + "github.com/containerd/errdefs" + "github.com/containerd/typeurl/v2" +) + +// Sentinel errors used by the task tests to assert that the service wraps and +// propagates errors from the underlying vm controller. +var ( + errVMUpdatePolicy = errors.New("vm update policy failed") + errVMUpdateMemory = errors.New("vm update memory failed") + errVMUpdateCPU = errors.New("vm update cpu failed") + errVMUpdateCPUGroup = errors.New("vm update cpu group failed") +) + +// ─── ensureVMRunning guard ──────────────────────────────────────────────── + +// TestTaskMethods_RejectVMNotRunning verifies that every task internal method +// enforces the VM-must-be-running precondition. We exercise one representative +// not-running state (NotCreated); a regression in the guard would let +// containerd issue task RPCs against a VM that has not booted. +func TestTaskMethods_RejectVMNotRunning(t *testing.T) { + tests := []struct { + name string + call func(*Service) error + }{ + { + name: "stateInternal", + call: func(svc *Service) error { + _, err := svc.stateInternal(context.Background(), &task.StateRequest{ID: "ctr"}) + return err + }, + }, + { + name: "createInternal", + call: func(svc *Service) error { + _, err := svc.createInternal(context.Background(), &task.CreateTaskRequest{ID: "ctr", Bundle: t.TempDir()}) + return err + }, + }, + { + name: "startInternal", + call: func(svc *Service) error { + _, err := svc.startInternal(context.Background(), &task.StartRequest{ID: "ctr"}) + return err + }, + }, + { + name: "deleteInternal", + call: func(svc *Service) error { + _, err := svc.deleteInternal(context.Background(), &task.DeleteRequest{ID: "ctr"}) + return err + }, + }, + { + name: "pidsInternal", + call: func(svc *Service) error { + _, err := svc.pidsInternal(context.Background(), &task.PidsRequest{ID: "ctr"}) + return err + }, + }, + { + name: "killInternal", + call: func(svc *Service) error { + _, err := svc.killInternal(context.Background(), &task.KillRequest{ID: "ctr"}) + return err + }, + }, + { + name: "execInternal", + call: func(svc *Service) error { + _, err := svc.execInternal(context.Background(), &task.ExecProcessRequest{ID: "ctr"}) + return err + }, + }, + { + name: "resizePtyInternal", + call: func(svc *Service) error { + _, err := svc.resizePtyInternal(context.Background(), &task.ResizePtyRequest{ID: "ctr"}) + return err + }, + }, + { + name: "closeIOInternal", + call: func(svc *Service) error { + _, err := svc.closeIOInternal(context.Background(), &task.CloseIORequest{ID: "ctr"}) + return err + }, + }, + { + name: "updateInternal", + call: func(svc *Service) error { + _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ID: "ctr"}) + return err + }, + }, + { + name: "waitInternal", + call: func(svc *Service) error { + _, err := svc.waitInternal(context.Background(), &task.WaitRequest{ID: "ctr"}) + return err + }, + }, + { + name: "statsInternal", + call: func(svc *Service) error { + _, err := svc.statsInternal(context.Background(), &task.StatsRequest{ID: "ctr"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run("reject/"+tt.name, func(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateNotCreated).AnyTimes() + + err := tt.call(svc) + if err == nil { + t.Fatal("expected error for VM not running, got nil") + } + if !errors.Is(err, errdefs.ErrFailedPrecondition) { + t.Errorf("expected error to wrap ErrFailedPrecondition, got %v", err) + } + }) + } +} + +// ─── Container lookup tests ─────────────────────────────────────────────── + +// TestTaskMethods_RejectUnknownContainer verifies that methods which need a +// container controller surface a NotFound when the container ID is not +// registered. A regression here would lead to a nil-deref deeper in the +// per-container code paths. +func TestTaskMethods_RejectUnknownContainer(t *testing.T) { + tests := []struct { + name string + call func(*Service) error + }{ + { + name: "stateInternal", + call: func(svc *Service) error { + _, err := svc.stateInternal(context.Background(), &task.StateRequest{ID: "missing-ctr"}) + return err + }, + }, + { + name: "pidsInternal", + call: func(svc *Service) error { + _, err := svc.pidsInternal(context.Background(), &task.PidsRequest{ID: "missing-ctr"}) + return err + }, + }, + { + name: "killInternal", + call: func(svc *Service) error { + _, err := svc.killInternal(context.Background(), &task.KillRequest{ID: "missing-ctr"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run("notfound/"+tt.name, func(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning).AnyTimes() + + err := tt.call(svc) + if err == nil { + t.Fatal("expected error for unknown container, got nil") + } + if !errors.Is(err, errdefs.ErrNotFound) { + t.Errorf("expected error to wrap ErrNotFound, got %v", err) + } + }) + } +} + +// ─── Not-implemented stubs ──────────────────────────────────────────────── + +// TestTaskMethods_NotImplemented verifies that the methods this shim does +// not implement return errdefs.ErrNotImplemented; containerd uses this to +// detect optional capabilities. +func TestTaskMethods_NotImplemented(t *testing.T) { + tests := []struct { + name string + call func(*Service) error + }{ + { + name: "pauseInternal", + call: func(svc *Service) error { + _, err := svc.pauseInternal(context.Background(), &task.PauseRequest{ID: "ctr"}) + return err + }, + }, + { + name: "resumeInternal", + call: func(svc *Service) error { + _, err := svc.resumeInternal(context.Background(), &task.ResumeRequest{ID: "ctr"}) + return err + }, + }, + { + name: "checkpointInternal", + call: func(svc *Service) error { + _, err := svc.checkpointInternal(context.Background(), &task.CheckpointTaskRequest{ID: "ctr"}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run("unimplemented/"+tt.name, func(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + + err := tt.call(svc) + if !errors.Is(err, errdefs.ErrNotImplemented) { + t.Errorf("expected ErrNotImplemented, got %v", err) + } + }) + } +} + +// TestShutdown_NoOp verifies that shutdownInternal is a no-op for this shim; +// the real teardown is driven by SandboxService.ShutdownSandbox and a +// regression here would terminate the shim prematurely. +func TestShutdown_NoOp(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t) + + resp, err := svc.shutdownInternal(context.Background(), &task.ShutdownRequest{ID: "ctr"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } +} + +// ─── Update dispatch tests ──────────────────────────────────────────────── + +// TestUpdate_NilResourcesRejected verifies that a nil Resources field is +// rejected before reaching typeurl.UnmarshalAny; without the guard, the +// unmarshal call would panic on the nil dereference. +func TestUpdate_NilResourcesRejected(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + mockCtrl.EXPECT().State().Return(vm.StateRunning) + + _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ID: "ctr"}) + if err == nil { + t.Fatal("expected error for nil Resources, got nil") + } + if !errors.Is(err, errdefs.ErrInvalidArgument) { + t.Errorf("expected error to wrap ErrInvalidArgument, got %v", err) + } +} + +// TestUpdate_PolicyFragmentDispatch verifies the pod-level update path for a +// security-policy-fragment payload: the resource is unmarshalled via typeurl +// and forwarded to vmController.UpdatePolicyFragment with the same fragment. +func TestUpdate_PolicyFragmentDispatch(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil // sentinel: pod is known, no real controller needed + + any, err := typeurl.MarshalAnyToProto(&ctrdtaskapi.PolicyFragment{Fragment: "test-fragment"}) + if err != nil { + t.Fatalf("marshal fragment: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT(). + UpdatePolicyFragment(gomock.Any(), guestresource.SecurityPolicyFragment{Fragment: "test-fragment"}). + Return(nil) + + if _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestUpdate_MemoryDispatch verifies that a LinuxResources update with a +// memory limit is converted to MiB and forwarded to vmController.UpdateMemory. +// The conversion is critical: a regression that forgets the divide would +// request gigabyte-scale memory in MiB and trigger HCS validation failures. +func TestUpdate_MemoryDispatch(t *testing.T) { + t.Parallel() + + const memoryBytes = int64(2 * 1024 * 1024 * 1024) // 2 GiB + const wantMiB = uint64(2 * 1024) + + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + limit := memoryBytes + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{ + Memory: &specs.LinuxMemory{Limit: &limit}, + }) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateMemory(gomock.Any(), wantMiB).Return(nil) + + if _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestUpdate_CPUDispatch verifies that a LinuxResources update with CPU +// quota+shares is mapped to ProcessorLimits{Limit, Weight} and forwarded. +func TestUpdate_CPUDispatch(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + quota := int64(50000) + shares := uint64(1024) + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{ + CPU: &specs.LinuxCPU{Quota: "a, Shares: &shares}, + }) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + wantLimits := &hcsschema.ProcessorLimits{Limit: 50000, Weight: 1024} + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateCPU(gomock.Any(), wantLimits).Return(nil) + + if _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestUpdate_CPUGroupAnnotation verifies that the CPUGroupID annotation is +// pulled out of the request and forwarded to vmController.UpdateCPUGroup. +// LinuxResources alone does not carry this value — it lives in annotations. +func TestUpdate_CPUGroupAnnotation(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + // Empty LinuxResources so we exercise the annotation branch alone. + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{}) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateCPUGroup(gomock.Any(), "cpu-group-42").Return(nil) + + if _, err := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + Annotations: map[string]string{annotations.CPUGroupID: "cpu-group-42"}, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestUpdate_PolicyFragmentFailure verifies that a controller-side failure +// during policy-fragment update is wrapped and returned with the pod ID for +// diagnostics. +func TestUpdate_PolicyFragmentFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + any, err := typeurl.MarshalAnyToProto(&ctrdtaskapi.PolicyFragment{Fragment: "test-fragment"}) + if err != nil { + t.Fatalf("marshal fragment: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdatePolicyFragment(gomock.Any(), gomock.Any()).Return(errVMUpdatePolicy) + + _, gotErr := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }) + if gotErr == nil { + t.Fatal("expected error from UpdatePolicyFragment, got nil") + } + if !errors.Is(gotErr, errVMUpdatePolicy) { + t.Errorf("expected error to wrap errVMUpdatePolicy, got %v", gotErr) + } +} + +// TestUpdate_MemoryFailure verifies that memory-update failures are wrapped. +func TestUpdate_MemoryFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + limit := int64(1024 * 1024 * 1024) + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{ + Memory: &specs.LinuxMemory{Limit: &limit}, + }) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateMemory(gomock.Any(), gomock.Any()).Return(errVMUpdateMemory) + + _, gotErr := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }) + if gotErr == nil { + t.Fatal("expected error from UpdateMemory, got nil") + } + if !errors.Is(gotErr, errVMUpdateMemory) { + t.Errorf("expected error to wrap errVMUpdateMemory, got %v", gotErr) + } +} + +// TestUpdate_CPUFailure verifies that CPU-update failures are wrapped. +func TestUpdate_CPUFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + quota := int64(10000) + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{ + CPU: &specs.LinuxCPU{Quota: "a}, + }) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateCPU(gomock.Any(), gomock.Any()).Return(errVMUpdateCPU) + + _, gotErr := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }) + if gotErr == nil { + t.Fatal("expected error from UpdateCPU, got nil") + } + if !errors.Is(gotErr, errVMUpdateCPU) { + t.Errorf("expected error to wrap errVMUpdateCPU, got %v", gotErr) + } +} + +// TestUpdate_CPUGroupFailure verifies that CPU-group-update failures are +// wrapped. +func TestUpdate_CPUGroupFailure(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + any, err := typeurl.MarshalAnyToProto(&specs.LinuxResources{}) + if err != nil { + t.Fatalf("marshal resources: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + mockCtrl.EXPECT().UpdateCPUGroup(gomock.Any(), "cpu-group-42").Return(errVMUpdateCPUGroup) + + _, gotErr := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + Annotations: map[string]string{annotations.CPUGroupID: "cpu-group-42"}, + }) + if gotErr == nil { + t.Fatal("expected error from UpdateCPUGroup, got nil") + } + if !errors.Is(gotErr, errVMUpdateCPUGroup) { + t.Errorf("expected error to wrap errVMUpdateCPUGroup, got %v", gotErr) + } +} + +// TestUpdate_UnsupportedResource verifies that an unsupported resource type +// is rejected with an InvalidArgument-wrapped error rather than panicking +// in the type switch. +func TestUpdate_UnsupportedResource(t *testing.T) { + t.Parallel() + svc, mockCtrl := newTestService(t) + svc.podControllers["pod-1"] = nil + + // Use ShutdownRequest as an arbitrary marshallable type the service + // does not know how to dispatch. + any, err := typeurl.MarshalAnyToProto(&task.ShutdownRequest{ID: "ignored"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + mockCtrl.EXPECT().State().Return(vm.StateRunning) + + _, gotErr := svc.updateInternal(context.Background(), &task.UpdateTaskRequest{ + ID: "pod-1", + Resources: any, + }) + if gotErr == nil { + t.Fatal("expected error for unsupported resource, got nil") + } + if !errors.Is(gotErr, errdefs.ErrInvalidArgument) { + t.Errorf("expected error to wrap ErrInvalidArgument, got %v", gotErr) + } + if !strings.Contains(gotErr.Error(), "unsupported resource type") { + t.Errorf("expected error to contain %q, got %q", "unsupported resource type", gotErr.Error()) + } +} + +// ─── enrichNotFoundError tests ──────────────────────────────────────────── + +// TestEnrichNotFoundError_PassesThroughNonNotFound verifies that errors that +// are not in any of the recognized "not-found" categories pass through +// unwrapped; otherwise every guest-side error would be misclassified as +// missing. +func TestEnrichNotFoundError_PassesThroughNonNotFound(t *testing.T) { + t.Parallel() + in := errors.New("some unrelated error") + out := enrichNotFoundError(in) + if !errors.Is(out, in) { + t.Errorf("expected pass-through, got %v", out) + } +} + +// TestEnrichNotFoundError_WrapsErrdefsNotFound verifies that an error already +// tagged with errdefs.ErrNotFound is returned with the same sentinel still +// reachable via errors.Is. +func TestEnrichNotFoundError_WrapsErrdefsNotFound(t *testing.T) { + t.Parallel() + in := errdefs.ErrNotFound + out := enrichNotFoundError(in) + if !errors.Is(out, errdefs.ErrNotFound) { + t.Errorf("expected output to wrap ErrNotFound, got %v", out) + } +} diff --git a/cmd/containerd-shim-lcow-v2/service/types.go b/cmd/containerd-shim-lcow-v2/service/types.go new file mode 100644 index 0000000000..564a3be0ec --- /dev/null +++ b/cmd/containerd-shim-lcow-v2/service/types.go @@ -0,0 +1,92 @@ +//go:build windows && lcow + +package service + +import ( + "context" + "time" + + "github.com/Microsoft/hcsshim/cmd/containerd-shim-runhcs-v1/stats" + "github.com/Microsoft/hcsshim/internal/builder/vm/lcow" + "github.com/Microsoft/hcsshim/internal/controller/device/plan9" + "github.com/Microsoft/hcsshim/internal/controller/device/scsi" + "github.com/Microsoft/hcsshim/internal/controller/device/vpci" + "github.com/Microsoft/hcsshim/internal/controller/network" + "github.com/Microsoft/hcsshim/internal/controller/vm" + hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" + "github.com/Microsoft/hcsshim/internal/protocol/guestresource" + "github.com/Microsoft/hcsshim/internal/shimdiag" + "github.com/Microsoft/hcsshim/internal/vm/guestmanager" +) + +// vmController is the subset of the VM controller that [Service] depends on. +// Implemented by [*vm.Controller]. Tests substitute a mock. +type vmController interface { + // State returns the current VM lifecycle state. + State() vm.State + + // StartTime returns the time when the VM entered the running state. + StartTime() time.Time + + // ExitStatus returns the final state for a stopped VM. + ExitStatus() (*vm.ExitStatus, error) + + // SandboxOptions returns the parsed LCOW sandbox options for the VM. + SandboxOptions() *lcow.SandboxOptions + + // CreateVM builds the HCS document and creates the underlying utility VM. + CreateVM(ctx context.Context, opts *vm.CreateOptions) error + + // StartVM starts the underlying HCS compute system and establishes the + // Guest Compute Service (GCS) connection. + StartVM(ctx context.Context, opts *vm.StartOptions) error + + // TerminateVM forcefully terminates the VM and releases its HCS handle. + TerminateVM(ctx context.Context) error + + // Wait blocks until the VM exits or ctx is cancelled. + Wait(ctx context.Context) error + + // Stats returns runtime statistics for the VM. + Stats(ctx context.Context) (*stats.VirtualMachineStatistics, error) + + // UpdatePolicyFragment injects a security policy fragment into the running guest. + UpdatePolicyFragment(ctx context.Context, fragment guestresource.SecurityPolicyFragment) error + + // UpdateMemory updates the assigned memory size for the running VM. + UpdateMemory(ctx context.Context, requestedSizeInMB uint64) error + + // UpdateCPU applies new processor limits to the running VM. + UpdateCPU(ctx context.Context, limits *hcsschema.ProcessorLimits) error + + // UpdateCPUGroup assigns the VM to the specified CPU group. + UpdateCPUGroup(ctx context.Context, cpuGroupID string) error + + // ExecIntoHost executes a process inside the utility VM (not inside a container). + ExecIntoHost(ctx context.Context, request *shimdiag.ExecProcessRequest) (int, error) + + // DumpStacks asks the guest to dump its goroutine stacks. + DumpStacks(ctx context.Context) (string, error) + + // The methods below are required by [pod.New], which Service hands the + // vmController to when constructing a [*pod.Controller]. Including them + // here lets the same field satisfy both interfaces via Go structural typing. + + // RuntimeID returns the unique runtime identifier for the VM. + RuntimeID() string + + // Guest returns the guest manager used for guest-side operations. + Guest() *guestmanager.Guest + + // SCSIController returns the SCSI device controller for the VM. + SCSIController() *scsi.Controller + + // VPCIController returns the vPCI device controller for the VM. + VPCIController() *vpci.Controller + + // Plan9Controller returns the Plan9 share controller for the VM. + Plan9Controller() *plan9.Controller + + // NetworkController returns the network controller for the VM. + NetworkController(networkNamespaceID string) *network.Controller +}