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
10 changes: 8 additions & 2 deletions cmd/api/api/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe
log := logger.FromContext(ctx)

domainReq := images.CreateImageRequest{
Name: request.Body.Name,
Tags: toMapTags(request.Body.Tags),
Name: request.Body.Name,
Tags: toMapTags(request.Body.Tags),
Credentials: registryCredentialsToAuthn(request.Body.Credentials),
}
if request.Body.Platform != nil {
domainReq.Platform = *request.Body.Platform
Expand Down Expand Up @@ -72,6 +73,11 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe
Code: "rate_limited",
Message: images.RateLimitMessage,
}, nil
case errors.Is(err, images.ErrCredentialConflict):
return oapi.CreateImage409JSONResponse{
Code: "credential_conflict",
Message: err.Error(),
}, nil
case errors.Is(err, images.ErrNotFound):
return oapi.CreateImage404JSONResponse{
Code: "not_found",
Expand Down
54 changes: 54 additions & 0 deletions cmd/api/api/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,52 @@ func (m createImageErrManager) CreateImage(context.Context, images.CreateImageRe
return nil, m.err
}

type captureCreateImageManager struct {
images.Manager
req images.CreateImageRequest
}

func (m *captureCreateImageManager) CreateImage(_ context.Context, req images.CreateImageRequest) (*images.Image, error) {
m.req = req
return &images.Image{Name: req.Name, Digest: "sha256:test", Status: images.StatusPending, CreatedAt: time.Now()}, nil
}

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

username, password, token := "borrower", "secret", "registry-token"
manager := &captureCreateImageManager{}
svc := &ApiService{ImageManager: manager}

resp, err := svc.CreateImage(context.Background(), oapi.CreateImageRequestObject{Body: &oapi.CreateImageRequest{
Name: "registry.example/private/image:latest",
Credentials: &oapi.PushCredentials{
Username: &username,
Password: &password,
RegistryToken: &token,
},
}})
require.NoError(t, err)
require.IsType(t, oapi.CreateImage202JSONResponse{}, resp)
require.NotNil(t, manager.req.Credentials)
assert.Equal(t, username, manager.req.Credentials.Username)
assert.Equal(t, password, manager.req.Credentials.Password)
assert.Equal(t, token, manager.req.Credentials.RegistryToken)
}

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

manager := &captureCreateImageManager{}
svc := &ApiService{ImageManager: manager}
_, err := svc.CreateImage(context.Background(), oapi.CreateImageRequestObject{Body: &oapi.CreateImageRequest{
Name: "docker.io/library/alpine:latest",
Credentials: &oapi.PushCredentials{},
}})
require.NoError(t, err)
assert.Nil(t, manager.req.Credentials)
}

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

