From e545119da0478c6ad34cce4a8e1c4ed3dfa2b8e1 Mon Sep 17 00:00:00 2001 From: "chenzhongming.czm" Date: Mon, 17 Aug 2026 23:36:07 +0800 Subject: [PATCH 1/5] feat: push snapshots and export cloud images to OCI Change-Id: I5af4c00ff44126853cb4d67c71fe0ce6b904ab24 --- README.md | 4 + cmd/core/ocipush.go | 38 ++++++ cmd/core/ocipush_test.go | 29 +++++ cmd/snapshot/commands.go | 13 +- cmd/snapshot/push.go | 81 +++++++++++++ cmd/snapshot/push_test.go | 72 ++++++++++++ cmd/vm/commands.go | 8 ++ cmd/vm/export.go | 69 +++++++++++ docs/cli.md | 34 +++++- docs/snapshots.md | 11 ++ go.mod | 17 ++- go.sum | 43 +++---- hypervisor/cloudhypervisor/export.go | 68 +++++++++++ hypervisor/cloudhypervisor/export_test.go | 71 +++++++++++ hypervisor/cloudhypervisor/start.go | 12 +- hypervisor/hypervisor.go | 5 + hypervisor/start.go | 5 + images/cloudimg/push.go | 137 ++++++++++++++++++++++ images/cloudimg/push_test.go | 82 +++++++++++++ 19 files changed, 758 insertions(+), 41 deletions(-) create mode 100644 cmd/core/ocipush.go create mode 100644 cmd/core/ocipush_test.go create mode 100644 cmd/snapshot/push.go create mode 100644 cmd/snapshot/push_test.go create mode 100644 cmd/vm/export.go create mode 100644 hypervisor/cloudhypervisor/export.go create mode 100644 hypervisor/cloudhypervisor/export_test.go create mode 100644 images/cloudimg/push.go create mode 100644 images/cloudimg/push_test.go diff --git a/README.md b/README.md index 7a929419..4e3db27f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ cocoon vm exec my-vm -- uname -a cocoon snapshot save --name base my-vm cocoon vm clone base --name fresh +# Publish fast-clone state, or export a portable custom OS image +cocoon snapshot push base registry.example.com/team/base:snapshot +cocoon vm export my-vm registry.example.com/team/custom-os:v1 + # Clean up cocoon vm rm --force my-vm fresh ``` diff --git a/cmd/core/ocipush.go b/cmd/core/ocipush.go new file mode 100644 index 00000000..411b30ef --- /dev/null +++ b/cmd/core/ocipush.go @@ -0,0 +1,38 @@ +package core + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + + commonoci "github.com/cocoonstack/cocoon-common/oci" +) + +// OCIPushTarget is a fully-qualified, tag-addressed OCI destination split for cocoon-common's registry client. +type OCIPushTarget struct { + Registry *commonoci.OCIRegistry + Repository string + Tag string +} + +// ParseOCIPushTarget parses registry/repository[:tag]; digest destinations cannot name a manifest that has not been created yet. +func ParseOCIPushTarget(raw string) (*OCIPushTarget, error) { + ref, err := name.ParseReference(raw, name.StrictValidation) + if err != nil { + repo, repoErr := name.NewRepository(raw, name.StrictValidation) + if repoErr != nil { + return nil, fmt.Errorf("parse OCI destination %q: %w", raw, err) + } + ref = repo.Tag("latest") + } + if _, ok := ref.(name.Tag); !ok { + return nil, fmt.Errorf("OCI destination %q must use a tag, not a digest", raw) + } + repo := ref.Context() + return &OCIPushTarget{ + Registry: commonoci.NewOCIRegistry(repo.RegistryStr(), authn.DefaultKeychain), + Repository: repo.RepositoryStr(), + Tag: ref.Identifier(), + }, nil +} diff --git a/cmd/core/ocipush_test.go b/cmd/core/ocipush_test.go new file mode 100644 index 00000000..a41ce228 --- /dev/null +++ b/cmd/core/ocipush_test.go @@ -0,0 +1,29 @@ +package core + +import "testing" + +func TestParseOCIPushTarget(t *testing.T) { + target, err := ParseOCIPushTarget("registry.example.com/team/image:v1") + if err != nil { + t.Fatal(err) + } + if target.Repository != "team/image" || target.Tag != "v1" { + t.Fatalf("target = repo %q tag %q", target.Repository, target.Tag) + } +} + +func TestParseOCIPushTargetDefaultsToLatest(t *testing.T) { + target, err := ParseOCIPushTarget("registry.example.com/team/image") + if err != nil { + t.Fatal(err) + } + if target.Repository != "team/image" || target.Tag != "latest" { + t.Fatalf("target = repo %q tag %q", target.Repository, target.Tag) + } +} + +func TestParseOCIPushTargetRejectsDigest(t *testing.T) { + if _, err := ParseOCIPushTarget("registry.example.com/team/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); err == nil { + t.Fatal("expected digest destination to be rejected") + } +} diff --git a/cmd/snapshot/commands.go b/cmd/snapshot/commands.go index 6cdf0bc5..90598165 100644 --- a/cmd/snapshot/commands.go +++ b/cmd/snapshot/commands.go @@ -64,6 +64,17 @@ func Command(h Handler) *cobra.Command { importCmd.Flags().String("name", "", "override snapshot name") importCmd.Flags().String("description", "", "override snapshot description") - snapshotCmd.AddCommand(saveCmd, listCmd, inspectCmd, rmCmd, exportCmd, importCmd) + pushCmd := &cobra.Command{ + Use: "push SNAPSHOT REF", + Short: "Push a snapshot to an OCI registry", + Args: cobra.ExactArgs(2), + RunE: h.Push, + } + pushCmd.Flags().Int("zstd-level", 0, "zstd-compress snapshot layers at this level (0 disables)") + pushCmd.Flags().Int("chunk-size-mib", 0, "split snapshot files into chunks of this many MiB (0 disables)") + pushCmd.Flags().Int("concurrency", 8, "parallel chunk upload/encoder workers") + pushCmd.Flags().Int("memory-budget-mib", 9216, "snapshot push pipeline memory cap in MiB") + + snapshotCmd.AddCommand(saveCmd, listCmd, inspectCmd, rmCmd, exportCmd, importCmd, pushCmd) return snapshotCmd } diff --git a/cmd/snapshot/push.go b/cmd/snapshot/push.go new file mode 100644 index 00000000..6013ad34 --- /dev/null +++ b/cmd/snapshot/push.go @@ -0,0 +1,81 @@ +package snapshot + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/projecteru2/core/log" + "github.com/spf13/cobra" + + commonsnapshot "github.com/cocoonstack/cocoon-common/snapshot" + cmdcore "github.com/cocoonstack/cocoon/cmd/core" + localsnapshot "github.com/cocoonstack/cocoon/snapshot" +) + +type backendExporter struct { + backend localsnapshot.Snapshot + ref string +} + +type backendExportStream struct { + io.ReadCloser + close func() error +} + +func (s *backendExportStream) Close() error { return s.close() } + +// Export ignores name because snapshot.Pusher conflates the source identifier with the destination repository while the CLI allows them to differ. +func (e backendExporter) Export(ctx context.Context, _ string) (io.ReadCloser, func() error, error) { + r, err := e.backend.Export(ctx, e.ref) + if err != nil { + return nil, nil, err + } + close := sync.OnceValue(r.Close) + return &backendExportStream{ReadCloser: r, close: close}, close, nil +} + +func (h Handler) Push(cmd *cobra.Command, args []string) error { + ctx, conf := h.Init(cmd) + ref, destination := args[0], args[1] + target, err := cmdcore.ParseOCIPushTarget(destination) + if err != nil { + return err + } + backend, err := cmdcore.InitSnapshot(ctx, conf) + if err != nil { + return err + } + snap, err := backend.Inspect(ctx, ref) + if err != nil { + return fmt.Errorf("inspect snapshot %s: %w", ref, err) + } + zstdLevel, _ := cmd.Flags().GetInt("zstd-level") + chunkSizeMiB, _ := cmd.Flags().GetInt("chunk-size-mib") + concurrency, _ := cmd.Flags().GetInt("concurrency") + memoryBudgetMiB, _ := cmd.Flags().GetInt("memory-budget-mib") + + logger := log.WithFunc("cmd.snapshot.Push") + logger.Infof(ctx, "pushing snapshot %s to %s ...", ref, destination) + result, err := (&commonsnapshot.Pusher{ + Uploader: target.Registry, + Cocoon: backendExporter{backend: backend, ref: snap.ID}, + }).Push(ctx, commonsnapshot.PushOptions{ + Name: target.Repository, + Tag: target.Tag, + BaseImage: snap.Image, + ZstdLevel: zstdLevel, + ChunkSizeMiB: chunkSizeMiB, + Concurrency: concurrency, + MemoryBudgetMiB: memoryBudgetMiB, + Progress: func(line string) { + logger.Info(ctx, line) + }, + }) + if err != nil { + return fmt.Errorf("push snapshot: %w", err) + } + fmt.Println(result.ManifestDigest) + return nil +} diff --git a/cmd/snapshot/push_test.go b/cmd/snapshot/push_test.go new file mode 100644 index 00000000..c4c375ee --- /dev/null +++ b/cmd/snapshot/push_test.go @@ -0,0 +1,72 @@ +package snapshot + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + localsnapshot "github.com/cocoonstack/cocoon/snapshot" +) + +type exportOnlyBackend struct { + localsnapshot.Snapshot + wantRef string + data []byte +} + +type closeErrorReader struct { + io.Reader + err error +} + +func (r *closeErrorReader) Close() error { return r.err } + +type closeErrorBackend struct { + localsnapshot.Snapshot + err error +} + +func (b closeErrorBackend) Export(context.Context, string) (io.ReadCloser, error) { + return &closeErrorReader{Reader: bytes.NewReader(nil), err: b.err}, nil +} + +func (b exportOnlyBackend) Export(_ context.Context, ref string) (io.ReadCloser, error) { + if ref != b.wantRef { + return nil, io.ErrUnexpectedEOF + } + return io.NopCloser(bytes.NewReader(b.data)), nil +} + +func TestBackendExporterWaitReturnsCloseError(t *testing.T) { + want := errors.New("finalize export") + r, wait, err := (backendExporter{backend: closeErrorBackend{err: want}, ref: "local-snapshot"}).Export(t.Context(), "remote/repository") + if err != nil { + t.Fatal(err) + } + if err := r.Close(); !errors.Is(err, want) { + t.Fatalf("close error = %v", err) + } + if err := wait(); !errors.Is(err, want) { + t.Fatalf("wait error = %v", err) + } +} + +func TestBackendExporterUsesFixedSourceRef(t *testing.T) { + b := exportOnlyBackend{wantRef: "local-snapshot", data: []byte("archive")} + r, wait, err := (backendExporter{backend: b, ref: b.wantRef}).Export(t.Context(), "remote/repository") + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if err := wait(); err != nil { + t.Fatal(err) + } + if string(got) != "archive" { + t.Fatalf("got %q", got) + } +} diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 0e400f1c..36a8b31d 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -179,6 +179,13 @@ func Command(h Handler) *cobra.Command { statusCmd.Flags().Bool("event", false, "event stream mode (append changes instead of refreshing); implies polling") statusCmd.Flags().String("format", "", "output format: json (one-shot + event modes; --watch always renders a table)") + exportCmd := &cobra.Command{ + Use: "export VM REF", + Short: "Export a cloudimg-backed VM as a standalone qcow2 OCI artifact", + Args: cobra.ExactArgs(2), + RunE: h.Export, + } + vmCmd.AddCommand( createCmd, runCmd, @@ -197,6 +204,7 @@ func Command(h Handler) *cobra.Command { hibernateCmd, debugCmd, statusCmd, + exportCmd, buildFsCommand(h), buildDeviceCommand(h), buildDiskCommand(h), diff --git a/cmd/vm/export.go b/cmd/vm/export.go new file mode 100644 index 00000000..2498de60 --- /dev/null +++ b/cmd/vm/export.go @@ -0,0 +1,69 @@ +package vm + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/projecteru2/core/log" + "github.com/spf13/cobra" + + cmdcore "github.com/cocoonstack/cocoon/cmd/core" + "github.com/cocoonstack/cocoon/hypervisor" + "github.com/cocoonstack/cocoon/images/cloudimg" +) + +func (h Handler) Export(cmd *cobra.Command, args []string) error { + ctx, conf := h.Init(cmd) + vmRef, destination := args[0], args[1] + target, err := cmdcore.ParseOCIPushTarget(destination) + if err != nil { + return err + } + hyper, err := cmdcore.FindHypervisor(ctx, conf, vmRef) + if err != nil { + return fmt.Errorf("find VM %s: %w", vmRef, err) + } + exporter, ok := hyper.(hypervisor.CloudImageExporter) + if !ok { + return fmt.Errorf("hypervisor %s does not support cloud-image export", hyper.Type()) + } + tmp, err := os.CreateTemp(conf.RootDir, ".cocoon-export-*.qcow2") + if err != nil { + return fmt.Errorf("create export temp file: %w", err) + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close export temp file: %w", err) + } + defer os.Remove(tmpPath) //nolint:errcheck + + logger := log.WithFunc("cmd.vm.Export") + logger.Infof(ctx, "flattening VM %s ...", vmRef) + vm, err := exporter.ExportCloudImage(ctx, vmRef, tmpPath) + if err != nil { + return fmt.Errorf("export VM %s: %w", vmRef, err) + } + + osName := "linux" + if vm.Config.Windows { + osName = "windows" + } + logger.Infof(ctx, "pushing VM %s to %s ...", vmRef, destination) + result, err := (&cloudimg.Pusher{Uploader: target.Registry}).Push(ctx, cloudimg.PushOptions{ + Name: target.Repository, + Tag: target.Tag, + Path: tmpPath, + Title: filepath.Base(target.Repository) + ".qcow2", + Annotations: map[string]string{ + "cocoonstack.os.name": osName, + "cocoonstack.source.vm": vm.ID, + }, + }) + if err != nil { + return fmt.Errorf("push cloud image: %w", err) + } + fmt.Println(result.ManifestDigest) + return nil +} diff --git a/docs/cli.md b/docs/cli.md index 5942e879..3264e0df 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,7 @@ cocoon │ ├── reconcile-stale-create VM Reclaim an ownerless creating placeholder (JSON outcome) │ ├── restore [flags] VM SNAP Restore a VM (running or stopped) to a snapshot │ ├── hibernate [flags] VM Atomically snapshot a running VM and stop it +│ ├── export VM REF Flatten a cloudimg VM and push it as an OCI artifact │ ├── status [VM...] Watch VM status in real time │ ├── fs │ │ ├── attach [flags] VM Attach a vhost-user-fs share (CH only) @@ -46,7 +47,8 @@ cocoon │ ├── inspect SNAPSHOT Show detailed snapshot info (JSON) │ ├── rm SNAPSHOT [SNAPSHOT...] Delete snapshot(s) │ ├── export [flags] SNAPSHOT Export snapshot to portable archive (or stdout) -│ └── import [flags] [FILE] Import snapshot from archive (or stdin) +│ ├── import [flags] [FILE] Import snapshot from archive (or stdin) +│ └── push [flags] SNAPSHOT REF Push snapshot memory/device/disk state to OCI ├── gc [flags] Remove unreferenced blobs, VM dirs; --snapshot for LRU snapshot eviction ├── meta │ ├── init Initialize a fresh sqlite meta store (normally automatic on fresh roots) @@ -190,6 +192,36 @@ Applies to `cocoon snapshot save`: | `--name` | | Snapshot name | | `--description` | | Snapshot description | +### Snapshot Push Flags + +`cocoon snapshot push SNAPSHOT REGISTRY/REPOSITORY[:TAG]` publishes the existing +snapshot as `application/vnd.cocoonstack.snapshot.v1+json` (or v2 when compression +or chunking is enabled). Docker-compatible credentials are read from the standard +Docker config. + +| Flag | Default | Description | +| --------------------- | ------- | ------------------------------------------------ | +| `--zstd-level` | `0` | Compress large snapshot layers; 0 disables | +| `--chunk-size-mib` | `0` | Split files into independently uploaded chunks | +| `--concurrency` | `8` | Parallel chunk upload/encoder workers | +| `--memory-budget-mib` | `9216` | Pipeline buffer cap | + +The pushed artifact remains a memory snapshot: clone/restore must use a compatible +guest CPU ABI and inherits the captured guest topology. + +### VM Cloud Image Export + +```bash +cocoon vm export my-vm registry.example.com/team/custom-os:v1 +``` + +`vm export` accepts Cloud Hypervisor VMs created from a cloud image (Linux or +Windows). If running, the VM is stopped, its qcow2 backing chain is flattened and +compressed, and the VM is restarted before the OCI upload begins. The resulting +`application/vnd.cocoonstack.os-image.v1+json` artifact is a standalone system disk +for `vm run`; data disks are not included. Direct-boot OCI/EROFS VMs are rejected +because their lower layers plus raw upper filesystem are not a qcow2 backing chain. + ### Export Flags Applies to `cocoon snapshot export`: diff --git a/docs/snapshots.md b/docs/snapshots.md index f469748d..1f9de6e1 100644 --- a/docs/snapshots.md +++ b/docs/snapshots.md @@ -108,6 +108,17 @@ cocoon vm clone imported-snap cocoon snapshot export my-snap -o - | ssh host2 cocoon snapshot import --name my-snap ``` +They can also be pushed directly to any OCI Distribution registry using the +credentials in the Docker config: + +```bash +cocoon snapshot push my-snap registry.example.com/team/my-snap:v1 +``` + +This publishes the full memory/device snapshot for `vm clone`; it is distinct +from `cocoon vm export`, which flattens a cloudimg-backed VM into a standalone +system disk without captured CPU or memory state for `vm run`. + The archive contains the snapshot config, VM config, COW disk (with sparse-aware pax headers for efficient compression), memory ranges, and device state — everything needed to reconstruct the snapshot on a different machine. #### Cross-Node Clone diff --git a/go.mod b/go.mod index dddcaaad..a6afdcdd 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,16 @@ go 1.26.5 require ( github.com/cocoonstack/cocoon-agent v0.2.0 + github.com/cocoonstack/cocoon-common v0.3.0 github.com/containernetworking/cni v1.3.0 github.com/containernetworking/plugins v1.9.0 github.com/creack/pty v1.1.24 github.com/docker/go-units v0.5.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gofrs/flock v0.13.0 - github.com/google/go-containerregistry v0.21.0 + github.com/google/go-containerregistry v0.21.7 github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.18.4 + github.com/klauspost/compress v1.18.6 github.com/moby/term v0.5.2 github.com/opencontainers/go-digest v1.0.0 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c @@ -31,9 +32,7 @@ require ( github.com/cockroachdb/errors v1.12.0 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect github.com/cockroachdb/redact v1.1.5 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect - github.com/docker/cli v29.2.1+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/cli v29.5.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/getsentry/sentry-go v0.42.0 // indirect @@ -44,27 +43,25 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.38.0 // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 46dc74a1..5ed8a8ae 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cocoonstack/cocoon-agent v0.2.0 h1:2P1Wm8jsga72rjN1OB/Bk4yrMPw0YsW/RRlwvrIfX60= github.com/cocoonstack/cocoon-agent v0.2.0/go.mod h1:OlCT+uN1zV7DJtNZaLtLycdKNsNxlArB5kzTDea4804= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/cocoonstack/cocoon-common v0.3.0 h1:GeQ5PF5jlvv8MhKSjCjyBwj5JZ1U/VZKudZy4qqxo9w= +github.com/cocoonstack/cocoon-common v0.3.0/go.mod h1:VSfgYiWxoHRnWybzQNaxmK4kVoLT9ffiMKll5/i92CM= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= @@ -23,13 +23,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= -github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -57,8 +54,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.0 h1:ocqxUOczFwAZQBMNE7kuzfqvDe0VWoZxQMOesXreCDI= -github.com/google/go-containerregistry v0.21.0/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -69,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -81,8 +78,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -111,16 +106,16 @@ github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c/go.mod h1:MmpwqgD github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -134,16 +129,12 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -171,8 +162,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -182,7 +173,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -192,8 +182,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -216,7 +206,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/hypervisor/cloudhypervisor/export.go b/hypervisor/cloudhypervisor/export.go new file mode 100644 index 00000000..7e7c50eb --- /dev/null +++ b/hypervisor/cloudhypervisor/export.go @@ -0,0 +1,68 @@ +package cloudhypervisor + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/cocoonstack/cocoon/hypervisor" + "github.com/cocoonstack/cocoon/types" + "github.com/cocoonstack/cocoon/utils" +) + +const exportRestartTimeout = 2 * time.Minute + +var _ hypervisor.CloudImageExporter = (*CloudHypervisor)(nil) + +// ExportCloudImage flattens a cloudimg VM under its ops lock and restores its original running state before the caller performs the registry upload. +func (ch *CloudHypervisor) ExportCloudImage(ctx context.Context, ref, dest string) (vm *types.VM, retErr error) { + id, err := ch.ResolveRef(ctx, ref) + if err != nil { + return nil, err + } + unlock, err := ch.LockVMOps(ctx, id) + if err != nil { + return nil, err + } + defer unlock() + + rec, err := ch.LoadRecord(ctx, id) + if err != nil { + return nil, err + } + if rec.Config.ImageType != types.ImageTypeCloudImg { + return nil, fmt.Errorf("vm %s uses image type %q; only cloudimg-backed VMs can be exported as qcow2", ref, rec.Config.ImageType) + } + cowPath := hypervisor.DiskPathByRole(rec.StorageConfigs, types.StorageRoleCOW) + if cowPath == "" { + return nil, fmt.Errorf("vm %s has no root COW disk", ref) + } + vm = ch.ToVM(&rec) + + wasRunning := rec.State == types.VMStateRunning + probeErr := ch.WithRunningVM(ctx, &rec, func(int) error { + wasRunning = true + return nil + }) + if probeErr != nil && !errors.Is(probeErr, hypervisor.ErrNotRunning) { + return nil, fmt.Errorf("inspect running state: %w", probeErr) + } + if wasRunning { + if err := ch.stopOneLocked(ctx, id); err != nil { + return nil, fmt.Errorf("stop VM before export: %w", err) + } + defer func() { + restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), exportRestartTimeout) + defer cancel() + if restartErr := ch.startOneLocked(restartCtx, id); restartErr != nil { + retErr = errors.Join(retErr, fmt.Errorf("restart VM after export: %w", restartErr)) + } + }() + } + + if err := utils.RunQemuImg(ctx, "convert", "-p", "-f", "qcow2", "-O", "qcow2", "-c", cowPath, dest); err != nil { + return vm, fmt.Errorf("flatten root disk: %w", err) + } + return vm, nil +} diff --git a/hypervisor/cloudhypervisor/export_test.go b/hypervisor/cloudhypervisor/export_test.go new file mode 100644 index 00000000..b1e5cdcf --- /dev/null +++ b/hypervisor/cloudhypervisor/export_test.go @@ -0,0 +1,71 @@ +package cloudhypervisor + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cocoonstack/cocoon/hypervisor" + "github.com/cocoonstack/cocoon/types" +) + +func TestExportCloudImageFlattensStoppedVM(t *testing.T) { + binDir := t.TempDir() + qemuImg := filepath.Join(binDir, "qemu-img") + if err := os.WriteFile(qemuImg, []byte("#!/bin/sh\nfor last in \"$@\"; do :; done\nprintf flattened > \"$last\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + ch := newTestCH(t) + overlay := filepath.Join(t.TempDir(), "overlay.qcow2") + if err := os.WriteFile(overlay, []byte("overlay"), 0o600); err != nil { + t.Fatal(err) + } + seedExportVM(t, ch, "vm1", "alpha", types.ImageTypeCloudImg, overlay) + dest := filepath.Join(t.TempDir(), "export.qcow2") + vm, err := ch.ExportCloudImage(t.Context(), "alpha", dest) + if err != nil { + t.Fatal(err) + } + if vm.ID != "vm1" { + t.Fatalf("exported VM ID = %q", vm.ID) + } + got, err := os.ReadFile(dest) //nolint:gosec // test temp path + if err != nil { + t.Fatal(err) + } + if string(got) != "flattened" { + t.Fatalf("export content = %q", got) + } + rec, err := ch.LoadRecord(t.Context(), "vm1") + if err != nil { + t.Fatal(err) + } + if rec.State != types.VMStateStopped { + t.Fatalf("state = %s, want stopped", rec.State) + } +} + +func TestExportCloudImageRejectsOCIBackedVM(t *testing.T) { + ch := newTestCH(t) + seedExportVM(t, ch, "vm1", "alpha", types.ImageTypeOCI, "/tmp/cow.raw") + if _, err := ch.ExportCloudImage(t.Context(), "alpha", filepath.Join(t.TempDir(), "export.qcow2")); err == nil { + t.Fatal("expected OCI-backed VM export to fail") + } +} + +func seedExportVM(t *testing.T, ch *CloudHypervisor, id, name, imageType, cowPath string) { + t.Helper() + cfg := &types.VMConfig{Name: name, Config: types.Config{ImageType: imageType}} + if err := ch.PrereserveVM(t.Context(), id, cfg, nil); err != nil { + t.Fatal(err) + } + if err := ch.UpdateRecord(t.Context(), id, func(r *hypervisor.VMRecord) error { + r.State = types.VMStateStopped + r.StorageConfigs = []*types.StorageConfig{{Path: cowPath, Role: types.StorageRoleCOW}} + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/hypervisor/cloudhypervisor/start.go b/hypervisor/cloudhypervisor/start.go index 01f763de..bbc9ccd1 100644 --- a/hypervisor/cloudhypervisor/start.go +++ b/hypervisor/cloudhypervisor/start.go @@ -16,7 +16,15 @@ func (ch *CloudHypervisor) Start(ctx context.Context, refs []string) ([]string, } func (ch *CloudHypervisor) startOne(ctx context.Context, id string) error { - return ch.StartSequence(ctx, id, hypervisor.StartSpec{ + return ch.StartSequence(ctx, id, ch.startSpec()) +} + +func (ch *CloudHypervisor) startOneLocked(ctx context.Context, id string) error { + return ch.StartOneLocked(ctx, id, ch.startSpec()) +} + +func (ch *CloudHypervisor) startSpec() hypervisor.StartSpec { + return hypervisor.StartSpec{ RuntimeFiles: runtimeFiles, Launch: func(ctx context.Context, rec *hypervisor.VMRecord, sockPath string) (int, error) { vmCfg := buildVMConfig(rec, hypervisor.ConsoleSockPath(rec.RunDir), ch.EffectiveCPUs(&rec.Config.Config)) @@ -24,7 +32,7 @@ func (ch *CloudHypervisor) startOne(ctx context.Context, id string) error { ch.saveCmdline(ctx, rec, args) return ch.launchProcess(ctx, rec, args, rec.ResolvedNetnsPath(), false) }, - }) + } } func (ch *CloudHypervisor) launchProcess(ctx context.Context, rec *hypervisor.VMRecord, args []string, netnsPath string, deferQuota bool) (int, error) { diff --git a/hypervisor/hypervisor.go b/hypervisor/hypervisor.go index 4223b39d..05c93b0c 100644 --- a/hypervisor/hypervisor.go +++ b/hypervisor/hypervisor.go @@ -51,3 +51,8 @@ type Direct interface { type Hibernator interface { Hibernate(ctx context.Context, ref string, persist func(cfg *types.SnapshotConfig, srcDir string) error) error } + +// CloudImageExporter flattens a cloudimg-backed VM's root disk while fencing stop/conversion/restart; the caller owns and removes dest. +type CloudImageExporter interface { + ExportCloudImage(ctx context.Context, ref, dest string) (*types.VM, error) +} diff --git a/hypervisor/start.go b/hypervisor/start.go index 582aa0eb..0a362521 100644 --- a/hypervisor/start.go +++ b/hypervisor/start.go @@ -32,6 +32,11 @@ func (b *Backend) StartSequence(ctx context.Context, id string, spec StartSpec) return err } defer unlock() + return b.StartOneLocked(ctx, id, spec) +} + +// StartOneLocked is StartSequence minus the lock for composite operations that already hold the VM's ops lock. +func (b *Backend) StartOneLocked(ctx context.Context, id string, spec StartSpec) error { rec, err := b.PrepareStart(ctx, id, spec.RuntimeFiles) if err != nil { return err diff --git a/images/cloudimg/push.go b/images/cloudimg/push.go new file mode 100644 index 00000000..b94c0ac6 --- /dev/null +++ b/images/cloudimg/push.go @@ -0,0 +1,137 @@ +package cloudimg + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "time" + + "github.com/cocoonstack/cocoon-common/manifest" + "github.com/cocoonstack/cocoon-common/ociutil" +) + +const mediaTypeOCIEmptyConfig = "application/vnd.oci.empty.v1+json" + +// Uploader is the OCI write surface needed by Pusher. +type Uploader interface { + HasBlob(ctx context.Context, name, digest string) (bool, error) + PutBlob(ctx context.Context, name, digest string, body io.Reader, size int64) error + PutManifest(ctx context.Context, name, tag string, data []byte, contentType string) error +} + +// PushOptions describes one standalone qcow2 publication. +type PushOptions struct { + Name string + Tag string + Path string + Title string + Annotations map[string]string +} + +// PushResult reports the immutable identity and stored size of a cloud image. +type PushResult struct { + ManifestDigest string + TotalSize int64 +} + +// Pusher publishes a standalone qcow2 as a Cocoon cloud-image OCI artifact. +type Pusher struct { + Uploader Uploader +} + +func (p *Pusher) Push(ctx context.Context, opts PushOptions) (*PushResult, error) { + if opts.Name == "" || opts.Path == "" { + return nil, errors.New("cloudimg push: name and path are required") + } + if opts.Tag == "" { + opts.Tag = "latest" + } + if opts.Title == "" { + opts.Title = filepath.Base(opts.Path) + } + + f, err := os.Open(opts.Path) //nolint:gosec // path is selected by the local CLI export flow + if err != nil { + return nil, fmt.Errorf("open cloud image: %w", err) + } + defer f.Close() //nolint:errcheck + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat cloud image: %w", err) + } + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return nil, fmt.Errorf("hash cloud image: %w", err) + } + digest := "sha256:" + hex.EncodeToString(h.Sum(nil)) + if err := putBlobIfMissing(ctx, p.Uploader, opts.Name, digest, f, info.Size()); err != nil { + return nil, fmt.Errorf("upload cloud image: %w", err) + } + + config := []byte("{}") + configDigest := "sha256:" + ociutil.SHA256Hex(config) + if err := putBlobIfMissing(ctx, p.Uploader, opts.Name, configDigest, bytes.NewReader(config), int64(len(config))); err != nil { + return nil, fmt.Errorf("upload config: %w", err) + } + annotations := maps.Clone(opts.Annotations) + if annotations == nil { + annotations = map[string]string{} + } + annotations[manifest.AnnotationCreated] = time.Now().UTC().Format(time.RFC3339) + annotations["cocoonstack.disk.format"] = "qcow2" + m := manifest.OCIManifest{ + SchemaVersion: 2, + MediaType: manifest.MediaTypeOCIManifest, + ArtifactType: manifest.ArtifactTypeOSImage, + Config: manifest.Descriptor{ + MediaType: mediaTypeOCIEmptyConfig, + Digest: configDigest, + Size: int64(len(config)), + }, + Layers: []manifest.Descriptor{{ + MediaType: manifest.MediaTypeDiskQcow2, + Digest: digest, + Size: info.Size(), + Annotations: map[string]string{ + manifest.AnnotationTitle: opts.Title, + }, + }}, + Annotations: annotations, + } + manifestBytes, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal manifest: %w", err) + } + if err := p.Uploader.PutManifest(ctx, opts.Name, opts.Tag, manifestBytes, manifest.MediaTypeOCIManifest); err != nil { + return nil, fmt.Errorf("put manifest %s:%s: %w", opts.Name, opts.Tag, err) + } + return &PushResult{ + ManifestDigest: "sha256:" + ociutil.SHA256Hex(manifestBytes), + TotalSize: info.Size() + int64(len(config)), + }, nil +} + +func putBlobIfMissing(ctx context.Context, uploader Uploader, name, digest string, body io.ReadSeeker, size int64) error { + exists, err := uploader.HasBlob(ctx, name, digest) + if err != nil { + return fmt.Errorf("check blob %s: %w", digest, err) + } + if exists { + return nil + } + if _, err := body.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek blob %s: %w", digest, err) + } + if err := uploader.PutBlob(ctx, name, digest, body, size); err != nil { + return fmt.Errorf("put blob %s: %w", digest, err) + } + return nil +} diff --git a/images/cloudimg/push_test.go b/images/cloudimg/push_test.go new file mode 100644 index 00000000..5d686542 --- /dev/null +++ b/images/cloudimg/push_test.go @@ -0,0 +1,82 @@ +package cloudimg + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "testing" + + commoncloudimg "github.com/cocoonstack/cocoon-common/cloudimg" + "github.com/cocoonstack/cocoon-common/manifest" +) + +type fakeUploader struct { + blobs map[string][]byte + manifest []byte +} + +func (f *fakeUploader) HasBlob(_ context.Context, _ string, digest string) (bool, error) { + _, ok := f.blobs[digest] + return ok, nil +} + +func (f *fakeUploader) PutBlob(_ context.Context, _ string, digest string, body io.Reader, _ int64) error { + b, err := io.ReadAll(body) + if err == nil { + f.blobs[digest] = b + } + return err +} + +func (f *fakeUploader) PutManifest(_ context.Context, _, _ string, data []byte, _ string) error { + f.manifest = bytes.Clone(data) + return nil +} + +func (f *fakeUploader) ReadBlob(_ context.Context, digest string) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(f.blobs[digest])), nil +} + +func TestPusherProducesCloudImageManifest(t *testing.T) { + path := t.TempDir() + "/disk.qcow2" + if err := os.WriteFile(path, []byte("qcow2-data"), 0o600); err != nil { + t.Fatal(err) + } + uploader := &fakeUploader{blobs: map[string][]byte{}} + result, err := (&Pusher{Uploader: uploader}).Push(t.Context(), PushOptions{ + Name: "team/image", + Tag: "v1", + Path: path, + Annotations: map[string]string{ + "cocoonstack.disk.format": "raw", + }, + }) + if err != nil { + t.Fatal(err) + } + if result.ManifestDigest == "" { + t.Fatal("manifest digest is empty") + } + var got manifest.OCIManifest + if err := json.Unmarshal(uploader.manifest, &got); err != nil { + t.Fatal(err) + } + if got.ArtifactType != manifest.ArtifactTypeOSImage || len(got.Layers) != 1 { + t.Fatalf("manifest artifact=%q layers=%d", got.ArtifactType, len(got.Layers)) + } + if got.Layers[0].MediaType != manifest.MediaTypeDiskQcow2 || got.Layers[0].Title() != "disk.qcow2" { + t.Fatalf("unexpected disk descriptor: %+v", got.Layers[0]) + } + if got.Annotations["cocoonstack.disk.format"] != "qcow2" { + t.Fatalf("disk format annotation = %q", got.Annotations["cocoonstack.disk.format"]) + } + var pulled bytes.Buffer + if err := commoncloudimg.Stream(t.Context(), uploader.manifest, uploader, &pulled); err != nil { + t.Fatal(err) + } + if pulled.String() != "qcow2-data" { + t.Fatalf("pulled cloud image = %q", pulled.String()) + } +} From a1a1b72d67433cd71cec66e23635e748ac947a56 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:25:54 +0800 Subject: [PATCH 2/5] feat: export and retain cloud images locally Change-Id: Ie4aa820df2f1ab1d6d760bff423a038d77e10e62 --- cmd/images/commands.go | 9 +++++ cmd/images/export.go | 60 ++++++++++++++++++++++++++++ cmd/vm/commands.go | 1 + cmd/vm/export.go | 12 ++++++ docs/cli.md | 4 ++ docs/images.md | 6 +++ images/cloudimg/cloudimg.go | 29 ++++++++++++++ images/cloudimg/export_test.go | 71 ++++++++++++++++++++++++++++++++++ 8 files changed, 192 insertions(+) create mode 100644 cmd/images/export.go create mode 100644 images/cloudimg/export_test.go diff --git a/cmd/images/commands.go b/cmd/images/commands.go index 0593bbb5..7214120f 100644 --- a/cmd/images/commands.go +++ b/cmd/images/commands.go @@ -35,6 +35,14 @@ Multiple FILE arguments are treated as split qcow2 parts or multiple tar layers. RunE: h.Import, } + exportCmd := &cobra.Command{ + Use: "export IMAGE", + Short: "Export a locally stored cloud image as qcow2", + Args: cobra.ExactArgs(1), + RunE: h.Export, + } + exportCmd.Flags().StringP("output", "o", "", "output file path (default: .qcow2; use - for stdout)") + pullCmd := &cobra.Command{ Use: "pull IMAGE [IMAGE...]", Short: "Pull OCI image(s) or cloud image URL(s)", @@ -46,6 +54,7 @@ Multiple FILE arguments are treated as split qcow2 parts or multiple tar layers. imageCmd.AddCommand( pullCmd, importCmd, + exportCmd, listCmd, &cobra.Command{ Use: "rm ID [ID...]", diff --git a/cmd/images/export.go b/cmd/images/export.go new file mode 100644 index 00000000..d5700613 --- /dev/null +++ b/cmd/images/export.go @@ -0,0 +1,60 @@ +package images + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/projecteru2/core/log" + "github.com/spf13/cobra" + + cmdcore "github.com/cocoonstack/cocoon/cmd/core" +) + +func (h Handler) Export(cmd *cobra.Command, args []string) (err error) { + ctx, conf := h.Init(cmd) + _, cloudimgStore, err := cmdcore.InitImageBackendsForPull(ctx, conf) + if err != nil { + return err + } + + ref := args[0] + stream, err := cloudimgStore.Export(ctx, ref) + if err != nil { + return fmt.Errorf("export %s: %w", ref, err) + } + defer stream.Close() //nolint:errcheck + defer cmdcore.CloseOnCancel(ctx, stream)() + + output, _ := cmd.Flags().GetString("output") + if output == "-" { + if _, err = io.Copy(os.Stdout, stream); err != nil { + return fmt.Errorf("write cloud image: %w", err) + } + return nil + } + if output == "" { + base := filepath.Base(ref) + base = strings.ReplaceAll(base, ":", "-") + output = base + ".qcow2" + } + + f, err := os.Create(output) //nolint:gosec + if err != nil { + return fmt.Errorf("create %s: %w", output, err) + } + defer func() { + _ = f.Close() + if err != nil { + _ = os.Remove(output) + } + }() + + log.WithFunc("cmd.images.Export").Infof(ctx, "exporting %s to %s ...", ref, output) + if _, err = io.Copy(f, stream); err != nil { + return fmt.Errorf("write %s: %w", output, err) + } + return nil +} diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 36a8b31d..e44555d1 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -185,6 +185,7 @@ func Command(h Handler) *cobra.Command { Args: cobra.ExactArgs(2), RunE: h.Export, } + exportCmd.Flags().String("local-name", "", "also retain the exported qcow2 in the local cloud-image store under this name") vmCmd.AddCommand( createCmd, diff --git a/cmd/vm/export.go b/cmd/vm/export.go index 2498de60..034cc136 100644 --- a/cmd/vm/export.go +++ b/cmd/vm/export.go @@ -11,6 +11,7 @@ import ( cmdcore "github.com/cocoonstack/cocoon/cmd/core" "github.com/cocoonstack/cocoon/hypervisor" "github.com/cocoonstack/cocoon/images/cloudimg" + "github.com/cocoonstack/cocoon/progress" ) func (h Handler) Export(cmd *cobra.Command, args []string) error { @@ -65,5 +66,16 @@ func (h Handler) Export(cmd *cobra.Command, args []string) error { return fmt.Errorf("push cloud image: %w", err) } fmt.Println(result.ManifestDigest) + localName, _ := cmd.Flags().GetString("local-name") + if localName != "" { + _, store, initErr := cmdcore.InitImageBackendsForPull(ctx, conf) + if initErr != nil { + return fmt.Errorf("init local cloud-image store: %w", initErr) + } + logger.Infof(ctx, "retaining exported VM as local image %s ...", localName) + if importErr := store.Import(ctx, localName, progress.Nop, tmpPath); importErr != nil { + return fmt.Errorf("retain local cloud image %s: %w", localName, importErr) + } + } return nil } diff --git a/docs/cli.md b/docs/cli.md index 3264e0df..e4fd832e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -222,6 +222,10 @@ compressed, and the VM is restarted before the OCI upload begins. The resulting for `vm run`; data disks are not included. Direct-boot OCI/EROFS VMs are rejected because their lower layers plus raw upper filesystem are not a qcow2 backing chain. +Use `--local-name NAME` to register the flattened qcow2 in the local cloud-image +store after a successful push. Later runs on the source node can then reuse it +without downloading it from the registry. + ### Export Flags Applies to `cocoon snapshot export`: diff --git a/docs/images.md b/docs/images.md index d732d557..c120a4d3 100644 --- a/docs/images.md +++ b/docs/images.md @@ -31,6 +31,12 @@ cocoon image import myimg disk.qcow2 cat layers.tar.gz | cocoon image import mylayers ``` +`cocoon image export IMAGE -o -` streams a locally stored cloud image as qcow2. This is useful for direct node-to-node prewarming: + +```bash +cocoon image export custom-linux-7a:tag -o - | ssh node2 cocoon image import custom-linux-7a:tag +``` + ## Managing ```bash diff --git a/images/cloudimg/cloudimg.go b/images/cloudimg/cloudimg.go index b4bfa4a1..f38926ee 100644 --- a/images/cloudimg/cloudimg.go +++ b/images/cloudimg/cloudimg.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "os" "github.com/projecteru2/core/log" "golang.org/x/sync/singleflight" @@ -76,6 +77,34 @@ func (c *CloudImg) ImportFromReader(ctx context.Context, name string, tracker pr return importQcow2Reader(ctx, c.conf, c.store, name, tracker, r) } +// Export opens the immutable qcow2 blob referenced by name. The returned file +// descriptor remains readable even if a concurrent GC unlinks the blob after +// it has been opened. +func (c *CloudImg) Export(ctx context.Context, name string) (io.ReadCloser, error) { + var digestHex string + if err := c.store.View(ctx, func(idx *imageIndex) error { + _, entry, ok := images.LookupOne(idx.Images, name) + if !ok || entry == nil { + return fmt.Errorf("cloud image %q not found or ambiguous", name) + } + digestHex = entry.ContentSum.Hex() + return nil + }); err != nil { + return nil, err + } + var locks images.BlobLocks + if err := locks.Lock(c.conf.BlobLockPath(digestHex)); err != nil { + return nil, err + } + defer locks.Release() + blobPath := c.conf.BlobPath(digestHex) + f, err := os.Open(blobPath) //nolint:gosec // blobPath is derived from the content digest + if err != nil { + return nil, fmt.Errorf("open cloud image %q: %w", name, err) + } + return f, nil +} + // Config resolves cloud images to qcow2 storage plus firmware boot config. func (c *CloudImg) Config(ctx context.Context, vms []*types.VMConfig) (result [][]*types.StorageConfig, boot []*types.BootConfig, err error) { err = c.store.View(ctx, func(idx *imageIndex) error { diff --git a/images/cloudimg/export_test.go b/images/cloudimg/export_test.go new file mode 100644 index 00000000..6cbd84f6 --- /dev/null +++ b/images/cloudimg/export_test.go @@ -0,0 +1,71 @@ +package cloudimg + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" + "testing" + + metajson "github.com/cocoonstack/cocoon/meta/json" +) + +func TestExportResolvesAliasAndDigest(t *testing.T) { + ctx := t.Context() + root := t.TempDir() + conf := NewConfig(root, 0) + engine, err := metajson.Open(conf.JSONNamespace()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + backend, err := New(ctx, root, 0, engine) + if err != nil { + t.Fatal(err) + } + + payload := []byte("qcow2-test-payload") + sum := sha256.Sum256(payload) + digestHex := hex.EncodeToString(sum[:]) + if err := os.WriteFile(conf.BlobPath(digestHex), payload, 0o444); err != nil { + t.Fatal(err) + } + if err := backend.store.Update(ctx, func(idx *imageIndex) error { + return writeIndexEntry(idx, conf, "ubuntu:24.04", digestHex) + }); err != nil { + t.Fatal(err) + } + + for _, ref := range []string{"ubuntu:24.04", "sha256:" + digestHex} { + stream, err := backend.Export(ctx, ref) + if err != nil { + t.Fatalf("Export(%q): %v", ref, err) + } + got, readErr := io.ReadAll(stream) + closeErr := stream.Close() + if readErr != nil || closeErr != nil { + t.Fatalf("read/close Export(%q): %v / %v", ref, readErr, closeErr) + } + if string(got) != string(payload) { + t.Fatalf("Export(%q) = %q", ref, got) + } + } +} + +func TestExportRejectsUnknownImage(t *testing.T) { + ctx := t.Context() + root := t.TempDir() + conf := NewConfig(root, 0) + engine, err := metajson.Open(conf.JSONNamespace()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = engine.Close() }) + backend, err := New(ctx, root, 0, engine) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Export(ctx, "missing:latest"); err == nil { + t.Fatal("Export() unexpectedly succeeded") + } +} From 4acc7349135004ce9f8e3b9b2a378614b7f9f452 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:05:49 +0800 Subject: [PATCH 3/5] fix: harden cloud image export recovery Change-Id: Icc50f1e7a30f312c9034d78c9dcf382a3b9ac203 --- cmd/images/export.go | 35 +++++++++++++---- cmd/images/export_test.go | 48 +++++++++++++++++++++++ hypervisor/cloudhypervisor/export.go | 14 ++++++- hypervisor/cloudhypervisor/export_test.go | 33 ++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 cmd/images/export_test.go diff --git a/cmd/images/export.go b/cmd/images/export.go index d5700613..815b6db3 100644 --- a/cmd/images/export.go +++ b/cmd/images/export.go @@ -41,20 +41,39 @@ func (h Handler) Export(cmd *cobra.Command, args []string) (err error) { output = base + ".qcow2" } - f, err := os.Create(output) //nolint:gosec + log.WithFunc("cmd.images.Export").Infof(ctx, "exporting %s to %s ...", ref, output) + return writeExportFile(output, stream) +} + +// writeExportFile replaces output only after a complete, durable copy, so a +// failed export cannot truncate a previously valid image at the same path. +func writeExportFile(output string, src io.Reader) (retErr error) { + dir := filepath.Dir(output) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(output)+".tmp-*") if err != nil { - return fmt.Errorf("create %s: %w", output, err) + return fmt.Errorf("create export temp file for %s: %w", output, err) } + tmpPath := tmp.Name() defer func() { - _ = f.Close() - if err != nil { - _ = os.Remove(output) + _ = tmp.Close() + if retErr != nil { + _ = os.Remove(tmpPath) } }() - - log.WithFunc("cmd.images.Export").Infof(ctx, "exporting %s to %s ...", ref, output) - if _, err = io.Copy(f, stream); err != nil { + if _, err := io.Copy(tmp, src); err != nil { return fmt.Errorf("write %s: %w", output, err) } + if err := tmp.Chmod(0o644); err != nil { + return fmt.Errorf("chmod %s: %w", output, err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("sync %s: %w", output, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", output, err) + } + if err := os.Rename(tmpPath, output); err != nil { + return fmt.Errorf("replace %s: %w", output, err) + } return nil } diff --git a/cmd/images/export_test.go b/cmd/images/export_test.go new file mode 100644 index 00000000..bba91c2e --- /dev/null +++ b/cmd/images/export_test.go @@ -0,0 +1,48 @@ +package images + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +type failingExportReader struct { + read bool +} + +func (r *failingExportReader) Read(p []byte) (int, error) { + if !r.read { + r.read = true + return copy(p, "partial"), nil + } + return 0, errors.New("read failed") +} + +func TestWriteExportFileReplacesOnlyAfterCompleteCopy(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.qcow2") + if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + if err := writeExportFile(path, &failingExportReader{}); err == nil { + t.Fatal("expected copy failure") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("existing output changed after failed export: %q", got) + } + if err := writeExportFile(path, strings.NewReader("replacement")); err != nil { + t.Fatal(err) + } + got, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "replacement" { + t.Fatalf("output = %q, want replacement", got) + } +} diff --git a/hypervisor/cloudhypervisor/export.go b/hypervisor/cloudhypervisor/export.go index 7e7c50eb..91a52165 100644 --- a/hypervisor/cloudhypervisor/export.go +++ b/hypervisor/cloudhypervisor/export.go @@ -40,12 +40,22 @@ func (ch *CloudHypervisor) ExportCloudImage(ctx context.Context, ref, dest strin } vm = ch.ToVM(&rec) - wasRunning := rec.State == types.VMStateRunning + wasRunning := false probeErr := ch.WithRunningVM(ctx, &rec, func(int) error { wasRunning = true return nil }) - if probeErr != nil && !errors.Is(probeErr, hypervisor.ErrNotRunning) { + switch { + case probeErr == nil: + case errors.Is(probeErr, hypervisor.ErrNotRunning): + // A crashed VMM may leave a Running record behind. Reconcile that stale + // state, but do not resurrect a VM that was already down before export. + if hypervisor.NeedsDeadConvergence(&rec) { + if err := ch.ConvergeDead(ctx, id, rec.TransitionGeneration, time.Now()); err != nil { + return nil, fmt.Errorf("reconcile stopped VM before export: %w", err) + } + } + default: return nil, fmt.Errorf("inspect running state: %w", probeErr) } if wasRunning { diff --git a/hypervisor/cloudhypervisor/export_test.go b/hypervisor/cloudhypervisor/export_test.go index b1e5cdcf..bd3d2373 100644 --- a/hypervisor/cloudhypervisor/export_test.go +++ b/hypervisor/cloudhypervisor/export_test.go @@ -55,6 +55,39 @@ func TestExportCloudImageRejectsOCIBackedVM(t *testing.T) { } } +func TestExportCloudImageDoesNotRestartStaleRunningRecord(t *testing.T) { + binDir := t.TempDir() + qemuImg := filepath.Join(binDir, "qemu-img") + if err := os.WriteFile(qemuImg, []byte("#!/bin/sh\nfor last in \"$@\"; do :; done\nprintf flattened > \"$last\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + ch := newTestCH(t) + overlay := filepath.Join(t.TempDir(), "overlay.qcow2") + if err := os.WriteFile(overlay, []byte("overlay"), 0o600); err != nil { + t.Fatal(err) + } + seedExportVM(t, ch, "vm1", "alpha", types.ImageTypeCloudImg, overlay) + if err := ch.UpdateRecord(t.Context(), "vm1", func(r *hypervisor.VMRecord) error { + r.State = types.VMStateRunning + return nil + }); err != nil { + t.Fatal(err) + } + + if _, err := ch.ExportCloudImage(t.Context(), "alpha", filepath.Join(t.TempDir(), "export.qcow2")); err != nil { + t.Fatalf("export stale-running record: %v", err) + } + rec, err := ch.LoadRecord(t.Context(), "vm1") + if err != nil { + t.Fatal(err) + } + if rec.State != types.VMStateStopped { + t.Fatalf("state = %s, want stopped without relaunch", rec.State) + } +} + func seedExportVM(t *testing.T, ch *CloudHypervisor, id, name, imageType, cowPath string) { t.Helper() cfg := &types.VMConfig{Name: name, Config: types.Config{ImageType: imageType}} From ae345cc82c0c6a78e53478bae0efe445210fa872 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:45:15 +0800 Subject: [PATCH 4/5] fix: report partial VM export success Change-Id: I8b2b168cb6a4f072e54bb3d36e1136f926adcf86 --- cmd/vm/export.go | 6 +++--- docs/cli.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/vm/export.go b/cmd/vm/export.go index 034cc136..ffa2b513 100644 --- a/cmd/vm/export.go +++ b/cmd/vm/export.go @@ -65,17 +65,17 @@ func (h Handler) Export(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("push cloud image: %w", err) } - fmt.Println(result.ManifestDigest) localName, _ := cmd.Flags().GetString("local-name") if localName != "" { _, store, initErr := cmdcore.InitImageBackendsForPull(ctx, conf) if initErr != nil { - return fmt.Errorf("init local cloud-image store: %w", initErr) + return fmt.Errorf("pushed %s as %s, but initializing the local cloud-image store failed: %w", destination, result.ManifestDigest, initErr) } logger.Infof(ctx, "retaining exported VM as local image %s ...", localName) if importErr := store.Import(ctx, localName, progress.Nop, tmpPath); importErr != nil { - return fmt.Errorf("retain local cloud image %s: %w", localName, importErr) + return fmt.Errorf("pushed %s as %s, but retaining local cloud image %s failed: %w", destination, result.ManifestDigest, localName, importErr) } } + fmt.Println(result.ManifestDigest) return nil } diff --git a/docs/cli.md b/docs/cli.md index e4fd832e..7dc1513a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -224,7 +224,8 @@ because their lower layers plus raw upper filesystem are not a qcow2 backing cha Use `--local-name NAME` to register the flattened qcow2 in the local cloud-image store after a successful push. Later runs on the source node can then reuse it -without downloading it from the registry. +without downloading it from the registry. A local-retention failure does not roll +back the already published OCI artifact; the error reports its manifest digest. ### Export Flags From e51e93695590af3dccc09ad806c75f66ef4d9600 Mon Sep 17 00:00:00 2001 From: czmDeRepository <56431414+czmDeRepository@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:29:56 +0800 Subject: [PATCH 5/5] fix lint issues in OCI image export Change-Id: I574397ec1ff22efe2966fa1c8d146c6db29b4e0b --- cmd/vm/export.go | 4 ++-- images/cloudimg/commit.go | 4 ++-- images/cloudimg/inspect.go | 4 +++- images/cloudimg/push.go | 14 +++++++------- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmd/vm/export.go b/cmd/vm/export.go index ffa2b513..9381790b 100644 --- a/cmd/vm/export.go +++ b/cmd/vm/export.go @@ -34,9 +34,9 @@ func (h Handler) Export(cmd *cobra.Command, args []string) error { return fmt.Errorf("create export temp file: %w", err) } tmpPath := tmp.Name() - if err := tmp.Close(); err != nil { + if closeErr := tmp.Close(); closeErr != nil { _ = os.Remove(tmpPath) - return fmt.Errorf("close export temp file: %w", err) + return fmt.Errorf("close export temp file: %w", closeErr) } defer os.Remove(tmpPath) //nolint:errcheck diff --git a/images/cloudimg/commit.go b/images/cloudimg/commit.go index dfa113da..73c3de0c 100644 --- a/images/cloudimg/commit.go +++ b/images/cloudimg/commit.go @@ -72,7 +72,7 @@ func prepareTmpBlob(ctx context.Context, conf *Config, tracker progress.Tracker, logger.Debugf(ctx, "detected source format: %s (compat=%q, backing=%t)", info.Format, info.Compat, info.HasBackingFile) - if info.Format == "qcow2" && info.Compat == "1.1" && !info.HasBackingFile { + if info.Format == qcow2Format && info.Compat == "1.1" && !info.HasBackingFile { tmpBlobPath := conf.tmpBlobPath(digestHex) if err := os.Rename(sourcePath, tmpBlobPath); err != nil { return "", fmt.Errorf("rename tmp blob: %w", err) @@ -103,7 +103,7 @@ func prepareTmpBlob(ctx context.Context, conf *Config, tracker progress.Tracker, } func convertToQcow2(ctx context.Context, srcFormat, src, dst string) error { - if err := utils.RunQemuImg(ctx, "convert", "-f", srcFormat, "-O", "qcow2", "-o", "compat=1.1", src, dst); err != nil { + if err := utils.RunQemuImg(ctx, "convert", "-f", srcFormat, "-O", qcow2Format, "-o", "compat=1.1", src, dst); err != nil { os.Remove(dst) //nolint:errcheck,gosec return err } diff --git a/images/cloudimg/inspect.go b/images/cloudimg/inspect.go index d5d3b26b..bec7b215 100644 --- a/images/cloudimg/inspect.go +++ b/images/cloudimg/inspect.go @@ -14,6 +14,8 @@ import ( "github.com/cocoonstack/cocoon/utils" ) +const qcow2Format = "qcow2" + // nonImageSignatures catches common payloads qemu-img would misclassify as raw. var nonImageSignatures = []struct { prefix []byte @@ -106,7 +108,7 @@ func inspectQcow2Header(path string) (*sourceImageInfo, bool, error) { return nil, true, fmt.Errorf("unsupported qcow2 version %d", hdr.Version) } return &sourceImageInfo{ - Format: "qcow2", + Format: qcow2Format, Compat: compat, HasBackingFile: hdr.HasBackingFile, }, true, nil diff --git a/images/cloudimg/push.go b/images/cloudimg/push.go index b94c0ac6..7d1ca4c4 100644 --- a/images/cloudimg/push.go +++ b/images/cloudimg/push.go @@ -68,25 +68,25 @@ func (p *Pusher) Push(ctx context.Context, opts PushOptions) (*PushResult, error return nil, fmt.Errorf("stat cloud image: %w", err) } h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return nil, fmt.Errorf("hash cloud image: %w", err) + if _, hashErr := io.Copy(h, f); hashErr != nil { + return nil, fmt.Errorf("hash cloud image: %w", hashErr) } digest := "sha256:" + hex.EncodeToString(h.Sum(nil)) - if err := putBlobIfMissing(ctx, p.Uploader, opts.Name, digest, f, info.Size()); err != nil { - return nil, fmt.Errorf("upload cloud image: %w", err) + if uploadErr := putBlobIfMissing(ctx, p.Uploader, opts.Name, digest, f, info.Size()); uploadErr != nil { + return nil, fmt.Errorf("upload cloud image: %w", uploadErr) } config := []byte("{}") configDigest := "sha256:" + ociutil.SHA256Hex(config) - if err := putBlobIfMissing(ctx, p.Uploader, opts.Name, configDigest, bytes.NewReader(config), int64(len(config))); err != nil { - return nil, fmt.Errorf("upload config: %w", err) + if configErr := putBlobIfMissing(ctx, p.Uploader, opts.Name, configDigest, bytes.NewReader(config), int64(len(config))); configErr != nil { + return nil, fmt.Errorf("upload config: %w", configErr) } annotations := maps.Clone(opts.Annotations) if annotations == nil { annotations = map[string]string{} } annotations[manifest.AnnotationCreated] = time.Now().UTC().Format(time.RFC3339) - annotations["cocoonstack.disk.format"] = "qcow2" + annotations["cocoonstack.disk.format"] = qcow2Format m := manifest.OCIManifest{ SchemaVersion: 2, MediaType: manifest.MediaTypeOCIManifest,