From 8c8a1135e48af8e2a82c8d5f0e5b7bc81e76c0d9 Mon Sep 17 00:00:00 2001 From: Zack Brady Date: Wed, 12 Aug 2026 21:22:56 -0400 Subject: [PATCH] added hauler copy cmd --- cmd/hauler/cli/cli.go | 1 + cmd/hauler/cli/copy.go | 175 ++++++++++++++++++++++++++++++++++ cmd/hauler/cli/copy_test.go | 111 +++++++++++++++++++++ internal/flags/audit_level.go | 12 +++ internal/flags/imagecopy.go | 27 ++++++ 5 files changed, 326 insertions(+) create mode 100644 cmd/hauler/cli/copy.go create mode 100644 cmd/hauler/cli/copy_test.go create mode 100644 internal/flags/audit_level.go create mode 100644 internal/flags/imagecopy.go diff --git a/cmd/hauler/cli/cli.go b/cmd/hauler/cli/cli.go index 8c1d030..8efb89e 100644 --- a/cmd/hauler/cli/cli.go +++ b/cmd/hauler/cli/cli.go @@ -69,6 +69,7 @@ func New(ctx context.Context, ro *flags.CliRootOpts) *cobra.Command { cmd.AddCommand(cranecmd.NewCmdAuthLogin("hauler")) cmd.AddCommand(cranecmd.NewCmdAuthLogout("hauler")) addStore(cmd, ro) + addCopy(cmd, ro) addVersion(cmd, ro) addCompletion(cmd, ro) diff --git a/cmd/hauler/cli/copy.go b/cmd/hauler/cli/copy.go new file mode 100644 index 0000000..1495ee6 --- /dev/null +++ b/cmd/hauler/cli/copy.go @@ -0,0 +1,175 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + gname "github.com/google/go-containerregistry/pkg/name" + gv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/spf13/cobra" + + "hauler.dev/go/hauler/v2/internal/flags" + "hauler.dev/go/hauler/v2/pkg/audit" + "hauler.dev/go/hauler/v2/pkg/content" + "hauler.dev/go/hauler/v2/pkg/log" + "hauler.dev/go/hauler/v2/pkg/retry" +) + +func addCopy(parent *cobra.Command, ro *flags.CliRootOpts) { + o := &flags.ImageCopyOpts{} + + cmd := &cobra.Command{ + Use: "copy SRC DST", + Aliases: []string{"cp"}, + Short: "(EXPERIMENTAL) Copy an artifact between registries", + Example: ` # copy an image to another registry + hauler copy busybox:latest registry.example.com/busybox:latest + + # copy a specific platform out of a multi-arch image + hauler copy ghcr.io/hauler-dev/hauler-debug:v2.0.3 registry.example.com/hauler-debug:v2.0.3 --platform linux/amd64 + + # copy to a registry with a self-signed certificate + hauler copy busybox:latest registry.example.com/busybox:latest --insecure-skip-tls-verify + + # copy to a registry with no TLS at all + hauler copy busybox:latest registry.example.com/busybox:latest --plain-http`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return CopyImageCmd(cmd.Context(), o, args[0], args[1], ro) + }, + } + o.AddFlags(cmd) + parent.AddCommand(cmd) +} + +// CopyImageCmd copies src to dst directly, registry to registry, no store involved. +func CopyImageCmd(ctx context.Context, o *flags.ImageCopyOpts, src, dst string, ro *flags.CliRootOpts) error { + l := log.FromContext(ctx) + + retries, err := flags.ResolveRetries(o.Retries) + if err != nil { + return err + } + + tr, err := content.BuildTransport(o.InsecureSkipTLSVerify, o.CaFile) + if err != nil { + return err + } + + opts := []remote.Option{ + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithContext(ctx), + remote.WithTransport(tr), + } + + var nameOpts []gname.Option + if o.PlainHTTP { + nameOpts = append(nameOpts, gname.Insecure) + } + + srcRef, err := gname.ParseReference(src, nameOpts...) + if err != nil { + return fmt.Errorf("parsing source reference %q: %w", src, err) + } + dstRef, err := gname.ParseReference(dst, nameOpts...) + if err != nil { + return fmt.Errorf("parsing destination reference %q: %w", dst, err) + } + + l.Infof("copying [%s] to [%s]", src, dst) + + start := time.Now() + var digest string + err = retry.Operation(ctx, &flags.StoreRootOpts{Retries: retries}, ro, func() error { + d, copyErr := copyOnce(srcRef, dstRef, o.Platform, opts) + if copyErr == nil { + digest = d + } + return copyErr + }) + if err != nil { + l.Errorf("unable to copy [%s] to [%s]: %v", src, dst, err) + return err + } + + if flags.AuditLevel(ro) != "none" { + e := audit.Entry{ + Command: "copy", + Args: []string{src, dst}, + Type: "image", + Reference: dst, + Digest: digest, + } + if flags.AuditLevel(ro) == "verbose" { + sys := audit.BuildSystem() + g := audit.BuildGlobal(ro, nil) + e.System = &sys + e.Global = &g + e.Flags = map[string]any{ + "insecure-skip-tls-verify": o.InsecureSkipTLSVerify, + "plain-http": o.PlainHTTP, + "ca-file": o.CaFile, + "platform": o.Platform, + } + } + if err := audit.Append(ro.HaulerDir, e); err != nil { + l.Warnf("failed to write audit entry: %v", err) + } + l.Debugf("generated audit id of [%s]", audit.ID()) + } else { + l.Debugf("generated audit id of [none]") + } + + l.Infof("✓ copied [%s] to [%s] (%.1fs)", src, dst, time.Since(start).Seconds()) + + return nil +} + +// copyOnce copies srcRef to dstRef and returns the digest copied. No +// platform filter keeps a multi-arch index intact; platform picks one child. +func copyOnce(srcRef, dstRef gname.Reference, platform string, opts []remote.Option) (string, error) { + desc, err := remote.Get(srcRef, opts...) + if err != nil { + return "", fmt.Errorf("fetching descriptor for %q: %w", srcRef.Name(), err) + } + + if idx, idxErr := desc.ImageIndex(); idxErr == nil && platform == "" { + if err := remote.WriteIndex(dstRef, idx, opts...); err != nil { + return "", fmt.Errorf("writing index for %q: %w", dstRef.Name(), err) + } + d, err := idx.Digest() + if err != nil { + return "", fmt.Errorf("getting index digest for %q: %w", srcRef.Name(), err) + } + return d.String(), nil + } + + var img gv1.Image + if platform != "" { + p, err := gv1.ParsePlatform(platform) + if err != nil { + return "", err + } + img, err = remote.Image(srcRef, append(append([]remote.Option{}, opts...), remote.WithPlatform(*p))...) + if err != nil { + return "", fmt.Errorf("fetching image %q: %w", srcRef.Name(), err) + } + } else { + img, err = desc.Image() + if err != nil { + return "", fmt.Errorf("fetching image %q: %w", srcRef.Name(), err) + } + } + + if err := remote.Write(dstRef, img, opts...); err != nil { + return "", fmt.Errorf("writing image for %q: %w", dstRef.Name(), err) + } + d, err := img.Digest() + if err != nil { + return "", fmt.Errorf("getting image digest for %q: %w", srcRef.Name(), err) + } + return d.String(), nil +} diff --git a/cmd/hauler/cli/copy_test.go b/cmd/hauler/cli/copy_test.go new file mode 100644 index 0000000..99d295c --- /dev/null +++ b/cmd/hauler/cli/copy_test.go @@ -0,0 +1,111 @@ +package cli + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + + "hauler.dev/go/hauler/v2/internal/flags" +) + +// newCopyTestRegistry starts an in-memory plain-HTTP OCI registry and returns +// its host:port. Shut down via t.Cleanup. +func newCopyTestRegistry(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://") +} + +// TestCopyImageCmd exercises the real copy path (CopyImageCmd -> copyOnce) +// registry-to-registry, with no store involved, asserting the destination +// ends up with the exact digest that was copied. +func TestCopyImageCmd(t *testing.T) { + src := newCopyTestRegistry(t) + dst := newCopyTestRegistry(t) + ro := &flags.CliRootOpts{AuditLevel: "none"} + + t.Run("single image", func(t *testing.T) { + img, err := random.Image(512, 2) + if err != nil { + t.Fatal(err) + } + want, _ := img.Digest() + srcRef, _ := name.NewTag(src+"/app:v1", name.Insecure) + if err := remote.Write(srcRef, img); err != nil { + t.Fatalf("seed source: %v", err) + } + + o := &flags.ImageCopyOpts{PlainHTTP: true} + if err := CopyImageCmd(context.Background(), o, src+"/app:v1", dst+"/app:v1", ro); err != nil { + t.Fatalf("CopyImageCmd: %v", err) + } + + dstRef, _ := name.NewTag(dst+"/app:v1", name.Insecure) + got, err := remote.Get(dstRef) + if err != nil { + t.Fatalf("get destination: %v", err) + } + if got.Digest.String() != want.String() { + t.Errorf("destination digest = %s, want %s", got.Digest, want) + } + }) + + t.Run("multi-arch index copied whole", func(t *testing.T) { + idx, err := random.Index(512, 2, 3) + if err != nil { + t.Fatal(err) + } + want, _ := idx.Digest() + srcRef, _ := name.NewTag(src+"/multi:v1", name.Insecure) + if err := remote.WriteIndex(srcRef, idx); err != nil { + t.Fatalf("seed source: %v", err) + } + + o := &flags.ImageCopyOpts{PlainHTTP: true} + if err := CopyImageCmd(context.Background(), o, src+"/multi:v1", dst+"/multi:v1", ro); err != nil { + t.Fatalf("CopyImageCmd: %v", err) + } + + dstRef, _ := name.NewTag(dst+"/multi:v1", name.Insecure) + got, err := remote.Get(dstRef) + if err != nil { + t.Fatalf("get destination: %v", err) + } + if got.Digest.String() != want.String() { + t.Errorf("destination index digest = %s, want %s", got.Digest, want) + } + }) + + t.Run("platform flag parsed and copied", func(t *testing.T) { + img, err := random.Image(512, 2) + if err != nil { + t.Fatal(err) + } + want, _ := img.Digest() + srcRef, _ := name.NewTag(src+"/plat:v1", name.Insecure) + if err := remote.Write(srcRef, img); err != nil { + t.Fatalf("seed source: %v", err) + } + + o := &flags.ImageCopyOpts{PlainHTTP: true, Platform: "linux/amd64"} + if err := CopyImageCmd(context.Background(), o, src+"/plat:v1", dst+"/plat:v1", ro); err != nil { + t.Fatalf("CopyImageCmd: %v", err) + } + + dstRef, _ := name.NewTag(dst+"/plat:v1", name.Insecure) + got, err := remote.Get(dstRef) + if err != nil { + t.Fatalf("get destination: %v", err) + } + if got.Digest.String() != want.String() { + t.Errorf("destination digest = %s, want %s", got.Digest, want) + } + }) +} diff --git a/internal/flags/audit_level.go b/internal/flags/audit_level.go new file mode 100644 index 0000000..d02d115 --- /dev/null +++ b/internal/flags/audit_level.go @@ -0,0 +1,12 @@ +package flags + +// AuditLevel returns the resolved audit level (none, standard, verbose). +func AuditLevel(ro *CliRootOpts) string { + if ro == nil { + return "none" + } + if ro.AuditLevel == "" { + return "standard" + } + return ro.AuditLevel +} diff --git a/internal/flags/imagecopy.go b/internal/flags/imagecopy.go new file mode 100644 index 0000000..6b8482f --- /dev/null +++ b/internal/flags/imagecopy.go @@ -0,0 +1,27 @@ +package flags + +import ( + "fmt" + + "github.com/spf13/cobra" + "hauler.dev/go/hauler/v2/pkg/consts" +) + +// ImageCopyOpts holds flags for `hauler copy` -- not to be confused with CopyOpts (`store copy`). +type ImageCopyOpts struct { + InsecureSkipTLSVerify bool + PlainHTTP bool + CaFile string + Retries int + Platform string +} + +func (o *ImageCopyOpts) AddFlags(cmd *cobra.Command) { + f := cmd.Flags() + + f.BoolVar(&o.InsecureSkipTLSVerify, "insecure-skip-tls-verify", false, "(Optional) Skip TLS certificate verification") + f.BoolVar(&o.PlainHTTP, "plain-http", false, "(Optional) Allow plain HTTP connections") + f.StringVar(&o.CaFile, "ca-file", "", "(Optional) Location of CA Bundle to enable certification verification") + f.IntVarP(&o.Retries, "retries", "r", 0, fmt.Sprintf("Set the number of retries for operations (0 uses HAULER_RETRIES, otherwise defaults to %d)", consts.DefaultRetries)) + f.StringVarP(&o.Platform, "platform", "p", "", "(Optional) Specify the platform of the image... i.e. linux/amd64 (defaults to all)") +}