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
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: ResolveResource skips POST /images/{name}/tag, so the source is
// resolved by ImageManager.TagImage and a missing source gets the specific
// 404 body below.
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 @@ -373,6 +373,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
48 changes: 25 additions & 23 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 @@ -334,6 +338,13 @@ func (m *manager) restoreTagGenerations(metas []*imageMetadata) {
for _, claim := range meta.TagClaims {
m.restoreTagGeneration(claim.Repository, claim.Tag, claim.TagGeneration)
}
for reference, generation := range meta.ReferenceGenerations {
ref, err := ParseNormalizedRef(reference)
if err != nil || ref.IsDigest() {
continue
}
m.restoreTagGeneration(ref.Repository(), ref.Tag(), generation)
}
}
}

Expand Down Expand Up @@ -634,27 +645,34 @@ func (m *manager) claimImageTags(ref *ResolvedRef, meta *imageMetadata) {
if requestedTag == "" {
requestedTag = ref.Tag()
}
m.claimTag(ref.Repository(), ref.DigestHex(), requestedTag, meta.PreviousTagDigest, meta.TagGeneration, meta.RequestedTag == "")
claimed := m.claimTag(ref.Repository(), ref.DigestHex(), requestedTag, meta.PreviousTagDigest, meta.TagGeneration, meta.RequestedTag == "")
for _, claim := range meta.TagClaims {
m.claimTag(claim.Repository, ref.DigestHex(), claim.Tag, claim.PreviousTagDigest, claim.TagGeneration, false)
claimed = m.claimTag(claim.Repository, ref.DigestHex(), claim.Tag, claim.PreviousTagDigest, claim.TagGeneration, false) || claimed
}
if !claimed {
if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), ref.DigestHex(), true); err != nil {
slog.Warn("failed to collect stale image", "repository", ref.Repository(), "digest", ref.DigestHex(), "error", err)
}
}
}

func (m *manager) claimTag(repository, digestHex, tag, previous string, generation uint64, allowMissing bool) {
func (m *manager) claimTag(repository, digestHex, tag, previous string, generation uint64, allowMissing bool) bool {
if tag == "" || m.tagGenerations[tagGenerationKey(repository, tag)] != generation {
return
return false
}
current, err := resolveTag(m.paths, repository, tag)
if err != nil {
if !allowMissing || !errors.Is(err, ErrNotFound) {
return
return false
}
} else if current != digestHex && current != previous {
return
return false
}
if err := createTagSymlink(m.paths, repository, tag, digestHex); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create tag symlink: %v\n", err)
return false
}
return true
}

func phaseStatus(err error) string {
Expand Down Expand Up @@ -768,23 +786,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
Loading
Loading