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

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 {
log := logger.FromContext(ctx)
switch {
case errors.Is(err, images.ErrInvalidName):
return oapi.TagImage400JSONResponse{
Code: "invalid_name",
Message: err.Error(),
}, nil
case errors.Is(err, images.ErrNotFound):
return oapi.TagImage404JSONResponse{
Code: "not_found",
Message: "source image not found",
}, nil
case errors.Is(err, images.ErrImageNotReady):
return oapi.TagImage409JSONResponse{
Code: "image_not_ready",
Message: err.Error(),
}, nil
default:
log.ErrorContext(ctx, "failed to tag image", "error", err, "source", request.Name, "target", request.Body.Target)
return oapi.TagImage500JSONResponse{
Code: "internal_error",
Message: "failed to tag image",
}, nil
}
}

return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil
}

// 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
137 changes: 137 additions & 0 deletions cmd/api/api/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -37,6 +38,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 @@ -530,3 +540,130 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im
require.NoError(t, err)
require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644))
}

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

cases := []struct {
name string
err error
wantType any
wantCode string
}{
{
name: "invalid name -> 400",
err: fmt.Errorf("tag: %w", images.ErrInvalidName),
wantType: oapi.TagImage400JSONResponse{},
wantCode: "invalid_name",
},
{
name: "not found -> 404",
err: fmt.Errorf("tag: %w", images.ErrNotFound),
wantType: oapi.TagImage404JSONResponse{},
wantCode: "not_found",
},
{
name: "not ready -> 409",
err: fmt.Errorf("tag: %w", images.ErrImageNotReady),
wantType: oapi.TagImage409JSONResponse{},
wantCode: "image_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.IsType(t, tc.wantType, resp)
require.Equal(t, tc.wantCode, tagImageErrorCode(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)
}

func tagImageErrorCode(resp oapi.TagImageResponseObject) string {
switch r := resp.(type) {
case oapi.TagImage400JSONResponse:
return r.Code
case oapi.TagImage404JSONResponse:
return r.Code
case oapi.TagImage409JSONResponse:
return r.Code
case oapi.TagImage500JSONResponse:
return r.Code
default:
return ""
}
}

// 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()

p := paths.New(svc.Config.DataDir)
contentDir := p.ImageContentDir(digestHex)
require.NoError(t, os.MkdirAll(contentDir, 0o755))
require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs"), 0o644))

meta := struct {
Name string `json:"name"`
Digest string `json:"digest"`
Status string `json:"status"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}{
Name: repository + ":" + tag,
Digest: "sha256:" + digestHex,
Status: "ready",
SizeBytes: int64(len("rootfs")),
CreatedAt: time.Now().UTC(),
}
data, err := json.Marshal(meta)
require.NoError(t, err)
require.NoError(t, os.WriteFile(p.ImageContentMetadata(digestHex), data, 0o644))

linkPath := p.ImageRepositoryTagSymlink(repository, tag)
target, err := filepath.Rel(filepath.Dir(linkPath), contentDir)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755))
require.NoError(t, os.Symlink(target, linkPath))
}

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)

// The new tag resolves through GetImage.
img, err := svc.ImageManager.GetImage(ctx(), "registry.example/apps/alpine:v1")
require.NoError(t, err)
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
14 changes: 9 additions & 5 deletions lib/hypervisor/firecracker/binaries.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,23 @@ func resolveBinaryPath(p *paths.Paths, version string) (string, error) {
return "", fmt.Errorf("paths are required when using embedded firecracker binaries")
}

return extractBinary(p, parseVersion(version))
parsedVersion, err := parseVersion(version)
if err != nil {
return "", err
}
return extractBinary(p, parsedVersion)
}

func parseVersion(version string) Version {
func parseVersion(version string) (Version, error) {
if version == "" {
return defaultVersion
return defaultVersion, nil
}
for _, supported := range supportedVersions {
if version == string(supported) {
return supported
return supported, nil
}
}
return defaultVersion
return "", fmt.Errorf("unsupported firecracker version %q", version)
}

func extractBinary(p *paths.Paths, version Version) (string, error) {
Expand Down
21 changes: 15 additions & 6 deletions lib/hypervisor/firecracker/binaries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,21 @@ func TestResolveBinaryPathInvalidCustomPath(t *testing.T) {
assert.Contains(t, err.Error(), "invalid firecracker custom binary path")
}

func TestParseVersionFallback(t *testing.T) {
assert.Equal(t, V1_16_1, defaultVersion)
assert.Equal(t, defaultVersion, parseVersion(""))
assert.Equal(t, defaultVersion, parseVersion("unknown"))
assert.Equal(t, V1_14_2, parseVersion("v1.14.2"))
assert.Equal(t, V1_16_1, parseVersion("v1.16.1"))
func TestParseVersion(t *testing.T) {
version, err := parseVersion("")
require.NoError(t, err)
assert.Equal(t, defaultVersion, version)

_, err = parseVersion("unknown")
require.ErrorContains(t, err, "unsupported firecracker version")

version, err = parseVersion("v1.14.2")
require.NoError(t, err)
assert.Equal(t, V1_14_2, version)

version, err = parseVersion("v1.16.1")
require.NoError(t, err)
assert.Equal(t, V1_16_1, version)
}

func TestResolveEmbeddedBinaryVersions(t *testing.T) {
Expand Down
3 changes: 1 addition & 2 deletions lib/images/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -203,7 +202,7 @@ func TestRecoverInterruptedCredentialedPullFailsForFreshRetry(t *testing.T) {
assert.Equal(t, ErrBorrowedCredentialsExpired.Error(), *stored.Error)
assert.Zero(t, m.queue.QueueLength())

data, err := os.ReadFile(filepath.Join(p.ImageDigestDir(repository, strings.TrimPrefix(digest, "sha256:")), "metadata.json"))
data, err := os.ReadFile(p.ImageContentMetadata(strings.TrimPrefix(digest, "sha256:")))
require.NoError(t, err)
assert.NotContains(t, string(data), "password")
}
Loading
Loading