Skip to content
33 changes: 33 additions & 0 deletions cmd/api/api/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,39 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe
return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil
}

// TagImage handles POST /images/{name}/tag.
// Note: the ResolveResource middleware also resolves {name} via GetImage before
// this handler runs, so a missing source returns the middleware's generic 404
// body and the source is resolved twice per request.
func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestObject) (oapi.TagImageResponseObject, error) {
if request.Body == nil {
return oapi.TagImage400JSONResponse{
Code: "invalid_request",
Message: "request body is required",
}, nil
}

img, err := s.ImageManager.TagImage(ctx, request.Name, request.Body.Target)
if err != nil {
return tagImageErrorResponse(ctx, err, request.Name, request.Body.Target), nil
}
return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil
}

func tagImageErrorResponse(ctx context.Context, err error, source, target string) oapi.TagImageResponseObject {
switch {
case errors.Is(err, images.ErrInvalidName):
return oapi.TagImage400JSONResponse{Code: "invalid_name", Message: err.Error()}
case errors.Is(err, images.ErrNotFound):
return oapi.TagImage404JSONResponse{Code: "not_found", Message: "source image not found"}
case errors.Is(err, images.ErrImageNotReady):
return oapi.TagImage409JSONResponse{Code: "image_not_ready", Message: err.Error()}
default:
logger.FromContext(ctx).ErrorContext(ctx, "failed to tag image", "error", err, "source", source, "target", target)
return oapi.TagImage500JSONResponse{Code: "internal_error", Message: "failed to tag image"}
}
}

