diff --git a/README.md b/README.md index df0817b..dfc4e24 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ 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 it to the remote registry (prefers the local Docker tag, +# falls back to the cached Hypeman image) +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/go.mod b/go.mod index fd4e9d0..7a943f7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.1 + github.com/docker/docker v28.5.2+incompatible github.com/google/go-containerregistry v0.20.7 github.com/gorilla/websocket v1.5.3 github.com/itchyny/json2yaml v0.1.4 @@ -43,7 +44,6 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.0.3+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 18284aa..642d515 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 0df7d9a..9e94e0e 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" "strings" "testing" @@ -47,3 +53,88 @@ func TestValidateTaggedImageReference(t *testing.T) { "registry.example.com/app@sha256:"+strings.Repeat("a", 64)), "explicit tag") assert.ErrorContains(t, validateTaggedImageReference("not valid"), "invalid target") } + +func TestTagCommandRejectsTaglessTarget(t *testing.T) { + err := Command.Run(context.Background(), []string{ + "hypeman", "tag", "builds/job:latest", "myapp", + }) + require.ErrorContains(t, err, "explicit tag") +} + +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 TestTagCommandPropagatesNotFound(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, + "tag", "alpine:missing", "myapp:latest", + }) + require.ErrorContains(t, err, "404") +} + +func TestTagCommandPropagatesNotReady(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"code":"image_not_ready","message":"image is not ready"}`)) + })) + defer server.Close() + + err := Command.Run(context.Background(), []string{ + "hypeman", "--base-url", server.URL, + "tag", "alpine:pending", "myapp:latest", + }) + require.ErrorContains(t, err, "image is not ready") +} diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index 526fe77..e2ed4ef 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 the local Docker image tagged TARGET to its remote registry. If + Docker does not have it, use the ready Hypeman image cached under TARGET. 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 @@ -104,7 +108,8 @@ func pushLocalImage(ctx context.Context, cmd *cli.Command, sourceImage, targetNa if err != nil { return err } - return uploadLocalImage(ctx, cmd, targetName, img) + _, err = uploadLocalImage(ctx, cmd, targetName, img) + return err } func loadDockerImage(image string) (v1.Image, error) { @@ -119,18 +124,18 @@ func loadDockerImage(image string) (v1.Image, error) { return img, nil } -func uploadLocalImage(ctx context.Context, cmd *cli.Command, targetName string, img v1.Image) error { +func uploadLocalImage(ctx context.Context, cmd *cli.Command, targetName string, img v1.Image) (string, error) { baseURL := resolveBaseURL(cmd) parsedURL, err := url.Parse(baseURL) if err != nil { - return fmt.Errorf("invalid base URL: %w", err) + return "", fmt.Errorf("invalid base URL: %w", err) } if parsedURL.Host == "" { - return fmt.Errorf("invalid base URL %q: missing host", baseURL) + 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) + return "", fmt.Errorf("invalid base URL %q: scheme must be http or https", baseURL) } registryHost := parsedURL.Host @@ -144,7 +149,7 @@ func uploadLocalImage(ctx context.Context, cmd *cli.Command, targetName string, } dstRef, err := name.ParseReference(targetRef, parseOptions...) if err != nil { - return fmt.Errorf("invalid target: %w", err) + return "", fmt.Errorf("invalid target: %w", err) } fmt.Fprintf(os.Stderr, "The push refers to repository [%s]\n", dstRef.Context().Name()) @@ -174,20 +179,20 @@ func uploadLocalImage(ctx context.Context, cmd *cli.Command, targetName string, close(progressStop) <-progressDone if err != nil { - return fmt.Errorf("push failed: %w", err) + return "", fmt.Errorf("push failed: %w", err) } digest, err := img.Digest() if err != nil { - return fmt.Errorf("read pushed image digest: %w", err) + 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) + 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 + return digest.String(), nil } // renderPushProgress consumes go-containerregistry's aggregate byte updates. diff --git a/pkg/cmd/pushcmd.go b/pkg/cmd/pushcmd.go index 290eb8c..abc4cc4 100644 --- a/pkg/cmd/pushcmd.go +++ b/pkg/cmd/pushcmd.go @@ -9,6 +9,7 @@ import ( "strings" "time" + dockerclient "github.com/docker/docker/client" "github.com/google/go-containerregistry/pkg/name" "github.com/kernel/hypeman-go" "github.com/kernel/hypeman-go/option" @@ -82,29 +83,69 @@ 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) - } + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target) - if err := uploadLocalImage(ctx, cmd, target, img); err != nil { - return err + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) } - client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) - imported, err := waitForImageRecord(ctx, &client, target) - if err != nil { - return err + img, loadErr := loadDockerImage(target) + if loadErr == nil { + digest, err := img.Digest() + if err != nil { + return fmt.Errorf("read local image digest: %w", err) + } + stagedName := stagingReferenceForTaggedImage(target, digest.String()) + + fmt.Fprintf(os.Stderr, "Staging local image %s in Hypeman...\n", target) + if _, err := uploadLocalImage(ctx, cmd, stagedName, img); err != nil { + return fmt.Errorf("upload local Docker image %q: %w", target, err) + } + imported, err := waitForImageRecord(ctx, &client, stagedName) + if err != nil { + return err + } + if err := waitForImageReady(ctx, &client, imported); err != nil { + return err + } + if _, err := client.Images.Tag(ctx, url.PathEscape(stagedName), hypeman.ImageTagParams{ + TagImageRequest: hypeman.TagImageRequestParam{Target: target}, + }, opts...); err != nil { + return fmt.Errorf("tag staged image %q as %q: %w", stagedName, target, err) + } + if err := client.Images.Delete(ctx, url.PathEscape(stagedName), opts...); err != nil && !isNotFoundError(err) { + fmt.Fprintf(os.Stderr, "Warning: failed to clean up staged image %q: %v\n", stagedName, err) + } + return runRemotePush(ctx, cmd, target, target) + } + if !dockerclient.IsErrNotFound(loadErr) { + return fmt.Errorf("load local Docker image %q: %w", target, loadErr) + } + + // Docker does not have TARGET, so fall back to a cached Hypeman image. + // This keeps `hypeman tag` followed by `hypeman push TARGET` working when + // the Docker daemon does not hold the same tag. + fmt.Fprintf(os.Stderr, "Docker image %s not available (%v); trying the Hypeman cache...\n", target, loadErr) + cachedImage, err := client.Images.Get(ctx, url.PathEscape(target)) + if err == nil { + // A cached record that never became ready deserves a push-specific + // message; the shared helper's "image build failed" reads odd here. + if err := waitForImageReady(ctx, &client, cachedImage); err != nil { + return fmt.Errorf("cached image %s is not ready: %w", target, err) + } + return runRemotePush(ctx, cmd, target, target) } - if err := waitForImageReady(ctx, &client, imported); err != nil { - return err + if !isNotFoundError(err) { + return fmt.Errorf("get cached image %s: %w", target, err) } - return runRemotePush(ctx, cmd, target, target) + return fmt.Errorf("image %q not found in local Docker or the Hypeman cache; use hypeman tag first", target) +} + +func stagingReferenceForTaggedImage(target, digest string) string { + lastColon := strings.LastIndex(target, ":") + return target[:lastColon] + ":hypeman-staged-" + strings.TrimPrefix(digest, "sha256:") } // validateTaggedImageReference rejects references that a tag-producing @@ -123,6 +164,8 @@ func validateTaggedImageReference(target string) error { return nil } +// waitForImageRecord waits for the server-side record created by the registry upload. +// The server converts the Docker manifest, so its image digest is expected to differ. 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..0bd0523 100644 --- a/pkg/cmd/pushcmd_test.go +++ b/pkg/cmd/pushcmd_test.go @@ -1,8 +1,15 @@ package cmd import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,6 +36,87 @@ func TestPushRepository(t *testing.T) { assert.Equal(t, "registry.example.com/app", pushRepository("registry.example.com/app:v1")) } +func TestStagingReferenceForTaggedImage(t *testing.T) { + assert.Equal(t, "registry.example.com:5000/app:hypeman-staged-abc", stagingReferenceForTaggedImage( + "registry.example.com:5000/app:v1", "sha256:abc")) +} + +func TestWaitForImageRecordAcceptsConvertedDigest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"registry.example.com/app:latest","digest":"sha256:converted","status":"ready","created_at":"2026-08-19T00:00:00Z"}`)) + })) + defer server.Close() + + client := hypeman.NewClient(option.WithBaseURL(server.URL)) + img, err := waitForImageRecord(context.Background(), &client, "registry.example.com/app:latest") + require.NoError(t, err) + require.Equal(t, "sha256:converted", img.Digest) +} + +func TestPushTargetFallsBackToCachedHypemanImage(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 TestPushTargetErrorsWhenImageMissingEverywhere(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + 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", + }) + require.ErrorContains(t, err, "not found in local Docker or the Hypeman cache") +} + +func TestPushTargetPropagatesCachedImageFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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":"failed","error":"layer fetch failed","created_at":"2026-08-19T00:00:00Z"}`)) + })) + 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", + }) + require.ErrorContains(t, err, "layer fetch failed") +} + 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..b083bff --- /dev/null +++ b/pkg/cmd/tag.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + "net/url" + "os" + + "github.com/google/go-containerregistry/pkg/name" + "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 without pulling or converting the image.", + 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] + if _, err := name.ParseReference(source); err != nil { + return fmt.Errorf("invalid source %q: %w", source, err) + } + if err := validateTaggedImageReference(target); err != nil { + return err + } + + client := hypeman.NewClient(getDefaultRequestOptions(cmd)...) + + var opts []option.RequestOption + if cmd.Root().Bool("debug") { + opts = append(opts, debugMiddlewareOption) + } + + res, err := client.Images.Tag(ctx, url.PathEscape(source), hypeman.ImageTagParams{ + TagImageRequest: hypeman.TagImageRequestParam{Target: target}, + }, opts...) + if err != nil { + return err + } + + format := cmd.Root().String("format") + transform := cmd.Root().String("transform") + if format != "auto" { + return ShowJSON(os.Stdout, "tag", gjson.Parse(res.RawJSON()), format, transform) + } + + imageName := res.Name + if imageName == "" { + imageName = target + } + fmt.Println(imageName) + return nil +}