Skip to content

Commit e4fc26e

Browse files
committed
Add LCOW live migration support across the controller stacks
Implement end-to-end live migration support for LCOW pods so a running guest and its workloads can be moved between hosts. Every controller in the stack — VM, pod, Linux container, process, network, and SCSI/Plan9/VPCI devices — gains a save/import lifecycle plus the destination-side patch and resume plumbing needed to rebind local resources and bring the workload back online. Migration lifecycle: - Source: Save serializes a controller's state into a self-describing protobuf envelope and freezes the controller (StateSourceMigrating) so no mutating ops race the transfer; Resume rolls the freeze back, and a finalize Stop or VM teardown terminates it. - Destination: Import rehydrates a controller into a migrating state, Patch repoints saved resources (layer VHDs, process IO/bundle, network namespace) at the destination host, and Resume binds the live VM, guest, and devices and republishes events so containerd treats the task as locally running. AbortMigrated discards an imported-but-never-resumed controller and emits synthetic exits so Delete can proceed. VM controller: - Add the source/destination migrating states and the full HCS migration lifecycle: InitializeLiveMigrationOnSource, StartLiveMigrationOnSource, StartLiveMigrationTransfer, FinalizeLiveMigration, plus StartWithMigrationOptions on the destination. - Exchange the opaque compatibility blob, retain the final HCS document so the destination can recreate an identical VM, and recover the GCS port/bridge-id allocator floors so reissued ids cannot collide. - Make SCSI initialization lazy (built on first use from the HCS document) and handle never-started/destination teardown paths, including the already-stopped HCS error. Controller-specific changes: - SCSI controller switches to an RWMutex and rejects all ops while migrating; ReserveForRootfs now carries the full disk config. - Process, network, container, and VM state machines document and enforce the new migrating states and transitions. - Pod gains a migrating guard, AbortMigrated fan-out, and routes new containers through lazy SCSI init. Includes accompanying unit tests for the new save/import/patch/resume paths across all controllers. Signed-off-by: Harsh Rawat <harshrawat@microsoft.com>
1 parent 90d47ab commit e4fc26e

60 files changed

