Skip to content

Commit ac727a5

Browse files
authored
support clone and restore from specific dir (#22)
* snapshot: extract ReadSnapshotEnvelope shared helper Move the canonical snapshot.json envelope read/write into the snapshot package so cmd/vm can consume an envelope-bearing directory directly (needed for the upcoming \`vm clone --from-dir\` and \`vm restore --from-dir\` paths) without depending on localfile internals. - snapshot.SnapshotJSONName + envelopeVersion (single const block). - snapshot.ReadSnapshotEnvelope(dir) parses + version-checks; returns ErrEnvelopeMissing when the file is absent so callers can surface a precise message instead of a generic open error. - snapshot.WriteSnapshotEnvelope(dir, cfg) writes via AtomicWriteJSON. - localfile/export.go now references snapshot.SnapshotJSONName. - localfile/import.go readAndRemoveSnapshotJSON delegates the parse to the shared helper; remove-after-read semantics preserved. * snapshot: add export --to-dir for directory-based handoff Adds the snapshot.DirectoryExporter optional interface and a LocalFile.ExportToDir implementation. The dir form pairs with the upcoming \`vm clone --from-dir\` and \`vm restore --from-dir\`, giving users an rsync-friendly handoff path without a tar round-trip. - snapshot.DirectoryExporter: ExportToDir(ctx, ref, dir) error - LocalFile.ExportToDir: rejects non-empty target dirs to avoid silently merging into an unrelated tree, writes a fresh snapshot.json envelope via WriteSnapshotEnvelope, then ReflinkCopy's each data file. Reflink is zero-cost on btrfs/xfs and falls back to plain copy elsewhere; the result is always standalone (rsync-friendly), unlike the hardlinks DirectClone uses internally for memory pages. - cmd/snapshot: --to-dir flag, mutually exclusive with --output/--gzip. * vm: clone --from-dir for arbitrary snapshot directories Lets users clone from any directory containing a snapshot.json envelope (e.g. an extracted export.tar, an rsync'd \`snapshot export --to-dir\` result, or an NFS-mounted golden image) without first registering the snapshot in the local DB. - cloneCmd args relaxed to MaximumNArgs(1); positional SNAPSHOT and --from-dir are mutually exclusive (validated in the handler so the error message names both forms). - cloneFromDir reads the envelope, routes to the matching backend by cfg.Hypervisor, asserts hypervisor.Direct, then funnels through the same DirectClone path as the snapshot-DB form. - cloneDirect refactored to share cloneFromSrcDir with cloneFromDir so the prepareClone → DirectClone → finalize sequence isn't duplicated. The dir is read-only across the call so multiple clones of the same dir (golden image use case) are safe. Trust boundary unchanged: existing ValidateMetaPaths still rejects envelopes whose storage paths escape the local rootDir/runDir. * vm: restore --from-dir with --force ownership gate Symmetric with \`vm clone --from-dir\` but with an extra safety check: the envelope's snapshot ID must belong to vm.SnapshotIDs unless --force. This keeps the typical cross-host "sync the same VM's backup over here" flow zero-friction while making "use unrelated state to overwrite this VM" an explicit, opt-in operation (data-loss footgun otherwise). - restoreCmd args relaxed to RangeArgs(1, 2); positional SNAPSHOT and --from-dir are mutually exclusive (handler-validated). - restoreFromDir reads the envelope, resolves the VM's hypervisor, asserts hypervisor.Direct, runs the same NIC-count + resource-flag validation as the snapshot-DB form, then DirectRestore over the dir. - --force does NOT add the envelope's ID to vm.SnapshotIDs — restoring doesn't change ownership semantics, the foreign snapshot remains external to this VM's lineage. * docs: --from-dir + --to-dir directory-based snapshot workflow Bullet point in features + extended export flag table + a worked example covering the three flows: golden image clone, cross-host same-VM restore, force-overriding a foreign-lineage restore. * style: trim verbose godoc on the new envelope/clone/restore paths Comments per SKILL.md should explain WHY, not narrate or restate the signature. This pass keeps the load-bearing rationale (atomic write reason, soft-gate semantics, reflink-vs-hardlink trade-off) and collapses the rest. * snapshot: write envelope last in ExportToDir as completion marker ExportToDir wrote snapshot.json before copying payload files. A concurrent \`vm restore --from-dir\` reading the envelope could pass preflight (which only stat()s presence, not byte-completeness) and kill the running VM while a memory-range or COW file was still mid-copy, leaving the source VM in Error state with a partial restore. Move WriteSnapshotEnvelope to after the copy loop so envelope existence is now an all-data-ready marker. ensureEmptyDir already guarantees the dir starts empty, so a stale envelope can't survive into the new export. * snapshot: envelope helpers take/return SnapshotConfig by value Returning *types.SnapshotConfig forced an awkward \`return &envelope.Config\` and a \`*cfg\` deref inside Write. SnapshotConfig is small (no identity, embedded Config + a few scalars + a map ref) so copying is cheap; switching to value drops the pointer dance in the helper bodies. Callers that need a pointer downstream now take \`&cfg\` once at the call site, which is symmetric with how every other local SnapshotConfig in cocoon is handled. readAndRemoveSnapshotJSON in snapshot/localfile/import.go follows the same shift; the Import flow now embeds cfg by value into the SnapshotRecord literal instead of dereferencing. * snapshot+vm: SnapshotConfig flows by value through read-only paths snapshot.Direct.DataDir, snapshotRecordToConfig, CloneVMConfigFromFlags, RestoreVMConfigFromFlags, mergeResourceFlags, prepareClone, cloneFromSrcDir, validateFCCloneOverrides — all of these previously took or returned *types.SnapshotConfig but only ever read fields. The chain forced \`return &cfg\` / \`*cfg\` / \`&cfg\` dances at every layer. Switch to value semantics inside the cmd-layer + snapshot-backend boundary; the hypervisor backend interface (Clone / DirectClone) keeps *SnapshotConfig because that's the wider stable API surface, and the single deref happens at that boundary. SnapshotConfig is small (no identity, embedded Config + a few scalars + a map ref) so the copy is cheap and pointer-only mattered for sharing semantics that nothing actually used here. * snapshot: Snapshot.Restore returns SnapshotConfig by value Mirrors the DataDir flip: the snap-DB-side Restore was the only place in the cmd/vm clone path that still forced \`*cfg\`/\`&cfg\` dancing. Drop the pointer return on the interface, value-out from LocalFile, and the cmd-side caller derefs once at the hypervisor boundary just like DirectClone. * vm: extract snapshotSource helper for --from-dir vs positional SNAPSHOT Clone and Restore had near-identical mutex / required-arg blocks; the only difference was the leading-positional count (0 for clone where SNAPSHOT is the only arg, 1 for restore where args[0] is VM). Capture that as baseArgs and let the helper return (fromDir, snapRef) with exactly one non-empty. * snapshot: inline ensureEmptyDir into ExportToDir Single caller (the new ExportToDir); the helper added a function-call indirection without earning it. Inline the 8-line check; the comment above the block carries the rationale (no silent merge into an unrelated tree). * vm+snapshot: /code+/simplify followups - ExportToDir: replace inline mkdir + 3-state switch with utils.EnsureDirs (existing 0o750 helper); drop the dead snapshot.json skip in the copy loop since registered DataDir results never carry the envelope (Create doesn't write it; Import removes it via readAndRemoveSnapshotJSON). - run.go: 'nic count mismatch' -> 'NIC count mismatch' (ALL-CAPS acronym rule); fixes both restoreFromDir and the pre-existing legacy Restore message for consistency. - cloneFromDir: copy *config.Config locally before flipping UseFirecracker so the mutation doesn't leak to the caller's shared conf (CLI tolerates it; daemons embedding cocoon would notice). - restoreCmd: PreRunE rejects --force without --from-dir so misuse fails loud instead of silently no-op. * vm: extract runDirectRestore to share between restoreDirect and restoreFromDir restoreDirect (snapshot-DB path) and restoreFromDir (--from-dir path) duplicated the wantJSON-log + DirectRestore + output tail. Pull it out into a single helper, parameterized by sourceLabel. -7 lines net. * snapshot: export EnvelopeVersion + add DirectoryExporter compile-time check Two strict-walk findings: 1. snapshot/localfile/export.go:80 had a literal Version: 1 that should reference the snapshot package's version constant. Promote envelopeVersion -> EnvelopeVersion (exported) and use snapshot.EnvelopeVersion at the cross-package call site. Future format bumps now have one source of truth. 2. localfile.LocalFile didn't carry a compile-time assertion against snapshot.DirectoryExporter (the new interface). Add it alongside the existing Snapshot/Direct/CompressedExporter checks so signature drift in either direction surfaces at build time.
1 parent 1360aa9 commit ac727a5

13 files changed

Lines changed: 423 additions & 66 deletions

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ Lightweight MicroVM engine with dual hypervisor backends: [Cloud Hypervisor](htt
2020
- **Graceful shutdown** — ACPI power-button for UEFI VMs with configurable timeout, fallback to SIGTERM → SIGKILL
2121
- **Interactive console**`cocoon vm console` with bidirectional PTY relay, SSH-style escape sequences (`~.` disconnect, `~?` help), configurable escape character, SIGWINCH propagation
2222
- **Snapshot & clone**`cocoon snapshot save` captures a running VM's full state (memory, disks, config); `cocoon vm clone` restores it as a new VM with fresh network and identity, resource inheritance with validation
23-
- **Snapshot export & import**`cocoon snapshot export` packages a snapshot into a portable `.tar.gz` archive (with sparse-aware pax headers); `cocoon snapshot import` restores it on another host or cluster; supports piping via stdout/stdin for direct host-to-host transfer
23+
- **Snapshot export & import**`cocoon snapshot export` packages a snapshot into a portable `.tar.gz` archive (with sparse-aware pax headers); `cocoon snapshot import` restores it on another host or cluster; supports piping via stdout/stdin for direct host-to-host transfer; `--to-dir` writes a directory form (with `snapshot.json` envelope) for NFS / rsync-friendly handoff
24+
- **Clone / restore from a directory**`cocoon vm clone --from-dir DIR` and `cocoon vm restore --from-dir DIR` consume any directory containing a `snapshot.json` envelope without first registering the snapshot in the local DB; the dir is treated as read-only so multi-VM golden-image use cases work without copying
2425
- **Live status monitoring**`cocoon vm status` watches VM state changes in real time via fsnotify, with refresh mode (top-like) and event-stream mode (append-only, for scripting and vk-cocoon integration)
2526
- **Docker-like CLI**`create`, `run`, `start`, `stop`, `list`, `inspect`, `console`, `rm`, `debug`, `clone`, `status`
2627
- **Structured logging** — configurable log level (`--log-level`), log rotation (max size / age / backups)
@@ -229,6 +230,10 @@ Applies to `cocoon snapshot export`:
229230
| Flag | Default | Description |
230231
| --------------- | -------------------------- | ------------------------------------------------- |
231232
| `--output`, `-o` | `<name-or-id>.tar.gz` | Output file path (`-` for stdout) |
233+
| `--gzip` | `false` | Compress output with gzip |
234+
| `--to-dir` | | Export into a directory (must be empty/absent) instead of a tar; pairs with `vm clone --from-dir` |
235+
236+
`--to-dir` writes a `snapshot.json` envelope alongside reflink-copied data files. Useful for NFS golden images or rsync-friendly handoff: `cocoon snapshot export snap -o ... --to-dir /nfs/golden && rsync ...`. Mutually exclusive with `--output` and `--gzip`.
232237

233238
### Import Flags
234239

@@ -241,6 +246,25 @@ Applies to `cocoon snapshot import`:
241246

242247
When FILE is omitted, data is read from stdin. This enables piping: `cocoon snapshot export snap1 -o - | ssh host2 cocoon snapshot import --name snap1`.
243248

249+
### Direct Clone / Restore From a Directory
250+
251+
`vm clone --from-dir DIR` and `vm restore --from-dir DIR` accept any directory containing a `snapshot.json` envelope (output of `snapshot export --to-dir`, or an extracted `.tar`). The snapshot does not need to be in the local snapshot DB:
252+
253+
```bash
254+
# Build a portable snapshot dir, ship it, clone from it:
255+
cocoon snapshot export my-snap --to-dir /nfs/golden
256+
# ... rsync /nfs/golden to host B if needed ...
257+
cocoon vm clone --from-dir /nfs/golden --name fresh-vm --pull
258+
259+
# Restore the same VM's externally-staged backup (envelope ID matches → silent OK):
260+
cocoon vm restore my-vm --from-dir /sync/from-host-a
261+
262+
# Force-restore a foreign snapshot (acknowledges data-loss risk):
263+
cocoon vm restore my-vm --from-dir /unrelated/lineage --force
264+
```
265+
266+
The dir is read-only across the call, so multiple clones of the same dir (golden image use case) are safe. Pass `--pull` if the base image's blobs may not be present locally — `EnsureImage` reads `image_blob_ids` from the envelope and pulls as needed.
267+
244268
### Status Flags
245269

246270
Applies to `cocoon vm status`:

cmd/core/helpers.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error
365365
}
366366

367367
// CloneVMConfigFromFlags builds VMConfig for clone (inherits from snapshot).
368-
func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg *types.SnapshotConfig) (*types.VMConfig, error) {
368+
func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg types.SnapshotConfig) (*types.VMConfig, error) {
369369
vmName, _ := cmd.Flags().GetString("name")
370370
flagNetwork, _ := cmd.Flags().GetString("network")
371371
network := cmp.Or(flagNetwork, snapCfg.Network)
@@ -410,7 +410,7 @@ func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg *types.SnapshotConfig) (
410410
}
411411

412412
// RestoreVMConfigFromFlags builds VMConfig for restore (allows overrides).
413-
func RestoreVMConfigFromFlags(cmd *cobra.Command, vm *types.VM, snapCfg *types.SnapshotConfig) (*types.VMConfig, error) {
413+
func RestoreVMConfigFromFlags(cmd *cobra.Command, vm *types.VM, snapCfg types.SnapshotConfig) (*types.VMConfig, error) {
414414
result := vm.Config // value copy — keep current VM values
415415

416416
cpu, memBytes, storBytes, err := mergeResourceFlags(cmd, result.CPU, result.Memory, result.Storage, snapCfg)
@@ -570,7 +570,7 @@ func sanitizeVMName(image string) string {
570570
return n
571571
}
572572

573-
func mergeResourceFlags(cmd *cobra.Command, cpu int, memory, storage int64, snapCfg *types.SnapshotConfig) (int, int64, int64, error) {
573+
func mergeResourceFlags(cmd *cobra.Command, cpu int, memory, storage int64, snapCfg types.SnapshotConfig) (int, int64, int64, error) {
574574
cpuFlag, _ := cmd.Flags().GetInt("cpu")
575575
memStr, _ := cmd.Flags().GetString("memory")
576576
storStr, _ := cmd.Flags().GetString("storage")

cmd/snapshot/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ func Command(h Actions) *cobra.Command {
6363
}
6464
exportCmd.Flags().StringP("output", "o", "", "output file path (default: <name-or-id>.tar)")
6565
exportCmd.Flags().Bool("gzip", false, "compress output with gzip")
66+
exportCmd.Flags().String("to-dir", "", "export into a directory (must be empty/absent) instead of a tar; pairs with `vm clone --from-dir`")
67+
exportCmd.MarkFlagsMutuallyExclusive("to-dir", "output")
68+
exportCmd.MarkFlagsMutuallyExclusive("to-dir", "gzip")
6669

6770
importCmd := &cobra.Command{
6871
Use: "import [FILE]",

cmd/snapshot/handler.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,20 @@ func (h Handler) Export(cmd *cobra.Command, args []string) (err error) {
174174
ref := args[0]
175175
output, _ := cmd.Flags().GetString("output")
176176
useGzip, _ := cmd.Flags().GetBool("gzip")
177+
toDir, _ := cmd.Flags().GetString("to-dir")
178+
179+
if toDir != "" {
180+
exporter, ok := snapBackend.(snapshot.DirectoryExporter)
181+
if !ok {
182+
return fmt.Errorf("backend does not support directory export")
183+
}
184+
logger.Infof(ctx, "exporting to dir %s ...", toDir)
185+
if err = exporter.ExportToDir(ctx, ref, toDir); err != nil {
186+
return fmt.Errorf("export-to-dir: %w", err)
187+
}
188+
logger.Infof(ctx, "exported: %s", toDir)
189+
return nil
190+
}
177191

178192
var stream io.ReadCloser
179193
if useGzip {

cmd/vm/commands.go

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package vm
22

33
import (
4+
"fmt"
5+
46
"github.com/spf13/cobra"
57

68
cmdcore "github.com/cocoonstack/cocoon/cmd/core"
@@ -52,9 +54,9 @@ func Command(h Actions) *cobra.Command {
5254
cmdcore.AddOutputFlag(runCmd)
5355

5456
cloneCmd := &cobra.Command{
55-
Use: "clone [flags] SNAPSHOT",
56-
Short: "Clone a new VM from a snapshot",
57-
Args: cobra.ExactArgs(1),
57+
Use: "clone [flags] [SNAPSHOT]",
58+
Short: "Clone a new VM from a snapshot (or a directory via --from-dir)",
59+
Args: cobra.MaximumNArgs(1),
5860
RunE: h.Clone,
5961
}
6062
addCloneFlags(cloneCmd)
@@ -111,15 +113,25 @@ func Command(h Actions) *cobra.Command {
111113
cmdcore.AddOutputFlag(rmCmd)
112114

113115
restoreCmd := &cobra.Command{
114-
Use: "restore [flags] VM SNAPSHOT",
115-
Short: "Restore a running VM to a previous snapshot",
116-
Args: cobra.ExactArgs(2),
117-
RunE: h.Restore,
116+
Use: "restore [flags] VM [SNAPSHOT]",
117+
Short: "Restore a running VM to a previous snapshot (or a directory via --from-dir)",
118+
Args: cobra.RangeArgs(1, 2),
119+
PreRunE: func(cmd *cobra.Command, _ []string) error {
120+
force, _ := cmd.Flags().GetBool("force")
121+
fromDir, _ := cmd.Flags().GetString("from-dir")
122+
if force && fromDir == "" {
123+
return fmt.Errorf("--force only applies with --from-dir")
124+
}
125+
return nil
126+
},
127+
RunE: h.Restore,
118128
}
119129
restoreCmd.Flags().Int("cpu", 0, "boot CPUs (0 = keep current)")
120130
restoreCmd.Flags().String("memory", "", "memory size (empty = keep current)")
121131
restoreCmd.Flags().String("storage", "", "COW disk size (empty = keep current)")
122132
restoreCmd.Flags().Bool("on-demand", false, "use UFFD on-demand memory loading for faster restore (CH only; snapshot file must remain on disk)")
133+
restoreCmd.Flags().String("from-dir", "", "restore from a snapshot directory (must contain snapshot.json) instead of the local snapshot DB; mutually exclusive with positional SNAPSHOT")
134+
restoreCmd.Flags().Bool("force", false, "skip the snapshot-belongs-to-VM check (only meaningful with --from-dir; risk of restoring to an unrelated lineage)")
123135
cmdcore.AddOutputFlag(restoreCmd)
124136

125137
debugCmd := &cobra.Command{
@@ -259,4 +271,5 @@ func addCloneFlags(cmd *cobra.Command) {
259271
cmd.Flags().Bool("no-direct-io", false, "disable O_DIRECT on writable disks (inherit from snapshot if not set)")
260272
cmd.Flags().Bool("on-demand", false, "use UFFD on-demand memory loading for faster clone (CH only; snapshot file must remain on disk)")
261273
cmd.Flags().Bool("pull", false, "auto-pull base image if not found locally (for cross-node clone)")
274+
cmd.Flags().String("from-dir", "", "clone from a snapshot directory (must contain snapshot.json) instead of the local snapshot DB; mutually exclusive with positional SNAPSHOT")
262275
}

cmd/vm/run.go

Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,18 @@ func (h Handler) Clone(cmd *cobra.Command, args []string) error {
7979
}
8080
logger := log.WithFunc("cmd.vm.clone")
8181

82-
snapBackend, err := cmdcore.InitSnapshot(conf)
82+
fromDir, snapRef, err := snapshotSource(cmd, args, 0)
8383
if err != nil {
8484
return err
8585
}
86+
if fromDir != "" {
87+
return h.cloneFromDir(ctx, cmd, conf, fromDir, logger)
88+
}
8689

87-
snapRef := args[0]
90+
snapBackend, err := cmdcore.InitSnapshot(conf)
91+
if err != nil {
92+
return err
93+
}
8894

8995
// Infer hypervisor backend from the snapshot's Hypervisor field.
9096
snapInfo, err := snapBackend.Inspect(ctx, snapRef)
@@ -124,7 +130,7 @@ func (h Handler) Clone(cmd *cobra.Command, args []string) error {
124130

125131
logger.Infof(ctx, "cloning VM from snapshot %s ...", snapRef)
126132

127-
vm, cloneErr := hyper.Clone(ctx, vmID, vmCfg, networkConfigs, cfg, stream)
133+
vm, cloneErr := hyper.Clone(ctx, vmID, vmCfg, networkConfigs, &cfg, stream)
128134
if cloneErr != nil {
129135
rollbackNetwork(ctx, netProvider, vmID)
130136
return fmt.Errorf("clone VM: %w", cloneErr)
@@ -146,7 +152,13 @@ func (h Handler) Restore(cmd *cobra.Command, args []string) error {
146152
logger := log.WithFunc("cmd.vm.restore")
147153

148154
vmRef := args[0]
149-
snapRef := args[1]
155+
fromDir, snapRef, err := snapshotSource(cmd, args, 1)
156+
if err != nil {
157+
return err
158+
}
159+
if fromDir != "" {
160+
return h.restoreFromDir(ctx, cmd, conf, vmRef, fromDir, logger)
161+
}
150162

151163
hyper, err := cmdcore.FindHypervisor(ctx, conf, vmRef)
152164
if err != nil {
@@ -170,11 +182,11 @@ func (h Handler) Restore(cmd *cobra.Command, args []string) error {
170182
}
171183

172184
if snapInfo.NICs != len(vm.NetworkConfigs) {
173-
return fmt.Errorf("nic count mismatch: vm has %d, snapshot has %d",
185+
return fmt.Errorf("NIC count mismatch: vm has %d, snapshot has %d",
174186
len(vm.NetworkConfigs), snapInfo.NICs)
175187
}
176188

177-
vmCfg, err := cmdcore.RestoreVMConfigFromFlags(cmd, vm, &snapInfo.SnapshotConfig)
189+
vmCfg, err := cmdcore.RestoreVMConfigFromFlags(cmd, vm, snapInfo.SnapshotConfig)
178190
if err != nil {
179191
return err
180192
}
@@ -209,23 +221,91 @@ func (h Handler) Restore(cmd *cobra.Command, args []string) error {
209221
return nil
210222
}
211223

224+
// restoreFromDir runs DirectRestore over an envelope-bearing dir. The
225+
// envelope's snapshot ID is gated against vm.SnapshotIDs; a foreign ID
226+
// requires --force so "overwrite VM with unrelated lineage" is opt-in.
227+
func (h Handler) restoreFromDir(ctx context.Context, cmd *cobra.Command, conf *config.Config, vmRef, dir string, logger *log.Fields) error {
228+
cfg, err := snapshot.ReadSnapshotEnvelope(dir)
229+
if err != nil {
230+
return fmt.Errorf("load envelope: %w", err)
231+
}
232+
hyper, err := cmdcore.FindHypervisor(ctx, conf, vmRef)
233+
if err != nil {
234+
return fmt.Errorf("find VM %s: %w", vmRef, err)
235+
}
236+
dcr, ok := hyper.(hypervisor.Direct)
237+
if !ok {
238+
return fmt.Errorf("backend %s does not support direct restore", hyper.Type())
239+
}
240+
vm, err := hyper.Inspect(ctx, vmRef)
241+
if err != nil {
242+
return fmt.Errorf("inspect VM: %w", err)
243+
}
244+
if _, owned := vm.SnapshotIDs[cfg.ID]; !owned {
245+
force, _ := cmd.Flags().GetBool("force")
246+
if !force {
247+
return fmt.Errorf("snapshot envelope id %s does not belong to VM %s; pass --force to override", cfg.ID, vmRef)
248+
}
249+
logger.Warnf(ctx, "snapshot envelope id %s does not belong to VM %s; --force in effect", cfg.ID, vmRef)
250+
}
251+
if cfg.NICs != len(vm.NetworkConfigs) {
252+
return fmt.Errorf("NIC count mismatch: vm has %d, snapshot has %d",
253+
len(vm.NetworkConfigs), cfg.NICs)
254+
}
255+
vmCfg, err := cmdcore.RestoreVMConfigFromFlags(cmd, vm, cfg)
256+
if err != nil {
257+
return err
258+
}
259+
return h.runDirectRestore(ctx, cmd, dcr, vmRef, vmCfg, dir,
260+
fmt.Sprintf("dir %s", dir), logger)
261+
}
262+
212263
func (h Handler) cloneDirect(ctx context.Context, cmd *cobra.Command, conf *config.Config, dcr hypervisor.Direct, da snapshot.Direct, snapRef string, logger *log.Fields) error {
213264
dataDir, cfg, err := da.DataDir(ctx, snapRef)
214265
if err != nil {
215266
return fmt.Errorf("open snapshot %s: %w", snapRef, err)
216267
}
268+
return h.cloneFromSrcDir(ctx, cmd, conf, dcr, cfg, dataDir,
269+
fmt.Sprintf("snapshot %s (direct)", snapRef), logger)
270+
}
271+
272+
// cloneFromDir runs DirectClone over an envelope-bearing dir. The dir stays
273+
// read-only across the call so concurrent clones of a golden image are safe.
274+
func (h Handler) cloneFromDir(ctx context.Context, cmd *cobra.Command, conf *config.Config, dir string, logger *log.Fields) error {
275+
cfg, err := snapshot.ReadSnapshotEnvelope(dir)
276+
if err != nil {
277+
return fmt.Errorf("load envelope: %w", err)
278+
}
279+
// Local copy so flipping the backend selection doesn't leak to the caller's
280+
// shared *config.Config (CLI is fine, daemons embedding cocoon would notice).
281+
localConf := *conf
282+
if cfg.Hypervisor != "" {
283+
localConf.UseFirecracker = cfg.Hypervisor == string(config.HypervisorFirecracker)
284+
}
285+
hyper, err := cmdcore.InitHypervisor(&localConf)
286+
if err != nil {
287+
return err
288+
}
289+
dcr, ok := hyper.(hypervisor.Direct)
290+
if !ok {
291+
return fmt.Errorf("backend %s does not support direct clone", hyper.Type())
292+
}
293+
return h.cloneFromSrcDir(ctx, cmd, &localConf, dcr, cfg, dir,
294+
fmt.Sprintf("dir %s", dir), logger)
295+
}
217296

297+
func (h Handler) cloneFromSrcDir(ctx context.Context, cmd *cobra.Command, conf *config.Config, dcr hypervisor.Direct, cfg types.SnapshotConfig, srcDir, sourceLabel string, logger *log.Fields) error {
218298
vmCfg, vmID, netProvider, networkConfigs, err := h.prepareClone(ctx, cmd, conf, cfg)
219299
if err != nil {
220300
return err
221301
}
222302

223303
wantJSON := cmdcore.WantJSON(cmd)
224304
if !wantJSON {
225-
logger.Infof(ctx, "cloning VM from snapshot %s (direct) ...", snapRef)
305+
logger.Infof(ctx, "cloning VM from %s ...", sourceLabel)
226306
}
227307

228-
vm, cloneErr := dcr.DirectClone(ctx, vmID, vmCfg, networkConfigs, cfg, dataDir)
308+
vm, cloneErr := dcr.DirectClone(ctx, vmID, vmCfg, networkConfigs, &cfg, srcDir)
229309
if cloneErr != nil {
230310
rollbackNetwork(ctx, netProvider, vmID)
231311
return fmt.Errorf("clone VM: %w", cloneErr)
@@ -239,7 +319,26 @@ func (h Handler) cloneDirect(ctx context.Context, cmd *cobra.Command, conf *conf
239319
return nil
240320
}
241321

242-
func (h Handler) prepareClone(ctx context.Context, cmd *cobra.Command, conf *config.Config, cfg *types.SnapshotConfig) (*types.VMConfig, string, network.Network, []*types.NetworkConfig, error) {
322+
// snapshotSource resolves the snapshot source for clone/restore: either a
323+
// directory via --from-dir or a positional SNAPSHOT ref. baseArgs is the
324+
// number of leading positional args before SNAPSHOT (0 for clone, 1 for
325+
// restore where args[0] is VM). Returns (fromDir, snapRef, err) with exactly
326+
// one of fromDir/snapRef non-empty on success.
327+
func snapshotSource(cmd *cobra.Command, args []string, baseArgs int) (string, string, error) {
328+
fromDir, _ := cmd.Flags().GetString("from-dir")
329+
if fromDir != "" {
330+
if len(args) > baseArgs {
331+
return "", "", fmt.Errorf("--from-dir and positional SNAPSHOT are mutually exclusive")
332+
}
333+
return fromDir, "", nil
334+
}
335+
if len(args) <= baseArgs {
336+
return "", "", fmt.Errorf("SNAPSHOT is required (or use --from-dir)")
337+
}
338+
return "", args[baseArgs], nil
339+
}
340+
341+
func (h Handler) prepareClone(ctx context.Context, cmd *cobra.Command, conf *config.Config, cfg types.SnapshotConfig) (*types.VMConfig, string, network.Network, []*types.NetworkConfig, error) {
243342
vmCfg, err := cmdcore.CloneVMConfigFromFlags(cmd, cfg)
244343
if err != nil {
245344
return nil, "", nil, nil, err
@@ -290,26 +389,30 @@ func (h Handler) restoreDirect(ctx context.Context, cmd *cobra.Command, snapRef,
290389
if !ok {
291390
return false, nil
292391
}
293-
294392
dataDir, _, err := da.DataDir(ctx, snapRef)
295393
if err != nil {
296394
return true, fmt.Errorf("open snapshot: %w", err)
297395
}
396+
return true, h.runDirectRestore(ctx, cmd, dcr, vmRef, vmCfg, dataDir,
397+
fmt.Sprintf("snapshot %s", snapRef), logger)
398+
}
298399

400+
// runDirectRestore is the shared tail for the snapshot-DB and --from-dir
401+
// restore paths: log, DirectRestore, output.
402+
func (h Handler) runDirectRestore(ctx context.Context, cmd *cobra.Command, dcr hypervisor.Direct, vmRef string, vmCfg *types.VMConfig, srcDir, sourceLabel string, logger *log.Fields) error {
299403
wantJSON := cmdcore.WantJSON(cmd)
300404
if !wantJSON {
301-
logger.Infof(ctx, "restoring VM %s from snapshot %s (direct) ...", vmRef, snapRef)
405+
logger.Infof(ctx, "restoring VM %s from %s (direct) ...", vmRef, sourceLabel)
302406
}
303-
result, err := dcr.DirectRestore(ctx, vmRef, vmCfg, dataDir)
407+
result, err := dcr.DirectRestore(ctx, vmRef, vmCfg, srcDir)
304408
if err != nil {
305-
return true, fmt.Errorf("restore: %w", err)
409+
return fmt.Errorf("restore: %w", err)
306410
}
307-
308411
if wantJSON {
309-
return true, cmdcore.OutputJSON(result)
412+
return cmdcore.OutputJSON(result)
310413
}
311414
logger.Infof(ctx, "VM %s restored (state: %s)", result.ID, result.State)
312-
return true, nil
415+
return nil
313416
}
314417

315418
func (h Handler) createVM(cmd *cobra.Command, image string) (context.Context, *types.VM, hypervisor.Hypervisor, error) {
@@ -529,7 +632,7 @@ func printOCINetworkHints(vm *types.VM, networkConfigs []*types.NetworkConfig) {
529632
fmt.Println(" systemctl restart systemd-networkd")
530633
}
531634

532-
func validateFCCloneOverrides(cmd *cobra.Command, cfg *types.SnapshotConfig) error {
635+
func validateFCCloneOverrides(cmd *cobra.Command, cfg types.SnapshotConfig) error {
533636
if cpuFlag, _ := cmd.Flags().GetInt("cpu"); cpuFlag > 0 && cpuFlag != cfg.CPU {
534637
return fmt.Errorf("--cpu %d not supported: Firecracker cannot change CPU after snapshot/load (snapshot has %d)", cpuFlag, cfg.CPU)
535638
}

0 commit comments

Comments
 (0)