Skip to content

Commit 8c8a113

Browse files
committed
added hauler copy cmd
1 parent bd70379 commit 8c8a113

5 files changed

Lines changed: 326 additions & 0 deletions

File tree

cmd/hauler/cli/cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func New(ctx context.Context, ro *flags.CliRootOpts) *cobra.Command {
6969
cmd.AddCommand(cranecmd.NewCmdAuthLogin("hauler"))
7070
cmd.AddCommand(cranecmd.NewCmdAuthLogout("hauler"))
7171
addStore(cmd, ro)
72+
addCopy(cmd, ro)
7273
addVersion(cmd, ro)
7374
addCompletion(cmd, ro)
7475

cmd/hauler/cli/copy.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/google/go-containerregistry/pkg/authn"
9+
gname "github.com/google/go-containerregistry/pkg/name"
10+
gv1 "github.com/google/go-containerregistry/pkg/v1"
11+
"github.com/google/go-containerregistry/pkg/v1/remote"
12+
"github.com/spf13/cobra"
13+
14+
"hauler.dev/go/hauler/v2/internal/flags"
15+
"hauler.dev/go/hauler/v2/pkg/audit"
16+
"hauler.dev/go/hauler/v2/pkg/content"
17+
"hauler.dev/go/hauler/v2/pkg/log"
18+
"hauler.dev/go/hauler/v2/pkg/retry"
19+
)
20+
21+
func addCopy(parent *cobra.Command, ro *flags.CliRootOpts) {
22+
o := &flags.ImageCopyOpts{}
23+
24+
cmd := &cobra.Command{
25+
Use: "copy SRC DST",
26+
Aliases: []string{"cp"},
27+
Short: "(EXPERIMENTAL) Copy an artifact between registries",
28+
Example: ` # copy an image to another registry
29+
hauler copy busybox:latest registry.example.com/busybox:latest
30+
31+
# copy a specific platform out of a multi-arch image
32+
hauler copy ghcr.io/hauler-dev/hauler-debug:v2.0.3 registry.example.com/hauler-debug:v2.0.3 --platform linux/amd64
33+
34+
# copy to a registry with a self-signed certificate
35+
hauler copy busybox:latest registry.example.com/busybox:latest --insecure-skip-tls-verify
36+
37+
# copy to a registry with no TLS at all
38+
hauler copy busybox:latest registry.example.com/busybox:latest --plain-http`,
39+
Args: cobra.ExactArgs(2),
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
return CopyImageCmd(cmd.Context(), o, args[0], args[1], ro)
42+
},
43+
}
44+
o.AddFlags(cmd)
45+
parent.AddCommand(cmd)
46+
}
47+
48+
// CopyImageCmd copies src to dst directly, registry to registry, no store involved.
49+
func CopyImageCmd(ctx context.Context, o *flags.ImageCopyOpts, src, dst string, ro *flags.CliRootOpts) error {
50+
l := log.FromContext(ctx)
51+
52+
retries, err := flags.ResolveRetries(o.Retries)
53+
if err != nil {
54+
return err
55+
}
56+
57+
tr, err := content.BuildTransport(o.InsecureSkipTLSVerify, o.CaFile)
58+
if err != nil {
59+
return err
60+
}
61+
62+
opts := []remote.Option{
63+
remote.WithAuthFromKeychain(authn.DefaultKeychain),
64+
remote.WithContext(ctx),
65+
remote.WithTransport(tr),
66+
}
67+
68+
var nameOpts []gname.Option
69+
if o.PlainHTTP {
70+
nameOpts = append(nameOpts, gname.Insecure)
71+
}
72+
73+
srcRef, err := gname.ParseReference(src, nameOpts...)
74+
if err != nil {
75+
return fmt.Errorf("parsing source reference %q: %w", src, err)
76+
}
77+
dstRef, err := gname.ParseReference(dst, nameOpts...)
78+
if err != nil {
79+
return fmt.Errorf("parsing destination reference %q: %w", dst, err)
80+
}
81+
82+
l.Infof("copying [%s] to [%s]", src, dst)
83+
84+
start := time.Now()
85+
var digest string
86+
err = retry.Operation(ctx, &flags.StoreRootOpts{Retries: retries}, ro, func() error {
87+
d, copyErr := copyOnce(srcRef, dstRef, o.Platform, opts)
88+
if copyErr == nil {
89+
digest = d
90+
}
91+
return copyErr
92+
})
93+
if err != nil {
94+
l.Errorf("unable to copy [%s] to [%s]: %v", src, dst, err)
95+
return err
96+
}
97+
98+
if flags.AuditLevel(ro) != "none" {
99+
e := audit.Entry{
100+
Command: "copy",
101+
Args: []string{src, dst},
102+
Type: "image",
103+
Reference: dst,
104+
Digest: digest,
105+
}
106+
if flags.AuditLevel(ro) == "verbose" {
107+
sys := audit.BuildSystem()
108+
g := audit.BuildGlobal(ro, nil)
109+
e.System = &sys
110+
e.Global = &g
111+
e.Flags = map[string]any{
112+
"insecure-skip-tls-verify": o.InsecureSkipTLSVerify,
113+
"plain-http": o.PlainHTTP,
114+
"ca-file": o.CaFile,
115+
"platform": o.Platform,
116+
}
117+
}
118+
if err := audit.Append(ro.HaulerDir, e); err != nil {
119+
l.Warnf("failed to write audit entry: %v", err)
120+
}
121+
l.Debugf("generated audit id of [%s]", audit.ID())
122+
} else {
123+
l.Debugf("generated audit id of [none]")
124+
}
125+
126+
l.Infof("✓ copied [%s] to [%s] (%.1fs)", src, dst, time.Since(start).Seconds())
127+
128+
return nil
129+
}
130+
131+
// copyOnce copies srcRef to dstRef and returns the digest copied. No
132+
// platform filter keeps a multi-arch index intact; platform picks one child.
133+
func copyOnce(srcRef, dstRef gname.Reference, platform string, opts []remote.Option) (string, error) {
134+
desc, err := remote.Get(srcRef, opts...)
135+
if err != nil {
136+
return "", fmt.Errorf("fetching descriptor for %q: %w", srcRef.Name(), err)
137+
}
138+
139+
if idx, idxErr := desc.ImageIndex(); idxErr == nil && platform == "" {
140+
if err := remote.WriteIndex(dstRef, idx, opts...); err != nil {
141+
return "", fmt.Errorf("writing index for %q: %w", dstRef.Name(), err)
142+
}
143+
d, err := idx.Digest()
144+
if err != nil {
145+
return "", fmt.Errorf("getting index digest for %q: %w", srcRef.Name(), err)
146+
}
147+
return d.String(), nil
148+
}
149+
150+
var img gv1.Image
151+
if platform != "" {
152+
p, err := gv1.ParsePlatform(platform)
153+
if err != nil {
154+
return "", err
155+
}
156+
img, err = remote.Image(srcRef, append(append([]remote.Option{}, opts...), remote.WithPlatform(*p))...)
157+
if err != nil {
158+
return "", fmt.Errorf("fetching image %q: %w", srcRef.Name(), err)
159+
}
160+
} else {
161+
img, err = desc.Image()
162+
if err != nil {
163+
return "", fmt.Errorf("fetching image %q: %w", srcRef.Name(), err)
164+
}
165+
}
166+
167+
if err := remote.Write(dstRef, img, opts...); err != nil {
168+
return "", fmt.Errorf("writing image for %q: %w", dstRef.Name(), err)
169+
}
170+
d, err := img.Digest()
171+
if err != nil {
172+
return "", fmt.Errorf("getting image digest for %q: %w", srcRef.Name(), err)
173+
}
174+
return d.String(), nil
175+
}

