mirror of
https://github.com/moby/buildkit.git
synced 2026-08-05 07:10:23 +00:00
991 lines
30 KiB
Go
991 lines
30 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/containerd/containerd/v2/core/content"
|
|
"github.com/containerd/containerd/v2/core/images"
|
|
"github.com/containerd/platforms"
|
|
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
|
"github.com/moby/buildkit/client/llb"
|
|
"github.com/moby/buildkit/client/llb/sourceresolver"
|
|
"github.com/moby/buildkit/exporter/containerimage/exptypes"
|
|
gateway "github.com/moby/buildkit/frontend/gateway/client"
|
|
solvererrdefs "github.com/moby/buildkit/solver/errdefs"
|
|
"github.com/moby/buildkit/solver/llbsolver/compat"
|
|
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
|
|
"github.com/moby/buildkit/util/contentutil"
|
|
"github.com/moby/buildkit/util/grpcerrors"
|
|
"github.com/moby/buildkit/util/testutil/httpserver"
|
|
"github.com/moby/buildkit/util/testutil/integration"
|
|
"github.com/moby/buildkit/util/testutil/workers"
|
|
policyimage "github.com/moby/policy-helpers/image"
|
|
digest "github.com/opencontainers/go-digest"
|
|
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/pkg/errors"
|
|
"github.com/pmezard/go-difflib/difflib"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const compatibilityUpdateEnv = "BUILDKIT_UPDATE_COMPAT_GOLDENS"
|
|
const compatibilityExpectedVersionEnv = "BUILDKIT_TEST_EXPECTED_COMPATIBILITY_VERSION"
|
|
const compatibilityEpoch = "1445412480"
|
|
const compatibilityPlatformString = "linux/amd64"
|
|
const compatibilityBusyboxMirrorRef = "busybox_amd64:latest"
|
|
const compatibilityBusyboxImageRef = "docker.io/library/" + compatibilityBusyboxMirrorRef
|
|
|
|
//go:embed testdata/compatibility
|
|
var compatibilityGoldens embed.FS
|
|
|
|
type compatibilityLayerExpectation struct {
|
|
MediaType string
|
|
Digest string
|
|
}
|
|
|
|
type compatibilityCase struct {
|
|
Name string
|
|
Attrs map[string]string
|
|
AttestProvenance bool
|
|
TouchExecOutputs bool
|
|
}
|
|
|
|
type compatibilityActual struct {
|
|
ManifestDigest string
|
|
ConfigDigest string
|
|
Layers []compatibilityLayerExpectation
|
|
ManifestBytes []byte
|
|
ConfigBytes []byte
|
|
ManifestJSON string
|
|
ConfigJSON string
|
|
}
|
|
|
|
var compatibilityCases = []compatibilityCase{
|
|
{
|
|
Name: "default-gzip",
|
|
Attrs: map[string]string{
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "gzip-level-0",
|
|
Attrs: map[string]string{
|
|
"compression": "gzip",
|
|
"compression-level": "0",
|
|
"force-compression": "true",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "gzip-level-9",
|
|
Attrs: map[string]string{
|
|
"compression": "gzip",
|
|
"compression-level": "9",
|
|
"force-compression": "true",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "uncompressed",
|
|
Attrs: map[string]string{
|
|
"compression": "uncompressed",
|
|
"force-compression": "true",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "zstd-oci-types",
|
|
Attrs: map[string]string{
|
|
"compression": "zstd",
|
|
"compression-level": "12",
|
|
"force-compression": "true",
|
|
"oci-mediatypes": "true",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "manifest-annotation",
|
|
Attrs: map[string]string{
|
|
"annotation-manifest.org.opencontainers.image.title": "compatibility-test",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "oci-mediatypes",
|
|
Attrs: map[string]string{
|
|
"oci-mediatypes": "true",
|
|
"force-compression": "true",
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
},
|
|
{
|
|
Name: "provenance-attestation",
|
|
Attrs: map[string]string{
|
|
"source-date-epoch": compatibilityEpoch,
|
|
"rewrite-timestamp": "true",
|
|
},
|
|
AttestProvenance: true,
|
|
},
|
|
}
|
|
|
|
func TestCompatibilityIntegration(t *testing.T) {
|
|
integration.SkipOnPlatform(t, "windows")
|
|
mirrors := integration.WithMirroredImages(map[string]string{
|
|
"library/" + compatibilityBusyboxMirrorRef: "docker.io/amd64/busybox:latest@sha256:023917ec6a886d0e8e15f28fb543515a5fcd8d938edb091e8147db4efed388ee",
|
|
})
|
|
integration.Run(t, integration.TestFuncs(
|
|
testImageExporterCompatibilityVersion,
|
|
testOCIExporterCompatibilityVersion,
|
|
testImageExporterCompatibilityVersionProvenance,
|
|
), mirrors)
|
|
}
|
|
|
|
func testImageExporterCompatibilityVersion(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance, workers.FeatureSourceDateEpoch)
|
|
requiresLinux(t)
|
|
|
|
ctx := sb.Context()
|
|
c, err := New(ctx, sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
platform := compatibilityPlatform(t)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
baseRef, baseConfig := createCompatibilityBaseImage(ctx, t, c, registry, platform)
|
|
|
|
expectedSet := compatibilityExpectedVersionIsSet()
|
|
for _, version := range compatibilityVersionsUnderTest(t) {
|
|
ensurePruneAll(t, c, sb)
|
|
for _, tc := range compatibilityCases {
|
|
t.Run(fmt.Sprintf("image/%s/%s", tc.Name, compatibilityRunName(version, expectedSet)), func(t *testing.T) {
|
|
def := createCompatibilityDefinition(t, sb, baseRef, platform, tc)
|
|
actual, err := exportCompatibilityImageCase(ctx, t, c, def, registry, tc, version, expectedSet, platform, baseConfig)
|
|
assertCompatibilityCaseResult(t, ExporterImage, tc, version, expectedSet, actual, err)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func testOCIExporterCompatibilityVersion(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureProvenance, workers.FeatureSourceDateEpoch)
|
|
requiresLinux(t)
|
|
|
|
ctx := sb.Context()
|
|
c, err := New(ctx, sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
platform := compatibilityPlatform(t)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
baseRef, baseConfig := createCompatibilityBaseImage(ctx, t, c, registry, platform)
|
|
|
|
expectedSet := compatibilityExpectedVersionIsSet()
|
|
for _, version := range compatibilityVersionsUnderTest(t) {
|
|
ensurePruneAll(t, c, sb)
|
|
for _, tc := range compatibilityCases {
|
|
t.Run(fmt.Sprintf("oci/%s/%s", tc.Name, compatibilityRunName(version, expectedSet)), func(t *testing.T) {
|
|
def := createCompatibilityDefinition(t, sb, baseRef, platform, tc)
|
|
actual, err := exportCompatibilityOCICase(ctx, t, c, def, tc, version, expectedSet, platform, baseConfig)
|
|
assertCompatibilityCaseResult(t, ExporterOCI, tc, version, expectedSet, actual, err)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func testImageExporterCompatibilityVersionProvenance(t *testing.T, sb integration.Sandbox) {
|
|
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance, workers.FeatureSourceDateEpoch)
|
|
requiresLinux(t)
|
|
|
|
if compatibilityExpectedVersionIsSet() {
|
|
t.Skip("provenance compatibility assertion requires explicit compatibility-version request")
|
|
}
|
|
|
|
ctx := sb.Context()
|
|
c, err := New(ctx, sb.Address())
|
|
require.NoError(t, err)
|
|
defer c.Close()
|
|
|
|
platform := compatibilityPlatform(t)
|
|
registry, err := sb.NewRegistry()
|
|
if errors.Is(err, integration.ErrRequirements) {
|
|
t.Skip(err.Error())
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
baseRef, baseConfig := createCompatibilityBaseImage(ctx, t, c, registry, platform)
|
|
ensurePruneAll(t, c, sb)
|
|
|
|
var tc compatibilityCase
|
|
for _, candidate := range compatibilityCases {
|
|
if candidate.Name == "default-gzip" {
|
|
tc = candidate
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, tc.Name)
|
|
|
|
def := createCompatibilityDefinition(t, sb, baseRef, platform, tc)
|
|
target := fmt.Sprintf("%s/buildkit/compatibility-provenance-v10:latest", registry)
|
|
attrs := cloneStringMap(tc.Attrs)
|
|
attrs["name"] = target
|
|
attrs["push"] = "true"
|
|
attrs[exptypes.ExporterImageConfigKey] = string(baseConfig)
|
|
|
|
err = solveCompatibilityWithPlatform(ctx, c, def, SolveOpt{
|
|
CompatibilityVersion: compat.CompatibilityVersion013,
|
|
FrontendAttrs: map[string]string{
|
|
"attest:provenance": "mode=max,version=v1",
|
|
},
|
|
Exports: []ExportEntry{{
|
|
Type: ExporterImage,
|
|
Attrs: attrs,
|
|
}},
|
|
}, platform)
|
|
require.NoError(t, err)
|
|
|
|
pr, err := readImageCompatibilityProvenance(ctx, target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, compat.CompatibilityVersion013, pr.BuildDefinition.ExternalParameters.Request.CompatibilityVersion)
|
|
}
|
|
|
|
func compatibilityVersionsUnderTest(t *testing.T) []int {
|
|
t.Helper()
|
|
|
|
versionStr := os.Getenv(compatibilityExpectedVersionEnv)
|
|
if versionStr == "" {
|
|
return compat.SupportedCompatibilityVersions()
|
|
}
|
|
|
|
version, err := strconv.Atoi(versionStr)
|
|
require.NoError(t, err)
|
|
require.NoError(t, compat.ValidateCompatibilityVersion(version))
|
|
return []int{version}
|
|
}
|
|
|
|
func compatibilityExpectedVersionIsSet() bool {
|
|
return os.Getenv(compatibilityExpectedVersionEnv) != ""
|
|
}
|
|
|
|
func compatibilityRunName(version int, expectedSet bool) string {
|
|
if expectedSet {
|
|
return "default"
|
|
}
|
|
return "v" + strconv.Itoa(version)
|
|
}
|
|
|
|
func createCompatibilityBaseImage(ctx context.Context, t *testing.T, c *Client, registry string, platform ocispecs.Platform) (string, []byte) {
|
|
t.Helper()
|
|
|
|
baseRef := fmt.Sprintf("%s/buildkit/compatibility-base:latest", registry)
|
|
_, err := c.Build(ctx, SolveOpt{
|
|
Exports: []ExportEntry{{
|
|
Type: ExporterImage,
|
|
Attrs: map[string]string{
|
|
"name": baseRef,
|
|
"push": "true",
|
|
"store": "false",
|
|
},
|
|
}},
|
|
}, "", func(ctx context.Context, gw gateway.Client) (*gateway.Result, error) {
|
|
baseConfig, err := resolveCompatibilityImageConfig(ctx, gw, platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base, err := llb.Image(compatibilityBusyboxImageRef, llb.Platform(platform)).WithImageConfig(baseConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
base = base.File(
|
|
// Precreate runtime mountpoints so the exec diff is based on the
|
|
// generated files, not on the rootless vs non-rootless /sys stub
|
|
// difference in the runtime snapshot.
|
|
llb.Mkdir("/proc", 0o755, llb.WithParents(true)).
|
|
Mkdir("/sys", 0o755, llb.WithParents(true)).
|
|
Mkdir("/base", 0o755, llb.WithParents(true)).
|
|
Mkfile("/base/base.txt", 0o644, []byte("compatibility base\n")),
|
|
)
|
|
|
|
def, err := base.Marshal(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r, err := gw.Solve(ctx, gateway.SolveRequest{
|
|
Definition: def.ToPB(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref, err := r.SingleRef()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := gateway.NewResult()
|
|
res.SetRef(ref)
|
|
res.AddMeta(exptypes.ExporterImageConfigKey, baseConfig)
|
|
return res, nil
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
desc, provider, err := contentutil.ProviderFromRef(baseRef)
|
|
require.NoError(t, err)
|
|
actual := readCompatibilityActualFromProvider(ctx, t, provider, desc)
|
|
|
|
return baseRef, actual.ConfigBytes
|
|
}
|
|
|
|
func resolveCompatibilityImageConfig(ctx context.Context, gw gateway.Client, platform ocispecs.Platform) ([]byte, error) {
|
|
_, _, baseConfig, err := gw.ResolveImageConfig(ctx, compatibilityBusyboxImageRef, sourceresolver.Opt{
|
|
ImageOpt: &sourceresolver.ResolveImageOpt{
|
|
Platform: &platform,
|
|
},
|
|
})
|
|
return baseConfig, err
|
|
}
|
|
|
|
func createCompatibilityDefinition(t *testing.T, sb integration.Sandbox, baseRef string, platform ocispecs.Platform, tc compatibilityCase) *llb.Definition {
|
|
t.Helper()
|
|
|
|
httpModTime := time.Date(2021, time.January, 2, 3, 4, 5, 0, time.UTC)
|
|
httpSrv := httpserver.NewTestServer(map[string]*httpserver.Response{
|
|
"/artifact.txt": {
|
|
Content: []byte("http fixture\n"),
|
|
LastModified: &httpModTime,
|
|
},
|
|
})
|
|
t.Cleanup(httpSrv.Close)
|
|
|
|
gitDir := t.TempDir()
|
|
err := runInDirEnv(gitDir, []string{
|
|
"GIT_AUTHOR_DATE=2020-01-02T03:04:05Z",
|
|
"GIT_COMMITTER_DATE=2020-01-02T03:04:05Z",
|
|
}, []string{
|
|
"git init",
|
|
"git config --local user.email test@example.com",
|
|
"git config --local user.name test",
|
|
"git checkout -b main",
|
|
"mkdir -p sub",
|
|
"printf 'git fixture\\n' > git.txt",
|
|
"printf 'nested fixture\\n' > sub/nested.txt",
|
|
"git add .",
|
|
"git commit -m fixture",
|
|
"git update-server-info",
|
|
}...)
|
|
require.NoError(t, err)
|
|
|
|
gitSrv := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir))))
|
|
t.Cleanup(gitSrv.Close)
|
|
|
|
httpState := llb.HTTP(httpSrv.URL+"/artifact.txt", llb.Filename("artifact.txt"))
|
|
gitState := llb.Git(gitSrv.URL+"/.git", "", llb.GitRef("main"))
|
|
|
|
st := llb.Image(baseRef, llb.Platform(platform)).File(
|
|
llb.Mkdir("/inputs/git", 0o755, llb.WithParents(true)).
|
|
Mkfile("/inputs/llb.txt", 0o644, []byte("llb fixture\n")).
|
|
Copy(httpState, "/artifact.txt", "/inputs/http.txt").
|
|
Copy(gitState, "/git.txt", "/inputs/git/git.txt").
|
|
Copy(gitState, "/sub/nested.txt", "/inputs/git/nested.txt"),
|
|
)
|
|
|
|
execScript := `sh -eux -c '
|
|
mkdir -p /generated/deeper
|
|
echo exec-fixture > /generated/exec.txt
|
|
echo nested-exec > /generated/deeper/nested.txt
|
|
`
|
|
if tc.TouchExecOutputs {
|
|
execScript += `touch -t 201903040506.07 /generated /generated/deeper
|
|
touch -t 201903040506.07 /generated/exec.txt /generated/deeper/nested.txt
|
|
`
|
|
}
|
|
execScript += `'`
|
|
|
|
run := st.Run(llb.Shlex(execScript))
|
|
|
|
def, err := run.Root().Marshal(sb.Context())
|
|
require.NoError(t, err)
|
|
return def
|
|
}
|
|
|
|
func exportCompatibilityImageCase(ctx context.Context, t *testing.T, c *Client, def *llb.Definition, registry string, tc compatibilityCase, version int, expectedSet bool, platform ocispecs.Platform, baseConfig []byte) (compatibilityActual, error) {
|
|
t.Helper()
|
|
|
|
target := fmt.Sprintf("%s/buildkit/compatibility-%s:%s", registry, sanitizeCompatibilityName(tc.Name), compatibilityRunName(version, expectedSet))
|
|
attrs := cloneStringMap(tc.Attrs)
|
|
attrs["name"] = target
|
|
attrs["push"] = "true"
|
|
|
|
if err := runCompatibilityExport(ctx, c, def, ExportEntry{
|
|
Type: ExporterImage,
|
|
Attrs: attrs,
|
|
}, tc, version, expectedSet, platform, baseConfig); err != nil {
|
|
return compatibilityActual{}, err
|
|
}
|
|
|
|
desc, provider, err := contentutil.ProviderFromRef(target)
|
|
if err != nil {
|
|
return compatibilityActual{}, err
|
|
}
|
|
return readCompatibilityActualFromProvider(ctx, t, provider, desc), nil
|
|
}
|
|
|
|
func exportCompatibilityOCICase(ctx context.Context, t *testing.T, c *Client, def *llb.Definition, tc compatibilityCase, version int, expectedSet bool, platform ocispecs.Platform, baseConfig []byte) (compatibilityActual, error) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
attrs := cloneStringMap(tc.Attrs)
|
|
attrs["tar"] = "false"
|
|
|
|
if err := runCompatibilityExport(ctx, c, def, ExportEntry{
|
|
Type: ExporterOCI,
|
|
OutputDir: dir,
|
|
Attrs: attrs,
|
|
}, tc, version, expectedSet, platform, baseConfig); err != nil {
|
|
return compatibilityActual{}, err
|
|
}
|
|
|
|
return readCompatibilityActualFromOCILayout(t, dir), nil
|
|
}
|
|
|
|
func runCompatibilityExport(ctx context.Context, c *Client, def *llb.Definition, export ExportEntry, tc compatibilityCase, version int, expectedSet bool, platform ocispecs.Platform, baseConfig []byte) error {
|
|
attrs := cloneStringMap(export.Attrs)
|
|
attrs[exptypes.ExporterImageConfigKey] = string(baseConfig)
|
|
export.Attrs = attrs
|
|
|
|
opt := SolveOpt{
|
|
Exports: []ExportEntry{export},
|
|
}
|
|
if tc.AttestProvenance {
|
|
opt.FrontendAttrs = map[string]string{
|
|
"attest:provenance": "mode=max,version=v1",
|
|
}
|
|
}
|
|
if !expectedSet {
|
|
opt.CompatibilityVersion = version
|
|
}
|
|
return solveCompatibilityWithPlatform(ctx, c, def, opt, platform)
|
|
}
|
|
|
|
func solveCompatibilityWithPlatform(ctx context.Context, c *Client, def *llb.Definition, opt SolveOpt, platform ocispecs.Platform) error {
|
|
platform = platforms.Normalize(platform)
|
|
platformKey := platforms.Format(platform)
|
|
|
|
_, err := c.Build(ctx, opt, "", func(ctx context.Context, gw gateway.Client) (*gateway.Result, error) {
|
|
r, err := gw.Solve(ctx, gateway.SolveRequest{
|
|
Definition: def.ToPB(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref, err := r.SingleRef()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res := gateway.NewResult()
|
|
res.SetRef(ref)
|
|
expPlatforms := &exptypes.Platforms{
|
|
Platforms: []exptypes.Platform{{ID: platformKey, Platform: platform}},
|
|
}
|
|
dt, err := json.Marshal(expPlatforms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.AddMeta(exptypes.ExporterPlatformsKey, dt)
|
|
return res, nil
|
|
}, nil)
|
|
return err
|
|
}
|
|
|
|
func readCompatibilityActualFromOCILayout(t *testing.T, dir string) compatibilityActual {
|
|
t.Helper()
|
|
|
|
indexDT, err := os.ReadFile(filepath.Join(dir, ocispecs.ImageIndexFile))
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
require.NoError(t, json.Unmarshal(indexDT, &index))
|
|
|
|
for _, desc := range index.Manifests {
|
|
if desc.Digest == "" {
|
|
continue
|
|
}
|
|
if desc.Platform != nil && desc.Platform.OS == "unknown" && desc.Platform.Architecture == "unknown" {
|
|
continue
|
|
}
|
|
manifestDT, err := os.ReadFile(filepath.Join(dir, ocispecs.ImageBlobsDir, desc.Digest.Algorithm().String(), desc.Digest.Encoded()))
|
|
require.NoError(t, err)
|
|
|
|
var manifest ocispecs.Manifest
|
|
require.NoError(t, json.Unmarshal(manifestDT, &manifest))
|
|
if manifest.Config.Digest == "" {
|
|
continue
|
|
}
|
|
if isCompatibilityAttestationManifest(manifest) {
|
|
continue
|
|
}
|
|
|
|
configDT, err := os.ReadFile(filepath.Join(dir, ocispecs.ImageBlobsDir, manifest.Config.Digest.Algorithm().String(), manifest.Config.Digest.Encoded()))
|
|
require.NoError(t, err)
|
|
|
|
return compatibilityActualFromBytes(manifestDT, configDT, manifest.Layers)
|
|
}
|
|
|
|
blobPaths, err := filepath.Glob(filepath.Join(dir, ocispecs.ImageBlobsDir, "*", "*"))
|
|
require.NoError(t, err)
|
|
sort.Strings(blobPaths)
|
|
for _, blobPath := range blobPaths {
|
|
manifestDT, err := os.ReadFile(blobPath)
|
|
require.NoError(t, err)
|
|
|
|
var manifest ocispecs.Manifest
|
|
if err := json.Unmarshal(manifestDT, &manifest); err != nil {
|
|
continue
|
|
}
|
|
if manifest.Config.Digest == "" {
|
|
continue
|
|
}
|
|
if isCompatibilityAttestationManifest(manifest) {
|
|
continue
|
|
}
|
|
|
|
configDT, err := os.ReadFile(filepath.Join(dir, ocispecs.ImageBlobsDir, manifest.Config.Digest.Algorithm().String(), manifest.Config.Digest.Encoded()))
|
|
require.NoError(t, err)
|
|
|
|
return compatibilityActualFromBytes(manifestDT, configDT, manifest.Layers)
|
|
}
|
|
|
|
t.Fatalf("missing platform manifest\nindex json:\n%s", normalizeJSON(indexDT))
|
|
return compatibilityActual{}
|
|
}
|
|
|
|
func compatibilityActualFromBytes(manifestDT, configDT []byte, layers []ocispecs.Descriptor) compatibilityActual {
|
|
out := compatibilityActual{
|
|
ManifestDigest: digest.FromBytes(manifestDT).String(),
|
|
ConfigDigest: digest.FromBytes(configDT).String(),
|
|
ManifestBytes: append([]byte(nil), manifestDT...),
|
|
ConfigBytes: append([]byte(nil), configDT...),
|
|
ManifestJSON: normalizeJSON(manifestDT),
|
|
ConfigJSON: normalizeJSON(configDT),
|
|
Layers: make([]compatibilityLayerExpectation, len(layers)),
|
|
}
|
|
for i, layer := range layers {
|
|
out.Layers[i] = compatibilityLayerExpectation{
|
|
MediaType: layer.MediaType,
|
|
Digest: layer.Digest.String(),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isCompatibilityAttestationManifest(manifest ocispecs.Manifest) bool {
|
|
if len(manifest.Layers) == 0 {
|
|
return false
|
|
}
|
|
for _, layer := range manifest.Layers {
|
|
if layer.MediaType != "application/vnd.in-toto+json" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func readCompatibilityActualFromProvider(ctx context.Context, t *testing.T, provider content.Provider, desc ocispecs.Descriptor) compatibilityActual {
|
|
t.Helper()
|
|
|
|
if images.IsIndexType(desc.MediaType) {
|
|
indexDT, err := content.ReadBlob(ctx, provider, desc)
|
|
require.NoError(t, err)
|
|
|
|
var index ocispecs.Index
|
|
require.NoError(t, json.Unmarshal(indexDT, &index))
|
|
|
|
platform := compatibilityPlatform(t)
|
|
matcher := platforms.Only(platform)
|
|
for _, manifestDesc := range index.Manifests {
|
|
if manifestDesc.Platform == nil || !matcher.Match(*manifestDesc.Platform) {
|
|
continue
|
|
}
|
|
return readCompatibilityActualFromProvider(ctx, t, provider, manifestDesc)
|
|
}
|
|
|
|
require.FailNow(t, "missing platform manifest in image index")
|
|
}
|
|
|
|
manifestDT, err := content.ReadBlob(ctx, provider, desc)
|
|
require.NoError(t, err)
|
|
|
|
var manifest ocispecs.Manifest
|
|
require.NoError(t, json.Unmarshal(manifestDT, &manifest))
|
|
require.True(t, images.IsManifestType(manifest.MediaType), "unexpected manifest media type %s", manifest.MediaType)
|
|
|
|
configDT, err := content.ReadBlob(ctx, provider, manifest.Config)
|
|
require.NoError(t, err)
|
|
|
|
return compatibilityActualFromBytes(manifestDT, configDT, manifest.Layers)
|
|
}
|
|
|
|
func readImageCompatibilityProvenance(ctx context.Context, ref string) (*provenancetypes.ProvenancePredicateSLSA1, error) {
|
|
desc, provider, err := contentutil.ProviderFromRef(ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
indexDT, err := content.ReadBlob(ctx, provider, desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var index ocispecs.Index
|
|
if err := json.Unmarshal(indexDT, &index); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, manifestDesc := range index.Manifests {
|
|
manifestDT, err := content.ReadBlob(ctx, provider, manifestDesc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var manifest ocispecs.Manifest
|
|
if err := json.Unmarshal(manifestDT, &manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
if !isCompatibilityAttestationManifest(manifest) {
|
|
continue
|
|
}
|
|
|
|
for _, layer := range manifest.Layers {
|
|
if layer.Annotations["in-toto.io/predicate-type"] != policyimage.SLSAProvenancePredicateType1 {
|
|
continue
|
|
}
|
|
|
|
stmtDT, err := content.ReadBlob(ctx, provider, layer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var stmt struct {
|
|
intoto.StatementHeader
|
|
Predicate json.RawMessage `json:"predicate"`
|
|
}
|
|
if err := json.Unmarshal(stmtDT, &stmt); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var pr provenancetypes.ProvenancePredicateSLSA1
|
|
if err := json.Unmarshal(stmt.Predicate, &pr); err != nil {
|
|
return nil, err
|
|
}
|
|
return &pr, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("missing SLSA provenance attestation")
|
|
}
|
|
|
|
func assertCompatibilityCase(t *testing.T, exporterType string, tc compatibilityCase, version int, actual compatibilityActual) {
|
|
t.Helper()
|
|
|
|
if os.Getenv(compatibilityUpdateEnv) != "" {
|
|
writeCompatibilityGoldens(t, exporterType, tc.Name, version, actual)
|
|
t.Logf("compatibility expectation %s/%s/v%d manifest=%q config=%q layers=%s",
|
|
exporterType, tc.Name, version, actual.ManifestDigest, actual.ConfigDigest, formatLayerExpectations(actual.Layers))
|
|
return
|
|
}
|
|
|
|
expectedManifestJSON, err := readGoldenFile(exporterType, tc.Name, version, "manifest.json")
|
|
require.NoError(t, err)
|
|
expectedConfigJSON, err := readGoldenFile(exporterType, tc.Name, version, "config.json")
|
|
require.NoError(t, err)
|
|
|
|
exp, err := compatibilityExpectationFromGoldens(expectedManifestJSON, expectedConfigJSON)
|
|
require.NoError(t, err)
|
|
|
|
manifestDiff := ""
|
|
if normalizeJSON(expectedManifestJSON) != actual.ManifestJSON {
|
|
manifestDiff = unifiedDiff("golden-manifest", normalizeJSON(expectedManifestJSON), actual.ManifestJSON)
|
|
}
|
|
configDiff := ""
|
|
if normalizeJSON(expectedConfigJSON) != actual.ConfigJSON {
|
|
configDiff = unifiedDiff("golden-config", normalizeJSON(expectedConfigJSON), actual.ConfigJSON)
|
|
}
|
|
|
|
if exp.ManifestDigest != actual.ManifestDigest ||
|
|
exp.ConfigDigest != actual.ConfigDigest ||
|
|
!equalLayerExpectations(exp.Layers, actual.Layers) ||
|
|
manifestDiff != "" ||
|
|
configDiff != "" {
|
|
t.Fatalf("%s", formatCompatibilityDebug(exporterType, tc.Name, version, tc.Attrs, exp, actual, manifestDiff, configDiff))
|
|
}
|
|
}
|
|
|
|
func assertCompatibilityCaseResult(t *testing.T, exporterType string, tc compatibilityCase, version int, expectedSet bool, actual compatibilityActual, err error) {
|
|
t.Helper()
|
|
|
|
if compatibilityCaseExpectedUnsupported(tc, version, expectedSet) {
|
|
require.Error(t, err)
|
|
err = grpcerrors.FromGRPC(err)
|
|
var unsupported *solvererrdefs.UnsupportedCompatibilityFeatureError
|
|
expectedFeature := fmt.Sprintf("%s exporter compression=zstd", exporterType)
|
|
if stderrors.As(err, &unsupported) {
|
|
require.Equal(t, int64(version), unsupported.Version)
|
|
require.Equal(t, expectedFeature, unsupported.Feature)
|
|
return
|
|
}
|
|
require.ErrorContains(t, err, fmt.Sprintf("unsupported compatibility-version %d feature %s", version, expectedFeature))
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assertCompatibilityCase(t, exporterType, tc, version, actual)
|
|
}
|
|
|
|
func compatibilityCaseExpectedUnsupported(tc compatibilityCase, version int, expectedSet bool) bool {
|
|
return !expectedSet && version == compat.CompatibilityVersion013 && tc.Attrs["compression"] == "zstd"
|
|
}
|
|
|
|
func compatibilityExpectationFromGoldens(manifestDT, configDT []byte) (compatibilityActual, error) {
|
|
var manifest ocispecs.Manifest
|
|
if err := json.Unmarshal(manifestDT, &manifest); err != nil {
|
|
return compatibilityActual{}, err
|
|
}
|
|
return compatibilityActualFromBytes(manifestDT, configDT, manifest.Layers), nil
|
|
}
|
|
|
|
func writeCompatibilityGoldens(t *testing.T, exporterType, caseName string, version int, actual compatibilityActual) {
|
|
t.Helper()
|
|
|
|
manifestPath := goldenAbsPath(exporterType, caseName, version, "manifest.json")
|
|
configPath := goldenAbsPath(exporterType, caseName, version, "config.json")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(manifestPath), 0o755))
|
|
require.NoError(t, os.WriteFile(manifestPath, actual.ManifestBytes, 0o644))
|
|
require.NoError(t, os.WriteFile(configPath, actual.ConfigBytes, 0o644))
|
|
|
|
otherExporter := compatibilityOtherExporter(exporterType)
|
|
if otherExporter == "" {
|
|
return
|
|
}
|
|
|
|
otherManifestPath := goldenAbsPath(otherExporter, caseName, version, "manifest.json")
|
|
otherConfigPath := goldenAbsPath(otherExporter, caseName, version, "config.json")
|
|
otherManifestDT, err := os.ReadFile(otherManifestPath)
|
|
if err == nil {
|
|
commonManifestPath := goldenAbsPath("common", caseName, version, "manifest.json")
|
|
if bytes.Equal(actual.ManifestBytes, otherManifestDT) {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(commonManifestPath), 0o755))
|
|
require.NoError(t, os.WriteFile(commonManifestPath, actual.ManifestBytes, 0o644))
|
|
removeGoldenFileIfExists(t, manifestPath)
|
|
removeGoldenFileIfExists(t, otherManifestPath)
|
|
} else {
|
|
removeGoldenFileIfExists(t, commonManifestPath)
|
|
}
|
|
}
|
|
|
|
otherConfigDT, err := os.ReadFile(otherConfigPath)
|
|
commonConfigPath := goldenAbsPath("common", caseName, version, "config.json")
|
|
if err == nil {
|
|
if bytes.Equal(actual.ConfigBytes, otherConfigDT) {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(commonConfigPath), 0o755))
|
|
require.NoError(t, os.WriteFile(commonConfigPath, actual.ConfigBytes, 0o644))
|
|
removeGoldenFileIfExists(t, configPath)
|
|
removeGoldenFileIfExists(t, otherConfigPath)
|
|
} else {
|
|
removeGoldenFileIfExists(t, commonConfigPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
func readGoldenFile(exporterType, caseName string, version int, file string) ([]byte, error) {
|
|
commonPath := goldenPath("common", caseName, version, file)
|
|
dt, err := compatibilityGoldens.ReadFile(commonPath)
|
|
if err == nil {
|
|
return dt, nil
|
|
}
|
|
return compatibilityGoldens.ReadFile(goldenPath(exporterType, caseName, version, file))
|
|
}
|
|
|
|
func goldenPath(exporterType, caseName string, version int, file string) string {
|
|
return filepath.ToSlash(filepath.Join("testdata", "compatibility", exporterType, caseName, fmt.Sprintf("v%d", version), file))
|
|
}
|
|
|
|
func goldenAbsPath(exporterType, caseName string, version int, file string) string {
|
|
_, currentFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
baseDir := filepath.Dir(currentFile)
|
|
return filepath.Join(baseDir, goldenPath(exporterType, caseName, version, file))
|
|
}
|
|
|
|
func compatibilityOtherExporter(exporterType string) string {
|
|
switch exporterType {
|
|
case ExporterImage:
|
|
return ExporterOCI
|
|
case ExporterOCI:
|
|
return ExporterImage
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func removeGoldenFileIfExists(t *testing.T, path string) {
|
|
t.Helper()
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
func formatCompatibilityDebug(exporterType, caseName string, version int, attrs map[string]string, exp compatibilityActual, actual compatibilityActual, manifestDiff, configDiff string) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "compatibility regression mismatch\n")
|
|
fmt.Fprintf(&b, "case: %s\n", caseName)
|
|
fmt.Fprintf(&b, "exporter: %s\n", exporterType)
|
|
fmt.Fprintf(&b, "compatibility-version: %d\n", version)
|
|
fmt.Fprintf(&b, "attrs:\n")
|
|
|
|
keys := make([]string, 0, len(attrs))
|
|
for k := range attrs {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
fmt.Fprintf(&b, " %s=%s\n", k, attrs[k])
|
|
}
|
|
|
|
fmt.Fprintf(&b, "\nexpected:\n")
|
|
fmt.Fprintf(&b, " manifest=%s\n", exp.ManifestDigest)
|
|
fmt.Fprintf(&b, " config=%s\n", exp.ConfigDigest)
|
|
for i, layer := range exp.Layers {
|
|
fmt.Fprintf(&b, " layer[%d]=%s %s\n", i, layer.MediaType, layer.Digest)
|
|
}
|
|
|
|
fmt.Fprintf(&b, "\nactual:\n")
|
|
fmt.Fprintf(&b, " manifest=%s\n", actual.ManifestDigest)
|
|
fmt.Fprintf(&b, " config=%s\n", actual.ConfigDigest)
|
|
for i, layer := range actual.Layers {
|
|
fmt.Fprintf(&b, " layer[%d]=%s %s\n", i, layer.MediaType, layer.Digest)
|
|
}
|
|
|
|
if manifestDiff != "" {
|
|
fmt.Fprintf(&b, "\nmanifest diff:\n%s\n", manifestDiff)
|
|
}
|
|
if configDiff != "" {
|
|
fmt.Fprintf(&b, "\nconfig diff:\n%s\n", configDiff)
|
|
}
|
|
|
|
fmt.Fprintf(&b, "\nmanifest json:\n%s\n", actual.ManifestJSON)
|
|
fmt.Fprintf(&b, "\nconfig json:\n%s\n", actual.ConfigJSON)
|
|
return b.String()
|
|
}
|
|
|
|
func unifiedDiff(name, expected, actual string) string {
|
|
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
|
|
A: difflib.SplitLines(expected),
|
|
B: difflib.SplitLines(actual),
|
|
FromFile: name + ".golden",
|
|
ToFile: name + ".actual",
|
|
Context: 3,
|
|
})
|
|
if err != nil {
|
|
return fmt.Sprintf("failed to render diff: %v", err)
|
|
}
|
|
return diff
|
|
}
|
|
|
|
func normalizeJSON(dt []byte) string {
|
|
var v any
|
|
if err := json.Unmarshal(dt, &v); err != nil {
|
|
return string(dt)
|
|
}
|
|
out, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return string(dt)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func equalLayerExpectations(a, b []compatibilityLayerExpectation) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func formatLayerExpectations(layers []compatibilityLayerExpectation) string {
|
|
parts := make([]string, len(layers))
|
|
for i, layer := range layers {
|
|
parts[i] = fmt.Sprintf("{MediaType:%q,Digest:%q}", layer.MediaType, layer.Digest)
|
|
}
|
|
return "[" + strings.Join(parts, ", ") + "]"
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return map[string]string{}
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
maps.Copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func sanitizeCompatibilityName(name string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(name))
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
b.WriteRune(r)
|
|
case r >= 'A' && r <= 'Z':
|
|
b.WriteRune(r + ('a' - 'A'))
|
|
case r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '.', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('-')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func compatibilityPlatform(t *testing.T) ocispecs.Platform {
|
|
t.Helper()
|
|
return platforms.Normalize(platforms.MustParse(compatibilityPlatformString))
|
|
}
|