added hauler copy cmd

This commit is contained in:
Zack Brady
2026-08-12 21:22:56 -04:00
parent bd70379158
commit 8c8a1135e4
5 changed files with 326 additions and 0 deletions
+1
View File
@@ -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)
+175
View File
@@ -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
}
+111
View File
@@ -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)
}
})
}
+12
View File
@@ -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
}
+27
View File
@@ -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)")
}