// GetImage gets image details by name
// Note: Resolution is handled by ResolveResource middleware
func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) {
Expand Down
119 changes: 97 additions & 22 deletions cmd/api/api/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ package api

import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"

"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/images/testutil"
"github.com/kernel/hypeman/lib/oapi"
"github.com/kernel/hypeman/lib/paths"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -37,6 +36,15 @@ func (m *captureCreateImageManager) CreateImage(_ context.Context, req images.Cr
return &images.Image{Name: req.Name, Digest: "sha256:test", Status: images.StatusPending, CreatedAt: time.Now()}, nil
}

type tagImageErrManager struct {
images.Manager
err error
}

func (m tagImageErrManager) TagImage(context.Context, string, string) (*images.Image, error) {
return nil, m.err
}

func TestCreateImage_MapsBorrowedCredentials(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -505,28 +513,95 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im
require.NoError(t, err)
require.True(t, ref.IsDigest(), "test helper expects a digest reference")

p := paths.New(svc.Config.DataDir)
digestDir := p.ImageDigestDir(ref.Repository(), ref.DigestHex())
require.NoError(t, os.MkdirAll(digestDir, 0o755))
require.NoError(t, os.WriteFile(p.ImageDigestPath(ref.Repository(), ref.DigestHex()), []byte("rootfs"), 0o644))

meta := struct {
Name string `json:"name"`
Digest string `json:"digest"`
Status string `json:"status"`
SizeBytes int64 `json:"size_bytes"`
Tags map[string]string `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
testutil.SeedReadyImage(t, paths.New(svc.Config.DataDir), testutil.Seed{
Repository: ref.Repository(),
DigestHex: ref.DigestHex(),
Name: imageRef,
Tags: imageTags,
})
}

func TestTagImage_ErrorStatusMapping(t *testing.T) {
t.Parallel()

cases := []struct {
name string
err error
want oapi.TagImageResponseObject
}{
Name: imageRef,
Digest: "sha256:" + ref.DigestHex(),
Status: "ready",
SizeBytes: int64(len("rootfs")),
Tags: imageTags,
CreatedAt: time.Now().UTC(),
{
name: "invalid name -> 400",
err: fmt.Errorf("tag: %w", images.ErrInvalidName),
want: oapi.TagImage400JSONResponse{Code: "invalid_name", Message: "tag: invalid image name"},
},
{
name: "not found -> 404",
err: fmt.Errorf("tag: %w", images.ErrNotFound),
want: oapi.TagImage404JSONResponse{Code: "not_found", Message: "source image not found"},
},
{
name: "not ready -> 409",
err: fmt.Errorf("tag: %w", images.ErrImageNotReady),
want: oapi.TagImage409JSONResponse{Code: "image_not_ready", Message: "tag: image is not ready"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc := &ApiService{ImageManager: tagImageErrManager{err: tc.err}}
resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{
Name: "docker.io/library/alpine:latest",
Body: &oapi.TagImageRequest{Target: "docker.io/library/alpine:stable"},
})
require.NoError(t, err)
require.Equal(t, tc.want, resp)
})
}
}

func TestTagImage_MissingBody(t *testing.T) {
t.Parallel()

svc := &ApiService{ImageManager: tagImageErrManager{}}
resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{
Name: "docker.io/library/alpine:latest",
})
require.NoError(t, err)
require.IsType(t, oapi.TagImage400JSONResponse{}, resp)
}

// seedReadyContentImage writes a ready image into the shared content layout
// plus a repository tag reference, without pulling from a registry.
func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) {
t.Helper()
testutil.SeedReadyImage(t, paths.New(svc.Config.DataDir), testutil.Seed{
Repository: repository,
Tag: tag,
DigestHex: digestHex,
Content: true,
})
}

func TestTagImage_Success(t *testing.T) {
t.Parallel()
svc := newTestService(t)

const repository = "docker.io/library/alpine"
const digestHex = "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34"
seedReadyContentImage(t, svc, repository, "latest", digestHex)

resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{
Name: repository + ":latest",
Body: &oapi.TagImageRequest{Target: "registry.example/apps/alpine:v1"},
})
require.NoError(t, err)
okResp, ok := resp.(oapi.TagImage200JSONResponse)
require.True(t, ok, "expected 200 response, got %T", resp)
require.Equal(t, "registry.example/apps/alpine:v1", okResp.Name)
require.Equal(t, "sha256:"+digestHex, okResp.Digest)

data, err := json.Marshal(meta)
// The new tag resolves through GetImage.
img, err := svc.ImageManager.GetImage(ctx(), "registry.example/apps/alpine:v1")
require.NoError(t, err)
require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644))
require.Equal(t, images.StatusReady, img.Status)
}
44 changes: 44 additions & 0 deletions cmd/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,50 @@ func (h *testImageHandler) GetImage(w http.ResponseWriter, r *http.Request, name
}
}

func TestTagImageRoute_URLDecodedName(t *testing.T) {
// Verifies the generated POST /images/{name}/tag route exists and that the
// URL-encoded source name is decoded before reaching the handler.
r := chi.NewRouter()

var receivedName string
handler := &testTagImageHandler{
tagImage: func(w http.ResponseWriter, r *http.Request, name string) {
receivedName = name
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"` + name + `","digest":"sha256:test","status":"ready","created_at":"2026-01-01T00:00:00Z"}`))
},
}

oapi.HandlerFromMux(handler, r)

token, err := generateValidJWT("user-123")
require.NoError(t, err)

body := bytes.NewReader([]byte(`{"target":"docker.io/library/alpine:stable"}`))
req := httptest.NewRequest(http.MethodPost, "/images/docker.io%2Flibrary%2Falpine%3Alatest/tag", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
r.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
assert.Equal(t, "docker.io/library/alpine:latest", receivedName)
}

// testTagImageHandler implements oapi.ServerInterface with just TagImage for testing
type testTagImageHandler struct {
oapi.Unimplemented
tagImage func(w http.ResponseWriter, r *http.Request, name string)
}

func (h *testTagImageHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) {
if h.tagImage != nil {
h.tagImage(w, r, name)
}
}

