Skip to content

Commit 720f5a7

Browse files
committed
Stage local Docker tags for remote pushes
1 parent b2d87b6 commit 720f5a7

3 files changed

Lines changed: 73 additions & 27 deletions

File tree

pkg/cmd/push.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,31 +25,37 @@ var pushCmd = cli.Command{
2525
ArgsUsage: "IMAGE [TARGET]",
2626
Description: `Push images between Docker, Hypeman, and remote registries.
2727
28-
hypeman push IMAGE
29-
Upload IMAGE from the local Docker daemon into Hypeman.
28+
hypeman push TARGET
29+
Push TARGET to its remote registry. If TARGET exists in local Docker,
30+
stage it in Hypeman first.
3031
3132
hypeman push IMAGE TARGET
3233
Push an image already in Hypeman to TARGET. Waits for completion.
3334
34-
hypeman push --detach IMAGE TARGET
35+
hypeman push --detach TARGET
3536
Queue a remote push and return its ID.
3637
37-
Use "hypeman push local IMAGE [TARGET]" to make the local-upload flow explicit.
38-
The --detach flag applies to remote pushes, not local uploads.
38+
Use "hypeman push local IMAGE [TARGET]" for Docker-daemon uploads that should
39+
only go to Hypeman. The --detach flag applies to remote pushes, not local
40+
uploads.
3941
4042
Push jobs can be inspected while they run:
4143
hypeman push ls
4244
hypeman push inspect <id>
4345
4446
Examples:
45-
# Push a cached image to ECR
47+
# Push a local Docker tag to ECR
48+
docker tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
49+
hypeman push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
50+
51+
# Push a cached Hypeman image to ECR
4652
hypeman push alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
4753
4854
# Push with credentials read from stdin
49-
echo "$ECR_PASSWORD" | hypeman push alpine:latest registry.example.com/app:v1 \
55+
echo "$ECR_PASSWORD" | hypeman push registry.example.com/app:v1 \
5056
--username AWS --password-stdin
5157
52-
# Upload a local Docker image into Hypeman
58+
# Upload a local Docker image into Hypeman only
5359
hypeman push local nginx:latest`,
5460
Flags: pushRemoteFlags(),
5561
Commands: []*cli.Command{&pushLocalCmd, &pushCreateCmd, &pushListCmd, &pushGetCmd},
@@ -69,12 +75,11 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
6975
args := cmd.Args().Slice()
7076
switch len(args) {
7177
case 1:
72-
// Keep the old one-argument form working for existing scripts.
73-
return handleLocalPush(ctx, cmd)
78+
return handleRemotePushTarget(ctx, cmd, args[0])
7479
case 2:
7580
return runRemotePush(ctx, cmd, args[0], args[1])
7681
default:
77-
return fmt.Errorf("image reference required\nUsage: hypeman push <image> [target]")
82+
return fmt.Errorf("image reference required\nUsage: hypeman push <target> or hypeman push <image> <target>")
7883
}
7984
}
8085

@@ -90,6 +95,10 @@ func handleLocalPush(ctx context.Context, cmd *cli.Command) error {
9095
targetName = args[1]
9196
}
9297

