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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
38 changes: 38 additions & 0 deletions cmd/core/ocipush.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions cmd/core/ocipush_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 9 additions & 0 deletions cmd/images/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <image>.qcow2; use - for stdout)")

pullCmd := &cobra.Command{
Use: "pull IMAGE [IMAGE...]",
Short: "Pull OCI image(s) or cloud image URL(s)",
Expand All @@ -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...]",
Expand Down
79 changes: 79 additions & 0 deletions cmd/images/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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"
}

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 export temp file for %s: %w", output, err)
}
tmpPath := tmp.Name()
defer func() {
_ = tmp.Close()
if retErr != nil {
_ = os.Remove(tmpPath)
}
}()
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
}
48 changes: 48 additions & 0 deletions cmd/images/export_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 12 additions & 1 deletion cmd/snapshot/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
81 changes: 81 additions & 0 deletions cmd/snapshot/push.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading