diff --git a/README.md b/README.md index 4a925d3..8a40ce9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ go run cmd/hypeman/main.go # Pull an image hypeman pull nginx:alpine +# Create a local tag without pulling the image again +hypeman tag nginx:alpine my-registry.example.com/myapp:latest + +# Push the cached Hypeman tag to its matching remote registry +hypeman push my-registry.example.com/myapp:latest + # Boot a new VM (auto-pulls image if needed) hypeman run --name my-app nginx:alpine diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 14f266b..e13df3e 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -73,6 +73,7 @@ func init() { &execCmd, &cpCmd, &pullCmd, + &tagCmd, &pushCmd, &runCmd, &psCmd, diff --git a/pkg/cmd/imagecmd_test.go b/pkg/cmd/imagecmd_test.go index 01f1995..e18d2e0 100644 --- a/pkg/cmd/imagecmd_test.go +++ b/pkg/cmd/imagecmd_test.go @@ -1,6 +1,12 @@ package cmd import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -37,3 +43,66 @@ func TestPlatformOrDash(t *testing.T) { assert.Equal(t, "linux/amd64", platformOrDash("linux/amd64")) assert.Equal(t, "-", platformOrDash("")) } + +func TestTagCommandPostsEscapedSourceAndTarget(t *testing.T) { + var method, path, target string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + path = r.URL.EscapedPath() + var body struct { + Target string `json:"target"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + target = body.Target + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"docker.io/library/myapp:latest"}`)) + })) + defer server.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, "--format", "json", + "tag", "builds/job:latest", "myapp:latest", + }) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.MethodPost, method) + assert.Equal(t, "/images/builds%2Fjob:latest/tag", path) + assert.Equal(t, "myapp:latest", target) + + stdout := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + err = Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, + "tag", "builds/job:latest", "myapp:latest", + }) + _ = writer.Close() + os.Stdout = stdout + if err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + assert.Contains(t, string(output), "docker.io/library/myapp:latest") +} + +func TestTagCommandFallsBackToDockerWhenHypemanMisses(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, + "tag", "not a valid image", "myapp:latest", + }) + require.ErrorContains(t, err, "was not found in Hypeman or Docker") +} diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index 526fe77..c2170f6 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -26,8 +26,8 @@ var pushCmd = cli.Command{ Description: `Push images between Docker, Hypeman, and remote registries. hypeman push TARGET - Push a local Docker image tagged TARGET to its remote registry. The CLI - stages it in Hypeman first. + Push a ready Hypeman image tagged TARGET to its remote registry. If the + image is not cached in Hypeman, fall back to staging the local Docker tag. hypeman push IMAGE TARGET Push an image already in Hypeman to TARGET. Waits for completion. @@ -44,11 +44,15 @@ Push jobs can be inspected while they run: hypeman push inspect Examples: + # Retag and push a cached Hypeman image to ECR + hypeman 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 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 + # Push a cached Hypeman image directly to a different remote target hypeman push alpine:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1 # Push with credentials read from stdin diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 432c7a1..5e5e337 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -91,26 +91,23 @@ func handleRemotePushTarget(ctx context.Context, cmd *cli.Command, target string return err } - // The one-argument form follows Docker's local-tag flow: TARGET must be - // present in the local Docker daemon before it can be staged and pushed. - // Cached Hypeman images use the explicit IMAGE TARGET form instead. - img, err := loadDockerImage(target) - if err != nil { - return fmt.Errorf("load local Docker image %q: %w; tag it first or use hypeman push for a cached Hypeman image", target, err) + // Prefer an image already cached in Hypeman. This makes `hypeman tag` followed + // by `hypeman push TARGET` work without requiring a local Docker daemon. + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + cachedImage, err := client.Images.Get(ctx, url.PathEscape(target)) + if err == nil { + if err := waitForImageReady(ctx, &client, cachedImage); err != nil { + return err + } + return runRemotePush(ctx, cmd, target, target) } - - fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target) - if err := uploadLocalImage(ctx, cmd, target, img); err != nil { - return err + if !isNotFoundError(err) { + return fmt.Errorf("get cached image %s: %w", target, err) } - client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - imported, err := waitForImageRecord(ctx, &client, target) - if err != nil { - return err - } - if err := waitForImageReady(ctx, &client, imported); err != nil { - return err + // If Hypeman does not have the image, preserve the Docker-daemon fallback. + if _, err := stageDockerImage(ctx, cmd, &client, target, target); err != nil { + return fmt.Errorf("load local Docker image %q: %w; tag it first or use hypeman push for a cached Hypeman image", target, err) } return runRemotePush(ctx, cmd, target, target) @@ -129,6 +126,32 @@ func validateRemotePushTarget(target string) error { return nil } +func stageDockerImage(ctx context.Context, cmd *cli.Command, client *hypeman.Client, source, target string) (*hypeman.Image, error) { + img, err := loadDockerImage(source) + if err != nil { + return nil, err + } + + fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", source) + if err := uploadLocalImage(ctx, cmd, target, img); err != nil { + return nil, err + } + + imported, err := waitForImageRecord(ctx, client, target) + if err != nil { + return nil, err + } + if err := waitForImageReady(ctx, client, imported); err != nil { + return nil, err + } + + ready, err := client.Images.Get(ctx, url.PathEscape(target)) + if err != nil { + return nil, fmt.Errorf("get staged image %s: %w", target, err) + } + return ready, nil +} + func waitForImageRecord(ctx context.Context, client *hypeman.Client, imageName string) (*hypeman.Image, error) { ticker := time.NewTicker(300 * time.Millisecond) defer ticker.Stop() diff --git a/pkg/cmd/pushcmd_test.go b/pkg/cmd/pushcmd_test.go index 7d19be2..7e4a211 100644 --- a/pkg/cmd/pushcmd_test.go +++ b/pkg/cmd/pushcmd_test.go @@ -1,6 +1,11 @@ package cmd import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,6 +34,44 @@ func TestPushRepository(t *testing.T) { assert.Equal(t, "registry.example.com/app", pushRepository("registry.example.com/app:v1")) } +func TestPushTargetPrefersCachedHypemanImage(t *testing.T) { + var pushImage, pushTarget string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/images/"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1","digest":"sha256:test","status":"ready","created_at":"2026-08-19T00:00:00Z"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/pushes": + var body struct { + Image string `json:"image"` + Target string `json:"target"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pushImage = body.Image + pushTarget = body.Target + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"push-1","created_at":"2026-08-19T00:00:00Z","digest":"sha256:test","image":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1","status":"pushed","target":"123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, "push", + "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", + }) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1", pushImage) + assert.Equal(t, pushImage, pushTarget) +} + func TestValidateRemotePushReferences(t *testing.T) { assert.NoError(t, validateRemotePushReferences("alpine:latest", "registry.example.com/app:v1")) assert.ErrorContains(t, validateRemotePushReferences("alpine:latest", "registry.example.com/app"), "explicit tag") diff --git a/pkg/cmd/tag.go b/pkg/cmd/tag.go new file mode 100644 index 0000000..12188f5 --- /dev/null +++ b/pkg/cmd/tag.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var tagCmd = cli.Command{ + Name: "tag", + Usage: "Create a local image tag", + ArgsUsage: " ", + Description: `Create a local image tag in Hypeman. If the source is not already +cached in Hypeman, fall back to the matching image in the local Docker daemon.`, + Action: handleTag, +} + +func handleTag(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args().Slice() + if len(args) != 2 { + return fmt.Errorf("source and target image references required\nUsage: hypeman tag ") + } + + source, target := args[0], args[1] + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + var res []byte + opts = append(opts, option.WithResponseBodyInto(&res)) + body := struct { + Target string `json:"target"` + }{Target: target} + path := "/images/" + url.PathEscape(source) + "/tag" + if err := client.Post(ctx, path, body, nil, opts...); err != nil { + if !isNotFoundError(err) { + return err + } + staged, stageErr := stageDockerImage(ctx, cmd, &client, source, target) + if stageErr != nil { + return fmt.Errorf("image %q was not found in Hypeman or Docker: %w", source, stageErr) + } + res = []byte(staged.RawJSON()) + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + result := gjson.ParseBytes(res) + if format != "auto" { + return ShowJSON(os.Stdout, "tag", result, format, transform) + } + + imageName := result.Get("name").String() + if imageName == "" { + imageName = target + } + fmt.Println(imageName) + return nil +}