cmd/hauler/cli/copy_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
9+
"github.com/google/go-containerregistry/pkg/name"
10+
"github.com/google/go-containerregistry/pkg/registry"
11+
"github.com/google/go-containerregistry/pkg/v1/random"
12+
"github.com/google/go-containerregistry/pkg/v1/remote"
13+
14+
"hauler.dev/go/hauler/v2/internal/flags"
15+
)
16+
17+
// newCopyTestRegistry starts an in-memory plain-HTTP OCI registry and returns
18+
// its host:port. Shut down via t.Cleanup.
19+
func newCopyTestRegistry(t *testing.T) string {
20+
t.Helper()
21+
srv := httptest.NewServer(registry.New())
22+
t.Cleanup(srv.Close)
23+
return strings.TrimPrefix(srv.URL, "http://")
24+
}
25+
26+
// TestCopyImageCmd exercises the real copy path (CopyImageCmd -> copyOnce)
27+
// registry-to-registry, with no store involved, asserting the destination
28+
// ends up with the exact digest that was copied.
29+
func TestCopyImageCmd(t *testing.T) {
30+
src := newCopyTestRegistry(t)
31+
dst := newCopyTestRegistry(t)
32+
ro := &flags.CliRootOpts{AuditLevel: "none"}
33+
34+
t.Run("single image", func(t *testing.T) {
35+
img, err := random.Image(512, 2)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
want, _ := img.Digest()
40+
srcRef, _ := name.NewTag(src+"/app:v1", name.Insecure)
41+
if err := remote.Write(srcRef, img); err != nil {
42+
t.Fatalf("seed source: %v", err)
43+
}
44+
45+
o := &flags.ImageCopyOpts{PlainHTTP: true}
46+
if err := CopyImageCmd(context.Background(), o, src+"/app:v1", dst+"/app:v1", ro); err != nil {
47+
t.Fatalf("CopyImageCmd: %v", err)
48+
}
49+
50+
dstRef, _ := name.NewTag(dst+"/app:v1", name.Insecure)
51+
got, err := remote.Get(dstRef)
52+
if err != nil {
53+
t.Fatalf("get destination: %v", err)
54+
}
55+
if got.Digest.String() != want.String() {
56+
t.Errorf("destination digest = %s, want %s", got.Digest, want)
57+
}
58+
})
59+
60+
t.Run("multi-arch index copied whole", func(t *testing.T) {
61+
idx, err := random.Index(512, 2, 3)
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
want, _ := idx.Digest()
66+
srcRef, _ := name.NewTag(src+"/multi:v1", name.Insecure)
67+
if err := remote.WriteIndex(srcRef, idx); err != nil {
68+
t.Fatalf("seed source: %v", err)
69+
}
70+
71+
o := &flags.ImageCopyOpts{PlainHTTP: true}
72+
if err := CopyImageCmd(context.Background(), o, src+"/multi:v1", dst+"/multi:v1", ro); err != nil {
73+
t.Fatalf("CopyImageCmd: %v", err)
74+
}
75+
76+
dstRef, _ := name.NewTag(dst+"/multi:v1", name.Insecure)
77+
got, err := remote.Get(dstRef)
78+
if err != nil {
79+
t.Fatalf("get destination: %v", err)
80+
}
81+
if got.Digest.String() != want.String() {
82+
t.Errorf("destination index digest = %s, want %s", got.Digest, want)
83+
}
84+
})
85+
86+
t.Run("platform flag parsed and copied", func(t *testing.T) {
87+
img, err := random.Image(512, 2)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
want, _ := img.Digest()
92+
srcRef, _ := name.NewTag(src+"/plat:v1", name.Insecure)
93+
if err := remote.Write(srcRef, img); err != nil {
94+
t.Fatalf("seed source: %v", err)
95+
}
96+
97+
o := &flags.ImageCopyOpts{PlainHTTP: true, Platform: "linux/amd64"}
98+
if err := CopyImageCmd(context.Background(), o, src+"/plat:v1", dst+"/plat:v1", ro); err != nil {
99+
t.Fatalf("CopyImageCmd: %v", err)
100+
}
101+
102+
dstRef, _ := name.NewTag(dst+"/plat:v1", name.Insecure)
103+
got, err := remote.Get(dstRef)
104+
if err != nil {
105+
t.Fatalf("get destination: %v", err)
106+
}
107+
if got.Digest.String() != want.String() {
108+
t.Errorf("destination digest = %s, want %s", got.Digest, want)
109+
}
110+
})
111+
}

