source: add support for oci-layout+blob schema

Matching the docker-image+blob implementation.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-02-13 15:36:52 -08:00
parent 9d821a3c12
commit 8874679130
10 changed files with 366 additions and 28 deletions

View File

@@ -58,6 +58,7 @@ import (
"github.com/moby/buildkit/session/secrets/secretsprovider"
"github.com/moby/buildkit/session/sshforward/sshprovider"
"github.com/moby/buildkit/solver/errdefs"
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
"github.com/moby/buildkit/solver/pb"
"github.com/moby/buildkit/solver/result"
"github.com/moby/buildkit/sourcepolicy"
@@ -237,6 +238,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
testRegistryEmptyCacheExport,
testSnapshotWithMultipleBlobs,
testImageBlobSource,
testOCILayoutBlobSource,
testExportLocalNoPlatformSplit,
testExportLocalNoPlatformSplitOverwrite,
testExportLocalForcePlatformSplit,
@@ -758,6 +760,9 @@ func testExportBusyboxLocal(t *testing.T, sb integration.Sandbox) {
destDir := t.TempDir()
_, err = c.Solve(sb.Context(), def, SolveOpt{
FrontendAttrs: map[string]string{
"attest:provenance": "",
},
Exports: []ExportEntry{
{
Type: ExporterLocal,
@@ -11597,12 +11602,8 @@ func testImageBlobSource(t *testing.T, sb integration.Sandbox) {
require.NoError(t, err)
var stmt struct {
Predicate struct {
Materials []struct {
URI string `json:"uri"`
Digest map[string]string `json:"digest"`
} `json:"materials"`
} `json:"predicate"`
intoto.StatementHeader
Predicate provenancetypes.ProvenancePredicateSLSA02 `json:"predicate"`
}
require.NoError(t, json.Unmarshal(provDt, &stmt))
@@ -11624,6 +11625,109 @@ func testImageBlobSource(t *testing.T, sb integration.Sandbox) {
require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.Materials)
}
func testOCILayoutBlobSource(t *testing.T, sb integration.Sandbox) {
workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout)
requiresLinux(t)
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()
st := llb.Image("alpine")
def, err := st.Marshal(sb.Context())
require.NoError(t, err)
ociDir := t.TempDir()
_, err = c.Solve(sb.Context(), def, SolveOpt{
Exports: []ExportEntry{
{
Type: ExporterOCI,
Attrs: map[string]string{
"tar": "false",
},
OutputDir: ociDir,
},
},
}, nil)
require.NoError(t, err)
indexDt, err := os.ReadFile(filepath.Join(ociDir, ocispecs.ImageIndexFile))
require.NoError(t, err)
var index ocispecs.Index
err = json.Unmarshal(indexDt, &index)
require.NoError(t, err)
require.Equal(t, 1, len(index.Manifests))
var mfst ocispecs.Manifest
mfstDt, err := os.ReadFile(filepath.Join(ociDir, "blobs/sha256", index.Manifests[0].Digest.Hex()))
require.NoError(t, err)
err = json.Unmarshal(mfstDt, &mfst)
require.NoError(t, err)
require.GreaterOrEqual(t, len(mfst.Layers), 1)
layer := mfst.Layers[0]
store, err := local.NewStore(ociDir)
require.NoError(t, err)
csID := "my-blob-content-store"
blob := llb.OCILayoutBlob("not/real@"+layer.Digest.String(), llb.ImageBlobOCIStore("", csID), llb.Filename("layer.tar.gz"), llb.Chown(123, 456))
st = llb.Image("alpine").Run(llb.Shlex(`sh -c 'sha256sum /layers/layer.tar.gz | cut -d" " -f0 > /out/checksum && stat -c "%u-%g-%s" /layers/layer.tar.gz > /out/stat'`), llb.AddMount("/layers", blob, llb.Readonly)).AddMount("/out", llb.Scratch())
def, err = st.Marshal(sb.Context())
require.NoError(t, err)
destDir := t.TempDir()
_, err = c.Solve(sb.Context(), def, SolveOpt{
FrontendAttrs: map[string]string{
"attest:provenance": "",
},
Exports: []ExportEntry{
{
Type: ExporterLocal,
OutputDir: destDir,
},
},
OCIStores: map[string]content.Store{
csID: store,
},
}, nil)
require.NoError(t, err)
dt, err := os.ReadFile(filepath.Join(destDir, "stat"))
require.NoError(t, err)
require.Equal(t, "123-456-"+strconv.FormatInt(layer.Size, 10), strings.TrimSpace(string(dt)))
dt, err = os.ReadFile(filepath.Join(destDir, "checksum"))
require.NoError(t, err)
require.Equal(t, layer.Digest.Hex(), strings.TrimSpace(string(dt)))
provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json"))
require.NoError(t, err)
var stmt struct {
intoto.StatementHeader
Predicate provenancetypes.ProvenancePredicateSLSA02 `json:"predicate"`
}
require.NoError(t, json.Unmarshal(provDt, &stmt))
expectedName, err := purl.RefToPURL(packageurl.TypeOCI, "not/real@"+layer.Digest.String(), nil)
require.NoError(t, err)
purlObj, err := packageurl.FromString(expectedName)
require.NoError(t, err)
purlObj.Qualifiers = append(purlObj.Qualifiers, packageurl.Qualifier{Key: "ref_type", Value: "blob"})
expectedName = purlObj.ToString()
found := false
for _, m := range stmt.Predicate.Materials {
if m.URI == expectedName {
found = true
require.Equal(t, layer.Digest.Hex(), m.Digest["sha256"])
break
}
}
require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.Materials)
}
func testFrontendVerifyPlatforms(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)

View File

@@ -100,6 +100,8 @@ func (s *SourceOp) Inputs() []Output {
type ImageBlobInfo struct {
constraintsWrapper
fileinfoWrapper
sessionID string
storeID string
}
type ImageBlobOption interface {
@@ -158,6 +160,60 @@ func ImageBlob(ref string, opts ...ImageBlobOption) State {
return NewState(source.Output())
}
// OCILayoutBlob returns a state that represents a single digest-addressed blob from an OCI layout store.
func OCILayoutBlob(ref string, opts ...ImageBlobOption) State {
bi := &ImageBlobInfo{}
for _, o := range opts {
o.SetImageBlobOption(bi)
}
attrs := map[string]string{}
if bi.Filename != "" {
attrs[pb.AttrHTTPFilename] = bi.Filename
}
if bi.Perm != 0 {
attrs[pb.AttrHTTPPerm] = "0" + strconv.FormatInt(int64(bi.Perm), 8)
}
if bi.UID != 0 {
attrs[pb.AttrHTTPUID] = strconv.Itoa(bi.UID)
}
if bi.GID != 0 {
attrs[pb.AttrHTTPGID] = strconv.Itoa(bi.GID)
}
if bi.sessionID != "" {
attrs[pb.AttrOCILayoutSessionID] = bi.sessionID
}
if bi.storeID != "" {
attrs[pb.AttrOCILayoutStoreID] = bi.storeID
}
addCap(&bi.Constraints, pb.CapSourceImageBlob)
var digested reference.Digested
r, err := reference.ParseNormalizedNamed(ref)
if err == nil {
if _, tagged := r.(reference.Tagged); tagged {
err = errors.Errorf("tagged image reference not allowed for blob reference")
} else if ref, ok := r.(reference.Digested); !ok {
err = errors.Errorf("checksum required in blob reference")
} else {
digested = ref
}
}
repoName := "invalid"
if digested != nil {
repoName = digested.String()
}
source := NewSource("oci-layout+blob://"+repoName, attrs, bi.Constraints)
if err != nil {
source.err = err
}
return NewState(source.Output())
}
// Image returns a state that represents a docker image in a registry.
// Example:
//
@@ -259,6 +315,12 @@ func (fn imageOptionFunc) SetImageOption(ii *ImageInfo) {
fn(ii)
}
type imageBlobOptionFunc func(*ImageBlobInfo)
func (fn imageBlobOptionFunc) SetImageBlobOption(ib *ImageBlobInfo) {
fn(ib)
}
var MarkImageInternal = imageOptionFunc(func(ii *ImageInfo) {
ii.RecordType = "internal"
})
@@ -685,6 +747,14 @@ func OCIStore(sessionID string, storeID string) OCILayoutOption {
})
}
// ImageBlobOCIStore returns an [ImageBlobOption] that configures the OCI layout session/store used by [OCILayoutBlob].
func ImageBlobOCIStore(sessionID string, storeID string) ImageBlobOption {
return imageBlobOptionFunc(func(ib *ImageBlobInfo) {
ib.sessionID = sessionID
ib.storeID = storeID
})
}
func OCILayerLimit(limit int) OCILayoutOption {
return ociLayoutOptionFunc(func(oi *OCILayoutInfo) {
oi.layerLimit = &limit

View File

@@ -104,6 +104,35 @@ func TestImageBlobSource(t *testing.T) {
require.Equal(t, "docker-image+blob://docker.io/myuser/myrepo@"+string(blobDgst), src.Source.Identifier)
}
func TestOCILayoutBlobSource(t *testing.T) {
t.Parallel()
ctx := context.TODO()
blobDgst := digest.FromBytes([]byte("foo"))
s := OCILayoutBlob("myrepo/blob@"+string(blobDgst), ImageBlobOCIStore("sid", "store0"))
def, err := s.Marshal(ctx)
require.NoError(t, err)
m, arr := parseDef(t, def.Def)
_ = m
require.Equal(t, 2, len(arr))
dgst, idx := last(t, arr)
require.Equal(t, 0, idx)
vtx, ok := m[dgst]
require.Equal(t, true, ok)
src, ok := vtx.Op.(*pb.Op_Source)
require.Equal(t, true, ok)
require.Nil(t, vtx.Platform)
require.Equal(t, "oci-layout+blob://docker.io/myrepo/blob@"+string(blobDgst), src.Source.Identifier)
require.Equal(t, "sid", src.Source.Attrs[pb.AttrOCILayoutSessionID])
require.Equal(t, "store0", src.Source.Attrs[pb.AttrOCILayoutStoreID])
}
func TestStateSourceMapMarshal(t *testing.T) {
t.Parallel()

View File

@@ -134,7 +134,7 @@ func (c *Capture) AddImage(i provenancetypes.ImageSource) {
func (c *Capture) AddImageBlob(i provenancetypes.ImageBlobSource) {
for _, v := range c.Sources.ImageBlobs {
if v.Ref == i.Ref {
if v.Ref == i.Ref && v.Local == i.Local {
return
}
}

View File

@@ -41,7 +41,11 @@ func slsaMaterials(srcs provenancetypes.Sources) ([]slsa.ProvenanceMaterial, err
}
for _, s := range srcs.ImageBlobs {
uri, err := purl.RefToPURL(packageurl.TypeDocker, s.Ref, nil)
purlType := packageurl.TypeDocker
if s.Local {
purlType = packageurl.TypeOCI
}
uri, err := purl.RefToPURL(purlType, s.Ref, nil)
if err != nil {
return nil, err
}

View File

@@ -37,3 +37,28 @@ func TestSLSAMaterialsImageBlobPURL(t *testing.T) {
require.Equal(t, dgst.Hex(), ms[0].Digest[dgst.Algorithm().String()])
}
func TestSLSAMaterialsOCILayoutBlobPURL(t *testing.T) {
t.Parallel()
dgst := digest.FromString("blobdata")
ms, err := slsaMaterials(provenancetypes.Sources{
ImageBlobs: []provenancetypes.ImageBlobSource{
{
Ref: "example.com/ns/repo@" + dgst.String(),
Digest: dgst,
Local: true,
},
},
})
require.NoError(t, err)
require.Len(t, ms, 1)
p, err := packageurl.FromString(ms[0].URI)
require.NoError(t, err)
require.Equal(t, packageurl.TypeOCI, p.Type)
q := p.Qualifiers.Map()
require.Equal(t, "blob", q["ref_type"])
require.Equal(t, dgst.String(), q["digest"])
}

View File

@@ -65,6 +65,7 @@ type ImageSource struct {
type ImageBlobSource struct {
Ref string
Digest digest.Digest
Local bool
}
type GitSource struct {

View File

@@ -15,6 +15,9 @@ import (
type ImageBlobIdentifier struct {
Reference reference.Spec
SchemeName string
SessionID string
StoreID string
RecordType client.UsageRecordType
Filename string
Perm int
@@ -22,7 +25,7 @@ type ImageBlobIdentifier struct {
GID int
}
func NewImageBlobIdentifier(str string) (*ImageBlobIdentifier, error) {
func NewImageBlobIdentifier(str string, scheme string) (*ImageBlobIdentifier, error) {
ref, err := reference.Parse(str)
if err != nil {
return nil, errors.WithStack(err)
@@ -31,13 +34,19 @@ func NewImageBlobIdentifier(str string) (*ImageBlobIdentifier, error) {
if ref.Object == "" {
return nil, errors.WithStack(reference.ErrObjectRequired)
}
return &ImageBlobIdentifier{Reference: ref}, nil
return &ImageBlobIdentifier{
Reference: ref,
SchemeName: scheme,
}, nil
}
var _ source.Identifier = (*ImageBlobIdentifier)(nil)
func (*ImageBlobIdentifier) Scheme() string {
return srctypes.DockerImageBlobScheme
func (id *ImageBlobIdentifier) Scheme() string {
if id.SchemeName == "" {
return srctypes.DockerImageBlobScheme
}
return id.SchemeName
}
func (id *ImageBlobIdentifier) Capture(c *provenance.Capture, pin string) error {
@@ -62,6 +71,7 @@ func (id *ImageBlobIdentifier) Capture(c *provenance.Capture, pin string) error
c.AddImageBlob(provenancetypes.ImageBlobSource{
Ref: id.Reference.String(),
Digest: dgst,
Local: id.Scheme() == srctypes.OCIBlobScheme,
})
return nil
}

View File

@@ -14,11 +14,15 @@ import (
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/buildkit/cache"
"github.com/moby/buildkit/session"
sessioncontent "github.com/moby/buildkit/session/content"
"github.com/moby/buildkit/snapshot"
"github.com/moby/buildkit/solver"
srctypes "github.com/moby/buildkit/source/types"
"github.com/moby/buildkit/util/contentutil"
"github.com/moby/buildkit/util/iohelper"
"github.com/moby/buildkit/util/resolver"
digest "github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
)
@@ -64,20 +68,31 @@ func (p *puller) ensureResolver(ctx context.Context, g session.Group) error {
return errors.Wrap(err, "invalid reference digest")
}
r := resolver.DefaultPool.GetResolver(p.src.RegistryHosts, p.id.Reference.String(), resolver.ScopeType{}, p.SessionManager, g)
f, err := r.Fetcher(ctx, p.id.Reference.String())
if err != nil {
return err
}
var (
rc io.ReadCloser
err error
)
if p.id.Scheme() == srctypes.OCIBlobScheme {
rc, err = p.fetchFromOCILayoutStore(ctx, g, dgst)
if err != nil {
return err
}
} else {
r := resolver.DefaultPool.GetResolver(p.src.RegistryHosts, p.id.Reference.String(), resolver.ScopeType{}, p.SessionManager, g)
f, err := r.Fetcher(ctx, p.id.Reference.String())
if err != nil {
return err
}
fd, ok := f.(remotes.FetcherByDigest)
if !ok {
return errors.Errorf("invalid blob fetcher: %T", f)
}
fd, ok := f.(remotes.FetcherByDigest)
if !ok {
return errors.Errorf("invalid blob fetcher: %T", f)
}
rc, _, err := fd.FetchByDigest(ctx, dgst)
if err != nil {
return err
rc, _, err = fd.FetchByDigest(ctx, dgst)
if err != nil {
return err
}
}
p.rc = rc
@@ -85,6 +100,53 @@ func (p *puller) ensureResolver(ctx context.Context, g session.Group) error {
return nil
}
func (p *puller) fetchFromOCILayoutStore(ctx context.Context, g session.Group, dgst digest.Digest) (io.ReadCloser, error) {
if p.id.StoreID == "" {
return nil, errors.Errorf("oci-layout blob source requires store id")
}
var rc io.ReadCloser
err := p.withOCICaller(ctx, g, func(ctx context.Context, caller session.Caller) error {
store := sessioncontent.NewCallerStore(caller, "oci:"+p.id.StoreID)
info, err := store.Info(ctx, dgst)
if err != nil {
return err
}
readerAt, err := store.ReaderAt(ctx, ocispecs.Descriptor{
Digest: info.Digest,
Size: info.Size,
})
if err != nil {
return err
}
rc = iohelper.ReadCloser(readerAt)
return nil
})
if err != nil {
return nil, err
}
return rc, nil
}
func (p *puller) withOCICaller(ctx context.Context, g session.Group, f func(context.Context, session.Caller) error) error {
if p.id.SessionID != "" {
timeoutCtx, cancel := context.WithCancelCause(ctx)
timeoutCtx, _ = context.WithTimeoutCause(timeoutCtx, 5*time.Second, errors.WithStack(context.DeadlineExceeded)) //nolint:govet
defer func() { cancel(errors.WithStack(context.Canceled)) }()
caller, err := p.SessionManager.Get(timeoutCtx, p.id.SessionID, false)
if err != nil {
return err
}
return f(ctx, caller)
}
return p.SessionManager.Any(ctx, g, func(ctx context.Context, _ string, caller session.Caller) error {
return f(ctx, caller)
})
}
func (p *puller) CacheKey(ctx context.Context, jobCtx solver.JobContext, index int) (cacheKey string, imgDigest string, cacheOpts solver.CacheOpts, cacheDone bool, err error) {
dgst := p.id.Reference.Digest()
if err := dgst.Validate(); err != nil {

View File

@@ -33,11 +33,18 @@ func NewSource(opt SourceOpt) (*Source, error) {
}
func (is *Source) Schemes() []string {
return []string{srctypes.DockerImageBlobScheme}
return []string{srctypes.DockerImageBlobScheme, srctypes.OCIBlobScheme}
}
func (is *Source) Identifier(scheme, ref string, attrs map[string]string, platform *pb.Platform) (source.Identifier, error) {
return is.registryIdentifier(ref, attrs, platform)
switch scheme {
case srctypes.DockerImageBlobScheme:
return is.registryIdentifier(ref, attrs, platform)
case srctypes.OCIBlobScheme:
return is.ociLayoutIdentifier(ref, attrs, platform)
default:
return nil, errors.Errorf("invalid image blob scheme %s", scheme)
}
}
func (is *Source) Resolve(ctx context.Context, id source.Identifier, sm *session.Manager, vtx solver.Vertex) (source.SourceInstance, error) {
@@ -52,12 +59,30 @@ func (is *Source) Resolve(ctx context.Context, id source.Identifier, sm *session
}, nil
}
func (is *Source) registryIdentifier(ref string, attrs map[string]string, platform *pb.Platform) (source.Identifier, error) {
id, err := NewImageBlobIdentifier(ref)
func (is *Source) registryIdentifier(ref string, attrs map[string]string, _ *pb.Platform) (source.Identifier, error) {
id, err := NewImageBlobIdentifier(ref, srctypes.DockerImageBlobScheme)
if err != nil {
return nil, err
}
return is.parseIdentifierAttrs(id, attrs, false)
}
func (is *Source) ociLayoutIdentifier(ref string, attrs map[string]string, _ *pb.Platform) (source.Identifier, error) {
id, err := NewImageBlobIdentifier(ref, srctypes.OCIBlobScheme)
if err != nil {
return nil, err
}
parsed, err := is.parseIdentifierAttrs(id, attrs, true)
if err != nil {
return nil, err
}
if id.StoreID == "" {
return nil, errors.Errorf("oci-layout blob source requires store id")
}
return parsed, nil
}
func (is *Source) parseIdentifierAttrs(id *ImageBlobIdentifier, attrs map[string]string, allowOCIStore bool) (source.Identifier, error) {
for k, v := range attrs {
switch k {
case pb.AttrHTTPFilename:
@@ -86,6 +111,14 @@ func (is *Source) registryIdentifier(ref string, attrs map[string]string, platfo
return nil, err
}
id.RecordType = rt
case pb.AttrOCILayoutSessionID:
if allowOCIStore {
id.SessionID = v
}
case pb.AttrOCILayoutStoreID:
if allowOCIStore {
id.StoreID = v
}
}
}