mirror of
https://github.com/moby/buildkit.git
synced 2026-11-06 05:16:45 +00:00
Independent local content stores only coordinate writers within each store instance. Concurrent cache exports to the same directory can open the same ingest files, causing rename failures or incomplete writes. Acquire a filesystem lock per ingest reference before opening a writer or aborting an ingest. Release it when the writer closes or commits, including error paths, and report contention as unavailable so existing retry logic can wait. Preserve references so interrupted uploads remain resumable, and retain lock files to avoid races with replacement inodes. Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
2409 lines
70 KiB
Go
2409 lines
70 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
ctd "github.com/containerd/containerd/v2/client"
|
|
"github.com/containerd/containerd/v2/core/content"
|
|
"github.com/containerd/containerd/v2/core/images"
|
|
ctdcompression "github.com/containerd/containerd/v2/pkg/archive/compression"
|
|
"github.com/containerd/containerd/v2/pkg/namespaces"
|
|
contentlocal "github.com/containerd/containerd/v2/plugins/content/local"
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/gofrs/flock"
|
|
cacheimporttypes "github.com/moby/buildkit/cache/remotecache/v1/types"
|
|
"github.com/moby/buildkit/client/llb"
|
|
"github.com/moby/buildkit/exporter/containerimage/exptypes"
|
|
gateway "github.com/moby/buildkit/frontend/gateway/client"
|
|
"github.com/moby/buildkit/identity"
|
|
"github.com/moby/buildkit/session"
|
|
sessioncontent "github.com/moby/buildkit/session/content"
|
|
"github.com/moby/buildkit/util/compression"
|
|
"github.com/moby/buildkit/util/testutil"
|
|
"github.com/moby/buildkit/util/testutil/helpers"
|
|
"github.com/moby/buildkit/util/testutil/integration"
|
|
"github.com/moby/buildkit/util/testutil/workers"
|
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/pkg/errors"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tonistiigi/fsutil"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
func testConcurrentLocalCacheExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
|
|
ctx, cancel := context.WithTimeoutCause(sb.Context(), time.Minute, errors.New("concurrent local cache export timed out"))
|
|
defer cancel()
|
|
|
|
c, err := New(ctx, sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
// Export a single scratch-mounted layer in the default min mode so both
|
|
// exports contend on the same ref before either writes config or manifest.
|
|
st := llb.Image("busybox:latest").Run(llb.Shlex("sh -c 'echo shared > /out/payload'")).AddMount("/out", llb.Scratch())
|
|
def, err := st.Marshal(ctx)
|
|
require.NoError(t, err)
|
|
|
|
dir := t.TempDir()
|
|
opened := make(chan struct{}, 2)
|
|
blocked := make(chan struct{}, 1)
|
|
releases := [2]chan struct{}{make(chan struct{}), make(chan struct{})}
|
|
completed := make(chan error, 2)
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
for i := range 2 {
|
|
store, err := newLocalCacheStore(dir)
|
|
require.NoError(t, err)
|
|
s, err := session.NewSession(ctx, "")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, s.Close()) })
|
|
s.Allow(sessioncontent.NewAttachable(map[string]content.Store{
|
|
"local:" + dir: &gatedCacheStore{Store: store, opened: opened, blocked: blocked, release: releases[i]},
|
|
}))
|
|
eg.Go(func() error { return s.Run(ctx, c.Dialer()) })
|
|
eg.Go(func() error {
|
|
defer s.Close()
|
|
_, err := c.Solve(ctx, def, SolveOpt{
|
|
SharedSession: s,
|
|
SessionPreInitialized: true,
|
|
CacheExports: []CacheOptionsEntry{{Type: "local", Attrs: map[string]string{
|
|
"dest": dir,
|
|
"tag": fmt.Sprintf("export-%d", i),
|
|
}}},
|
|
}, nil)
|
|
completed <- err
|
|
return err
|
|
})
|
|
if i == 0 {
|
|
select {
|
|
case <-opened:
|
|
case <-ctx.Done():
|
|
t.Fatal(context.Cause(ctx))
|
|
}
|
|
}
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- eg.Wait() }()
|
|
|
|
// The first export owns the ingest. The second must retry rather than
|
|
// opening and modifying the same temporary files.
|
|
select {
|
|
case <-blocked:
|
|
case <-opened:
|
|
t.Fatal("second export opened an active ingest")
|
|
case <-ctx.Done():
|
|
t.Fatal(context.Cause(ctx))
|
|
}
|
|
|
|
entries, err := os.ReadDir(filepath.Join(dir, "ingest"))
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 1, "exports of the same reference must reuse its ingest")
|
|
// Index updates use a non-blocking lock. Delay the second export's retry
|
|
// until the first has published its index entry.
|
|
for _, release := range releases {
|
|
close(release)
|
|
require.NoError(t, <-completed)
|
|
}
|
|
require.NoError(t, <-done)
|
|
|
|
dt, err := os.ReadFile(filepath.Join(dir, "index.json"))
|
|
require.NoError(t, err)
|
|
var index ocispecs.Index
|
|
require.NoError(t, json.Unmarshal(dt, &index))
|
|
require.Len(t, index.Manifests, 2)
|
|
|
|
store, err := contentlocal.NewStore(dir)
|
|
require.NoError(t, err)
|
|
|
|
for _, desc := range index.Manifests {
|
|
dt, err := content.ReadBlob(sb.Context(), store, desc)
|
|
require.NoError(t, err)
|
|
var manifest ocispecs.Manifest
|
|
require.NoError(t, json.Unmarshal(dt, &manifest))
|
|
for _, blob := range append(manifest.Layers, manifest.Config) {
|
|
dt, err := content.ReadBlob(sb.Context(), store, blob)
|
|
require.NoError(t, err)
|
|
require.Equal(t, blob.Digest, blob.Digest.Algorithm().FromBytes(dt))
|
|
}
|
|
}
|
|
}
|
|
|
|
// gatedCacheStore pauses the first writer so two exports can overlap without
|
|
// relying on scheduling or a large payload to keep an ingest active.
|
|
type gatedCacheStore struct {
|
|
content.Store
|
|
opened chan<- struct{}
|
|
blocked chan<- struct{}
|
|
release <-chan struct{}
|
|
paused atomic.Bool
|
|
}
|
|
|
|
func (s *gatedCacheStore) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) {
|
|
w, err := s.Store.Writer(ctx, opts...)
|
|
if err != nil {
|
|
if errors.Is(err, cerrdefs.ErrUnavailable) {
|
|
select {
|
|
case s.blocked <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-s.release:
|
|
case <-ctx.Done():
|
|
return nil, context.Cause(ctx)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
if s.paused.CompareAndSwap(false, true) {
|
|
s.opened <- struct{}{}
|
|
select {
|
|
case <-s.release:
|
|
case <-ctx.Done():
|
|
w.Close()
|
|
return nil, context.Cause(ctx)
|
|
}
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
func testBasicAzblobCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendAzblob,
|
|
)
|
|
|
|
opts := helpers.AzuriteOpts{
|
|
AccountName: "azblobcacheaccount",
|
|
AccountKey: base64.StdEncoding.EncodeToString([]byte("azblobcacheaccountkey")),
|
|
}
|
|
|
|
azAddr, cleanup, err := helpers.NewAzuriteServer(t, sb, opts)
|
|
require.NoError(t, err)
|
|
defer cleanup()
|
|
|
|
im := CacheOptionsEntry{
|
|
Type: "azblob",
|
|
Attrs: map[string]string{
|
|
"account_url": azAddr,
|
|
"account_name": opts.AccountName,
|
|
"secret_access_key": opts.AccountKey,
|
|
"container": "cachecontainer",
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "azblob",
|
|
Attrs: map[string]string{
|
|
"account_url": azAddr,
|
|
"account_name": opts.AccountName,
|
|
"secret_access_key": opts.AccountKey,
|
|
"container": "cachecontainer",
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
func testBasicCacheImportExport(t *testing.T, sb integration.Sandbox, cacheOptionsEntryImport, cacheOptionsEntryExport []CacheOptionsEntry) {
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
busybox := llb.Image(imgName)
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
|
|
run := func(cmd string) {
|
|
baseState := integration.UnixOrWindows(busybox, st)
|
|
st = baseState.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
|
|
}
|
|
|
|
run(integration.UnixOrWindows(
|
|
`sh -c "echo -n foobar > const"`,
|
|
`cmd /C echo foobar > const`,
|
|
))
|
|
run(integration.UnixOrWindows(
|
|
`sh -c "cat /dev/urandom | head -c 100 | sha256sum > unique"`,
|
|
`cmd /C echo %RANDOM% > unique`,
|
|
))
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
destDir := t.TempDir()
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: destDir,
|
|
},
|
|
},
|
|
CacheExports: cacheOptionsEntryExport,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dt, err := os.ReadFile(filepath.Join(destDir, "const"))
|
|
require.NoError(t, err)
|
|
newLine := integration.UnixOrWindows("", " \r\n")
|
|
require.Equal(t, "foobar"+newLine, string(dt))
|
|
|
|
dt, err = os.ReadFile(filepath.Join(destDir, "unique"))
|
|
require.NoError(t, err)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
destDir = t.TempDir()
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: destDir,
|
|
},
|
|
},
|
|
CacheImports: cacheOptionsEntryImport,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dt2, err := os.ReadFile(filepath.Join(destDir, "const"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "foobar"+newLine, string(dt2))
|
|
|
|
dt2, err = os.ReadFile(filepath.Join(destDir, "unique"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(dt), string(dt2))
|
|
}
|
|
|
|
func testBasicInlineCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureDirectPush,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheBackendInline,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
busybox := llb.Image(imgName)
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
|
|
run := func(cmd string) {
|
|
st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
|
|
}
|
|
|
|
run(integration.UnixOrWindows(
|
|
`sh -c "echo -n foobar > const"`,
|
|
`cmd /C echo foobar > const`,
|
|
))
|
|
run(integration.UnixOrWindows(
|
|
`sh -c "cat /dev/urandom | head -c 100 | sha256sum > unique"`,
|
|
`cmd /C echo %RANDOM% > unique`,
|
|
))
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
target := registry + "/buildkit/testexportinline:latest"
|
|
|
|
resp, err := c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
unique, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst, "/unique")
|
|
require.NoError(t, err)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheImport, workers.FeatureCacheBackendRegistry)
|
|
|
|
resp, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
// specifying inline cache exporter is needed for reproducing containerimage.digest
|
|
// (not needed for reproducing rootfs/unique)
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst2, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
require.Equal(t, dgst, dgst2)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
// Export the cache again with compression
|
|
resp, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
// specifying inline cache exporter is needed for reproducing containerimage.digest
|
|
// (not needed for reproducing rootfs/unique)
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
"compression": "uncompressed", // inline cache should work with compression
|
|
"force-compression": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst2uncompress, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
// dgst2uncompress != dgst, because the compression type is different
|
|
unique2uncompress, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst2uncompress, "/unique")
|
|
require.NoError(t, err)
|
|
require.Equal(t, unique, unique2uncompress)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
resp, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst3, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
// dgst3 != dgst, because inline cache is not exported for dgst3
|
|
unique3, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst3, "/unique")
|
|
require.NoError(t, err)
|
|
require.Equal(t, unique, unique3)
|
|
}
|
|
|
|
func testBasicLocalCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendLocal,
|
|
)
|
|
dir := t.TempDir()
|
|
im := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"src": dir,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": dir,
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
// testAlternativeInputKeysCacheImport checks that an independently built COPY
|
|
// can match an imported cache entry through its content key even when its source
|
|
// key is absent from that cache. Changing an unselected file gives the source a
|
|
// different key while preserving the copied content. A downstream RUN must still
|
|
// reuse its imported result: the COPY's source and content keys are alternatives
|
|
// for the same input, so matching both is not required.
|
|
func testAlternativeInputKeysCacheImport(t *testing.T, sb integration.Sandbox) {
|
|
requiresLinux(t)
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
parent := llb.Image("busybox:latest")
|
|
copyValue := func(ctxVal string) llb.State {
|
|
src := llb.Scratch().File(llb.Mkfile("/value", 0644, []byte("same content"))).
|
|
File(llb.Mkfile("/unselected", 0644, []byte(ctxVal)))
|
|
// Keep the source at input 1, where structural and content keys are
|
|
// alternatives. The parent makes this a copy with two inputs.
|
|
return parent.File(llb.Copy(src, "/value", "/value"))
|
|
}
|
|
marker := func(input llb.State) llb.State {
|
|
return input.Run(
|
|
llb.Shlex("sh -c 'test \"$(cat /value)\" = \"same content\" && head -c 100 /dev/urandom > /out/unique'"),
|
|
).AddMount("/out", llb.Scratch())
|
|
}
|
|
|
|
cacheRef := registry + "/buildkit/alternative-input-keys:latest"
|
|
def, err := marker(copyValue("original context")).Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
first := t.TempDir()
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: first}},
|
|
CacheExports: []CacheOptionsEntry{{Type: "registry", Attrs: map[string]string{
|
|
"ref": cacheRef, "mode": "max",
|
|
}}},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
want, err := os.ReadFile(filepath.Join(first, "unique"))
|
|
require.NoError(t, err)
|
|
require.Len(t, want, 100)
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
// Build the equivalent copy independently, before making the imported
|
|
// cache available. Its source key differs, but its selected content agrees.
|
|
// Importing during this solve would let Query link the new source key to
|
|
// the old copy and hide the getIDFromDeps regression.
|
|
_, err = c.Build(sb.Context(), SolveOpt{}, "", func(ctx context.Context, gw gateway.Client) (*gateway.Result, error) {
|
|
copied := copyValue("changed context")
|
|
def, err := copied.Marshal(ctx)
|
|
require.NoError(t, err)
|
|
_, err = gw.Solve(ctx, gateway.SolveRequest{Definition: def.ToPB(), Evaluate: true})
|
|
require.NoError(t, err)
|
|
|
|
// Keep the first solve active so this result retains both its source
|
|
// key and content checksum when it is reused by the downstream solve.
|
|
// Mapping it into the imported cache must use the content alternative.
|
|
def, err = marker(copied).Marshal(ctx)
|
|
require.NoError(t, err)
|
|
res, err := gw.Solve(ctx, gateway.SolveRequest{
|
|
Definition: def.ToPB(),
|
|
Evaluate: true,
|
|
CacheImports: []gateway.CacheOptionsEntry{{Type: "registry", Attrs: map[string]string{
|
|
"ref": cacheRef,
|
|
}}},
|
|
})
|
|
require.NoError(t, err)
|
|
ref, err := res.SingleRef()
|
|
require.NoError(t, err)
|
|
got, err := ref.ReadFile(ctx, gateway.ReadRequest{Filename: "/unique"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, got, "downstream operation was reexecuted instead of using imported cache")
|
|
return res, nil
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func testBasicRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
o := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{o}, []CacheOptionsEntry{o})
|
|
}
|
|
|
|
func testBasicS3CacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendS3,
|
|
)
|
|
|
|
opts := helpers.MinioOpts{
|
|
Region: "us-east-1",
|
|
AccessKeyID: "minioadmin",
|
|
SecretAccessKey: "minioadmin",
|
|
}
|
|
|
|
var putRequests atomic.Int64
|
|
opts.RequestVerifier = func(r *http.Request) error {
|
|
if r.Method != http.MethodPut {
|
|
return nil
|
|
}
|
|
putRequests.Add(1)
|
|
if got := r.Header.Get("X-Amz-Sdk-Checksum-Algorithm"); got != "" {
|
|
return errors.Errorf("unexpected checksum algorithm header %q", got)
|
|
}
|
|
if got := r.Header.Get("X-Amz-Trailer"); got != "" {
|
|
return errors.Errorf("unexpected checksum trailer header %q", got)
|
|
}
|
|
if got := r.Header.Get("Content-Encoding"); strings.Contains(strings.ToLower(got), "aws-chunked") {
|
|
return errors.Errorf("unexpected aws-chunked content encoding %q", got)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
s3Addr, s3Bucket, cleanup, err := helpers.NewMinioServer(t, sb, opts)
|
|
require.NoError(t, err)
|
|
defer cleanup()
|
|
|
|
o := CacheOptionsEntry{
|
|
Type: "s3",
|
|
Attrs: s3CacheAttrs(opts, s3Addr, s3Bucket),
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{o}, []CacheOptionsEntry{o})
|
|
require.NotZero(t, putRequests.Load())
|
|
}
|
|
|
|
func testZstdS3CacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendS3,
|
|
)
|
|
|
|
opts := helpers.MinioOpts{
|
|
Region: "us-east-1",
|
|
AccessKeyID: "minioadmin",
|
|
SecretAccessKey: "minioadmin",
|
|
}
|
|
|
|
s3Addr, s3Bucket, cleanup, err := helpers.NewMinioServer(t, sb, opts)
|
|
require.NoError(t, err)
|
|
defer cleanup()
|
|
|
|
// the key layout is set explicitly so that the objects can be read back
|
|
// below from the same attributes the daemon was given
|
|
attrs := s3CacheAttrs(opts, s3Addr, s3Bucket)
|
|
attrs["prefix"] = "cache/"
|
|
attrs["manifests_prefix"] = "manifests/"
|
|
attrs["blobs_prefix"] = "blobs/"
|
|
attrs["name"] = "zstd"
|
|
|
|
exAttrs := maps.Clone(attrs)
|
|
exAttrs["compression"] = "zstd"
|
|
exAttrs["force-compression"] = "true"
|
|
|
|
im := CacheOptionsEntry{
|
|
Type: "s3",
|
|
Attrs: attrs,
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "s3",
|
|
Attrs: exAttrs,
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
|
|
s3c := newS3TestClient(opts, s3Addr)
|
|
|
|
// the exported layers must be zstd, otherwise the compression attributes
|
|
// were ignored and the default was used instead
|
|
requireS3CacheCompression(t, sb, s3c, exAttrs, compression.Zstd)
|
|
|
|
// the build above only creates new layers, which are born in the requested
|
|
// compression; force-compression must also convert layers that already
|
|
// exist in another one, so export a result that includes the gzip layers
|
|
// of the base image under a separate manifest
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
st := llb.Image("busybox:latest").Run(llb.Shlex(`sh -c "echo -n zstd > /data"`)).Root()
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
forcedAttrs := maps.Clone(exAttrs)
|
|
forcedAttrs["name"] = "forced"
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "s3",
|
|
Attrs: forcedAttrs,
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
layers := requireS3CacheCompression(t, sb, s3c, forcedAttrs, compression.Zstd)
|
|
require.Greater(t, len(layers), 1, "expected the base image layers to be exported next to the new one")
|
|
}
|
|
|
|
func testUncompressedS3CacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendS3,
|
|
)
|
|
|
|
opts := helpers.MinioOpts{
|
|
Region: "us-east-1",
|
|
AccessKeyID: "minioadmin",
|
|
SecretAccessKey: "minioadmin",
|
|
}
|
|
|
|
s3Addr, s3Bucket, cleanup, err := helpers.NewMinioServer(t, sb, opts)
|
|
require.NoError(t, err)
|
|
defer cleanup()
|
|
|
|
// the key layout is set explicitly so that the objects can be read back
|
|
// below from the same attributes the daemon was given
|
|
attrs := s3CacheAttrs(opts, s3Addr, s3Bucket)
|
|
attrs["prefix"] = "cache/"
|
|
attrs["manifests_prefix"] = "manifests/"
|
|
attrs["blobs_prefix"] = "blobs/"
|
|
attrs["name"] = "uncompressed"
|
|
|
|
exAttrs := maps.Clone(attrs)
|
|
exAttrs["compression"] = "uncompressed"
|
|
exAttrs["force-compression"] = "true"
|
|
|
|
im := CacheOptionsEntry{
|
|
Type: "s3",
|
|
Attrs: attrs,
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "s3",
|
|
Attrs: exAttrs,
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
|
|
// uncompressed is the other end of the range: the blob digest equals the
|
|
// diffID and the media type carries no compression suffix. A silent gzip
|
|
// fallback would still round-trip above, so what the manifest records is
|
|
// the only thing that proves the attributes were honored.
|
|
requireS3CacheCompression(t, sb, newS3TestClient(opts, s3Addr), exAttrs, compression.Uncompressed)
|
|
}
|
|
|
|
// s3CacheAttrs returns the attributes of an s3 cache import or export entry
|
|
// pointing at the minio server started with opts.
|
|
func s3CacheAttrs(opts helpers.MinioOpts, addr, bucket string) map[string]string {
|
|
return map[string]string{
|
|
"region": opts.Region,
|
|
"access_key_id": opts.AccessKeyID,
|
|
"secret_access_key": opts.SecretAccessKey,
|
|
"bucket": bucket,
|
|
"endpoint_url": addr,
|
|
"use_path_style": "true",
|
|
}
|
|
}
|
|
|
|
func newS3TestClient(opts helpers.MinioOpts, addr string) *s3.Client {
|
|
return s3.NewFromConfig(aws.Config{
|
|
Region: opts.Region,
|
|
Credentials: credentials.NewStaticCredentialsProvider(opts.AccessKeyID, opts.SecretAccessKey, ""),
|
|
}, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(addr)
|
|
o.UsePathStyle = true
|
|
})
|
|
}
|
|
|
|
// requireS3CacheCompression reads the cache manifest written by an export with
|
|
// attrs and checks that every layer it references is recorded as comp and that
|
|
// the stored blob really is in that format.
|
|
func requireS3CacheCompression(t *testing.T, sb integration.Sandbox, s3c *s3.Client, attrs map[string]string, comp compression.Type) []cacheimporttypes.CacheLayer {
|
|
t.Helper()
|
|
|
|
var frame ctdcompression.Compression
|
|
switch comp {
|
|
case compression.Uncompressed:
|
|
frame = ctdcompression.Uncompressed
|
|
case compression.Gzip:
|
|
frame = ctdcompression.Gzip
|
|
case compression.Zstd:
|
|
frame = ctdcompression.Zstd
|
|
default:
|
|
t.Fatalf("cannot detect %s blobs", comp)
|
|
}
|
|
|
|
dt := getS3Object(t, sb, s3c, attrs["bucket"], attrs["prefix"]+attrs["manifests_prefix"]+attrs["name"])
|
|
var config cacheimporttypes.CacheConfig
|
|
require.NoError(t, json.Unmarshal(dt, &config))
|
|
|
|
require.NotEmpty(t, config.Layers)
|
|
for _, l := range config.Layers {
|
|
require.NotNil(t, l.Annotations)
|
|
require.True(t, compression.IsMediaType(comp, l.Annotations.MediaType),
|
|
"layer %s has media type %q, expected %s", l.Blob, l.Annotations.MediaType, comp)
|
|
|
|
blob := getS3Object(t, sb, s3c, attrs["bucket"], attrs["prefix"]+attrs["blobs_prefix"]+l.Blob.String())
|
|
require.Equal(t, frame, ctdcompression.DetectCompression(blob), "layer %s is not %s", l.Blob, comp)
|
|
}
|
|
return config.Layers
|
|
}
|
|
|
|
func getS3Object(t *testing.T, sb integration.Sandbox, s3c *s3.Client, bucket, key string) []byte {
|
|
t.Helper()
|
|
|
|
out, err := s3c.GetObject(sb.Context(), &s3.GetObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
require.NoError(t, err)
|
|
defer out.Body.Close()
|
|
|
|
dt, err := io.ReadAll(out.Body)
|
|
require.NoError(t, err)
|
|
return dt
|
|
}
|
|
|
|
func testCacheExportCacheDeletedContent(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
tmpdir := integration.Tmpdir(t)
|
|
|
|
err = os.WriteFile(filepath.Join(tmpdir.Name, "foo"), []byte("foodata"), 0600)
|
|
require.NoError(t, err)
|
|
|
|
base := llb.Image("alpine:latest").Run(llb.Shlex(`sh -c "echo abc-def > /foo && echo abc > /detection-file"`)).Root()
|
|
st := llb.Scratch().File(llb.Copy(base, "/foo", "/out"))
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": filepath.Join(tmpdir.Name, "cache"),
|
|
"mode": "max",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dt, err := os.ReadFile(filepath.Join(tmpdir.Name, "cache", "index.json"))
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
err = json.Unmarshal(dt, &index)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, index.Manifests, 1)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache/blobs/sha256", index.Manifests[0].Digest.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
var mfst ocispecs.Manifest
|
|
err = json.Unmarshal(dt, &mfst)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, mfst.Layers, 3)
|
|
require.Equal(t, "application/vnd.buildkit.cacheconfig.v0", mfst.Config.MediaType)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache/blobs/sha256", mfst.Config.Digest.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
var cc cacheimporttypes.CacheConfig
|
|
err = json.Unmarshal(dt, &cc)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, 3, len(cc.Layers))
|
|
require.Equal(t, 4, len(cc.Records))
|
|
|
|
var runLayer *int
|
|
for i, l := range cc.Layers {
|
|
if l.ParentIndex != -1 {
|
|
if runLayer != nil {
|
|
t.Fatal("multiple RUN layers")
|
|
}
|
|
runLayer = &i
|
|
}
|
|
}
|
|
require.NotNil(t, runLayer)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache/blobs/sha256", cc.Layers[*runLayer].Blob.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
m, err := testutil.ReadTarToMap(dt, true)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "abc\n", string(m["detection-file"].Data))
|
|
|
|
// delete the blob for the run layer
|
|
err = os.Remove(filepath.Join(tmpdir.Name, "cache/blobs/sha256", cc.Layers[*runLayer].Blob.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
// prune all buildkit state
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"src": filepath.Join(tmpdir.Name, "cache"),
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": filepath.Join(tmpdir.Name, "cache2"),
|
|
"mode": "max",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache2", "index.json"))
|
|
require.NoError(t, err)
|
|
|
|
var index2 ocispecs.Index
|
|
err = json.Unmarshal(dt, &index2)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, index2.Manifests, 1)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache2/blobs/sha256", index2.Manifests[0].Digest.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
var mfst2 ocispecs.Manifest
|
|
err = json.Unmarshal(dt, &mfst2)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, mfst2.Layers, 1)
|
|
require.Equal(t, "application/vnd.buildkit.cacheconfig.v0", mfst2.Config.MediaType)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(tmpdir.Name, "cache2/blobs/sha256", mfst2.Config.Digest.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
var cc2 cacheimporttypes.CacheConfig
|
|
err = json.Unmarshal(dt, &cc2)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, 1, len(cc2.Layers))
|
|
require.Equal(t, len(cc.Records), len(cc2.Records))
|
|
|
|
for i, r := range cc.Records {
|
|
require.Equal(t, cc2.Records[i].Digest, r.Digest)
|
|
require.Equal(t, cc2.Records[i].Inputs, r.Inputs)
|
|
}
|
|
}
|
|
|
|
// moby/buildkit#1336
|
|
func testCacheExportCacheKeyLoop(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
tmpdir := integration.Tmpdir(t)
|
|
|
|
err = os.WriteFile(filepath.Join(tmpdir.Name, "foo"), []byte("foodata"), 0600)
|
|
require.NoError(t, err)
|
|
|
|
imgName := integration.UnixOrWindows("alpine:latest", "nanoserver:latest")
|
|
scratch := integration.UnixOrWindows(
|
|
llb.Scratch(),
|
|
llb.Image(imgName),
|
|
)
|
|
cmdStr := integration.UnixOrWindows("true", "cmd /C exit 0")
|
|
for _, mode := range []bool{false, true} {
|
|
func(mode bool) {
|
|
t.Run(fmt.Sprintf("mode=%v", mode), func(t *testing.T) {
|
|
buildbase := llb.Image(imgName).File(llb.Copy(llb.Local("mylocal"), "foo", "foo"))
|
|
if mode { // same cache keys with a separating node go to different code-path
|
|
buildbase = buildbase.Run(llb.Shlex(cmdStr)).Root()
|
|
}
|
|
intermed := llb.Image(imgName).File(llb.Copy(buildbase, "foo", "foo"))
|
|
final := scratch.File(llb.Copy(intermed, "foo", "foooooo"))
|
|
|
|
def, err := final.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": filepath.Join(tmpdir.Name, "cache"),
|
|
},
|
|
},
|
|
},
|
|
LocalMounts: map[string]fsutil.FS{
|
|
"mylocal": tmpdir,
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
})
|
|
}(mode)
|
|
}
|
|
}
|
|
|
|
func testCacheExportIgnoreError(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
busybox := llb.Image(imgName)
|
|
cmd := integration.UnixOrWindows(
|
|
`sh -e -c "echo -n ignore-error > data"`,
|
|
"cmd /C echo ignore-error> data",
|
|
)
|
|
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
tests := map[string]struct {
|
|
Exports []ExportEntry
|
|
CacheExports []CacheOptionsEntry
|
|
expectedErrors []string
|
|
}{
|
|
"local-ignore-error": {
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: t.TempDir(),
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": "éèç",
|
|
},
|
|
},
|
|
},
|
|
expectedErrors: []string{"failed to solve", "contains value with non-printable ASCII characters"},
|
|
},
|
|
"registry-ignore-error": {
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": "test-registry-ignore-error",
|
|
"push": "false",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": "fake-url:5000/myrepo:buildcache",
|
|
},
|
|
},
|
|
},
|
|
expectedErrors: []string{"failed to solve", "dial tcp: lookup fake-url", "no such host"},
|
|
},
|
|
"s3-ignore-error": {
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: t.TempDir(),
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "s3",
|
|
Attrs: map[string]string{
|
|
"endpoint_url": "http://fake-url:9000",
|
|
"bucket": "my-bucket",
|
|
"region": "us-east-1",
|
|
"access_key_id": "minioadmin",
|
|
"secret_access_key": "minioadmin",
|
|
"use_path_style": "true",
|
|
},
|
|
},
|
|
},
|
|
expectedErrors: []string{"failed to solve", "dial tcp: lookup fake-url", "no such host"},
|
|
},
|
|
}
|
|
ignoreErrorValues := []bool{true, false}
|
|
for _, ignoreError := range ignoreErrorValues {
|
|
ignoreErrStr := strconv.FormatBool(ignoreError)
|
|
for n, test := range tests {
|
|
require.Equal(t, 1, len(test.Exports))
|
|
require.Equal(t, 1, len(test.CacheExports))
|
|
require.NotEmpty(t, test.CacheExports[0].Attrs)
|
|
test.CacheExports[0].Attrs["ignore-error"] = ignoreErrStr
|
|
testName := fmt.Sprintf("%s-%s", n, ignoreErrStr)
|
|
t.Run(testName, func(t *testing.T) {
|
|
switch n {
|
|
case "local-ignore-error":
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheBackendLocal)
|
|
case "registry-ignore-error":
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheBackendRegistry)
|
|
case "s3-ignore-error":
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheBackendS3)
|
|
}
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: test.Exports,
|
|
CacheExports: test.CacheExports,
|
|
}, nil)
|
|
if ignoreError {
|
|
require.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
for _, errStr := range test.expectedErrors {
|
|
require.Contains(t, err.Error(), errStr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func testLocalCacheExportIgnoreIndexLockError(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
busybox := llb.Image(imgName)
|
|
cmd := integration.UnixOrWindows(
|
|
`sh -e -c "echo -n index-lock > data"`,
|
|
"cmd /C echo index-lock> data",
|
|
)
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
for _, ignoreError := range []bool{true, false} {
|
|
t.Run(strconv.FormatBool(ignoreError), func(t *testing.T) {
|
|
cacheDir := t.TempDir()
|
|
lock := flock.New(filepath.Join(cacheDir, "index.json.lock"))
|
|
locked, err := lock.TryLock()
|
|
require.NoError(t, err)
|
|
require.True(t, locked)
|
|
defer lock.Unlock()
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{{
|
|
Type: ExporterLocal,
|
|
OutputDir: t.TempDir(),
|
|
}},
|
|
CacheExports: []CacheOptionsEntry{{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": cacheDir,
|
|
"ignore-error": strconv.FormatBool(ignoreError),
|
|
},
|
|
}},
|
|
}, nil)
|
|
if ignoreError {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "could not lock")
|
|
})
|
|
}
|
|
}
|
|
|
|
func testImageManifestRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
im := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
"oci-mediatypes": "true",
|
|
"mode": "max",
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
func testLocalCacheExportReset(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
tmpdir := integration.Tmpdir(t)
|
|
cacheDir := filepath.Join(tmpdir.Name, "cache")
|
|
|
|
// Build 1: export cache for a simple build
|
|
st1 := llb.Image("alpine:latest").Run(llb.Shlex(`sh -c "echo build1 > /out"`)).Root()
|
|
def1, err := llb.Scratch().File(llb.Copy(st1, "/out", "/out")).Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def1, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": cacheDir,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Verify blobs were written
|
|
blobDir := filepath.Join(cacheDir, "blobs", "sha256")
|
|
entries1, err := os.ReadDir(blobDir)
|
|
require.NoError(t, err)
|
|
require.Greater(t, len(entries1), 0)
|
|
|
|
// Build 2: different build, export with reset=true
|
|
st2 := llb.Image("alpine:latest").Run(llb.Shlex(`sh -c "echo build2-different > /out2"`)).Root()
|
|
def2, err := llb.Scratch().File(llb.Copy(st2, "/out2", "/out2")).Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def2, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": cacheDir,
|
|
"reset": "true",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Read the manifest to determine which blobs should be referenced
|
|
dt, err := os.ReadFile(filepath.Join(cacheDir, "index.json"))
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
err = json.Unmarshal(dt, &index)
|
|
require.NoError(t, err)
|
|
require.Len(t, index.Manifests, 1)
|
|
|
|
dt, err = os.ReadFile(filepath.Join(blobDir, index.Manifests[0].Digest.Hex()))
|
|
require.NoError(t, err)
|
|
|
|
var mfst ocispecs.Manifest
|
|
err = json.Unmarshal(dt, &mfst)
|
|
require.NoError(t, err)
|
|
|
|
// Build the set of expected blobs: manifest + config + all layers
|
|
expectedBlobs := map[string]struct{}{
|
|
index.Manifests[0].Digest.Hex(): {},
|
|
mfst.Config.Digest.Hex(): {},
|
|
}
|
|
for _, l := range mfst.Layers {
|
|
expectedBlobs[l.Digest.Hex()] = struct{}{}
|
|
}
|
|
|
|
// Verify only referenced blobs remain
|
|
entries2, err := os.ReadDir(blobDir)
|
|
require.NoError(t, err)
|
|
|
|
actualBlobs := map[string]struct{}{}
|
|
for _, e := range entries2 {
|
|
actualBlobs[e.Name()] = struct{}{}
|
|
}
|
|
require.Equal(t, expectedBlobs, actualBlobs, "after reset, only blobs referenced by the current manifest should remain")
|
|
}
|
|
|
|
func testMultipleCacheExports(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureMultiCacheExport)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
busybox := llb.Image(imgName)
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
|
|
run := func(cmd string) {
|
|
st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st)
|
|
}
|
|
|
|
constCmd := integration.UnixOrWindows(
|
|
`sh -c "echo -n foobar > const"`,
|
|
`cmd /C echo foobar > const`,
|
|
)
|
|
uniqueCmd := integration.UnixOrWindows(
|
|
`sh -c "cat /dev/urandom | head -c 100 | sha256sum > unique"`,
|
|
`cmd /C echo %RANDOM% > unique`,
|
|
)
|
|
|
|
run(constCmd)
|
|
run(uniqueCmd)
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
target := path.Join(registry, "image:test")
|
|
target2 := path.Join(registry, "image-copy:test")
|
|
cacheRef := path.Join(registry, "cache:test")
|
|
cacheOutDir, cacheOutDir2 := t.TempDir(), t.TempDir()
|
|
|
|
res, err := c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": cacheOutDir,
|
|
},
|
|
},
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": cacheOutDir2,
|
|
},
|
|
},
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": cacheRef,
|
|
},
|
|
},
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ensureFile(t, filepath.Join(cacheOutDir, ocispecs.ImageIndexFile))
|
|
ensureFile(t, filepath.Join(cacheOutDir2, ocispecs.ImageIndexFile))
|
|
|
|
dgst := res.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
|
|
uniqueFile, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst, "/unique")
|
|
require.NoError(t, err)
|
|
|
|
res, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target2,
|
|
"push": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst2 := res.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, dgst, dgst2)
|
|
|
|
destDir := t.TempDir()
|
|
ensurePruneAll(t, c, sb)
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: destDir,
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": cacheRef,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ensureFileContents(t, filepath.Join(destDir, "const"), integration.UnixOrWindows("foobar", "foobar \r\n"))
|
|
ensureFileContents(t, filepath.Join(destDir, "unique"), string(uniqueFile))
|
|
}
|
|
|
|
func testMultipleRecordsWithSameLayersCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
cacheOpts := []CacheOptionsEntry{{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}}
|
|
|
|
requiresLinux(t)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
base := llb.Image("busybox:latest")
|
|
// layerA and layerB create identical layers with different LLB
|
|
layerA := base.Run(llb.Args([]string{
|
|
"sh", "-c",
|
|
`echo $(( 1 + 2 )) > /result && touch -d "1970-01-01 00:00:00" /result`,
|
|
})).Root()
|
|
layerB := base.Run(llb.Args([]string{
|
|
"sh", "-c",
|
|
`echo $(( 2 + 1 )) > /result && touch -d "1970-01-01 00:00:00" /result`,
|
|
})).Root()
|
|
|
|
combined := llb.Merge([]llb.State{layerA, layerB})
|
|
combinedDef, err := combined.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), combinedDef, SolveOpt{
|
|
CacheExports: cacheOpts,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
singleDef, err := layerA.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), singleDef, SolveOpt{
|
|
CacheImports: cacheOpts,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure that even though layerA and layerB were both loaded as possible results
|
|
// and only was used, all the cache refs are released
|
|
// More context: https://github.com/moby/buildkit/pull/3815
|
|
ensurePruneAll(t, c, sb)
|
|
}
|
|
|
|
// testRemoteCacheSharedMergeBranches verifies remote-cache reuse when distinct
|
|
// merge branches produce byte-identical content. After export, it prunes local
|
|
// endpoint records and checks that imported cache preserves random markers.
|
|
// Previously, a leftover nil in-progress marker in addItemToStorage's per-leaf
|
|
// visited map could silently drop a shared link and rerun a marker operation.
|
|
func testRemoteCacheSharedMergeBranches(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
workers.FeatureMergeDiff,
|
|
)
|
|
requiresLinux(t)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
base := llb.Image("busybox:latest")
|
|
// These operations have distinct cache keys but produce byte-identical
|
|
// snapshots. The test depends on this content collision making the cache
|
|
// graph reconverge; repeated copy and merge operations preserve the
|
|
// identical content while giving it distinct dependency chains.
|
|
sameA := base.Run(llb.Args([]string{
|
|
"sh", "-c",
|
|
`echo $(( 1 + 2 )) > /value && touch -d "1970-01-01 00:00:00" /value`,
|
|
})).Root()
|
|
sameB := base.Run(llb.Args([]string{
|
|
"sh", "-c",
|
|
`echo $(( 2 + 1 )) > /value && touch -d "1970-01-01 00:00:00" /value`,
|
|
})).Root()
|
|
|
|
copyValue := func(src llb.State) llb.State {
|
|
return llb.Scratch().File(llb.Copy(src, "/value", "/value"))
|
|
}
|
|
copiedA := copyValue(sameA)
|
|
copiedB := copyValue(sameB)
|
|
mergedA := llb.Merge([]llb.State{copiedA, copiedB})
|
|
mergedB := llb.Merge([]llb.State{copiedB, copiedA})
|
|
deepA := copyValue(mergedA)
|
|
deepB := copyValue(mergedB)
|
|
|
|
// A cache miss after import is observable because rerunning either command
|
|
// changes its marker. The final state retains both branches independently.
|
|
randomA := base.Run(
|
|
llb.Shlex("sh -c 'test -f /input/value && head -c 100 /dev/urandom | sha256sum > /random-a'"),
|
|
llb.AddMount("/input", deepA, llb.Readonly),
|
|
).Root()
|
|
randomB := base.Run(
|
|
llb.Shlex("sh -c 'test -f /input/value && head -c 100 /dev/urandom | sha256sum > /random-b'"),
|
|
llb.AddMount("/input", deepB, llb.Readonly),
|
|
).Root()
|
|
final := llb.Scratch().
|
|
File(llb.Copy(randomA, "/random-a", "/random-a")).
|
|
File(llb.Copy(randomB, "/random-b", "/random-b"))
|
|
|
|
def, err := final.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
cache := []CacheOptionsEntry{{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": registry + "/buildkit/testremotecachesharedmergebranches:latest",
|
|
"mode": "max",
|
|
},
|
|
}}
|
|
|
|
firstOutput := t.TempDir()
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: firstOutput}},
|
|
CacheExports: cache,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
randomAContents, err := os.ReadFile(filepath.Join(firstOutput, "random-a"))
|
|
require.NoError(t, err)
|
|
randomBContents, err := os.ReadFile(filepath.Join(firstOutput, "random-b"))
|
|
require.NoError(t, err)
|
|
|
|
for i := range 20 {
|
|
// Drop local results so the endpoint records must be recovered from
|
|
// the exported cache on every solve.
|
|
require.Eventually(t, func() bool {
|
|
if err := c.Prune(sb.Context(), nil, PruneAll); err != nil {
|
|
return false
|
|
}
|
|
usage, err := c.DiskUsage(sb.Context())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, record := range usage {
|
|
if strings.Contains(record.Description, "random-a") || strings.Contains(record.Description, "random-b") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}, 30*time.Second, 500*time.Millisecond, "random endpoint cache records were not pruned")
|
|
|
|
output := t.TempDir()
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: output}},
|
|
CacheImports: cache,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
actualA, err := os.ReadFile(filepath.Join(output, "random-a"))
|
|
require.NoError(t, err)
|
|
require.Equalf(t, randomAContents, actualA, "iteration %d: random-a was recomputed", i)
|
|
actualB, err := os.ReadFile(filepath.Join(output, "random-b"))
|
|
require.NoError(t, err)
|
|
require.Equalf(t, randomBContents, actualB, "iteration %d: random-b was recomputed", i)
|
|
}
|
|
}
|
|
|
|
func testMultipleRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
o := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}
|
|
o2 := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target + "notexist",
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{o, o2}, []CacheOptionsEntry{o})
|
|
}
|
|
|
|
func testRegistryEmptyCacheExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
|
|
for _, ociMediaTypes := range []bool{true, false} {
|
|
for _, imageManifest := range []bool{true, false} {
|
|
if imageManifest && !ociMediaTypes {
|
|
// invalid configuration for remote cache
|
|
continue
|
|
}
|
|
|
|
t.Run(t.Name()+fmt.Sprintf("/ociMediaTypes=%t/imageManifest=%t", ociMediaTypes, imageManifest), func(t *testing.T) {
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
st := llb.Scratch()
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
cacheTarget := registry + "/buildkit/testregistryemptycache:latest"
|
|
|
|
cacheOptionsEntry := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": cacheTarget,
|
|
"image-manifest": strconv.FormatBool(imageManifest),
|
|
"oci-mediatypes": strconv.FormatBool(ociMediaTypes),
|
|
},
|
|
}
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
CacheExports: []CacheOptionsEntry{cacheOptionsEntry},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ctx := namespaces.WithNamespace(sb.Context(), "buildkit")
|
|
cdAddress := sb.ContainerdAddress()
|
|
var client *ctd.Client
|
|
if cdAddress != "" {
|
|
client, err = newContainerd(cdAddress)
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
|
|
_, err := client.Fetch(ctx, cacheTarget)
|
|
require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// testSnapshotWithMultipleBlobs verifies that BuildKit handles a single snapshot with multiple
|
|
// compressed blob representations. It pushes the same layer with compression levels 0 and 9,
|
|
// producing different blob digests, then pulls and exports both images to ensure no conflicts.
|
|
// moby/buildkit#3809
|
|
func testSnapshotWithMultipleBlobs(t *testing.T, sb integration.Sandbox) {
|
|
// TODO: Re-enable on Windows once the HCS layer activation race is fixed upstream.
|
|
// Passes locally but fails on GitHub Actions with: mkdir ...\ProgramData\Microsoft:
|
|
// Cannot create a file when that file already exists. The bug is in the Windows
|
|
// snapshotter/differ layer activation path, not in this test.
|
|
integration.SkipOnPlatform(t, "windows", "HCS layer activation race: concurrent mkdir fails on CI (passes locally)")
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
// build two images with same layer but different compressed blobs
|
|
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
|
|
imgName := integration.UnixOrWindows("alpine", "nanoserver")
|
|
// On Windows:
|
|
// - llb.Scratch() doesn't work (Windows snapshotter requires a base OS layer)
|
|
// - llb.Copy across images doesn't work (dual mount: "number of mounts should always be 1")
|
|
// So we use llb.Image(nanoserver).File(llb.Mkfile(...)) which only needs one mount.
|
|
// The nanoserver base layers appear before our layer, so the final assertion
|
|
// compares a layer index that skips the base layers.
|
|
st := integration.UnixOrWindows(
|
|
llb.Scratch().File(llb.Copy(llb.Image(imgName), "/", "/"+imgName+"/", llb.WithCreatedTime(now))),
|
|
llb.Image(imgName).File(llb.Mkfile("/testdata", 0600, []byte("snapshot-test-content"), llb.WithCreatedTime(now))),
|
|
)
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
name1 := registry + "/multiblobtest1/image:latest"
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: "image",
|
|
Attrs: map[string]string{
|
|
"name": name1,
|
|
"push": "true",
|
|
"compression-level": "0",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
st = st.File(llb.Mkfile("test", 0600, []byte("test"))) // extra layer so we don't get a cache match based on image config rootfs only
|
|
|
|
def, err = st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
name2 := registry + "/multiblobtest2/image:latest"
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: "image",
|
|
Attrs: map[string]string{
|
|
"name": name2,
|
|
"push": "true",
|
|
"compression-level": "9",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
// first build with first image
|
|
destDir := t.TempDir()
|
|
|
|
out1 := filepath.Join(destDir, "out1.tar")
|
|
outW1, err := os.Create(out1)
|
|
require.NoError(t, err)
|
|
|
|
st = llb.Image(name1).File(llb.Mkfile("test", 0600, []byte("test1")))
|
|
|
|
def, err = st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterOCI, // make sure to export so blobs need to be loaded
|
|
Output: fixedWriteCloser(outW1),
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// make sure second image does not cause any errors
|
|
out2 := filepath.Join(destDir, "out2.tar")
|
|
outW2, err := os.Create(out2)
|
|
require.NoError(t, err)
|
|
|
|
st = llb.Image(name2).File(llb.Mkfile("test", 0600, []byte("test2")))
|
|
|
|
def, err = st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
FrontendAttrs: integration.UnixOrWindows(
|
|
map[string]string{"attest:sbom": ""},
|
|
map[string]string{},
|
|
),
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterOCI, // make sure to export so blobs need to be loaded
|
|
Output: fixedWriteCloser(outW2),
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// extra validation that we did get different layer blobs
|
|
dt, err := os.ReadFile(out1)
|
|
require.NoError(t, err)
|
|
|
|
m, err := testutil.ReadTarToMap(dt, false)
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index)
|
|
require.NoError(t, err)
|
|
|
|
var mfst ocispecs.Manifest
|
|
err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst)
|
|
require.NoError(t, err)
|
|
|
|
dt, err = os.ReadFile(out2)
|
|
require.NoError(t, err)
|
|
|
|
m, err = testutil.ReadTarToMap(dt, false)
|
|
require.NoError(t, err)
|
|
|
|
err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index)
|
|
require.NoError(t, err)
|
|
|
|
err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &index)
|
|
require.NoError(t, err)
|
|
|
|
var mfst2 ocispecs.Manifest
|
|
err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst2)
|
|
require.NoError(t, err)
|
|
|
|
// On Linux, Layers[0] is the layer we created (copy from alpine).
|
|
// On Windows, the nanoserver base layer(s) come first, so our layer is at a later index.
|
|
// We find the first layer that differs between the two manifests — that's the layer
|
|
// pushed with different compression levels.
|
|
layerIdx := integration.UnixOrWindows(0, len(mfst.Layers)-2)
|
|
require.NotEqual(t, mfst.Layers[layerIdx].Digest, mfst2.Layers[layerIdx].Digest)
|
|
}
|
|
|
|
func testUncompressedLocalCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendLocal,
|
|
)
|
|
dir := t.TempDir()
|
|
im := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"src": dir,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": dir,
|
|
"compression": "uncompressed",
|
|
"force-compression": "true",
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
func testUncompressedRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
im := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
"compression": "uncompressed",
|
|
"force-compression": "true",
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
// TODO: Re-enable the skipped test on Windows (see issue #6296)
|
|
func testZstdLocalCacheExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows", "This test passed locally on windows, but failed on github action")
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheExport, workers.FeatureCacheBackendLocal)
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest")
|
|
cmdStr := integration.UnixOrWindows(
|
|
`sh -e -c "echo -n zstd > data"`,
|
|
`cmd /C "echo zstd > C:\data"`,
|
|
)
|
|
|
|
st := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName))
|
|
st = llb.Image(imgName).Run(llb.Shlex(cmdStr), llb.Dir("/wd")).AddMount("/wd", st)
|
|
|
|
def, err := st.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
|
|
destDir := t.TempDir()
|
|
destOutDir := t.TempDir()
|
|
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterLocal,
|
|
OutputDir: destOutDir,
|
|
},
|
|
},
|
|
// compression option should work even with inline cache exports
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": destDir,
|
|
"image-manifest": "false",
|
|
"compression": "zstd",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
dt, err := os.ReadFile(filepath.Join(destDir, ocispecs.ImageIndexFile))
|
|
require.NoError(t, err)
|
|
err = json.Unmarshal(dt, &index)
|
|
require.NoError(t, err)
|
|
|
|
var layerIndex ocispecs.Index
|
|
dt, err = os.ReadFile(filepath.Join(destDir, ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()))
|
|
require.NoError(t, err)
|
|
err = json.Unmarshal(dt, &layerIndex)
|
|
require.NoError(t, err)
|
|
|
|
lastLayer := layerIndex.Manifests[len(layerIndex.Manifests)-2]
|
|
require.Equal(t, ocispecs.MediaTypeImageLayerZstd, lastLayer.MediaType)
|
|
|
|
zstdLayerDigest := lastLayer.Digest.Hex()
|
|
dt, err = os.ReadFile(filepath.Join(destDir, ocispecs.ImageBlobsDir+"/sha256/"+zstdLayerDigest))
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte{0x28, 0xb5, 0x2f, 0xfd}, dt[:4])
|
|
}
|
|
|
|
func testZstdLocalCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendLocal,
|
|
)
|
|
dir := t.TempDir()
|
|
im := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"src": dir,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "local",
|
|
Attrs: map[string]string{
|
|
"dest": dir,
|
|
"compression": "zstd",
|
|
"force-compression": "true",
|
|
"oci-mediatypes": "true", // containerd applier supports only zstd with oci-mediatype.
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
// TODO: Re-enable the skipped test on Windows (see issue #6296)
|
|
func testZstdRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
integration.SkipOnPlatform(t, "windows", "This test passed locally on windows, but failed on github action")
|
|
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testexport:latest"
|
|
im := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
},
|
|
}
|
|
ex := CacheOptionsEntry{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": target,
|
|
"compression": "zstd",
|
|
"force-compression": "true",
|
|
"image-manifest": "false",
|
|
"oci-mediatypes": "true", // containerd applier supports only zstd with oci-mediatype.
|
|
},
|
|
}
|
|
testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex})
|
|
}
|
|
|
|
func testStargzLazyInlineCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheImport,
|
|
workers.FeatureCacheBackendInline,
|
|
workers.FeatureCacheBackendRegistry,
|
|
)
|
|
requiresLinux(t)
|
|
cdAddress := sb.ContainerdAddress()
|
|
if cdAddress == "" || sb.Snapshotter() != "stargz" {
|
|
t.Skip("test requires containerd worker with stargz snapshotter")
|
|
}
|
|
|
|
client, err := newContainerd(cdAddress)
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
var (
|
|
imageService = client.ImageService()
|
|
contentStore = client.ContentStore()
|
|
ctx = namespaces.WithNamespace(sb.Context(), "buildkit")
|
|
)
|
|
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
// Prepare stargz inline cache
|
|
orgImage := "docker.io/library/alpine:latest"
|
|
sgzImage := registry + "/stargz/alpine:" + identity.NewID()
|
|
baseDef := llb.Image(orgImage).Run(llb.Args([]string{"/bin/touch", "/foo"}))
|
|
def, err := baseDef.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": sgzImage,
|
|
"push": "true",
|
|
"compression": "estargz",
|
|
"oci-mediatypes": "true",
|
|
"force-compression": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// clear all local state out
|
|
err = imageService.Delete(ctx, sgzImage, images.SynchronousDelete())
|
|
require.NoError(t, err)
|
|
checkAllReleasable(t, c, sb, true)
|
|
|
|
// stargz layers should be lazy even for executing something on them
|
|
def, err = baseDef.
|
|
Run(llb.Args([]string{"/bin/touch", "/bar"})).
|
|
Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testlazyimage:" + identity.NewID()
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
"oci-mediatypes": "true",
|
|
"compression": "estargz",
|
|
"store": "true",
|
|
"unsafe-internal-store-allow-incomplete": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "inline",
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzImage,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
img, err := imageService.Get(ctx, target)
|
|
require.NoError(t, err)
|
|
|
|
manifest, err := images.Manifest(ctx, contentStore, img.Target, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Check if image layers are lazy.
|
|
// The topmost(last) layer created by `Run` isn't lazy so we skip the check for the layer.
|
|
var sgzLayers []ocispecs.Descriptor
|
|
for i, layer := range manifest.Layers[:len(manifest.Layers)-1] {
|
|
_, err = contentStore.Info(ctx, layer.Digest)
|
|
require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v on layer %+v (%d)", err, layer, i)
|
|
sgzLayers = append(sgzLayers, layer)
|
|
}
|
|
require.NotEqual(t, 0, len(sgzLayers), "no layer can be used for checking lazypull")
|
|
|
|
// The topmost(last) layer created by `Run` shouldn't be lazy
|
|
_, err = contentStore.Info(ctx, manifest.Layers[len(manifest.Layers)-1].Digest)
|
|
require.NoError(t, err)
|
|
|
|
// clear all local state out
|
|
err = imageService.Delete(ctx, img.Name, images.SynchronousDelete())
|
|
require.NoError(t, err)
|
|
checkAllReleasable(t, c, sb, true)
|
|
|
|
// stargz layers should be exportable
|
|
destDir := t.TempDir()
|
|
out := filepath.Join(destDir, "out.tar")
|
|
outW, err := os.Create(out)
|
|
require.NoError(t, err)
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterOCI,
|
|
Output: fixedWriteCloser(outW),
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzImage,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Check if image layers are un-lazied
|
|
for _, layer := range sgzLayers {
|
|
_, err = contentStore.Info(ctx, layer.Digest)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
}
|
|
|
|
func testStargzLazyRegistryCacheImportExport(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb,
|
|
workers.FeatureCacheExport,
|
|
workers.FeatureCacheBackendRegistry,
|
|
workers.FeatureOCIExporter,
|
|
)
|
|
requiresLinux(t)
|
|
cdAddress := sb.ContainerdAddress()
|
|
if cdAddress == "" || sb.Snapshotter() != "stargz" {
|
|
t.Skip("test requires containerd worker with stargz snapshotter")
|
|
}
|
|
|
|
client, err := newContainerd(cdAddress)
|
|
require.NoError(t, err)
|
|
defer client.Close()
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
var (
|
|
imageService = client.ImageService()
|
|
contentStore = client.ContentStore()
|
|
ctx = namespaces.WithNamespace(sb.Context(), "buildkit")
|
|
)
|
|
|
|
c, err := New(sb.Context(), sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
destDir := t.TempDir()
|
|
|
|
// Prepare stargz registry cache
|
|
orgImage := "docker.io/library/alpine:latest"
|
|
sgzCache := registry + "/stargz/alpinecache:" + identity.NewID()
|
|
baseDef := llb.Image(orgImage).Run(llb.Args([]string{"/bin/touch", "/foo"}))
|
|
def, err := baseDef.Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
out := filepath.Join(destDir, "out.tar")
|
|
outW, err := os.Create(out)
|
|
require.NoError(t, err)
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterOCI,
|
|
Output: fixedWriteCloser(outW),
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzCache,
|
|
"compression": "estargz",
|
|
"oci-mediatypes": "true",
|
|
"force-compression": "true",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// clear all local state out
|
|
ensurePruneAll(t, c, sb)
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureCacheImport, workers.FeatureDirectPush)
|
|
|
|
// stargz layers should be lazy even for executing something on them
|
|
def, err = baseDef.
|
|
Run(llb.Args([]string{"sh", "-c", "cat /dev/urandom | head -c 100 | sha256sum > unique"})).
|
|
Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
target := registry + "/buildkit/testlazyimage:" + identity.NewID()
|
|
resp, err := c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
"store": "true",
|
|
"oci-mediatypes": "true",
|
|
"unsafe-internal-store-allow-incomplete": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzCache,
|
|
},
|
|
},
|
|
},
|
|
CacheExports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzCache,
|
|
"compression": "estargz",
|
|
"oci-mediatypes": "true",
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
unique, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst, "/unique")
|
|
require.NoError(t, err)
|
|
|
|
img, err := imageService.Get(ctx, target)
|
|
require.NoError(t, err)
|
|
|
|
manifest, err := images.Manifest(ctx, contentStore, img.Target, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Check if image layers are lazy.
|
|
// The topmost(last) layer created by `Run` isn't lazy so we skip the check for the layer.
|
|
var sgzLayers []ocispecs.Descriptor
|
|
for i, layer := range manifest.Layers[:len(manifest.Layers)-1] {
|
|
_, err = contentStore.Info(ctx, layer.Digest)
|
|
require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v on layer %+v (%d)", err, layer, i)
|
|
sgzLayers = append(sgzLayers, layer)
|
|
}
|
|
require.NotEqual(t, 0, len(sgzLayers), "no layer can be used for checking lazypull")
|
|
|
|
// The topmost(last) layer created by `Run` shouldn't be lazy
|
|
_, err = contentStore.Info(ctx, manifest.Layers[len(manifest.Layers)-1].Digest)
|
|
require.NoError(t, err)
|
|
|
|
// Run build again and check if cache is reused
|
|
resp, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": target,
|
|
"push": "true",
|
|
"store": "true",
|
|
"oci-mediatypes": "true",
|
|
"unsafe-internal-store-allow-incomplete": "true",
|
|
},
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzCache,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
dgst2, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey]
|
|
require.Equal(t, true, ok)
|
|
|
|
unique2, err := readFileInImage(sb.Context(), t, c, target+"@"+dgst2, "/unique")
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, dgst, dgst2)
|
|
require.Equal(t, unique, unique2)
|
|
|
|
// clear all local state out
|
|
err = imageService.Delete(ctx, img.Name, images.SynchronousDelete())
|
|
require.NoError(t, err)
|
|
checkAllReleasable(t, c, sb, true)
|
|
|
|
// stargz layers should be exportable
|
|
out = filepath.Join(destDir, "out2.tar")
|
|
outW, err = os.Create(out)
|
|
require.NoError(t, err)
|
|
_, err = c.Solve(sb.Context(), def, SolveOpt{
|
|
Exports: []ExportEntry{
|
|
{
|
|
Type: ExporterOCI,
|
|
Output: fixedWriteCloser(outW),
|
|
},
|
|
},
|
|
CacheImports: []CacheOptionsEntry{
|
|
{
|
|
Type: "registry",
|
|
Attrs: map[string]string{
|
|
"ref": sgzCache,
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Check if image layers are un-lazied
|
|
for _, layer := range sgzLayers {
|
|
_, err = contentStore.Info(ctx, layer.Digest)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
ensurePruneAll(t, c, sb)
|
|
}
|