Skip to content
Merged
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
185 changes: 149 additions & 36 deletions pkg/cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,90 @@ package cmd
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/daemon"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)

var pushCmd = cli.Command{
Name: "push",
Aliases: []string{"pushes"},
Usage: "Push a local Docker image to hypeman",
ArgsUsage: "<image> [target-name]",
Description: `Push a local Docker image into the hypeman image cache.
Usage: "Push images between Docker, Hypeman, and registries",
ArgsUsage: "IMAGE [TARGET]",
Description: `Push images between Docker, Hypeman, and remote registries.

Subcommands manage outbound pushes, which export a cached hypeman image to a
remote registry (e.g. AWS ECR, Docker Hub):
hypeman push create <image> <target> Push a hypeman image to a remote registry
hypeman push list List outbound image push jobs
hypeman push get <id> Get push details
hypeman push TARGET
Push a local Docker image tagged TARGET to its remote registry. The CLI
stages it in Hypeman first.

hypeman push IMAGE TARGET
Push an image already in Hypeman to TARGET. Waits for completion.

hypeman push --detach TARGET
Queue a remote push and return its ID.

Use "hypeman push local IMAGE [TARGET]" for Docker-daemon uploads that should
only go to Hypeman. The --detach flag applies to remote pushes, not local
uploads.

Push jobs can be inspected while they run:
hypeman push ls
hypeman push inspect <id>

Examples:
# Push a local Docker image into hypeman
hypeman push nginx:latest

# Export a cached hypeman image to a remote registry
hypeman push create nginx:latest registry.example.com/nginx:latest`,
Commands: []*cli.Command{
&pushCreateCmd,
&pushListCmd,
&pushGetCmd,
},
# Push a local Docker tag to ECR
docker tag alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1
hypeman push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1

# Push a cached Hypeman image to ECR
hypeman push alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1

# Push with credentials read from stdin
echo "$ECR_PASSWORD" | hypeman push registry.example.com/app:v1 \
--username AWS --password-stdin

# Upload a local Docker image into Hypeman only
hypeman push local nginx:latest`,
Flags: pushRemoteFlags(),
Commands: []*cli.Command{&pushLocalCmd, &pushCreateCmd, &pushListCmd, &pushGetCmd},
Action: handlePush,
HideHelpCommand: true,
}

var pushLocalCmd = cli.Command{
Name: "local",
Usage: "Upload a local Docker image to Hypeman",
ArgsUsage: "IMAGE [TARGET]",
Action: handleLocalPush,
HideHelpCommand: true,
}

func handlePush(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) < 1 {
return fmt.Errorf("image reference required\nUsage: hypeman push <image>")
switch len(args) {
case 1:
return handleRemotePushTarget(ctx, cmd, args[0])
case 2:
return runRemotePush(ctx, cmd, args[0], args[1])
default:
return fmt.Errorf("image reference required\nUsage: hypeman push <target> or hypeman push <image> <target>")
}
}

func handleLocalPush(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
if len(args) < 1 || len(args) > 2 {
return fmt.Errorf("image reference required\nUsage: hypeman push local <image> [target]")
}

sourceImage := args[0]
Expand All @@ -55,36 +95,60 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
targetName = args[1]
}

baseURL := resolveBaseURL(cmd)
return pushLocalImage(ctx, cmd, sourceImage, targetName)
}

parsedURL, err := url.Parse(baseURL)
func pushLocalImage(ctx context.Context, cmd *cli.Command, sourceImage, targetName string) error {
fmt.Fprintf(os.Stderr, "Loading image %s from Docker...\n", sourceImage)
img, err := loadDockerImage(sourceImage)
if err != nil {
return fmt.Errorf("invalid base URL: %w", err)
return err
}
return uploadLocalImage(ctx, cmd, targetName, img)
}

registryHost := parsedURL.Host

fmt.Fprintf(os.Stderr, "Loading image %s from Docker...\n", sourceImage)

srcRef, err := name.ParseReference(sourceImage)
func loadDockerImage(image string) (v1.Image, error) {
srcRef, err := name.ParseReference(image)
if err != nil {
return fmt.Errorf("invalid source image: %w", err)
return nil, fmt.Errorf("invalid source image: %w", err)
}

img, err := daemon.Image(srcRef)
if err != nil {
return fmt.Errorf("load image: %w", err)
return nil, fmt.Errorf("load image: %w", err)
}
return img, nil
}

// Build target reference - server computes digest from manifest
targetRef := registryHost + "/" + strings.TrimPrefix(targetName, "/")
fmt.Fprintf(os.Stderr, "Pushing to %s...\n", targetRef)
func uploadLocalImage(ctx context.Context, cmd *cli.Command, targetName string, img v1.Image) error {
baseURL := resolveBaseURL(cmd)

parsedURL, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("invalid base URL: %w", err)
}
if parsedURL.Host == "" {
return fmt.Errorf("invalid base URL %q: missing host", baseURL)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("invalid base URL %q: scheme must be http or https", baseURL)
}

registryHost := parsedURL.Host

