Skip to content
Open
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
406 changes: 406 additions & 0 deletions client/client_test.go

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions cmd/buildkitd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/moby/buildkit/control"
"github.com/moby/buildkit/executor/oci"
"github.com/moby/buildkit/frontend"
artifactfrontend "github.com/moby/buildkit/frontend/artifact"
dockerfile "github.com/moby/buildkit/frontend/dockerfile/builder"
"github.com/moby/buildkit/frontend/gateway"
"github.com/moby/buildkit/frontend/gateway/forwarder"
Expand Down Expand Up @@ -817,6 +818,7 @@ func newController(ctx context.Context, c *cli.Context, cfg *config.Config) (*co
if cfg.Frontends.Dockerfile.Enabled == nil || *cfg.Frontends.Dockerfile.Enabled {
frontends["dockerfile.v0"] = forwarder.NewGatewayForwarder(wc.Infos(), dockerfile.Build)
}
frontends[artifactfrontend.Name] = forwarder.NewGatewayForwarder(wc.Infos(), artifactfrontend.Build)
if cfg.Frontends.Gateway.Enabled == nil || *cfg.Frontends.Gateway.Enabled {
gwfe, err := gateway.NewGatewayFrontend(wc.Infos(), cfg.Frontends.Gateway.AllowedRepositories)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions control/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ func (c *Controller) Solve(ctx context.Context, req *controlapi.SolveRequest) (*
procs = append(procs, proc.SBOMProcessor(ref.String(), useCache, resolveMode, params))
}

procs = append(procs, proc.ArtifactProcessor())

if attrs, ok := attests["provenance"]; ok {
var slsaVersion provenancetypes.ProvenanceSLSA
params := make(map[string]string)
Expand Down
21 changes: 21 additions & 0 deletions exporter/containerimage/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ func (ag AnnotationsGroup) Platform(p *ocispecs.Platform) *Annotations {
return res
}

// ResolveArtifactAnnotations validates and resolves annotations for OCI
// artifact exports. Artifact exports always produce a single manifest
// descriptor, unless attestations are attached, in which case the top-level
// descriptor becomes an index that wraps the artifact manifest and attestation
// manifests.
func ResolveArtifactAnnotations(ag AnnotationsGroup, hasIndex bool) (*Annotations, error) {
resolved := ag.Platform(nil)
if !hasIndex && (len(resolved.Index) > 0 || len(resolved.IndexDescriptor) > 0) {
return nil, errors.Errorf("index annotations are not supported for OCI artifact exports without attestations")
}
for platform, annotations := range ag {
if platform == "" {
continue
}
if len(annotations.Manifest) > 0 || len(annotations.ManifestDescriptor) > 0 {
return nil, errors.Errorf("platform-specific annotations are not supported for OCI artifact exports")
}
}
return resolved, nil
}

func (ag AnnotationsGroup) Merge(other AnnotationsGroup) AnnotationsGroup {
if other == nil {
return ag
Expand Down
87 changes: 87 additions & 0 deletions exporter/containerimage/artifact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package containerimage

import (
"strconv"
"strings"

intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/exporter"
"github.com/moby/buildkit/exporter/attestation"
"github.com/moby/buildkit/exporter/containerimage/exptypes"
"github.com/moby/buildkit/solver/result"
"github.com/moby/buildkit/util/purl"
digest "github.com/opencontainers/go-digest"
"github.com/package-url/packageurl-go"
"github.com/pkg/errors"
)

// ResolveArtifactAttestations selects the single attestation group that applies
// to an OCI artifact export. If forceInline is false, only inline-only
// attestations are retained, matching the regular image exporter behavior.
func ResolveArtifactAttestations(src *exporter.Source, forceInline bool) ([]exporter.Attestation, error) {
if len(src.Attestations) == 0 {
return nil, nil
}

attestationGroups := make(map[string][]exporter.Attestation, len(src.Attestations))
for k, atts := range src.Attestations {
if !forceInline {
atts = attestation.Filter(atts, nil, map[string][]byte{
result.AttestationInlineOnlyKey: []byte(strconv.FormatBool(true)),
})
}
if len(atts) > 0 {
attestationGroups[k] = atts
}
}
if len(attestationGroups) == 0 {
return nil, nil
}
if len(attestationGroups) == 1 {
for _, atts := range attestationGroups {
return atts, nil
}
}

ps, err := exptypes.ParsePlatforms(src.Metadata)
if err != nil {
return nil, err
}
if len(ps.Platforms) != 1 {
return nil, errors.Errorf("OCI artifact exports require exactly one attestation group, got %d", len(attestationGroups))
}

atts, ok := attestationGroups[ps.Platforms[0].ID]
if !ok {
return nil, errors.Errorf("OCI artifact export missing attestation group for %s", ps.Platforms[0].ID)
}
if len(attestationGroups) != 1 {
return nil, errors.Errorf("OCI artifact exports require exactly one attestation group, got %d", len(attestationGroups))
}
return atts, nil
}

// DefaultArtifactSubjects creates default in-toto subjects for registry-pushed
// OCI artifacts. Artifact exports are platform-less, so no platform qualifier
// is added to the generated purls.
func DefaultArtifactSubjects(imageNames string, dgst digest.Digest) ([]intoto.Subject, error) {
if imageNames == "" {
return nil, nil
}

var subjects []intoto.Subject
for name := range strings.SplitSeq(imageNames, ",") {
if name == "" {
continue
}
pl, err := purl.RefToPURL(packageurl.TypeDocker, name, nil)
if err != nil {
return nil, err
}
subjects = append(subjects, intoto.Subject{
Name: pl,
Digest: result.ToDigestMap(dgst),
})
}
return subjects, nil
}
182 changes: 182 additions & 0 deletions exporter/containerimage/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ func (e *imageExporterInstance) Export(ctx context.Context, src *exporter.Source
}
opts.Annotations = opts.Annotations.Merge(as)

// If the result contains a pre-built OCI layout (from ArtifactProcessor),
// use the dedicated OCI layout export path instead of the normal image commit.
if _, ok := src.Metadata[exptypes.ExporterOCILayoutKey]; ok {
return e.exportOCILayout(ctx, src, buildInfo, opts.Annotations)
}

ctx, done, err := leaseutil.WithLease(ctx, e.opt.LeaseManager, leaseutil.MakeTemporary)
if err != nil {
return nil, nil, nil, err
Expand Down Expand Up @@ -435,6 +441,182 @@ func (e *imageExporterInstance) pushImage(ctx context.Context, src *exporter.Sou
return push.Push(ctx, e.opt.SessionManager, sessionID, mprovider, e.opt.ImageWriter.ContentStore(), dgst, targetName, e.insecure, e.opt.RegistryHosts, e.pushByDigest, annotations)
}

// exportOCILayout handles exporting a pre-built OCI layout that was assembled
// by the ArtifactProcessor. It ingests the layout into the content store,
// stores the image if configured, and optionally pushes it.
func (e *imageExporterInstance) exportOCILayout(ctx context.Context, src *exporter.Source, buildInfo exporter.ExportBuildInfo, annotations AnnotationsGroup) (_ map[string]string, _ exporter.FinalizeFunc, descref exporter.DescriptorReference, err error) {
if e.unpack {
return nil, nil, nil, errors.New("unpack is not supported for OCI artifact layouts")
}
if e.opts.RewriteTimestamp {
return nil, nil, nil, errors.New("rewrite-timestamp is not supported for OCI artifact layouts")
}

// Resolve the single ref from the source
var ref cache.ImmutableRef
if src.Ref != nil {
ref = src.Ref
} else if len(src.Refs) == 1 {
for _, r := range src.Refs {
ref = r
}
}
if ref == nil {
return nil, nil, nil, errors.New("OCI layout export requires exactly one ref")
}

// Acquire a lease to protect content from GC during export
ctx, done, err := leaseutil.WithLease(ctx, e.opt.LeaseManager, leaseutil.MakeTemporary)
if err != nil {
return nil, nil, nil, err
}
defer func() {
if descref == nil {
done(context.WithoutCancel(ctx))
}
}()

if n, ok := src.Metadata["image.name"]; e.opts.ImageName == "*" && ok {
e.opts.ImageName = string(n)
}

attests, err := ResolveArtifactAttestations(src, e.opts.ForceInlineAttestations)
if err != nil {
return nil, nil, nil, err
}

resolvedAnnotations, err := ResolveArtifactAnnotations(annotations, len(attests) > 0)
if err != nil {
return nil, nil, nil, err
}

desc, err := e.opt.ImageWriter.CommitOCILayout(ctx, ref, buildInfo.SessionID, resolvedAnnotations)
if err != nil {
return nil, nil, nil, err
}
if len(attests) > 0 {
subjects, err := DefaultArtifactSubjects(e.opts.ImageName, desc.Digest)
if err != nil {
return nil, nil, nil, err
}
desc, err = e.opt.ImageWriter.CommitAttestationsForDescriptor(ctx, &e.opts, *desc, buildInfo.SessionID, attests, subjects, resolvedAnnotations)
if err != nil {
return nil, nil, nil, err
}
}

resp := make(map[string]string)

nameCanonical := e.nameCanonical
if e.danglingPrefix != "" && (!e.danglingEmptyOnly || e.opts.ImageName == "") {
danglingImageName := e.danglingPrefix + "@" + desc.Digest.String()
if e.opts.ImageName != "" {
e.opts.ImageName += "," + danglingImageName
} else {
e.opts.ImageName = danglingImageName
nameCanonical = false
}
}

// Collect names for finalize callback to push
var namesToPush []string

if e.opts.ImageName != "" {
targetNames := strings.SplitSeq(e.opts.ImageName, ",")
for targetName := range targetNames {
if e.opt.Images != nil && e.store {
tagDone := progress.OneOff(ctx, "naming to "+targetName)

// imageClientCtx is used for propagating the epoch to e.opt.Images.Update() and e.opt.Images.Create().
//
// Ideally, we should be able to propagate the epoch via images.Image.CreatedAt.
// However, due to a bug of containerd, we are temporarily stuck with this workaround.
// https://github.com/containerd/containerd/issues/8322
imageClientCtx := ctx
if e.opts.Epoch != nil {
imageClientCtx = epoch.WithSourceDateEpoch(imageClientCtx, e.opts.Epoch)
}
img := images.Image{
Target: *desc,
// CreatedAt in images.Images is ignored due to a bug of containerd.
// See the comment lines for imageClientCtx.
}

sfx := []string{""}
if nameCanonical && !strings.ContainsRune(targetName, '@') {
sfx = append(sfx, "@"+desc.Digest.String())
}
for _, sfx := range sfx {
img.Name = targetName + sfx
for { // handle possible race between Update and Create
if _, err := e.opt.Images.Update(imageClientCtx, img); err != nil {
if !errors.Is(err, cerrdefs.ErrNotFound) {
return nil, nil, nil, tagDone(err)
}

if _, err := e.opt.Images.Create(imageClientCtx, img); err != nil {
if !errors.Is(err, cerrdefs.ErrAlreadyExists) {
return nil, nil, nil, tagDone(err)
}
continue
}
}
break
}
}
tagDone(nil)
}
if e.push {
namesToPush = append(namesToPush, targetName)
}
}
resp[exptypes.ExporterImageNameKey] = e.opts.ImageName
}

resp[exptypes.ExporterImageDigestKey] = desc.Digest.String()
if v, ok := desc.Annotations[exptypes.ExporterConfigDigestKey]; ok {
resp[exptypes.ExporterImageConfigDigestKey] = v
delete(desc.Annotations, exptypes.ExporterConfigDigestKey)
}

dtdesc, err := json.Marshal(desc)
if err != nil {
return nil, nil, nil, err
}
resp[exptypes.ExporterImageDescriptorKey] = base64.StdEncoding.EncodeToString(dtdesc)

// Transfer lease ownership to descref
descref = NewDescriptorReference(*desc, done)

if len(namesToPush) == 0 {
return resp, nil, descref, nil
}

// Create finalize callback for pushing
finalize := func(ctx context.Context) error {
for _, targetName := range namesToPush {
if err := e.pushOCILayout(ctx, buildInfo.SessionID, targetName, desc.Digest); err != nil {
var statusErr remoteserrors.ErrUnexpectedStatus
if errors.As(err, &statusErr) {
err = errutil.WithDetails(err)
}
return errors.Wrapf(err, "failed to push OCI layout to %v", targetName)
}
}
return nil
}

return resp, finalize, descref, nil
}

// pushOCILayout pushes an OCI artifact layout to a registry.
// Unlike pushImage, it does not need to resolve remote providers from cache refs
// because all blobs have already been ingested into the content store by CommitOCILayout.
func (e *imageExporterInstance) pushOCILayout(ctx context.Context, sessionID string, targetName string, dgst digest.Digest) error {
cs := e.opt.ImageWriter.ContentStore()
return push.Push(ctx, e.opt.SessionManager, sessionID, cs, cs, dgst, targetName, e.insecure, e.opt.RegistryHosts, e.pushByDigest, nil)
}

func (e *imageExporterInstance) unpackImage(ctx context.Context, img images.Image, src *exporter.Source, s session.Group) (err0 error) {
matcher := platforms.Only(platforms.Normalize(platforms.DefaultSpec()))

Expand Down
11 changes: 11 additions & 0 deletions exporter/containerimage/exptypes/artifact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package exptypes

// ArtifactLayer describes a single layer in an OCI artifact.
// The frontend produces an array of these as JSON metadata, which the
// ArtifactProcessor passes to the OCI layout assembler container.
type ArtifactLayer struct {
// Path is the root-relative file path inside the artifact input reference.
Path string `json:"path"`
MediaType string `json:"mediaType"`
Annotations map[string]string `json:"annotations,omitempty"`
}
14 changes: 14 additions & 0 deletions exporter/containerimage/exptypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ const (
ExporterImageDescriptorKey = "containerimage.descriptor"
ExporterImageBaseConfigKey = "containerimage.base.config"
ExporterPlatformsKey = "refs.platforms"

ExporterArtifactKey = "containerimage.artifact"
ExporterArtifactTypeKey = "containerimage.artifact.type"
ExporterArtifactConfigTypeKey = "containerimage.artifact.config.mediatype"
ExporterArtifactLayersKey = "containerimage.artifact.layers"
ExporterPostprocessorsKey = "containerimage.postprocessors"
ExporterOCILayoutKey = "containerimage.oci-layout"

PostprocessInputKey = "input"
)

// KnownRefMetadataKeys are the subset of exporter keys that can be suffixed by
Expand All @@ -38,3 +47,8 @@ type InlineCacheEntry struct {
Data []byte
}
type InlineCache func(ctx context.Context) (*result.Result[*InlineCacheEntry], error)

type PostprocessRequest struct {
Frontend string `json:"frontend"`
FrontendOpt map[string]string `json:"frontendOpt,omitempty"`
}
Loading
Loading