Skip to content
Open
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
151 changes: 120 additions & 31 deletions pkg/cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,79 @@ 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 an image to a registry",
ArgsUsage: "SOURCE [TARGET]",
Description: `Push an image from Hypeman to a remote registry.

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
The source image must already exist in Hypeman. TARGET is the remote registry
reference, matching Docker's push syntax as closely as possible.

Local Docker-daemon uploads remain available explicitly with "push local":
hypeman push local IMAGE [TARGET]

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 cached 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 alpine:latest registry.example.com/app:v1 \
--username AWS --password-stdin

# Upload a local Docker image into Hypeman
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:
// Keep the old one-argument form working for existing scripts.
return handleLocalPush(ctx, cmd)
case 2:
return runRemotePush(ctx, cmd, args[0], args[1])
default:
return fmt.Errorf("source image and target required\nUsage: hypeman push <source> <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 @@ -61,30 +90,41 @@ func handlePush(ctx context.Context, cmd *cli.Command) error {
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

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

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

img, err := daemon.Image(srcRef)
// Build and validate the target before opening the Docker daemon. 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("load image: %w", err)
return fmt.Errorf("invalid target: %w", err)
}

// Build target reference - server computes digest from manifest
targetRef := registryHost + "/" + strings.TrimPrefix(targetName, "/")
fmt.Fprintf(os.Stderr, "Pushing to %s...\n", targetRef)

dstRef, err := name.ParseReference(targetRef, name.Insecure)
fmt.Fprintf(os.Stderr, "Loading image %s from Docker...\n", sourceImage)
img, err := daemon.Image(srcRef)
if err != nil {
return fmt.Errorf("invalid target: %w", err)
return fmt.Errorf("load image: %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 +133,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
56 changes: 56 additions & 0 deletions pkg/cmd/push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package cmd

import (
"bytes"
"testing"

v1 "github.com/google/go-containerregistry/pkg/v1"
"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 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