dstRef, err := name.ParseReference(targetRef, name.Insecure)
// The server computes the image digest from the manifest, while the tag
// keeps the image addressable with Docker-like image names after the push.
targetRef := registryHost + "/" + strings.TrimPrefix(targetName, "/")
parseOptions := []name.Option(nil)
if parsedURL.Scheme == "http" {
parseOptions = append(parseOptions, name.Insecure)
}
dstRef, err := name.ParseReference(targetRef, parseOptions...)
if err != nil {
return fmt.Errorf("invalid target: %w", err)
}

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

token := resolveAPIKey()

// Use custom transport that always sends Basic auth header
Expand All @@ -93,19 +157,68 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
token: token,
}

progress := make(chan v1.Update, 32)
progressDone := make(chan struct{})
progressStop := make(chan struct{})
go func() {
defer close(progressDone)
renderPushProgress(progress, os.Stderr, term.IsTerminal(int(os.Stderr.Fd())), progressStop)
}()

err = remote.Write(dstRef, img,
remote.WithContext(ctx),
remote.WithAuth(authn.Anonymous),
remote.WithTransport(transport),
remote.WithProgress(progress),
)
close(progressStop)
<-progressDone
Comment thread
cursor[bot] marked this conversation as resolved.
if err != nil {
return fmt.Errorf("push failed: %w", err)
}

fmt.Fprintf(os.Stderr, "Pushed %s\n", targetRef)
digest, err := img.Digest()
if err != nil {
return fmt.Errorf("read pushed image digest: %w", err)
}
rawManifest, err := img.RawManifest()
if err != nil {
return fmt.Errorf("read pushed image manifest: %w", err)
}

fmt.Fprintf(os.Stderr, "%s: digest: %s size: %d\n", dstRef.Identifier(), digest, len(rawManifest))
return nil
}

// renderPushProgress consumes go-containerregistry's aggregate byte updates.
// Keep progress on stderr so stdout remains available for shell pipelines.
func renderPushProgress(updates <-chan v1.Update, output io.Writer, interactive bool, stop <-chan struct{}) {
printed := false
for {
select {
case <-stop:
if printed && interactive {
fmt.Fprintln(output)
}
return
case update, ok := <-updates:
if !ok {
if printed && interactive {
fmt.Fprintln(output)
}
return
}
if update.Error != nil || update.Total <= 0 {
continue
}
if interactive {
fmt.Fprintf(output, "\r%s / %s", formatBytes(update.Complete), formatBytes(update.Total))
printed = true
}
}
}
}

// authTransport adds Basic auth header to all requests
type authTransport struct {
base http.RoundTripper
Expand Down
88 changes: 88 additions & 0 deletions pkg/cmd/push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"bytes"
"testing"

v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/kernel/hypeman-go"
"github.com/stretchr/testify/assert"
)

func TestRenderPushProgress(t *testing.T) {
updates := make(chan v1.Update, 3)
updates <- v1.Update{Total: 2048, Complete: 1024}
updates <- v1.Update{Total: 2048, Complete: 2048}
updates <- v1.Update{Error: assert.AnError}
close(updates)

var output bytes.Buffer
renderPushProgress(updates, &output, true, make(chan struct{}))

assert.Equal(t, "\r1.0 KB / 2.0 KB\r2.0 KB / 2.0 KB\n", output.String())
}

func TestRenderPushProgressNonInteractive(t *testing.T) {
updates := make(chan v1.Update, 1)
updates <- v1.Update{Total: 1024, Complete: 1024}
close(updates)

var output bytes.Buffer
renderPushProgress(updates, &output, false, make(chan struct{}))

assert.Empty(t, output.String())
}

func TestPushStatusText(t *testing.T) {
lastBytes := int64(0)

message, lastBytes := pushStatusText(&hypeman.Push{
Status: hypeman.PushStatusQueued,
Target: "registry.example.com/app:v1",
}, lastBytes)
assert.Equal(t, "queued · registry.example.com/app:v1", message)

message, lastBytes = pushStatusText(&hypeman.Push{
Status: hypeman.PushStatusPushing,
Bytes: 2048,
Layers: 2,
Target: "registry.example.com/app:v1",
}, lastBytes)
assert.Equal(t, "pushing 2.0 KB · 2 layers · registry.example.com/app:v1", message)
assert.Equal(t, int64(2048), lastBytes)

message, lastBytes = pushStatusText(&hypeman.Push{
Status: hypeman.PushStatusPushed,
Digest: "sha256:abc",
}, lastBytes)
assert.Equal(t, "pushed · digest: sha256:abc", message)

message, _ = pushStatusText(&hypeman.Push{
Status: hypeman.PushStatusFailed,
Error: "registry unavailable",
}, lastBytes)
assert.Equal(t, "failed · registry unavailable", message)
}

func TestPushStatusRenderer(t *testing.T) {
var output bytes.Buffer
renderer := &pushStatusRenderer{output: &output, interactive: true}

renderer.update("queued")
renderer.update("queued")
renderer.update("pushing 1.0 MB")
renderer.finish()

assert.Equal(t, "\r\033[Kqueued\r\033[Kpushing 1.0 MB\n", output.String())
}

func TestPushStatusRendererNonInteractive(t *testing.T) {
var output bytes.Buffer
renderer := &pushStatusRenderer{output: &output}

renderer.update("queued")
renderer.update("pushing 1.0 MB")
renderer.finish()

assert.Equal(t, "queued\npushing 1.0 MB\n", output.String())
}
Loading
Loading