Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions provider/cocoon/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ func (p *Provider) bringUpVM(ctx context.Context, pod *corev1.Pod, spec meta.VMS
}
switch {
case meta.ReadRestoreFromHibernate(pod):
sourceName, err := p.resolveWakeSource(ctx, spec.VMName)
sourceName, snapshot, err := p.resolveWakeSource(ctx, spec.VMName)
if err != nil {
return nil, "", err
}
v, err := p.cloneFromHibernate(ctx, spec, sourceName)
v, err := p.cloneFromHibernate(ctx, spec, sourceName, snapshot)
if err != nil {
return nil, "", err
}
Expand Down
110 changes: 110 additions & 0 deletions provider/cocoon/restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,113 @@ func TestBringUpVMRestoreFromHibernate(t *testing.T) {
})
}
}

func TestBringUpVMRestoreEnsuresOCIRefBaseImage(t *testing.T) {
const vmName = "vk-ns-demo-0"
rt := &fakeRuntime{
snapshots: map[string]*vm.Snapshot{
vmName: {
Name: vmName,
Image: "simular/win11:25h2-20260705-1", // bare OCI ref: cocoon's --pull refuses these
},
},
}
p := newTestProvider(t)
p.Runtime = rt
p.Probes = probes.NewManager(t.Context())

pod := newPodWithSpec(meta.VMSpec{
VMName: vmName,
Backend: string(cocoonv1.BackendCloudHypervisor),
OS: string(cocoonv1.OSWindows),
})
pod.Annotations[meta.AnnotationRestoreFromHibernate] = "true"
spec := meta.ParseVMSpec(pod)

if _, _, err := p.bringUpVM(t.Context(), pod, spec); err != nil {
t.Fatalf("bringUpVM: %v", err)
}
if rt.cloned == nil {
t.Fatal("restore must clone from the hibernate snapshot, Clone was never called")
}
if len(rt.ensuredImages) != 1 || rt.ensuredImages[0].image != "simular/win11:25h2-20260705-1" {
t.Fatalf("OCI-ref base image was not ensured before restore, got %#v", rt.ensuredImages)
}
if !rt.cloned.Pull {
t.Error("materialized OCI-ref base still needs --pull so core resolves the backing file by digest")
}
}

func TestBringUpVMRestoreSkipsEnsureWhenDigestPresent(t *testing.T) {
const vmName = "vk-ns-demo-0"
rt := &fakeRuntime{
snapshots: map[string]*vm.Snapshot{
vmName: {
Name: vmName,
Image: "simular/win11:25h2-20260705-1",
ImageDigest: "sha256:142ab794",
},
},
imagesPresent: map[string]bool{"sha256:142ab794": true}, // same bytes under another name
}
p := newTestProvider(t)
p.Runtime = rt
p.Probes = probes.NewManager(t.Context())

pod := newPodWithSpec(meta.VMSpec{
VMName: vmName,
Backend: string(cocoonv1.BackendCloudHypervisor),
OS: string(cocoonv1.OSWindows),
})
pod.Annotations[meta.AnnotationRestoreFromHibernate] = "true"
spec := meta.ParseVMSpec(pod)

if _, _, err := p.bringUpVM(t.Context(), pod, spec); err != nil {
t.Fatalf("bringUpVM: %v", err)
}
if len(rt.ensuredImages) != 0 {
t.Fatalf("EnsureImage should be skipped when the digest is already local, got %#v", rt.ensuredImages)
}
if len(rt.imageInspectCalls) != 1 || rt.imageInspectCalls[0] != "sha256:142ab794" {
t.Fatalf("presence must be resolved by digest via Image(), got %#v", rt.imageInspectCalls)
}
if rt.cloned == nil || !rt.cloned.Pull {
t.Error("restore clone must pass --pull when the base is present")
}
}