Lines changed: 5650 additions & 198 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//go:build windows && lcow
2+
3+
package plan9
4+
5+
import (
6+
"fmt"
7+
)
8+
9+
// Save is not yet supported for the Plan9 sub-controller; any tracked state
10+
// indicates a live-migration scenario the controller cannot represent.
11+
func (c *Controller) Save() error {
12+
c.mu.Lock()
13+
defer c.mu.Unlock()
14+
15+
if len(c.sharesByHostPath) > 0 || len(c.reservations) > 0 {
16+
return fmt.Errorf("plan9 controller save not supported: %d shares, %d reservations", len(c.sharesByHostPath), len(c.reservations))
17+
}
18+
19+
return nil
20+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//go:build windows && lcow
2+
3+
package plan9
4+
5+
import (
6+
"testing"
7+
8+
"github.com/Microsoft/go-winio/pkg/guid"
9+
10+
"github.com/Microsoft/hcsshim/internal/controller/device/plan9/share"
11+
)
12+
13+
func TestSave_EmptyOK(t *testing.T) {
14+
c := &Controller{
15+
reservations: map[guid.GUID]*reservation{},
16+
sharesByHostPath: map[string]*share.Share{},
17+
}
18+
19+
if err := c.Save(); err != nil {
20+
t.Fatalf("Save on empty controller: %v", err)
21+
}
22+
}
23+
24+
func TestSave_NonEmptyErrors(t *testing.T) {
25+
c := &Controller{
26+
reservations: map[guid.GUID]*reservation{{}: {hostPath: "/h"}},
27+
sharesByHostPath: map[string]*share.Share{},
28+
}
29+
30+
if err := c.Save(); err == nil {
31+
t.Fatal("expected Save to error when reservations are present")
32+
}
33+
}

internal/controller/device/scsi/controller.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import (
3434
// it succeeds to release the reservation and all resources.
3535
type Controller struct {
3636
// mu serializes all public operations on the Controller.
37-
mu sync.Mutex
37+
mu sync.RWMutex
3838

3939
// vm is the host-side interface for adding and removing SCSI disks.
4040
// Immutable after construction.
@@ -58,6 +58,10 @@ type Controller struct {
5858
// ControllerID = index / numLUNsPerController
5959
// LUN = index % numLUNsPerController
6060
controllerSlots []*disk.Disk
61+
62+
// isMigrating rejects all public ops while set: true once a snapshot has
63+
// been taken or imported, until migration is resumed. Guarded by mu.
64+
isMigrating bool
6165
}
6266

6367
// New creates a new [Controller] for the given number of SCSI controllers and
@@ -78,18 +82,22 @@ func New(numControllers int, vm VMSCSIOps, guest GuestSCSIOps) *Controller {
7882
// once per controller and lun location, and must be called before any calls to
7983
// Reserve() to ensure the rootfs reservation is not evicted by a dynamic
8084
// reservation.
81-
func (c *Controller) ReserveForRootfs(ctx context.Context, controller, lun uint) error {
85+
func (c *Controller) ReserveForRootfs(ctx context.Context, controller, lun uint, cfg disk.Config) error {
8286
c.mu.Lock()
8387
defer c.mu.Unlock()
8488

89+
if c.isMigrating {
90+
return fmt.Errorf("SCSI controller is migrating; call Resume first")
91+
}
92+
8593
slot := int(controller*numLUNsPerController + lun)
8694
if slot >= len(c.controllerSlots) {
8795
return fmt.Errorf("invalid controller %d or lun %d", controller, lun)
8896
}
8997
if c.controllerSlots[slot] != nil {
9098
return fmt.Errorf("slot for controller %d and lun %d is already reserved", controller, lun)
9199
}
92-
c.controllerSlots[slot] = disk.NewReserved(controller, lun, disk.Config{})
100+
c.controllerSlots[slot] = disk.NewReserved(controller, lun, cfg)
93101
return nil
94102
}
95103

@@ -103,6 +111,10 @@ func (c *Controller) Reserve(ctx context.Context, diskConfig disk.Config, mountC
103111
c.mu.Lock()
104112
defer c.mu.Unlock()
105113

114+
if c.isMigrating {
115+
return guid.GUID{}, fmt.Errorf("SCSI controller is migrating; call Resume first")
116+
}
117+
106118
ctx, _ = log.WithContext(ctx, logrus.WithFields(logrus.Fields{
107119
logfields.HostPath: diskConfig.HostPath,
108120
logfields.Partition: mountConfig.Partition,
@@ -178,6 +190,10 @@ func (c *Controller) MapToGuest(ctx context.Context, id guid.GUID) (string, erro
178190
c.mu.Lock()
179191
defer c.mu.Unlock()
180192

193+
if c.isMigrating {
194+
return "", fmt.Errorf("SCSI controller is migrating; call Resume first")
195+
}
196+
181197
r, ok := c.reservations[id]
182198
if !ok {
183199
return "", fmt.Errorf("reservation %s not found", id)
@@ -212,6 +228,10 @@ func (c *Controller) UnmapFromGuest(ctx context.Context, id guid.GUID) error {
212228
c.mu.Lock()
213229
defer c.mu.Unlock()
214230

231+
if c.isMigrating {
232+
return fmt.Errorf("SCSI controller is migrating; call Resume first")
233+
}
234+
215235
ctx, _ = log.WithContext(ctx, logrus.WithField("reservation", id.String()))
216236

217237
r, ok := c.reservations[id]

internal/controller/device/scsi/controller_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"strings"
910
"testing"
1011

1112
"github.com/Microsoft/hcsshim/internal/controller/device/scsi/disk"
@@ -70,6 +71,17 @@ func mappedController(t *testing.T) (*Controller, guid.GUID) {
7071
return c, id
7172
}
7273

74+
func attachmentsContainPath(att map[string]hcsschema.Scsi, path string) bool {
75+
for _, s := range att {
76+
for _, a := range s.Attachments {
77+
if a.Path == path {
78+
return true
79+
}
80+
}
81+
}
82+
return false
83+
}
84+
7385
// --- Tests: New ---
7486

7587
func TestNew(t *testing.T) {
@@ -397,3 +409,111 @@ func TestUnmapFromGuest_RetryAfterDetachFailure(t *testing.T) {
397409
t.Fatalf("re-reserve after retry: %v", err)
398410
}
399411
}
412+
413+
// --- Tests: ReserveForRootfs ---
414+
415+
func TestReserveForRootfs_Success(t *testing.T) {
416+
c := newController(&mockVMOps{}, newMockGuestOps())
417+
cfg := defaultDiskConfig()
418+
if err := c.ReserveForRootfs(context.Background(), 0, 0, cfg); err != nil {
419+
t.Fatalf("unexpected error: %v", err)
420+
}
421+
// The reserved rootfs disk surfaces in the VM topology with its config.
422+
if !attachmentsContainPath(c.HCSAttachments(), cfg.HostPath) {
423+
t.Errorf("expected rootfs path %q in HCS attachments", cfg.HostPath)
424+
}
425+
}
426+
427+
func TestReserveForRootfs_InvalidLocation(t *testing.T) {
428+
c := newController(&mockVMOps{}, newMockGuestOps())
429+
// Controller index beyond the single configured controller.
430+
if err := c.ReserveForRootfs(context.Background(), 1, 0, defaultDiskConfig()); err == nil {
431+
t.Fatal("expected error for out-of-range location")
432+
}
433+
}
434+
435+
func TestReserveForRootfs_AlreadyReserved(t *testing.T) {
436+
c := newController(&mockVMOps{}, newMockGuestOps())
437+
if err := c.ReserveForRootfs(context.Background(), 0, 0, defaultDiskConfig()); err != nil {
438+
t.Fatalf("first reserve: %v", err)
439+
}
440+
if err := c.ReserveForRootfs(context.Background(), 0, 0, defaultDiskConfig()); err == nil {
441+
t.Fatal("expected error reserving an occupied location")
442+
}
443+
}
444+
445+
// --- Tests: migration guard ---
446+
447+
func TestPublicOps_RejectedWhileMigrating(t *testing.T) {
448+
ctx := t.Context()
449+
ops := []struct {
450+
name string
451+
call func(*Controller) error
452+
}{
453+
{"ReserveForRootfs", func(c *Controller) error {
454+
return c.ReserveForRootfs(ctx, 0, 0, defaultDiskConfig())
455+
}},
456+
{"Reserve", func(c *Controller) error {
457+
_, err := c.Reserve(ctx, defaultDiskConfig(), defaultMountConfig())
458+
return err
459+
}},
460+
{"MapToGuest", func(c *Controller) error {
461+
_, err := c.MapToGuest(ctx, guid.GUID{})
462+
return err
463+
}},
464+
{"UnmapFromGuest", func(c *Controller) error {
465+
return c.UnmapFromGuest(ctx, guid.GUID{})
466+
}},
467+
}
468+
for _, op := range ops {
469+
t.Run(op.name, func(t *testing.T) {
470+
// Saving the source blocks further operations until migration resumes.
471+
src := New(1, &mockVMOps{}, newMockGuestOps())
472+
env, err := src.Save(ctx)
473+
if err != nil {
474+
t.Fatalf("Save: %v", err)
475+
}
476+
if err := op.call(src); err == nil || !strings.Contains(err.Error(), "migrating") {
477+
t.Fatalf("source: expected migrating error, got %v", err)
478+
}
479+
480+
// A freshly imported controller is also mid-migration until resumed.
481+
c, err := Import(ctx, env)
482+
if err != nil {
483+
t.Fatalf("Import: %v", err)
484+
}
485+
if err := op.call(c); err == nil || !strings.Contains(err.Error(), "migrating") {
486+
t.Fatalf("imported: expected migrating error, got %v", err)
487+
}
488+
})
489+
}
490+
}
491+
492+
func TestResume_LiftsMigrationGuard(t *testing.T) {
493+
ctx := t.Context()
494+
// Snapshot a controller holding a reservation, then import it.
495+
src := newController(&mockVMOps{}, newMockGuestOps())
496+
if _, err := src.Reserve(ctx, defaultDiskConfig(), defaultMountConfig()); err != nil {
497+
t.Fatalf("setup Reserve: %v", err)
498+
}
499+
env, err := src.Save(ctx)
500+
if err != nil {
501+
t.Fatalf("Save: %v", err)
502+
}
503+
c, err := Import(ctx, env)
504+
if err != nil {
505+
t.Fatalf("Import: %v", err)
506+
}
507+
508+
// Rejected while migrating.
509+
if _, err := c.Reserve(ctx, defaultDiskConfig(), defaultMountConfig()); err == nil {
510+
t.Fatal("expected migrating error before Resume")
511+
}
512+
513+
// Resuming binds live interfaces and lifts the guard.
514+
c.Resume(ctx, &mockVMOps{}, newMockGuestOps())
515+
dc := disk.Config{HostPath: `C:\other.vhdx`, Type: disk.TypeVirtualDisk}
516+
if _, err := c.Reserve(ctx, dc, defaultMountConfig()); err != nil {
517+
t.Fatalf("Reserve after Resume: %v", err)
518+
}
519+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//go:build windows && (lcow || wcow)
2+
3+
package disk
4+
5+
import (
6+
"fmt"
7+
8+
"github.com/Microsoft/hcsshim/internal/controller/device/scsi/mount"
9+
scsisave "github.com/Microsoft/hcsshim/internal/controller/device/scsi/save"
10+
)
11+
12+
// Save returns a migration snapshot of the disk and its mounts. It fails unless
13+
// the disk is attached or reserved and every mount can be saved.
14+
func (d *Disk) Save() (*scsisave.DiskState, error) {
15+
if d.state != StateAttached && d.state != StateReserved {
16+
return nil, fmt.Errorf("scsi disk controller=%d lun=%d in state %s; want %s", d.controller, d.lun, d.state, StateAttached)
17+
}
18+
19+
out := &scsisave.DiskState{
20+
Config: &scsisave.DiskConfig{
21+
HostPath: d.config.HostPath,
22+
ReadOnly: d.config.ReadOnly,
23+
Type: string(d.config.Type),
24+
EvdType: d.config.EVDType,
25+
},
26+
}
27+
28+
if len(d.mounts) > 0 {
29+
out.Mounts = make(map[uint64]*scsisave.MountState, len(d.mounts))
30+
31+
// Snapshot every mount; abort if any cannot be saved.
32+
for partition, m := range d.mounts {
33+
ms, err := m.Save()
34+
if err != nil {
35+
return nil, err
36+
}
37+
out.Mounts[partition] = ms
38+
}
39+
}
40+
return out, nil
41+
}
42+
43+
// Import reconstructs a disk and its mounts from a migration snapshot at the
44+
// given controller and lun. It returns nil if the snapshot is nil.
45+
func Import(state *scsisave.DiskState, controller, lun uint) *Disk {
46+
if state == nil {
47+
return nil
48+
}
49+
50+
// Rebuild the host-side config from the snapshot, if present.
51+
cfg := Config{}
52+
if c := state.GetConfig(); c != nil {
53+
cfg = Config{
54+
HostPath: c.GetHostPath(),
55+
ReadOnly: c.GetReadOnly(),
56+
Type: Type(c.GetType()),
57+
EVDType: c.GetEvdType(),
58+
}
59+
}
60+
61+
// An imported disk is assumed to be live on the SCSI bus.
62+
d := &Disk{
63+
controller: controller,
64+
lun: lun,
65+
config: cfg,
66+
state: StateAttached,
67+
mounts: make(map[uint64]*mount.Mount, len(state.GetMounts())),
68+
}
69+
70+
// Reconstruct each partition mount, skipping any that fail to import.
71+
for partition, ms := range state.GetMounts() {
72+
m := mount.Import(ms, controller, lun, partition)
73+
if m == nil {
74+
continue
75+
}
76+
d.mounts[partition] = m
77+
}
78+
return d
79+
}
80+
81+
// UpdateHostPath rewrites the host-side path of the disk image.
82+
func (d *Disk) UpdateHostPath(p string) {
83+
d.config.HostPath = p
84+
}

0 commit comments

Comments
 (0)