internal/flags/audit_level.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package flags
2+
3+
// AuditLevel returns the resolved audit level (none, standard, verbose).
4+
func AuditLevel(ro *CliRootOpts) string {
5+
if ro == nil {
6+
return "none"
7+
}
8+
if ro.AuditLevel == "" {
9+
return "standard"
10+
}
11+
return ro.AuditLevel
12+
}

internal/flags/imagecopy.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package flags
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
"hauler.dev/go/hauler/v2/pkg/consts"
8+
)
9+
10+
// ImageCopyOpts holds flags for `hauler copy` -- not to be confused with CopyOpts (`store copy`).
11+
type ImageCopyOpts struct {
12+
InsecureSkipTLSVerify bool
13+
PlainHTTP bool
14+
CaFile string
15+
Retries int
16+
Platform string
17+
}
18+
19+
func (o *ImageCopyOpts) AddFlags(cmd *cobra.Command) {
20+
f := cmd.Flags()
21+
22+
f.BoolVar(&o.InsecureSkipTLSVerify, "insecure-skip-tls-verify", false, "(Optional) Skip TLS certificate verification")
23+
f.BoolVar(&o.PlainHTTP, "plain-http", false, "(Optional) Allow plain HTTP connections")
24+
f.StringVar(&o.CaFile, "ca-file", "", "(Optional) Location of CA Bundle to enable certification verification")
25+
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))
26+
f.StringVarP(&o.Platform, "platform", "p", "", "(Optional) Specify the platform of the image... i.e. linux/amd64 (defaults to all)")
27+
}

0 commit comments

Comments
 (0)