Tonis Tiigi 45dfb84361
history: add support for exporting multiple and all records
Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
2025-03-21 11:21:00 -07:00

283 lines
7.7 KiB
Go

package bundle
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"io"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/content/proxy"
imgarchive "github.com/containerd/containerd/v2/core/images/archive"
"github.com/docker/buildx/localstate"
controlapi "github.com/moby/buildkit/api/services/control"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/util/contentutil"
"github.com/opencontainers/go-digest"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
)
const (
HistoryRecordMediaTypeV0 = "application/vnd.buildkit.historyrecord.v0"
RefDescriptorMediaType = "vnd.export-build.descriptor.mediatype"
)
type Record struct {
*controlapi.BuildHistoryRecord
DefaultPlatform string
LocalState *localstate.State `json:"localState,omitempty"`
StateGroup *localstate.StateGroup `json:"stateGroup,omitempty"`
}
func Export(ctx context.Context, c []*client.Client, w io.Writer, records []*Record) error {
var store content.Store
for _, c := range c {
s := proxy.NewContentStore(c.ContentClient())
if store == nil {
store = s
break
}
store = &nsFallbackStore{
main: store,
fb: s,
}
}
if store == nil {
return errors.New("no buildkit client found")
}
mp := contentutil.NewMultiProvider(store)
desc, err := export(ctx, mp, records)
if err != nil {
return errors.Wrap(err, "failed to export")
}
gz := gzip.NewWriter(w)
defer gz.Close()
if err := imgarchive.Export(ctx, mp, gz, imgarchive.WithManifest(desc), imgarchive.WithSkipDockerManifest()); err != nil {
return errors.Wrap(err, "failed to create dockerbuild archive")
}
return nil
}
func export(ctx context.Context, mp *contentutil.MultiProvider, records []*Record) (ocispecs.Descriptor, error) {
if len(records) == 1 {
desc, err := exportRecord(ctx, mp, records[0])
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to export record")
}
return desc, nil
}
var idx ocispecs.Index
idx.MediaType = ocispecs.MediaTypeImageIndex
idx.SchemaVersion = 2
for _, r := range records {
desc, err := exportRecord(ctx, mp, r)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to export record")
}
if desc.Annotations == nil {
desc.Annotations = make(map[string]string)
}
desc.Annotations["vnd.buildkit.history.reference"] = r.Ref
idx.Manifests = append(idx.Manifests, desc)
}
desc, err := writeJSON(ctx, mp, idx.MediaType, idx)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to write index")
}
return desc, nil
}
func writeJSON(ctx context.Context, mp *contentutil.MultiProvider, mt string, data any) (ocispecs.Descriptor, error) {
dt, err := json.MarshalIndent(data, "", " ")
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to marshal data")
}
desc := ocispecs.Descriptor{
MediaType: mt,
Size: int64(len(dt)),
Digest: digest.FromBytes(dt),
}
buf := contentutil.NewBuffer()
if err := content.WriteBlob(ctx, buf, "blob-"+desc.Digest.String(), bytes.NewReader(dt), desc); err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to write blob")
}
mp.Add(desc.Digest, buf)
return desc, nil
}
func sanitizeCacheImports(v string) (string, error) {
type cacheImport struct {
Type string `json:"Type"`
Attrs map[string]string `json:"Attrs"`
}
var arr []cacheImport
if err := json.Unmarshal([]byte(v), &arr); err != nil {
return "", errors.Wrap(err, "failed to unmarshal cache imports")
}
for i := range arr {
m := map[string]string{}
for k, v := range arr[i].Attrs {
if k == "scope" || k == "ref" {
m[k] = v
}
}
arr[i].Attrs = m
}
dt, err := json.Marshal(arr)
if err != nil {
return "", errors.Wrap(err, "failed to marshal cache imports")
}
return string(dt), nil
}
func sanitizeRecord(rec *controlapi.BuildHistoryRecord) {
for k, v := range rec.FrontendAttrs {
if k == "cache-imports" {
v, err := sanitizeCacheImports(v)
if err != nil {
rec.FrontendAttrs[k] = ""
} else {
rec.FrontendAttrs[k] = v
}
}
}
}
func exportRecord(ctx context.Context, mp *contentutil.MultiProvider, record *Record) (ocispecs.Descriptor, error) {
var mfst ocispecs.Manifest
mfst.MediaType = ocispecs.MediaTypeImageManifest
mfst.SchemaVersion = 2
sanitizeRecord(record.BuildHistoryRecord)
visited := map[string]struct{}{}
if trace := record.Trace; trace != nil {
desc, err := loadDescriptor(ctx, mp, trace, visited)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to load trace descriptor")
}
desc, err = sanitizeTrace(ctx, mp, *desc)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to sanitize trace")
}
record.Trace.Digest = desc.Digest.String()
record.Trace.Size = desc.Size
mfst.Layers = append(mfst.Layers, *desc)
}
config, err := writeJSON(ctx, mp, HistoryRecordMediaTypeV0, record)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to write history record")
}
mfst.Config = config
if logs := record.Logs; logs != nil {
desc, err := loadDescriptor(ctx, mp, logs, visited)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to load logs descriptor")
}
mfst.Layers = append(mfst.Layers, *desc)
}
if res := record.Result; res != nil {
results, err := loadResult(ctx, mp, res, visited)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to load result")
}
mfst.Layers = append(mfst.Layers, results...)
}
if exterr := record.ExternalError; exterr != nil {
desc, err := loadDescriptor(ctx, mp, exterr, visited)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to load external error descriptor")
}
mfst.Layers = append(mfst.Layers, *desc)
}
for _, res := range record.Results {
results, err := loadResult(ctx, mp, res, visited)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to load result")
}
mfst.Layers = append(mfst.Layers, results...)
}
desc, err := writeJSON(ctx, mp, mfst.MediaType, mfst)
if err != nil {
return ocispecs.Descriptor{}, errors.Wrap(err, "failed to write manifest")
}
return desc, nil
}
func loadResult(ctx context.Context, ip content.InfoProvider, in *controlapi.BuildResultInfo, visited map[string]struct{}) ([]ocispecs.Descriptor, error) {
var out []ocispecs.Descriptor
for _, attest := range in.Attestations {
desc, err := loadDescriptor(ctx, ip, attest, visited)
if err != nil {
return nil, errors.Wrap(err, "failed to load attestation descriptor")
}
if desc != nil {
out = append(out, *desc)
}
}
for _, r := range in.Results {
desc, err := loadDescriptor(ctx, ip, r, visited)
if err != nil {
return nil, errors.Wrap(err, "failed to load result descriptor")
}
if desc != nil {
if desc.Annotations == nil {
desc.Annotations = make(map[string]string)
}
// Override media type to avoid containerd to walk children. Also
// keep original media type in annotations.
desc.Annotations[RefDescriptorMediaType] = desc.MediaType
desc.MediaType = "application/json"
out = append(out, *desc)
}
}
return out, nil
}
func loadDescriptor(ctx context.Context, ip content.InfoProvider, in *controlapi.Descriptor, visited map[string]struct{}) (*ocispecs.Descriptor, error) {
if _, ok := visited[in.Digest]; ok {
return nil, nil
}
visited[in.Digest] = struct{}{}
dgst, err := digest.Parse(in.Digest)
if err != nil {
return nil, errors.Wrap(err, "failed to parse digest")
}
if _, err := ip.Info(ctx, dgst); err != nil {
return nil, errors.Wrap(err, "failed to get info")
}
return &ocispecs.Descriptor{
MediaType: in.MediaType,
Digest: dgst,
Size: in.Size,
Annotations: in.Annotations,
}, nil
}