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 +}