func TestImageNameWithSlashes_URLEncoding(t *testing.T) {
// This test verifies how chi router handles image names with slashes.
// Image names like "docker.io/onkernel/chromium-headful:latest" contain slashes
Expand Down
13 changes: 13 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,19 @@ func (m *mockImageManager) DeleteImage(ctx context.Context, name string) error {
return nil
}

func (m *mockImageManager) TagImage(ctx context.Context, source, target string) (*images.Image, error) {
m.mu.Lock()
defer m.mu.Unlock()
img, ok := m.images[source]
if !ok {
return nil, images.ErrNotFound
}
tagged := *img
tagged.Name = target
m.images[target] = &tagged
return &tagged, nil
}

func (m *mockImageManager) RecoverInterruptedBuilds() {}

func (m *mockImageManager) TotalImageBytes(ctx context.Context) (int64, error) {
Expand Down
1 change: 1 addition & 0 deletions lib/images/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var (
ErrNotFound = errors.New("image not found")
ErrInvalidName = errors.New("invalid image name")
ErrInvalidPlatform = errors.New("invalid platform")
ErrImageNotReady = errors.New("image is not ready")
// ErrPlatformNotAvailable means the requested platform is well-formed but the
// image's manifest index does not publish a matching variant. Unlike
// ErrInvalidPlatform (bad user syntax), the platform itself is valid; the
Expand Down
22 changes: 5 additions & 17 deletions lib/images/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ type Manager interface {
// Unlike CreateImage, it does not resolve from a remote registry.
ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error)
GetImage(ctx context.Context, name string) (*Image, error)
// TagImage creates or updates a local tag pointing at an existing ready
// image, without pulling or reconverting. Source and target may be in
// different repositories.
TagImage(ctx context.Context, source, target string) (*Image, error)
DeleteImage(ctx context.Context, name string) error
RecoverInterruptedBuilds()
// TotalImageBytes returns the total size of all ready images on disk.
Expand Down Expand Up @@ -768,23 +772,7 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) {
return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error())
}

repository := ref.Repository()

var digestHex string
if ref.IsDigest() {
// Direct digest lookup
digestHex = ref.DigestHex()
} else {
// Tag lookup - resolve symlink
tag := ref.Tag()
d, err := resolveTag(m.paths, repository, tag)
if err != nil {
return nil, err
}
digestHex = d
}

meta, err := readMetadata(m.paths, repository, digestHex)
_, meta, err := resolveRefMetadata(m.paths, ref)
if err != nil {
return nil, err
}
Expand Down
24 changes: 24 additions & 0 deletions lib/images/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,24 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata,
return readMetadataAt(resolveImageLayout(p, repository, digestHex))
}

// resolveRefMetadata resolves a digest reference directly or a tag reference
// through its symlink, then reads the metadata it points at.
func resolveRefMetadata(p *paths.Paths, ref *NormalizedRef) (string, *imageMetadata, error) {
digestHex := ref.DigestHex()
if !ref.IsDigest() {
var err error
digestHex, err = resolveTag(p, ref.Repository(), ref.Tag())
if err != nil {
return "", nil, err
}
}
meta, err := readMetadata(p, ref.Repository(), digestHex)
if err != nil {
return "", nil, err
}
return digestHex, meta, nil
}

func readContentMetadata(p *paths.Paths, digestHex string) (*imageMetadata, error) {
return readMetadataAt(contentLayout(p, digestHex))
}
Expand Down Expand Up @@ -322,6 +340,12 @@ func promoteLegacyTags(p *paths.Paths, repository, digestHex string) error {
if err != nil || target != digestHex {
continue
}
// resolveTag validated that content-relative links resolve to this
// digest's content dir, so only legacy links (bare digest target)
// still need restaging.
if raw, err := os.Readlink(tagSymlinkPath(p, repository, tag)); err == nil && raw != digestHex {
continue
}
ref, err := stageTagSymlink(p, repository, tag, digestHex)
if err != nil {
return fmt.Errorf("stage legacy tag %s: %w", tag, err)
Expand Down
Loading
Loading