func TestBringUpVMRestorePullsHTTPBase(t *testing.T) {
const vmName = "vk-ns-demo-0"
rt := &fakeRuntime{
snapshots: map[string]*vm.Snapshot{
vmName: {
Name: vmName,
Image: "https://epoch.simular.cloud/dl/simular/win11/25h2-20260608",
},
},
}
p := newTestProvider(t)
p.Runtime = rt
p.Probes = probes.NewManager(t.Context())

pod := newPodWithSpec(meta.VMSpec{
VMName: vmName,
Backend: string(cocoonv1.BackendCloudHypervisor),
OS: string(cocoonv1.OSWindows),
})
pod.Annotations[meta.AnnotationRestoreFromHibernate] = "true"
spec := meta.ParseVMSpec(pod)

if _, _, err := p.bringUpVM(t.Context(), pod, spec); err != nil {
t.Fatalf("bringUpVM: %v", err)
}
if rt.cloned == nil {
t.Fatal("Runtime.Clone was not called")
}
if !rt.cloned.Pull {
t.Error("restore clone must pass --pull so core fetches a missing http(s) base")
}
if len(rt.ensuredImages) != 0 {
t.Errorf("EnsureImage should not run for an http(s) base (core --pull handles it), got %#v", rt.ensuredImages)
}
}
38 changes: 26 additions & 12 deletions provider/cocoon/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,14 @@ func (p *Provider) wake(ctx context.Context, pod *corev1.Pod) error {
return nil
}
p.markLifecycleState(ctx, pod, meta.LifecycleStateCreating, "")
sourceName, err := p.resolveWakeSource(ctx, spec.VMName)
sourceName, snapshot, err := p.resolveWakeSource(ctx, spec.VMName)
if err != nil {
metrics.WakeTotal.WithLabelValues("failed").Inc()
p.failOp(ctx, pod, "WakePullFailed", "update", err)
return err
}
cloneStart := time.Now()
v, err := p.cloneFromHibernate(ctx, spec, sourceName)
v, err := p.cloneFromHibernate(ctx, spec, sourceName, snapshot)
if err != nil {
metrics.WakeTotal.WithLabelValues("failed").Inc()
p.failOp(ctx, pod, "WakeCloneFailed", "update", err)
Expand All @@ -191,15 +191,24 @@ func (p *Provider) wake(ctx context.Context, pod *corev1.Pod) error {
// source. CH+Windows hibernate snapshots are captured NIC-less, so the clone
// hot-adds a fresh NIC that Windows enumerates as new hardware. The local import
// copy (cross-node pull) is dropped whether the clone succeeds or fails.
func (p *Provider) cloneFromHibernate(ctx context.Context, spec meta.VMSpec, sourceName string) (*vm.VM, error) {
func (p *Provider) cloneFromHibernate(ctx context.Context, spec meta.VMSpec, sourceName string, snapshot *vm.Snapshot) (*vm.VM, error) {
defer p.cleanupWakeImport(spec.VMName, sourceName)
// Same guard as the fresh-clone path: `vm clone --pull` fetches only
// http(s) bases, so an OCI-ref base absent from the local store must be
// materialized here or the restore fails resolving the backing file.
if snapshot != nil && snapshot.Image != "" && !isHTTPURL(snapshot.Image) && !p.imagePresent(ctx, snapshot.ImageDigest) {
if _, imgErr := p.ensureRunImage(ctx, snapshot.Image, false); imgErr != nil {
return nil, fmt.Errorf("ensure restore base image %s: %w", snapshot.Image, imgErr)
}
}
opts := vm.CloneOptions{
From: sourceName,
To: spec.VMName,
Network: spec.Network,
Backend: spec.Backend,
NoDirectIO: spec.NoDirectIO,
OnDemand: useOnDemandClone(spec.OS),
Pull: snapshot != nil && snapshot.Image != "",
}
if shouldDropNICBeforeHibernate(spec) {
opts.NICs = ptr.To(1)
Expand Down Expand Up @@ -298,27 +307,32 @@ func (p *Provider) waitForFreshIP(ctx context.Context, namespace, name string) b
}
}

// resolveWakeSource returns the local snapshot when present, else pulls.
func (p *Provider) resolveWakeSource(ctx context.Context, vmName string) (string, error) {
_, err := p.Runtime.Snapshot(ctx, vmName)
// resolveWakeSource returns the clone source name and its snapshot metadata —
// the local snapshot when present, else pulled from the registry.
func (p *Provider) resolveWakeSource(ctx context.Context, vmName string) (string, *vm.Snapshot, error) {
snapshot, err := p.Runtime.Snapshot(ctx, vmName)
if err == nil {
return vmName, nil
return vmName, snapshot, nil
}
if !errors.Is(err, vm.ErrSnapshotNotFound) {
return "", fmt.Errorf("inspect local snapshot %s: %w", vmName, err)
return "", nil, fmt.Errorf("inspect local snapshot %s: %w", vmName, err)
}
if p.Puller == nil {
return "", fmt.Errorf("wake %s: no local snapshot and no puller configured", vmName)
return "", nil, fmt.Errorf("wake %s: no local snapshot and no puller configured", vmName)
}
importName := vmName + hibernateImportSuffix
pullStart := time.Now()
if err := p.Puller.PullSnapshot(ctx, vmName, meta.HibernateSnapshotTag, importName); err != nil {
if pullErr := p.Puller.PullSnapshot(ctx, vmName, meta.HibernateSnapshotTag, importName); pullErr != nil {
metrics.SnapshotPullTotal.WithLabelValues("failed").Inc()
return "", fmt.Errorf("pull hibernation snapshot %s: %w", vmName, err)
return "", nil, fmt.Errorf("pull hibernation snapshot %s: %w", vmName, pullErr)
}
metrics.SnapshotPullDuration.Observe(time.Since(pullStart).Seconds())
metrics.SnapshotPullTotal.WithLabelValues("ok").Inc()
return importName, nil
snapshot, err = p.Runtime.Snapshot(ctx, importName)
if err != nil {
return "", nil, fmt.Errorf("inspect imported snapshot %s: %w", importName, err)
}
return importName, snapshot, nil
}

// cleanupWakeImport drops the cross-node import; same-node keeps the
Expand Down
7 changes: 5 additions & 2 deletions provider/cocoon/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,21 +269,24 @@ func TestResolveWakeSourceUsesLocalSnapshot(t *testing.T) {
p := newTestProvider(t)
p.Runtime = rt

got, err := p.resolveWakeSource(t.Context(), "vk-ns-demo-0")
got, snapshot, err := p.resolveWakeSource(t.Context(), "vk-ns-demo-0")
if err != nil {
t.Fatalf("resolveWakeSource: %v", err)
}
if got != "vk-ns-demo-0" {
t.Errorf("source = %q, want vk-ns-demo-0 (local snapshot)", got)
}
if snapshot == nil || snapshot.Name != "vk-ns-demo-0" {
t.Errorf("snapshot metadata = %+v, want the local snapshot", snapshot)
}
}

func TestResolveWakeSourceErrorsWhenLocalMissingAndNoPuller(t *testing.T) {
rt := &fakeRuntime{}
p := newTestProvider(t)
p.Runtime = rt

if _, err := p.resolveWakeSource(t.Context(), "vk-ns-demo-0"); err == nil {
if _, _, err := p.resolveWakeSource(t.Context(), "vk-ns-demo-0"); err == nil {
t.Fatal("expected error when local snapshot is missing and no Puller is set")
}
}
Expand Down