98+
return pushLocalImage(ctx, cmd, sourceImage, targetName, nil)
99+
}
100+
101+
func pushLocalImage(ctx context.Context, cmd *cli.Command, sourceImage, targetName string, img v1.Image) error {
93102
baseURL := resolveBaseURL(cmd)
94103

95104
parsedURL, err := url.Parse(baseURL)
@@ -123,10 +132,12 @@ func handleLocalPush(ctx context.Context, cmd *cli.Command) error {
123132
return fmt.Errorf("invalid target: %w", err)
124133
}
125134

126-
fmt.Fprintf(os.Stderr, "Loading image %s from Docker...\n", sourceImage)
127-
img, err := daemon.Image(srcRef)
128-
if err != nil {
129-
return fmt.Errorf("load image: %w", err)
135+
if img == nil {
136+
fmt.Fprintf(os.Stderr, "Loading image %s from Docker...\n", sourceImage)
137+
img, err = daemon.Image(srcRef)
138+
if err != nil {
139+
return fmt.Errorf("load image: %w", err)
140+
}
130141
}
131142

132143
fmt.Fprintf(os.Stderr, "The push refers to repository [%s]\n", dstRef.Context().Name())

pkg/cmd/pushcmd.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"net/url"
78
"os"
89
"strings"
910
"time"
1011

1112
"github.com/google/go-containerregistry/pkg/name"
13+
"github.com/google/go-containerregistry/pkg/v1/daemon"
1214
"github.com/kernel/hypeman-go"
1315
"github.com/kernel/hypeman-go/option"
1416
"github.com/tidwall/gjson"
@@ -85,6 +87,48 @@ var pushGetCmd = cli.Command{
8587
HideHelpCommand: true,
8688
}
8789

90+
func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string) error {
91+
if err := validateRemotePushTarget(target); err != nil {
92+
return err
93+
}
94+
95+
// A local Docker tag can be staged into Hypeman before the remote push.
96+
// When no matching local image exists, use the already-cached Hypeman image.
97+
srcRef, err := name.ParseReference(target)
98+
if err == nil {
99+
if img, loadErr := daemon.Image(srcRef); loadErr == nil {
100+
fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target)
101+
if err := pushLocalImage(ctx, cmd, target, target, img); err != nil {
102+
return err
103+
}
104+
105+
client := hypeman.NewClient(getDefaultRequestOptions(cmd)...)
106+
imported, err := client.Images.Get(ctx, url.PathEscape(target))
107+
if err != nil {
108+
return fmt.Errorf("get staged image %s: %w", target, err)
109+
}
110+
if err := waitForImageReady(ctx, &client, imported); err != nil {
111+
return err
112+
}
113+
}
114+
}
115+
116+
return runRemotePush(ctx, cmd, target, target)
117+
}
118+
119+
func validateRemotePushTarget(target string) error {
120+
if _, err := name.ParseReference(target); err != nil {
121+
return fmt.Errorf("invalid target %q: %w", target, err)
122+
}
123+
lastSlash := strings.LastIndex(target, "/")
124+
lastColon := strings.LastIndex(target, ":")
125+
lastAt := strings.LastIndex(target, "@")
126+
if lastAt > lastSlash || lastColon <= lastSlash {
127+
return fmt.Errorf("target %q must include an explicit tag", target)
128+
}
129+
return nil
130+
}
131+
88132
func handlePushCreate(ctx context.Context, cmd *cli.Command) error {
89133
args := cmd.Args().Slice()
90134
if len(args) != 2 {
@@ -150,16 +194,7 @@ func validateRemotePushReferences(image, target string) error {
150194
if _, err := name.ParseReference(image); err != nil {
151195
return fmt.Errorf("invalid source image %q: %w", image, err)
152196
}
153-
if _, err := name.ParseReference(target); err != nil {
154-
return fmt.Errorf("invalid target %q: %w", target, err)
155-
}
156-
lastSlash := strings.LastIndex(target, "/")
157-
lastColon := strings.LastIndex(target, ":")
158-
lastAt := strings.LastIndex(target, "@")
159-
if lastAt > lastSlash || lastColon <= lastSlash {
160-
return fmt.Errorf("target %q must include an explicit tag", target)
161-
}
162-
return nil
197+
return validateRemotePushTarget(target)
163198
}
164199

165200
func pushPassword(cmd *cli.Command) (string, error) {

pkg/cmd/pushcmd_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ func TestPushCommandStructure(t *testing.T) {
2020
assert.Contains(t, pushListCmd.Aliases, "ls")
2121
assert.Contains(t, pushGetCmd.Aliases, "inspect")
2222

23-
// The parent action remains reachable for the legacy local-upload form and
24-
// the new direct remote-push form.
23+
// The parent action remains reachable for the staged local and direct
24+
// remote-push forms.
2525
assert.NotNil(t, pushCmd.Action)
2626
}
2727

0 commit comments

Comments
 (0)