Expand All @@ -48,6 +94,12 @@ func TestCreateImage_ErrorStatusMapping(t *testing.T) {
wantType: oapi.CreateImage429JSONResponse{},
wantCode: "rate_limited",
},
{
name: "credential conflict -> 409",
err: fmt.Errorf("resolve: %w", images.ErrCredentialConflict),
wantType: oapi.CreateImage409JSONResponse{},
wantCode: "credential_conflict",
},
{
name: "not found -> 404",
err: fmt.Errorf("resolve: %w", images.ErrNotFound),
Expand Down Expand Up @@ -84,6 +136,8 @@ func errorCodeOf(resp oapi.CreateImageResponseObject) string {
return r.Code
case oapi.CreateImage404JSONResponse:
return r.Code
case oapi.CreateImage409JSONResponse:
return r.Code
case oapi.CreateImage429JSONResponse:
return r.Code
case oapi.CreateImage500JSONResponse:
Expand Down
11 changes: 5 additions & 6 deletions cmd/api/api/pushes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (s *ApiService) CreatePush(ctx context.Context, request oapi.CreatePushRequ
domainReq := imagepush.PushRequest{
Image: request.Body.Image,
Target: request.Body.Target,
Credentials: pushCredentialsToAuthn(request.Body.Credentials),
Credentials: registryCredentialsToAuthn(request.Body.Credentials),
}
if request.Body.Insecure != nil {
domainReq.Insecure = *request.Body.Insecure
Expand Down Expand Up @@ -123,11 +123,10 @@ func (s *ApiService) ListPushes(ctx context.Context, request oapi.ListPushesRequ
return oapi.ListPushes200JSONResponse(out), nil
}

// pushCredentialsToAuthn maps API credentials to the go-containerregistry
// auth config. Returns nil when absent or empty so the push falls back to
// the server's default credential resolution — an empty credentials object
// must not mask the keychain.
func pushCredentialsToAuthn(creds *oapi.PushCredentials) *authn.AuthConfig {
// registryCredentialsToAuthn maps borrowed API credentials to the
// go-containerregistry auth config. Nil and empty credentials preserve the
// server's default keychain behavior.
func registryCredentialsToAuthn(creds *oapi.PushCredentials) *authn.AuthConfig {
if creds == nil {
return nil
}
Expand Down
190 changes: 190 additions & 0 deletions lib/images/credentials_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package images

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/kernel/hypeman/lib/paths"
"github.com/kernel/hypeman/lib/queue"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBorrowedCredentialsAuthenticateResolveAndPull(t *testing.T) {
const username = "borrower"
const password = "pull-secret"

handler := registry.New()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, gotPassword, ok := r.BasicAuth()
if !ok || gotUser != username || gotPassword != password {
w.Header().Set("WWW-Authenticate", `Basic realm="registry"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
handler.ServeHTTP(w, r)
}))
defer server.Close()

registryHost := strings.TrimPrefix(server.URL, "http://")
ref, err := name.ParseReference(registryHost+"/private/image:latest", name.Insecure)
require.NoError(t, err)
credentials := &authn.AuthConfig{Username: username, Password: password}

img, err := random.Image(256, 1)
require.NoError(t, err)
require.NoError(t, remote.Write(ref, img, remote.WithAuth(authn.FromConfig(*credentials))))

client, err := newOCIClient(t.TempDir())
require.NoError(t, err)
platform := vmPlatform()
digest, err := client.inspectManifestWithPlatformAuth(context.Background(), ref.String(), platform, credentials)
require.NoError(t, err)

err = client.pullToOCILayoutWithPlatformAuth(context.Background(), ref.Context().Digest(digest).String(), digestToLayoutTag(digest), platform, credentials)
require.NoError(t, err)
assert.True(t, client.existsInLayout(digestToLayoutTag(digest)))
}

func TestCreateImageRequestCredentialsAreNotPersisted(t *testing.T) {
const secret = "must-not-reach-disk"
const repository = "registry.example/private/image"
const digest = "abc"
p := paths.New(t.TempDir())
meta := imageMetadata{
Name: repository + ":latest",
Digest: "sha256:" + digest,
Status: StatusPending,
BorrowedAuth: true,
Request: &CreateImageRequest{
Name: repository + ":latest",
Credentials: &authn.AuthConfig{Username: "borrower", Password: secret},
},
}

require.NoError(t, writeMetadata(p, repository, digest, &meta))
data, err := os.ReadFile(metadataPath(p, repository, digest))
require.NoError(t, err)
assert.NotContains(t, string(data), secret)
assert.NotContains(t, string(data), "borrower")
assert.Contains(t, string(data), `"borrowed_auth": true`)
}

func TestInflightPullRejectsDifferentCredentials(t *testing.T) {
m := &manager{
inflightPulls: make(map[string]*inflightImagePull),
borrowedCredentialsTimeout: time.Minute,
}
const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
credentials := &authn.AuthConfig{Username: "AWS", Password: "token-a"}
inflight := m.registerInflightPull(digest, credentials)
t.Cleanup(m.releaseInflightPull(digest, inflight))

assert.True(t, m.inflightCredentialsMatch(digest, &authn.AuthConfig{Username: "AWS", Password: "token-a"}))
assert.False(t, m.inflightCredentialsMatch(digest, nil))
assert.False(t, m.inflightCredentialsMatch(digest, &authn.AuthConfig{Username: "AWS", Password: "token-b"}))
}

func TestBorrowedCredentialsExpireWhileQueued(t *testing.T) {
m := &manager{
inflightPulls: make(map[string]*inflightImagePull),
borrowedCredentialsTimeout: time.Millisecond,
}
const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
inflight := m.registerInflightPull(digest, &authn.AuthConfig{Username: "AWS", Password: "secret"})
defer m.releaseInflightPull(digest, inflight)()

require.Eventually(t, func() bool {
m.createMu.Lock()
defer m.createMu.Unlock()
return m.inflightPulls[digest].credentials == nil
}, time.Second, time.Millisecond)

credentials, _, expired := m.borrowedAuth(digest)
assert.True(t, expired)
assert.Nil(t, credentials)
}

func TestRecoverInterruptedCredentialedConversionFromCache(t *testing.T) {
p := paths.New(t.TempDir())
img := createTestDockerImage(t)
digest, err := img.Digest()
require.NoError(t, err)
digestString := digest.String()

require.NoError(t, os.MkdirAll(p.SystemOCICache(), 0o755))
cache, err := layout.Write(p.SystemOCICache(), empty.Index)
require.NoError(t, err)
require.NoError(t, cache.AppendImage(img, layout.WithAnnotations(map[string]string{
"org.opencontainers.image.ref.name": digestToLayoutTag(digestString),
})))

const repository = "registry.example/private/image"
meta := &imageMetadata{
Name: repository + ":latest",
Digest: digestString,
Status: StatusConverting,
BorrowedAuth: true,
Request: &CreateImageRequest{Name: repository + ":latest"},
CreatedAt: time.Now(),
}
require.NoError(t, writeMetadata(p, repository, strings.TrimPrefix(digestString, "sha256:"), meta))

mgr, err := NewManager(p, 1, nil)
require.NoError(t, err)
waitForReady(t, mgr, context.Background(), meta.Name)

recovered, err := mgr.GetImage(context.Background(), meta.Name)
require.NoError(t, err)
assert.Equal(t, StatusReady, recovered.Status)
}

func TestRecoverInterruptedCredentialedPullFailsForFreshRetry(t *testing.T) {
p := paths.New(t.TempDir())
client, err := newOCIClient(p.SystemOCICache())
require.NoError(t, err)
m := &manager{
paths: p,
ociClient: client,
queue: queue.New(1),
readySubscribers: make(map[string][]chan StatusEvent),
}

const repository = "registry.example/private/image"
const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
meta := &imageMetadata{
Name: repository + ":latest",
Digest: digest,
Status: StatusPulling,
BorrowedAuth: true,
Request: &CreateImageRequest{Name: repository + ":latest"},
CreatedAt: time.Now(),
}
require.NoError(t, writeMetadata(p, repository, strings.TrimPrefix(digest, "sha256:"), meta))

m.RecoverInterruptedBuilds()

stored, err := readMetadata(p, repository, strings.TrimPrefix(digest, "sha256:"))
require.NoError(t, err)
require.NotNil(t, stored.Error)
assert.Equal(t, StatusFailed, stored.Status)
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"))
require.NoError(t, err)
assert.NotContains(t, string(data), "password")
}
4 changes: 3 additions & 1 deletion lib/images/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ var (
// ErrRateLimited means the registry rejected the request with a rate-limit
// response (e.g. Docker Hub's unauthenticated pull limit). Transient and
// caller-actionable (retry later or authenticate), not a server fault.
ErrRateLimited = errors.New("registry rate limit exceeded")
ErrRateLimited = errors.New("registry rate limit exceeded")
ErrBorrowedCredentialsExpired = errors.New("credentialed image pull expired or was interrupted; submit POST /images again with fresh credentials")
ErrCredentialConflict = errors.New("image pull already in flight with different credentials")
)

// ClassifyRegistryError classifies a raw registry/go-containerregistry error into a
Expand Down
Loading
Loading