From 38f8536ee3fd1cb850efd8ae3f79bbee4e9addbc Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Mon, 6 Jul 2026 21:51:40 -0700 Subject: [PATCH] client: split client integration tests by area Move client integration test declarations out of the large harness files into focused files for cache, exporters, sources, mounts, gateway behavior, and related client features. Keep registration lists grouped by source file, move non-gateway tests out of the gateway harness, and remove the now-empty build_test.go. This is intended as a mechanical split with no test logic changes. Signed-off-by: Tonis Tiigi --- client/build_test.go | 2618 ---- client/client_cache_test.go | 1804 +++ client/client_control_test.go | 130 + client/client_diskusage_test.go | 16 + client/client_exec_test.go | 743 + client/client_export_image_test.go | 2005 +++ client/client_export_local_test.go | 910 ++ client/client_export_metadata_test.go | 2422 ++++ client/client_fileop_test.go | 936 ++ client/client_frontend_test.go | 431 + client/client_git_source_test.go | 706 + client/client_http_source_test.go | 877 ++ client/client_image_source_test.go | 365 + client/client_local_source_test.go | 400 + client/client_mergeop_test.go | 590 + client/client_mount_test.go | 1080 ++ client/client_network_test.go | 437 + client/client_oci_source_test.go | 606 + client/client_session_test.go | 199 + client/client_source_map_test.go | 132 + client/client_test.go | 15201 +------------------- client/client_utils_test.go | 328 + client/client_warnings_test.go | 155 + client/gateway_container_exec_test.go | 1272 ++ client/gateway_container_mount_test.go | 386 + client/gateway_container_network_test.go | 201 + client/gateway_container_security_test.go | 221 + client/gateway_solve_test.go | 317 + client/policy_test.go | 186 + 29 files changed, 18119 insertions(+), 17555 deletions(-) delete mode 100644 client/build_test.go create mode 100644 client/client_cache_test.go create mode 100644 client/client_control_test.go create mode 100644 client/client_diskusage_test.go create mode 100644 client/client_exec_test.go create mode 100644 client/client_export_image_test.go create mode 100644 client/client_export_local_test.go create mode 100644 client/client_export_metadata_test.go create mode 100644 client/client_fileop_test.go create mode 100644 client/client_frontend_test.go create mode 100644 client/client_git_source_test.go create mode 100644 client/client_http_source_test.go create mode 100644 client/client_image_source_test.go create mode 100644 client/client_local_source_test.go create mode 100644 client/client_mergeop_test.go create mode 100644 client/client_mount_test.go create mode 100644 client/client_network_test.go create mode 100644 client/client_oci_source_test.go create mode 100644 client/client_session_test.go create mode 100644 client/client_source_map_test.go create mode 100644 client/client_utils_test.go create mode 100644 client/client_warnings_test.go create mode 100644 client/gateway_container_exec_test.go create mode 100644 client/gateway_container_mount_test.go create mode 100644 client/gateway_container_network_test.go create mode 100644 client/gateway_container_security_test.go create mode 100644 client/gateway_solve_test.go diff --git a/client/build_test.go b/client/build_test.go deleted file mode 100644 index 2e9e2d166..000000000 --- a/client/build_test.go +++ /dev/null @@ -1,2618 +0,0 @@ -package client - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "testing" - "time" - - "github.com/moby/buildkit/client/llb" - "github.com/moby/buildkit/client/llb/sourceresolver" - "github.com/moby/buildkit/frontend/gateway/client" - gatewayapi "github.com/moby/buildkit/frontend/gateway/pb" - "github.com/moby/buildkit/identity" - "github.com/moby/buildkit/session" - sessionauth "github.com/moby/buildkit/session/auth" - "github.com/moby/buildkit/session/secrets/secretsprovider" - "github.com/moby/buildkit/session/sshforward/sshprovider" - "github.com/moby/buildkit/solver/errdefs" - "github.com/moby/buildkit/solver/pb" - "github.com/moby/buildkit/util/entitlements" - "github.com/moby/buildkit/util/grpcerrors" - "github.com/moby/buildkit/util/iohelper" - utilsystem "github.com/moby/buildkit/util/system" - "github.com/moby/buildkit/util/testutil/echoserver" - "github.com/moby/buildkit/util/testutil/integration" - "github.com/moby/buildkit/util/testutil/workers" - "github.com/pkg/errors" - "github.com/stretchr/testify/require" - "github.com/tonistiigi/fsutil" - "golang.org/x/crypto/ssh/agent" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" -) - -func TestClientGatewayIntegration(t *testing.T) { - integration.Run(t, integration.TestFuncs( - testClientGatewaySolve, - testClientGatewayFailedSolve, - testClientGatewayEmptySolve, - testNoBuildID, - testUnknownBuildID, - testClientGatewayContainerExecPipe, - testClientGatewayContainerExecPipeRelease, - testClientGatewayContainerExecPipeSignalKill, - testClientGatewayContainerCancelOnRelease, - testClientGatewayContainerPID1Fail, - testClientGatewayContainerPID1Exit, - testClientGatewayContainerMounts, - testClientGatewayContainerSecretEnv, - testClientGatewayContainerPID1Tty, - testClientGatewayContainerCancelPID1Tty, - testClientGatewayContainerExecTty, - testClientGatewayContainerCancelExecTty, - testClientSlowCacheRootfsRef, - testClientGatewayContainerPlatformPATH, - testClientGatewayContainerInvalidSecurityMode, - testClientGatewayExecError, - testClientGatewaySlowCacheExecError, - testClientGatewayExecFileActionError, - testClientGatewayContainerExtraHosts, - testClientGatewayContainerSignal, - testWarnings, - testClientGatewayCanceledCredentialsCallbackReturns, - testClientGatewayNilResult, - testClientGatewayEmptyImageExec, - ), integration.WithMirroredImages(integration.OfficialImages("busybox:latest"))) - - integration.Run(t, integration.TestFuncs( - testClientGatewayContainerSecurityModeCaps, - testClientGatewayContainerSecurityModeValidation, - ), integration.WithMirroredImages(integration.OfficialImages("busybox:latest")), - integration.WithMatrix("secmode", map[string]any{ - "sandbox": securitySandbox, - "insecure": securityInsecure, - }), - ) - - integration.Run(t, integration.TestFuncs( - testClientGatewayContainerHostNetworkingAccess, - testClientGatewayContainerHostNetworkingValidation, - ), - integration.WithMirroredImages(integration.OfficialImages("busybox:latest")), - integration.WithMatrix("netmode", map[string]any{ - "default": defaultNetwork, - "host": hostNetwork, - }), - ) -} - -func testClientGatewaySolve(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - optKey := "test-string" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - if c.BuildOpts().Product != product { - return nil, errors.Errorf("expected product %q, got %q", product, c.BuildOpts().Product) - } - opts := c.BuildOpts().Opts - testStr, ok := opts[optKey] - if !ok { - return nil, errors.Errorf(`build option %q missing`, optKey) - } - - run := llb.Image("busybox:latest").Run( - llb.ReadonlyRootFS(), - llb.Args([]string{"/bin/sh", "-ec", `echo -n '` + testStr + `' > /out/foo`}), - ) - st := run.AddMount("/out", llb.Scratch()) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - read, err := r.Ref.ReadFile(ctx, client.ReadRequest{ - Filename: "/foo", - }) - if err != nil { - return nil, errors.Wrap(err, "failed to read result") - } - if testStr != string(read) { - return nil, errors.Errorf("read back %q, expected %q", string(read), testStr) - } - return r, nil - } - - tmpdir := t.TempDir() - - testStr := "This is a test" - - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: tmpdir, - }, - }, - FrontendAttrs: map[string]string{ - optKey: testStr, - }, - }, product, b, nil) - require.NoError(t, err) - - read, err := os.ReadFile(filepath.Join(tmpdir, "foo")) - require.NoError(t, err) - require.Equal(t, testStr, string(read)) - - checkAllReleasable(t, c, sb, true) -} - -func testWarnings(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Scratch().File(llb.Mkfile("/dummy", 0o600, []byte("foo"))) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - dgst, _, _, _, err := st.Output().Vertex(ctx, def.Constraints).Marshal(ctx, def.Constraints) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - require.NoError(t, c.Warn(ctx, dgst, "this is warning", client.WarnOpts{ - Level: 3, - SourceInfo: &pb.SourceInfo{ - Filename: "mydockerfile", - Data: []byte("filedata"), - }, - Range: []*pb.Range{ - {Start: &pb.Position{Line: 2}, End: &pb.Position{Line: 4}}, - }, - Detail: [][]byte{[]byte("this is detail"), []byte("and more detail")}, - URL: "https://example.com", - })) - - return r, nil - } - - wc := newWarningsCapture() - - _, err = c.Build(ctx, SolveOpt{}, product, b, wc.status) - require.NoError(t, err) - - warnings := wc.wait() - - require.Equal(t, 1, len(wc.vertexes)) - require.Equal(t, 1, len(warnings)) - - w := warnings[0] - - require.Equal(t, "this is warning", string(w.Short)) - require.Equal(t, 2, len(w.Detail)) - require.Equal(t, "this is detail", string(w.Detail[0])) - require.Equal(t, "and more detail", string(w.Detail[1])) - require.Equal(t, "https://example.com", w.URL) - require.Equal(t, 3, w.Level) - _, ok := wc.vertexes[w.Vertex] - require.True(t, ok) - - require.Equal(t, "mydockerfile", w.SourceInfo.Filename) - require.Equal(t, "filedata", string(w.SourceInfo.Data)) - require.Equal(t, 1, len(w.Range)) - require.Equal(t, int32(2), w.Range[0].Start.Line) - require.Equal(t, int32(4), w.Range[0].End.Line) - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayFailedSolve(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - return nil, errors.New("expected to fail") - } - - _, err = c.Build(ctx, SolveOpt{}, "", b, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "expected to fail") -} - -func testClientGatewayEmptySolve(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - r, err := c.Solve(ctx, client.SolveRequest{}) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - if r.Ref != nil || r.Refs != nil || r.Metadata != nil { - return nil, errors.Errorf("got unexpected non-empty result %+v", r) - } - return r, nil - } - - _, err = c.Build(ctx, SolveOpt{}, "", b, nil) - require.NoError(t, err) -} - -func testNoBuildID(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - g := gatewayapi.NewLLBBridgeClient(c.conn) - _, err = g.Ping(ctx, &gatewayapi.PingRequest{}) - require.Error(t, err) - require.Contains(t, err.Error(), "no buildid found in context") -} - -func testUnknownBuildID(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - g := c.gatewayClientForBuild(t.Name() + identity.NewID()) - _, err = g.Ping(ctx, &gatewayapi.PingRequest{}) - require.Error(t, err) - require.Contains(t, err.Error(), "no such job") - require.Equal(t, codes.NotFound, grpcerrors.Code(err)) -} - -// testClientGatewayContainerCancelOnRelease is testing that all running -// processes are terminated when the container is released. -func testClientGatewayContainerCancelOnRelease(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - - start := time.Now() - defer func() { - // ensure pid1 and pid2 exit from cancel before the 10s sleep - // exits naturally - require.WithinDuration(t, start, time.Now(), 10*time.Second) - }() - - // background pid1 process that starts container - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - pid2, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - ctr.Release(ctx) - err = pid1.Wait() - require.Contains(t, err.Error(), context.Canceled.Error()) - - err = pid2.Wait() - require.Contains(t, err.Error(), context.Canceled.Error()) - - return &client.Result{}, nil - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerExecPipe is testing the ability to pipe multiple -// process together all started via `Exec` into the same container. -// We are mimicing: `echo testing | cat | cat > /tmp/foo && cat /tmp/foo` -func testClientGatewayContainerExecPipe(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, stdin *io.PipeWriter) { - stdin.Close() - pid1.Wait() - ctr.Release(context.WithoutCancel(ctx)) - }) -} - -// testClientGatewayContainerExecPipeRelease verifies that releasing the -// container while pid1 still has an open stdin reader does not leave runc -// blocked on the stdin copy. -func testClientGatewayContainerExecPipeRelease(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, _ *io.PipeWriter) { - ctr.Release(context.WithoutCancel(ctx)) - pid1.Wait() - }) -} - -// testClientGatewayContainerExecPipeSignalKill verifies that SIGKILL delivered -// via the process Signal channel unblocks runc when pid1 still has an open -// stdin reader. -func testClientGatewayContainerExecPipeSignalKill(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, _ *io.PipeWriter) { - pid1.Signal(ctx, syscall.SIGKILL) - pid1.Wait() - ctr.Release(context.WithoutCancel(ctx)) - }) -} - -func testClientGatewayContainerExecPipeWithCleanup(t *testing.T, sb integration.Sandbox, cleanup func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, stdin *io.PipeWriter)) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - output := bytes.NewBuffer(nil) - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - - // background pid1 process that starts container - pid1StdinR, pid1StdinW := io.Pipe() - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat"}, - Stdin: pid1StdinR, - }) - if err != nil { - ctr.Release(context.WithoutCancel(ctx)) - return nil, err - } - - defer cleanup(ctx, ctr, pid1, pid1StdinW) - - // first part is `echo testing | cat` - stdin2 := bytes.NewBuffer([]byte("testing")) - stdin3, stdout2 := io.Pipe() - - pid2, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat"}, - Cwd: "/", - Tty: false, - Stdin: io.NopCloser(stdin2), - Stdout: stdout2, - }) - if err != nil { - return nil, err - } - - // next part is: `| cat > /tmp/test` - pid3, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-c", "cat > /tmp/test"}, - Stdin: stdin3, - }) - if err != nil { - return nil, err - } - - err = pid2.Wait() - if err != nil { - stdout2.Close() - return nil, err - } - - err = stdout2.Close() - if err != nil { - return nil, err - } - - err = pid3.Wait() - if err != nil { - return nil, err - } - - err = stdin3.Close() - if err != nil { - return nil, err - } - - pid4, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat", "/tmp/test"}, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - }) - if err != nil { - return nil, err - } - - err = pid4.Wait() - if err != nil { - return nil, err - } - - return &client.Result{}, nil - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.NoError(t, err) - require.Equal(t, "testing", output.String()) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerPID1Fail is testing clean shutdown and release -// of resources when the primary pid1 exits with non-zero exit status -func testClientGatewayContainerPID1Fail(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-c", "exit 99"}, - }) - if err != nil { - ctr.Release(ctx) - return nil, err - } - - defer ctr.Release(ctx) - err = pid1.Wait() - - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(99), exitError.ExitCode) - - return nil, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerPID1Exit is testing that all process started -// via `Exec` are shutdown when the primary pid1 process exits -func testClientGatewayContainerPID1Exit(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - defer ctr.Release(ctx) - - start := time.Now() - defer func() { - // ensure pid1 and pid2 exits from cancel before the 10s sleep - // exits naturally - require.WithinDuration(t, start, time.Now(), 10*time.Second) - // assert this test ran for at least one second for pid1 - lapse := time.Since(start) - require.Greater(t, lapse.Seconds(), float64(1)) - }() - - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "1"}, - }) - require.NoError(t, err) - defer pid1.Wait() - - pid2, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - return &client.Result{}, pid2.Wait() - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(137), exitError.ExitCode) - // `exit code: 137` (ie sigkill) - require.Regexp(t, "exit code: 137", err.Error()) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerMounts is testing mounts derived from various -// llb.States -func testClientGatewayContainerMounts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - tmpdir := integration.Tmpdir(t) - - err = os.WriteFile(filepath.Join(tmpdir.Name, "local-file"), []byte("local"), 0o644) - require.NoError(t, err) - - a := agent.NewKeyring() - sockPath, err := makeSSHAgentSock(t, a) - require.NoError(t, err) - - ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ - ID: t.Name(), - Paths: []string{sockPath}, - }}) - require.NoError(t, err) - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - mounts := map[string]llb.State{ - "/": llb.Image("busybox:latest").Run( - llb.Shlex("touch /root-file /cached/cache-file"), - llb.AddMount("/cached", llb.Scratch(), llb.AsPersistentCacheDir(t.Name(), llb.CacheMountShared)), - ).Root(), - "/foo": llb.Image("busybox:latest").Run( - llb.Shlex("touch foo-file"), - llb.Dir("/tmp"), - llb.AddMount("/tmp", llb.Scratch()), - ).GetMount("/tmp"), - "/local": llb.Local("mylocal"), - // TODO How do we get a results.Ref for a cache mount, tmpfs mount - } - - containerMounts := []client.Mount{ - { - Dest: "/", - MountType: pb.MountType_BIND, - }, - { - Dest: "/foo", - MountType: pb.MountType_BIND, - }, - { - Dest: "/local", - MountType: pb.MountType_BIND, - }, - { - Dest: "/cached", - MountType: pb.MountType_CACHE, - CacheOpt: &pb.CacheOpt{ - ID: t.Name(), - Sharing: pb.CacheSharingOpt_SHARED, - }, - }, - { - Dest: "/tmpfs", - MountType: pb.MountType_TMPFS, - }, - { - Dest: "/run/secrets/mysecret", - MountType: pb.MountType_SECRET, - SecretOpt: &pb.SecretOpt{ - ID: "/run/secrets/mysecret", - }, - }, - { - Dest: sockPath, - MountType: pb.MountType_SSH, - SSHOpt: &pb.SSHOpt{ - ID: t.Name(), - }, - }, - } - - // Fill in mount references. - for i, m := range containerMounts { - st := mounts[m.Dest] - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - containerMounts[i].Ref = r.Ref - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts}) - if err != nil { - return nil, err - } - - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - Stderr: os.Stderr, - }) - require.NoError(t, err) - defer pid1.Wait() - - files := []struct { - index int - path string - data []byte - }{ - {0, "/root-file", []byte(nil)}, - {1, "/foo-file", []byte(nil)}, - {2, "/local-file", []byte(`local`)}, - {3, "/cache-file", []byte(nil)}, - } - for _, file := range files { - cpath := containerMounts[file.index].Dest + file.path - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-f", cpath}, - }) - require.NoError(t, err, "cannot start container to check for file: %s", cpath) - err = pid.Wait() - require.NoError(t, err, "process for checking file failed: %s", cpath) - - _, err = ctr.StatFile(ctx, client.StatContainerRequest{ - StatRequest: client.StatRequest{ - Path: file.path, - }, - MountIndex: file.index, - }) - require.NoError(t, err, "stat file for %q on mount %d failed", file.path, file.index) - - b, err := ctr.ReadFile(ctx, client.ReadContainerRequest{ - ReadRequest: client.ReadRequest{ - Filename: file.path, - }, - MountIndex: file.index, - }) - require.NoError(t, err, "read file for %q on mount %d failed", file.path, file.index) - require.Equal(t, file.data, b) - } - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-f", "/root-file"}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-f", "/foo/foo-file"}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-f", "/local/local-file"}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-f", "/cached/cache-file"}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-w", "/tmpfs"}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - secretOutput := bytes.NewBuffer(nil) - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat", "/run/secrets/mysecret"}, - Stdout: &iohelper.NopWriteCloser{Writer: secretOutput}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - require.Equal(t, "foo-secret", secretOutput.String()) - - pid, err = ctr.Start(ctx, client.StartRequest{ - Args: []string{"test", "-S", sockPath}, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - return &client.Result{}, ctr.Release(ctx) - } - - _, err = c.Build(ctx, SolveOpt{ - LocalMounts: map[string]fsutil.FS{ - "mylocal": tmpdir, - }, - Session: []session.Attachable{ - ssh, - secretsprovider.FromMap(map[string][]byte{ - "/run/secrets/mysecret": []byte("foo-secret"), - }), - }, - }, product, b, nil) - require.Error(t, err) - require.Contains(t, err.Error(), context.Canceled.Error()) - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayContainerSecretEnv(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - mounts := map[string]llb.State{ - "/": llb.Image("busybox:latest"), - } - - var containerMounts []client.Mount - for mountpoint, st := range mounts { - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - containerMounts = append(containerMounts, client.Mount{ - Dest: mountpoint, - MountType: pb.MountType_BIND, - Ref: r.Ref, - }) - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts}) - if err != nil { - return nil, err - } - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-c", "test $SOME_SECRET = foo-secret"}, - SecretEnv: []*pb.SecretEnv{ - { - ID: "sekrit", - Name: "SOME_SECRET", - }, - }, - }) - require.NoError(t, err) - err = pid.Wait() - require.NoError(t, err) - - return &client.Result{}, ctr.Release(ctx) - } - - _, err = c.Build(ctx, SolveOpt{ - Session: []session.Attachable{ - secretsprovider.FromMap(map[string][]byte{ - "sekrit": []byte("foo-secret"), - }), - }, - }, product, b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerPID1Tty is testing that we can get a tty via -// a container pid1, executor.Run -func testClientGatewayContainerPID1Tty(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - inputR, inputW := io.Pipe() - output := bytes.NewBuffer(nil) - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) - defer timeout() - - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - defer ctr.Release(ctx) - - prompt := newTestPrompt(ctx, t, inputW, output) - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh"}, - Tty: true, - Stdin: inputR, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - Stderr: &iohelper.NopWriteCloser{Writer: output}, - Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, - }) - require.NoError(t, err) - err = pid1.Resize(ctx, client.WinSize{Rows: 40, Cols: 80}) - require.NoError(t, err) - prompt.SendExpect("ttysize", "80 40") - prompt.Send("cd /tmp") - prompt.SendExpect("pwd", "/tmp") - prompt.Send("echo foobar > newfile") - prompt.SendExpect("cat /tmp/newfile", "foobar") - err = pid1.Resize(ctx, client.WinSize{Rows: 60, Cols: 100}) - require.NoError(t, err) - prompt.SendExpect("ttysize", "100 60") - prompt.SendExit(99) - - err = pid1.Wait() - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(99), exitError.ExitCode) - - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - - inputW.Close() - inputR.Close() - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerCancelPID1Tty is testing that the tty will cleanly -// shutdown on context cancel -func testClientGatewayContainerCancelPID1Tty(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - inputR, inputW := io.Pipe() - output := bytes.NewBuffer(nil) - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - ctx, cancel := context.WithTimeoutCause(ctx, 10*time.Second, nil) - defer cancel() - - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - defer ctr.Release(ctx) - - prompt := newTestPrompt(ctx, t, inputW, output) - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh"}, - Tty: true, - Stdin: inputR, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - Stderr: &iohelper.NopWriteCloser{Writer: output}, - Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, - }) - require.NoError(t, err) - prompt.SendExpect("echo hi", "hi") - cancel() - - err = pid1.Wait() - require.ErrorIs(t, err, context.Canceled) - - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - - inputW.Close() - inputR.Close() - - checkAllReleasable(t, c, sb, true) -} - -type testPrompt struct { - ctx context.Context - t *testing.T - output *bytes.Buffer - input io.Writer - prompt string - pos int -} - -func newTestPrompt(ctx context.Context, t *testing.T, input io.Writer, output *bytes.Buffer) *testPrompt { - return &testPrompt{ - ctx: ctx, - t: t, - input: input, - output: output, - prompt: "% ", - } -} - -func (p *testPrompt) String() string { return p.prompt } - -func (p *testPrompt) SendExit(status int) { - p.input.Write(fmt.Appendf(nil, "exit %d\n", status)) -} - -func (p *testPrompt) Send(cmd string) { - p.input.Write([]byte(cmd + "\n")) - p.wait(p.prompt) -} - -func (p *testPrompt) SendExpect(cmd, expected string) { - for { - p.input.Write([]byte(cmd + "\n")) - response := p.wait(p.prompt) - if strings.Contains(response, expected) { - return - } - } -} - -func (p *testPrompt) wait(msg string) string { - for { - newOutput := p.output.String()[p.pos:] - if strings.Contains(newOutput, msg) { - p.pos += len(newOutput) - return newOutput - } - select { - case <-p.ctx.Done(): - p.t.Logf("Output at timeout: %s", p.output.String()) - p.t.Fatalf("Timeout waiting for %q", msg) - case <-time.After(100 * time.Millisecond): - } - } -} - -// testClientGatewayContainerExecTty is testing that we can get a tty via -// executor.Exec (secondary process) -func testClientGatewayContainerExecTty(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - inputR, inputW := io.Pipe() - output := bytes.NewBuffer(nil) - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) - defer timeout() - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - defer pid1.Wait() - defer ctr.Release(ctx) - - prompt := newTestPrompt(ctx, t, inputW, output) - pid2, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh"}, - Tty: true, - Stdin: inputR, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - Stderr: &iohelper.NopWriteCloser{Writer: output}, - Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, - }) - require.NoError(t, err) - - err = pid2.Resize(ctx, client.WinSize{Rows: 40, Cols: 80}) - require.NoError(t, err) - prompt.SendExpect("ttysize", "80 40") - prompt.Send("cd /tmp") - prompt.SendExpect("pwd", "/tmp") - prompt.Send("echo foobar > newfile") - prompt.SendExpect("cat /tmp/newfile", "foobar") - err = pid2.Resize(ctx, client.WinSize{Rows: 60, Cols: 100}) - require.NoError(t, err) - prompt.SendExpect("ttysize", "100 60") - prompt.SendExit(99) - - err = pid2.Wait() - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(99), exitError.ExitCode) - - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(99), exitError.ExitCode) - require.Regexp(t, "exit code: 99", err.Error()) - - inputW.Close() - inputR.Close() - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerCancelExecTty is testing the tty shuts down cleanly -// on context.Cancel -func testClientGatewayContainerCancelExecTty(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - inputR, inputW := io.Pipe() - output := bytes.NewBuffer(nil) - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) - defer timeout() - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - defer pid1.Wait() - defer ctr.Release(ctx) - - execCtx, cancel := context.WithCancelCause(ctx) - defer func() { cancel(errors.WithStack(context.Canceled)) }() - - prompt := newTestPrompt(execCtx, t, inputW, output) - pid2, err := ctr.Start(execCtx, client.StartRequest{ - Args: []string{"sh"}, - Tty: true, - Stdin: inputR, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - Stderr: &iohelper.NopWriteCloser{Writer: output}, - Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, - }) - require.NoError(t, err) - - prompt.SendExpect("echo hi", "hi") - cancel(errors.WithStack(context.Canceled)) - - err = pid2.Wait() - require.ErrorIs(t, err, context.Canceled) - - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - require.Contains(t, err.Error(), context.Canceled.Error()) - - inputW.Close() - inputR.Close() - - checkAllReleasable(t, c, sb, true) -} - -func testClientSlowCacheRootfsRef(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - id := identity.NewID() - input := llb.Scratch().File( - llb.Mkdir("/found", 0o700). - Mkfile("/found/data", 0o600, []byte(id)), - ) - - st := llb.Image("busybox:latest").Run( - llb.Shlexf("echo hello"), - // Only readonly mounts trigger slow cache errors. - llb.AddMount("/src", input, llb.SourcePath("/notfound"), llb.Readonly), - ).Root() - - def1, err := st.Marshal(ctx) - require.NoError(t, err) - - res1, err := c.Solve(ctx, client.SolveRequest{ - Definition: def1.ToPB(), - }) - require.NoError(t, err) - - ref1, err := res1.SingleRef() - require.NoError(t, err) - - // First stat should error because unlazy-ing the reference causes an error - // in CalcSlowCache. - _, err = ref1.StatFile(ctx, client.StatRequest{ - Path: ".", - }) - require.Error(t, err) - - def2, err := llb.Image("busybox:latest").Marshal(ctx) - require.NoError(t, err) - - res2, err := c.Solve(ctx, client.SolveRequest{ - Definition: def2.ToPB(), - }) - require.NoError(t, err) - - ref2, err := res2.SingleRef() - require.NoError(t, err) - - // Second stat should not error because the rootfs for `busybox` should not - // have been released. - _, err = ref2.StatFile(ctx, client.StatRequest{ - Path: ".", - }) - require.NoError(t, err) - - return res2, nil - } - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerPlatformPATH is testing the correct default PATH -// gets set for the requested platform -func testClientGatewayContainerPlatformPATH(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - def, err := st.Marshal(ctx) - require.NoError(t, err) - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - require.NoError(t, err) - - tests := []struct { - Name string - Platform *pb.Platform - Expected string - }{{ - "default path", - nil, - utilsystem.DefaultPathEnvUnix, - }, { - "linux path", - &pb.Platform{OS: "linux"}, - utilsystem.DefaultPathEnvUnix, - }, { - "windows path", - &pb.Platform{OS: "windows"}, - utilsystem.DefaultPathEnvWindows, - }} - - for _, tt := range tests { - t.Run(tt.Name, func(t *testing.T) { - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - Platform: tt.Platform, - }) - require.NoError(t, err) - output := bytes.NewBuffer(nil) - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"/bin/sh", "-c", "echo -n $PATH"}, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - }) - require.NoError(t, err) - - err = pid1.Wait() - require.NoError(t, err) - require.Equal(t, tt.Expected, output.String()) - err = ctr.Release(ctx) - require.NoError(t, err) - }) - } - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayExecError is testing gateway exec to recreate the container -// process for a failed execop. -func testClientGatewayExecError(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - id := identity.NewID() - tests := []struct { - Name string - State llb.State - NumMounts int - Paths []string - }{{ - "only rootfs", - llb.Image("busybox:latest").Run( - llb.Shlexf(`sh -c "echo %s > /data && fail"`, id), - ).Root(), - 1, - []string{"/data"}, - }, { - "rootfs and readwrite scratch mount", - llb.Image("busybox:latest").Run( - llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), - llb.AddMount("/rw", llb.Scratch()), - ).Root(), - 2, - []string{"/data", "/rw/data"}, - }, { - "rootfs and readwrite mount", - llb.Image("busybox:latest").Run( - llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), - llb.AddMount("/rw", llb.Scratch().File(llb.Mkfile("foo", 0o700, []byte(id)))), - ).Root(), - 2, - []string{"/data", "/rw/data", "/rw/foo"}, - }, { - "rootfs and readonly scratch mount", - llb.Image("busybox:latest").Run( - llb.Shlexf(`sh -c "echo %s > /data && echo %s > /readonly/foo"`, id, id), - llb.AddMount("/readonly", llb.Scratch(), llb.Readonly), - ).Root(), - 2, - []string{"/data"}, - }, { - "rootfs and readwrite force no output mount", - llb.Image("busybox:latest").Run( - llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), - llb.AddMount( - "/rw", - llb.Scratch().File(llb.Mkfile("foo", 0o700, []byte(id))), - llb.ForceNoOutput, - ), - ).Root(), - 2, - []string{"/data", "/rw/data", "/rw/foo"}, - }} - - for _, tt := range tests { - t.Run(tt.Name, func(t *testing.T) { - def, err := tt.State.Marshal(ctx) - require.NoError(t, err) - - _, solveErr := c.Solve(ctx, client.SolveRequest{ - Evaluate: true, - Definition: def.ToPB(), - }) - require.Error(t, solveErr) - - var se *errdefs.SolveError - require.ErrorAs(t, solveErr, &se) - require.Len(t, se.InputIDs, tt.NumMounts) - require.Len(t, se.MountIDs, tt.NumMounts) - - op := se.Op - require.NotNil(t, op) - require.NotNil(t, op.Op) - - opExec, ok := se.Op.Op.(*pb.Op_Exec) - require.True(t, ok) - - exec := opExec.Exec - - var mounts []client.Mount - for i, mnt := range exec.Mounts { - mounts = append(mounts, client.Mount{ - Selector: mnt.Selector, - Dest: mnt.Dest, - ResultID: se.MountIDs[i], - Readonly: mnt.Readonly, - MountType: mnt.MountType, - CacheOpt: mnt.CacheOpt, - SecretOpt: mnt.SecretOpt, - SSHOpt: mnt.SSHOpt, - }) - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: mounts, - NetMode: exec.Network, - Platform: op.Platform, - Constraints: op.Constraints, - }) - require.NoError(t, err) - defer ctr.Release(ctx) - - inputR, inputW := io.Pipe() - defer inputW.Close() - defer inputR.Close() - - pid1Output := bytes.NewBuffer(nil) - - prompt := newTestPrompt(ctx, t, inputW, pid1Output) - pid1, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh"}, - Tty: true, - Stdin: inputR, - Stdout: &iohelper.NopWriteCloser{Writer: pid1Output}, - Stderr: &iohelper.NopWriteCloser{Writer: pid1Output}, - Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, - }) - require.NoError(t, err) - - meta := exec.Meta - for _, p := range tt.Paths { - output := bytes.NewBuffer(nil) - proc, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat", p}, - Env: meta.Env, - User: meta.User, - Cwd: meta.Cwd, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - SecurityMode: exec.Security, - }) - require.NoError(t, err) - - err = proc.Wait() - require.NoError(t, err) - require.Equal(t, id, strings.TrimSpace(output.String())) - } - - prompt.SendExit(0) - err = pid1.Wait() - require.NoError(t, err) - }) - } - - return client.NewResult(), nil - } - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewaySlowCacheExecError is testing gateway exec into the ref -// that failed to mount during an execop. -func testClientGatewaySlowCacheExecError(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - id := identity.NewID() - input := llb.Scratch().File( - llb.Mkdir("/found", 0o700). - Mkfile("/found/data", 0o600, []byte(id)), - ) - - st := llb.Image("busybox:latest").Run( - llb.Shlexf("echo hello"), - // Only readonly mounts trigger slow cache errors. - llb.AddMount("/src", input, llb.SourcePath("/notfound"), llb.Readonly), - ).Root() - - def, err := st.Marshal(ctx) - require.NoError(t, err) - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - _, solveErr := c.Solve(ctx, client.SolveRequest{ - Evaluate: true, - Definition: def.ToPB(), - }) - require.Error(t, solveErr) - - var se *errdefs.SolveError - require.ErrorAs(t, solveErr, &se) - - _, ok := se.Op.Op.(*pb.Op_Exec) - require.True(t, ok) - - _, ok = se.Subject.(*errdefs.Solve_Cache) - require.True(t, ok) - // Slow cache errors should only have exactly one input and no outputs. - require.Len(t, se.InputIDs, 1) - require.Len(t, se.MountIDs, 0) - - st := llb.Image("busybox:latest") - def, err := st.Marshal(ctx) - require.NoError(t, err) - - res, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - require.NoError(t, err) - - ref, err := res.SingleRef() - require.NoError(t, err) - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: ref, - }, { - Dest: "/problem", - MountType: pb.MountType_BIND, - ResultID: se.InputIDs[0], - }}, - }) - require.NoError(t, err) - defer ctr.Release(ctx) - - output := bytes.NewBuffer(nil) - proc, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat", "/problem/found/data"}, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - }) - require.NoError(t, err) - - err = proc.Wait() - require.NoError(t, err) - require.Equal(t, id, strings.TrimSpace(output.String())) - - return client.NewResult(), nil - } - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayExecFileActionError is testing gateway exec into the modified -// mount of a failed fileop during a solve. -func testClientGatewayExecFileActionError(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - def, err := st.Marshal(ctx) - require.NoError(t, err) - - res, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - require.NoError(t, err) - - debugfs, err := res.SingleRef() - require.NoError(t, err) - - id := identity.NewID() - tests := []struct { - Name string - State llb.State - NumInputs int - NumOutputs int - Path string - }{{ - "mkfile", - llb.Scratch().File( - llb.Mkdir("/found", 0o700). - Mkfile("/found/foo", 0o600, []byte(id)). - Mkfile("/notfound/foo", 0o600, []byte(id)), - ), - 0, 3, "/input/found/foo", - }, { - "copy from input", - llb.Image("busybox").File( - llb.Copy( - llb.Scratch().File( - llb.Mkdir("/foo", 0o600).Mkfile("/foo/bar", 0o700, []byte(id)), - ), - "/foo/bar", - "/notfound/baz", - ), - ), - 2, 1, "/secondary/foo/bar", - }, { - "copy from action", - llb.Image("busybox").File( - llb.Copy( - llb.Mkdir("/foo", 0o600).Mkfile("/foo/bar", 0o700, []byte(id)).WithState(llb.Scratch()), - "/foo/bar", - "/notfound/baz", - ), - ), - 1, 3, "/secondary/foo/bar", - }} - - for _, tt := range tests { - t.Run(tt.Name, func(t *testing.T) { - def, err := tt.State.Marshal(ctx) - require.NoError(t, err) - - _, err = c.Solve(ctx, client.SolveRequest{ - Evaluate: true, - Definition: def.ToPB(), - }) - require.Error(t, err) - - var se *errdefs.SolveError - require.ErrorAs(t, err, &se) - require.Len(t, se.InputIDs, tt.NumInputs) - - // There is one output for every action in the fileop that failed. - require.Len(t, se.MountIDs, tt.NumOutputs) - - op, ok := se.Op.Op.(*pb.Op_File) - require.True(t, ok) - - subject, ok := se.Subject.(*errdefs.Solve_File) - require.True(t, ok) - - // Retrieve the action that failed from the sbuject. - idx := subject.File.Index - require.Less(t, int(idx), len(op.File.Actions)) - action := op.File.Actions[idx] - - // The output for a file action is mapped by its index. - inputID := se.MountIDs[idx] - - var secondaryID string - if action.SecondaryInput != -1 { - // If the secondary input is a result from another exec, it will be one - // of the input IDs, otherwise it's a intermediary mutable from another - // action in the same fileop. - if int(action.SecondaryInput) < len(se.InputIDs) { - secondaryID = se.InputIDs[action.SecondaryInput] - } else { - secondaryID = se.MountIDs[int(action.SecondaryInput)-len(se.InputIDs)] - } - } - - mounts := []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: debugfs, - }, { - Dest: "/input", - MountType: pb.MountType_BIND, - ResultID: inputID, - }} - - if secondaryID != "" { - mounts = append(mounts, client.Mount{ - Dest: "/secondary", - MountType: pb.MountType_BIND, - ResultID: secondaryID, - }) - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: mounts}) - require.NoError(t, err) - - // Verify that the randomly generated data can be found in a mutable ref - // created by the actions that have succeeded. - output := bytes.NewBuffer(nil) - proc, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"cat", tt.Path}, - Stdout: &iohelper.NopWriteCloser{Writer: output}, - }) - require.NoError(t, err) - - err = proc.Wait() - require.NoError(t, err) - require.Equal(t, id, strings.TrimSpace(output.String())) - - err = ctr.Release(ctx) - require.NoError(t, err) - }) - } - - return client.NewResult(), nil - } - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerSecurityModeCaps ensures that the correct security mode -// is propagated to the gateway container -func testClientGatewayContainerSecurityModeCaps(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerSecurityMode(t, sb, false) -} - -func testClientGatewayContainerSecurityModeValidation(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerSecurityMode(t, sb, true) -} - -func testClientGatewayContainerInvalidSecurityMode(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - defer checkAllReleasable(t, c, sb, true) - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - defer ctr.Release(ctx) - - stdout := bytes.NewBuffer(nil) - stderr := bytes.NewBuffer(nil) - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-euc", `sec=$(awk '/^Seccomp:/{print $2}' /proc/self/status); echo "Seccomp=$sec"; test "$sec" = 0`}, - Stdout: &iohelper.NopWriteCloser{Writer: stdout}, - Stderr: &iohelper.NopWriteCloser{Writer: stderr}, - SecurityMode: pb.SecurityMode(2), - }) - if err != nil { - return nil, err - } - - err = pid.Wait() - - t.Logf("Stdout: %q", stdout.String()) - t.Logf("Stderr: %q", stderr.String()) - - return nil, err - } - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid security mode") -} - -func testClientGatewayContainerSecurityMode(t *testing.T, sb integration.Sandbox, expectFail bool) { - workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - command := []string{"sh", "-c", `cat /proc/self/status | grep CapEff | cut -f 2`} - mode := llb.SecurityModeSandbox - var allowedEntitlements []string - var assertCaps func(caps uint64) - secMode := sb.Value("secmode") - if secMode == securitySandbox { - assertCaps = func(caps uint64) { - /* - $ capsh --decode=00000000a80425fb - 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap, - cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap - */ - require.Equal(t, uint64(0xa80425fb), caps) - } - allowedEntitlements = []string{} - if expectFail { - return - } - } else { - assertCaps = func(caps uint64) { - /* - $ capsh --decode=0000003fffffffff - 0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid, - cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw, - cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin, - cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write, - cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read - */ - - // require that _at least_ minimum capabilities are granted - require.Equal(t, uint64(0x3fffffffff), caps&0x3fffffffff) - } - mode = llb.SecurityModeInsecure - allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} - if expectFail { - allowedEntitlements = []string{} - } - } - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - if err != nil { - return nil, err - } - - stdout := bytes.NewBuffer(nil) - stderr := bytes.NewBuffer(nil) - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: command, - Stdout: &iohelper.NopWriteCloser{Writer: stdout}, - Stderr: &iohelper.NopWriteCloser{Writer: stderr}, - SecurityMode: mode, - }) - if err != nil { - ctr.Release(ctx) - return nil, err - } - defer ctr.Release(ctx) - - err = pid.Wait() - - t.Logf("Stdout: %q", stdout.String()) - t.Logf("Stderr: %q", stderr.String()) - - if expectFail { - require.Error(t, err) - require.Contains(t, err.Error(), "security.insecure is not allowed") - return nil, err - } - - require.NoError(t, err) - - capsValue, err := strconv.ParseUint(strings.TrimSpace(stdout.String()), 16, 64) - require.NoError(t, err) - - assertCaps(capsValue) - - return &client.Result{}, nil - } - - solveOpts := SolveOpt{ - AllowedEntitlements: allowedEntitlements, - } - _, err = c.Build(ctx, solveOpts, product, b, nil) - - if expectFail { - require.Error(t, err) - require.Contains(t, err.Error(), "security.insecure is not allowed") - } else { - require.NoError(t, err) - } - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayContainerExtraHosts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - ctx := sb.Context() - product := "buildkit_test" - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - ExtraHosts: []*pb.HostIP{{ - Host: "some.host", - IP: "169.254.11.22", - }}, - }) - if err != nil { - return nil, err - } - - stdout := bytes.NewBuffer(nil) - stderr := bytes.NewBuffer(nil) - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"grep", "169.254.11.22\tsome.host", "/etc/hosts"}, - Stdout: &iohelper.NopWriteCloser{Writer: stdout}, - Stderr: &iohelper.NopWriteCloser{Writer: stderr}, - }) - if err != nil { - ctr.Release(ctx) - return nil, err - } - defer ctr.Release(ctx) - - err = pid.Wait() - - t.Logf("Stdout: %q", stdout.String()) - t.Logf("Stderr: %q", stderr.String()) - - require.NoError(t, err) - - return &client.Result{}, nil - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayContainerHostNetworkingAccess(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerHostNetworking(t, sb, false) -} - -func testClientGatewayContainerHostNetworkingValidation(t *testing.T, sb integration.Sandbox) { - testClientGatewayContainerHostNetworking(t, sb, true) -} - -func testClientGatewayContainerHostNetworking(t *testing.T, sb integration.Sandbox, expectFail bool) { - if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { - t.SkipNow() - } - - if sb.Rootless() && sb.Value("netmode") == defaultNetwork { - // skip "default" network test for rootless, it always runs with "host" network - // https://github.com/moby/buildkit/blob/v0.9.0/docs/rootless.md#known-limitations - t.SkipNow() - } - - requiresLinux(t) - - ctx := sb.Context() - product := "buildkit_test" - - var allowedEntitlements []string - netMode := pb.NetMode_UNSET - if sb.Value("netmode") == hostNetwork { - netMode = pb.NetMode_HOST - allowedEntitlements = []string{entitlements.EntitlementNetworkHost.String()} - if expectFail { - allowedEntitlements = []string{} - } - } - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - s, err := echoserver.NewTestServer("foo") - require.NoError(t, err) - addrParts := strings.Split(s.Addr().String(), ":") - port := addrParts[len(addrParts)-1] - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - NetMode: netMode, - }) - if err != nil { - return nil, err - } - - stdout := bytes.NewBuffer(nil) - stderr := bytes.NewBuffer(nil) - - pid, err := ctr.Start(ctx, client.StartRequest{ - Args: []string{"/bin/sh", "-c", fmt.Sprintf("nc 127.0.0.1 %s | grep foo", port)}, - Stdout: &iohelper.NopWriteCloser{Writer: stdout}, - Stderr: &iohelper.NopWriteCloser{Writer: stderr}, - }) - if err != nil { - ctr.Release(ctx) - return nil, err - } - defer ctr.Release(ctx) - - err = pid.Wait() - - t.Logf("Stdout: %q", stdout.String()) - t.Logf("Stderr: %q", stderr.String()) - - if netMode == pb.NetMode_HOST { - if expectFail { - require.Error(t, err) - require.Contains(t, err.Error(), "network.host is not allowed") - } else { - require.NoError(t, err) - } - } else { - require.Error(t, err) - } - - return &client.Result{}, nil - } - - solveOpts := SolveOpt{ - AllowedEntitlements: allowedEntitlements, - } - _, err = c.Build(ctx, solveOpts, product, b, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// testClientGatewayContainerSignal is testing that we can send a signal -func testClientGatewayContainerSignal(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - product := "buildkit_test" - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) - defer timeout() - - st := llb.Image("busybox:latest") - - def, err := st.Marshal(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal state") - } - - r, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, errors.Wrap(err, "failed to solve") - } - - ctr1, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - defer ctr1.Release(ctx) - - pid1, err := ctr1.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-c", `trap 'kill $(jobs -p); exit 99' HUP; sleep 10 & wait`}, - }) - require.NoError(t, err) - - // allow for the shell script to setup the trap before we signal it - time.Sleep(time.Second) - - err = pid1.Signal(ctx, syscall.SIGHUP) - require.NoError(t, err) - - err = pid1.Wait() - var exitError *gatewayapi.ExitError - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(99), exitError.ExitCode) - - // Now try again to signal an exec process - - ctr2, err := c.NewContainer(ctx, client.NewContainerRequest{ - Mounts: []client.Mount{{ - Dest: "/", - MountType: pb.MountType_BIND, - Ref: r.Ref, - }}, - }) - require.NoError(t, err) - defer ctr2.Release(ctx) - - pid1, err = ctr2.Start(ctx, client.StartRequest{ - Args: []string{"sleep", "10"}, - }) - require.NoError(t, err) - - pid2, err := ctr2.Start(ctx, client.StartRequest{ - Args: []string{"sh", "-c", `trap 'kill $(jobs -p); exit 111' INT; sleep 10 & wait`}, - }) - require.NoError(t, err) - - // allow for the shell script to setup the trap before we signal it - time.Sleep(time.Second) - - err = pid2.Signal(ctx, syscall.SIGINT) - require.NoError(t, err) - - err = pid2.Wait() - require.ErrorAs(t, err, &exitError) - require.Equal(t, uint32(111), exitError.ExitCode) - - pid1.Signal(ctx, syscall.SIGKILL) - pid1.Wait() - return &client.Result{}, err - } - - _, err = c.Build(ctx, SolveOpt{}, product, b, nil) - require.Error(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayCanceledCredentialsCallbackReturns(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - - ctx := sb.Context() - - c, err := New(ctx, 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) - - username := "buildkit-user" - password := "buildkit-pass" - repo := "buildkit/auth-session-" + identity.NewID() - backendRef := registry + "/" + repo + ":latest" - - st := llb.Scratch().File(llb.Mkfile("hello", 0o644, []byte("world"))) - def, err := st.Marshal(ctx) - require.NoError(t, err) - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": backendRef, - "push": "true", - }, - }}, - }, nil) - require.NoError(t, err) - - target, err := url.Parse("http://" + registry) - require.NoError(t, err) - - proxy := httputil.NewSingleHostReverseProxy(target) - director := proxy.Director - proxy.Director = func(req *http.Request) { - director(req) - req.Host = target.Host - } - - proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, secret, ok := r.BasicAuth() - if !ok || user != username || secret != password { - w.Header().Set("WWW-Authenticate", `Basic realm="buildkit-test"`) - w.WriteHeader(http.StatusUnauthorized) - return - } - proxy.ServeHTTP(w, r) - })) - t.Cleanup(proxyServer.Close) - - ref := strings.TrimPrefix(proxyServer.URL, "http://") + "/" + repo + ":latest" - host, _, _ := strings.Cut(ref, "/") - - started := make(chan struct{}) - release := make(chan struct{}) - var startedOnce sync.Once - - provider := &blockingAuthProvider{ - t: t, - host: host, - username: username, - password: password, - started: started, - startedOnce: &startedOnce, - release: release, - } - - _, err = c.Build(ctx, SolveOpt{ - Session: []session.Attachable{provider}, - }, "buildkit_test", func(ctx context.Context, gw client.Client) (*client.Result, error) { - reqCtx, cancel := context.WithCancelCause(ctx) - defer cancel(context.Canceled) - - errCh := make(chan error, 1) - go func() { - _, _, _, err := gw.ResolveImageConfig(reqCtx, ref, sourceresolver.Opt{}) - errCh <- err - }() - - select { - case <-started: - case <-time.After(15 * time.Second): - return nil, errors.New("timed out waiting for registry credential callback") - } - - cancel(context.Canceled) - - select { - case err := <-errCh: - require.Error(t, err) - return client.NewResult(), nil - case <-time.After(3 * time.Second): - close(release) - } - - select { - case err := <-errCh: - require.Error(t, err) - case <-time.After(15 * time.Second): - return nil, errors.New("timed out draining canceled image config resolution") - } - - return nil, errors.New("canceled image config resolution stayed blocked until the credentials callback was released") - }, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testClientGatewayNilResult(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - b := func(ctx context.Context, c client.Client) (*client.Result, error) { - st := llb.Image("busybox:latest") - diff := llb.Diff(st, st) - def, err := diff.Marshal(sb.Context()) - if err != nil { - return nil, err - } - res, err := c.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - Evaluate: true, - }) - require.NoError(t, err) - - ref, err := res.SingleRef() - require.NoError(t, err) - - dirEnts, err := ref.ReadDir(ctx, client.ReadDirRequest{ - Path: "/", - }) - require.NoError(t, err) - require.Len(t, dirEnts, 0) - return nil, nil - } - - _, err = c.Build(sb.Context(), SolveOpt{}, "", b, nil) - require.NoError(t, err) -} - -func testClientGatewayEmptyImageExec(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - 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) - target := registry + "/buildkit/testemptyimage:latest" - - // push an empty image - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", func(ctx context.Context, c client.Client) (*client.Result, error) { - return client.NewResult(), nil - }, nil) - require.NoError(t, err) - - _, err = c.Build(sb.Context(), SolveOpt{}, "", func(ctx context.Context, gw client.Client) (*client.Result, error) { - // create an exec on that empty image (expected to fail, but not to panic) - st := llb.Image(target).Run( - llb.Args([]string{"echo", "hello"}), - ).Root() - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - _, err = gw.Solve(ctx, client.SolveRequest{ - Definition: def.ToPB(), - Evaluate: true, - }) - require.ErrorContains(t, err, `process "echo hello" did not complete successfully`) - return nil, nil - }, nil) - require.NoError(t, err) -} - -type blockingAuthProvider struct { - sessionauth.UnimplementedAuthServer - - t *testing.T - host string - username string - password string - started chan struct{} - startedOnce *sync.Once - release chan struct{} -} - -func (p *blockingAuthProvider) Register(server *grpc.Server) { - sessionauth.RegisterAuthServer(server, p) -} - -func (p *blockingAuthProvider) Credentials(ctx context.Context, req *sessionauth.CredentialsRequest) (*sessionauth.CredentialsResponse, error) { - require.Equal(p.t, p.host, req.Host) - p.startedOnce.Do(func() { - close(p.started) - }) - - select { - case <-ctx.Done(): - return nil, context.Cause(ctx) - case <-p.release: - return &sessionauth.CredentialsResponse{ - Username: p.username, - Secret: p.password, - }, nil - } -} diff --git a/client/client_cache_test.go b/client/client_cache_test.go new file mode 100644 index 000000000..03a4d65ca --- /dev/null +++ b/client/client_cache_test.go @@ -0,0 +1,1804 @@ +package client + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "testing" + "time" + + ctd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/namespaces" + cerrdefs "github.com/containerd/errdefs" + cacheimporttypes "github.com/moby/buildkit/cache/remotecache/v1/types" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/identity" + "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" +) + +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}) +} + +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", + } + + s3Addr, s3Bucket, cleanup, err := helpers.NewMinioServer(t, sb, opts) + require.NoError(t, err) + defer cleanup() + + im := CacheOptionsEntry{ + Type: "s3", + Attrs: map[string]string{ + "region": opts.Region, + "access_key_id": opts.AccessKeyID, + "secret_access_key": opts.SecretAccessKey, + "bucket": s3Bucket, + "endpoint_url": s3Addr, + "use_path_style": "true", + }, + } + ex := CacheOptionsEntry{ + Type: "s3", + Attrs: map[string]string{ + "region": opts.Region, + "access_key_id": opts.AccessKeyID, + "secret_access_key": opts.SecretAccessKey, + "bucket": s3Bucket, + "endpoint_url": s3Addr, + "use_path_style": "true", + }, + } + testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex}) +} + +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, 5, 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, 5, 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 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) +} + +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) +} diff --git a/client/client_control_test.go b/client/client_control_test.go new file mode 100644 index 000000000..693e27a34 --- /dev/null +++ b/client/client_control_test.go @@ -0,0 +1,130 @@ +package client + +import ( + "context" + "fmt" + "io" + "testing" + + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func testCallInfo(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureInfo) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + _, err = c.Info(sb.Context()) + require.NoError(t, err) +} + +func testClientCustomGRPCOpts(t *testing.T, sb integration.Sandbox) { + var interceptedMethods []string + intercept := func( + ctx context.Context, + method string, + req, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + interceptedMethods = append(interceptedMethods, method) + return invoker(ctx, method, req, reply, cc, opts...) + } + c, err := New(sb.Context(), sb.Address(), WithGRPCDialOption(grpc.WithChainUnaryInterceptor(intercept))) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + st := llb.Image(imgName) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + require.Contains(t, interceptedMethods, "/moby.buildkit.v1.Control/Solve") +} + +func testListenBuildHistoryExcludesSoftDeletedRecords(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // Create 3 completed builds so we have multiple history records. + var buildRefs [3]string + for i := range buildRefs { + def, err := llb.Scratch().File(llb.Mkfile(fmt.Sprintf("file%d", i), 0o644, nil)).Marshal(sb.Context()) + require.NoError(t, err) + + buildRefs[i] = identity.NewID() + _, err = c.Solve(sb.Context(), def, SolveOpt{Ref: buildRefs[i]}, nil) + require.NoError(t, err) + } + + refToDelete := buildRefs[1] + + // Start a streaming listener on one specific ref. This increments the + // internal reference count, which causes Delete to soft-delete instead + // of removing the record from the database. + listenerCtx, listenerCancel := context.WithCancelCause(sb.Context()) + defer listenerCancel(nil) + + cl, err := c.ControlClient().ListenBuildHistory(listenerCtx, &controlapi.BuildHistoryRequest{ + Ref: refToDelete, + }) + require.NoError(t, err) + + // Read the initial record so the listener is fully registered. + _, err = cl.Recv() + require.NoError(t, err) + + // Soft-delete: the record is marked deleted but stays in the DB because + // the listener above still holds a reference. + _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ + Ref: refToDelete, + Delete: true, + }) + require.NoError(t, err) + + // List all remaining history records with a limit to trigger sorting. + // Before the fix this panicked with a nil pointer dereference because + // the soft-deleted record left a nil entry in the event slice. + cl2, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ + EarlyExit: true, + Limit: 10, + }) + require.NoError(t, err) + + gotRefs := map[string]bool{} + for { + resp, err := cl2.Recv() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + gotRefs[resp.Record.Ref] = true + } + + // The soft-deleted record must not appear in the results. + assert.False(t, gotRefs[refToDelete], "soft-deleted ref %s should not appear in history list", refToDelete) + assert.True(t, gotRefs[buildRefs[0]], "ref %s should appear in history list", buildRefs[0]) + assert.True(t, gotRefs[buildRefs[2]], "ref %s should appear in history list", buildRefs[2]) + + // Clean up the streaming listener. + listenerCancel(nil) + // Drain the stream so gRPC can clean up. + for { + if _, err := cl.Recv(); err != nil { + break + } + } +} diff --git a/client/client_diskusage_test.go b/client/client_diskusage_test.go new file mode 100644 index 000000000..563e30bb8 --- /dev/null +++ b/client/client_diskusage_test.go @@ -0,0 +1,16 @@ +package client + +import ( + "testing" + + "github.com/moby/buildkit/util/testutil/integration" + "github.com/stretchr/testify/require" +) + +func testCallDiskUsage(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + _, err = c.DiskUsage(sb.Context()) + require.NoError(t, err) +} diff --git a/client/client_exec_test.go b/client/client_exec_test.go new file mode 100644 index 000000000..fde26a365 --- /dev/null +++ b/client/client_exec_test.go @@ -0,0 +1,743 @@ +package client + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/entitlements" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testCgroupParent(t *testing.T, sb integration.Sandbox) { + if sb.Rootless() { + t.SkipNow() + } + + if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) { + t.Skipf("test requires cgroup v2") + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + img := llb.Image("alpine:latest") + st := llb.Scratch() + + run := func(cmd string, ro ...llb.RunOption) { + st = img.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) + } + + cgroupName := "test." + identity.NewID() + + err = os.MkdirAll(filepath.Join("/sys/fs/cgroup", cgroupName), 0755) + require.NoError(t, err) + + defer func() { + err := os.RemoveAll(filepath.Join("/sys/fs/cgroup", cgroupName)) + require.NoError(t, err) + }() + + err = os.WriteFile(filepath.Join("/sys/fs/cgroup", cgroupName, "pids.max"), []byte("10"), 0644) + require.NoError(t, err) + + run(`sh -c "(for i in $(seq 1 10); do sleep 1 & done 2>first.error); cat /proc/self/cgroup >> first"`, llb.WithCgroupParent(cgroupName)) + run(`sh -c "(for i in $(seq 1 10); do sleep 1 & done 2>second.error); cat /proc/self/cgroup >> second"`) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + // neither process leaks parent cgroup name inside container + dt, err := os.ReadFile(filepath.Join(destDir, "first")) + require.NoError(t, err) + require.NotContains(t, strings.TrimSpace(string(dt)), cgroupName) + + dt2, err := os.ReadFile(filepath.Join(destDir, "second")) + require.NoError(t, err) + require.NotContains(t, strings.TrimSpace(string(dt2)), cgroupName) + + dt, err = os.ReadFile(filepath.Join(destDir, "first.error")) + require.NoError(t, err) + require.Contains(t, strings.TrimSpace(string(dt)), "Resource temporarily unavailable") + + dt, err = os.ReadFile(filepath.Join(destDir, "second.error")) + require.NoError(t, err) + require.Equal(t, "", strings.TrimSpace(string(dt))) +} + +func testLinuxResources(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + if sb.Rootless() { + t.SkipNow() + } + + if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) { + t.Skipf("test requires cgroup v2") + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + img := llb.Image("alpine:latest") + st := llb.Scratch() + + run := func(cmd string, ro ...llb.RunOption) { + st = img.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) + } + + // Test memory limit: set 64MiB and verify via cgroup + run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_limited"`, llb.MemoryLimit(64*1024*1024)) + run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_default"`) + + // Test CPU quota: set quota=50000 period=100000 (50% CPU) and verify + run(`sh -c "cat /sys/fs/cgroup/cpu.max > cpu_limited"`, llb.CPUQuota(50000), llb.CPUPeriod(100000)) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "mem_limited")) + require.NoError(t, err) + require.Equal(t, "67108864", strings.TrimSpace(string(dt))) + + dt2, err := os.ReadFile(filepath.Join(destDir, "mem_default")) + require.NoError(t, err) + require.Equal(t, "max", strings.TrimSpace(string(dt2))) + + dt3, err := os.ReadFile(filepath.Join(destDir, "cpu_limited")) + require.NoError(t, err) + require.Equal(t, "50000 100000", strings.TrimSpace(string(dt3))) +} + +func testPassthroughOp(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + ctx := sb.Context() + busybox := llb.Image("busybox:latest") + base := llb.Scratch().File(llb.Mkfile("/base", 0644, []byte("base"))) + + t.Run("requires returns receiver and builds non-output dependency", func(t *testing.T) { + cacheID := "passthrough-requires-" + identity.NewID() + nonOutputDep := busybox.Run(llb.Shlex(`sh -e -c "echo requires-dep-built > /cache/marker; echo should-not-export > /not-output"`)) + nonOutputDep.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) + + outDir := solveStateToLocalDir(ctx, t, c, base.Requires("test.passthrough.requires", nonOutputDep.Root())) + requireLocalFile(t, outDir, "base", "base") + require.NoFileExists(t, filepath.Join(outDir, "not-output")) + + markerDir := solveStateToLocalDir(ctx, t, c, cacheMountFile(busybox, cacheID, "marker")) + requireLocalFile(t, markerDir, "marker", "requires-dep-built\n") + }) + + t.Run("direct passthrough returns selected outputs and builds non-output input", func(t *testing.T) { + cacheID := "passthrough-direct-" + identity.NewID() + nonOutputInput := busybox.Run(llb.Shlex(`sh -e -c "echo direct-input-built > /cache/marker; echo should-not-export > /not-output"`)) + nonOutputInput.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) + + pass := llb.NewPassthroughOp("test.passthrough.direct", []llb.PassthroughInput{ + {State: llb.Scratch().File(llb.Mkfile("/a", 0644, []byte("a"))), Output: true}, + {State: nonOutputInput.Root()}, + {State: llb.Scratch().File(llb.Mkfile("/c", 0644, []byte("c"))), Output: true}, + }) + + outA := llb.NewState(pass.Output()) + outC := llb.NewState(pass.OutputAt(1)) + out := llb.Scratch(). + File(llb.Copy(outA, "/a", "/a")). + File(llb.Copy(outC, "/c", "/c")) + + outDir := solveStateToLocalDir(ctx, t, c, out) + requireLocalFile(t, outDir, "a", "a") + requireLocalFile(t, outDir, "c", "c") + require.NoFileExists(t, filepath.Join(outDir, "not-output")) + + markerDir := solveStateToLocalDir(ctx, t, c, cacheMountFile(busybox, cacheID, "marker")) + requireLocalFile(t, markerDir, "marker", "direct-input-built\n") + }) + + t.Run("failing non-output dependency fails solve", func(t *testing.T) { + fail := busybox.Run(llb.Shlex(`sh -e -c "exit 42"`)).Root() + def, err := base.Requires("test.passthrough.fail", fail).Marshal(ctx) + require.NoError(t, err) + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterLocal, + OutputDir: t.TempDir(), + }}, + }, nil) + require.Error(t, err) + }) +} + +// testRelativeMountpoint is a test that relative paths for mountpoints don't +// fail when runc is upgraded to at least rc95, which introduces an error when +// mountpoints are not absolute. Relative paths should be transformed to +// absolute points based on the llb.State's current working directory. +func testRelativeMountpoint(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + id := identity.NewID() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) + cmdStr := integration.UnixOrWindows( + "sh -c 'echo -n %s > /root/relpath/data'", + `cmd /C echo %s > \\root\\relpath\\data`, + ) + st := llb.Image(imgName).Dir("/root").Run( + llb.Shlexf(cmdStr, id), + ).AddMount("relpath", scratch) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "data")) + require.NoError(t, err) + newLine := integration.UnixOrWindows("", " \r\n") + require.Equal(t, dt, []byte(id+newLine)) +} + +func testRelativeWorkDir(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows( + "docker.io/library/busybox:latest", + "mcr.microsoft.com/windows/nanoserver:ltsc2022", + ) + cmdStr := integration.UnixOrWindows( + `sh -c "pwd > /out/pwd"`, + `cmd /C "cd > /out/pwd"`, + ) + pwd := llb.Image(imgName). + Dir("test1"). + Dir("test2"). + Run(llb.Shlex(cmdStr)). + AddMount("/out", llb.Scratch()) + + def, err := pwd.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "pwd")) + require.NoError(t, err) + pathStr := integration.UnixOrWindows( + "/test1/test2\n", + "C:\\test1\\test2\r\n", + ) + require.Equal(t, []byte(pathStr), dt) +} + +func testRunValidExitCodes(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // no exit codes specified, equivalent to [0] + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + out := llb.Image(imgName) + shellPrefix := integration.UnixOrWindows("sh -c", "cmd /C") + + out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix)).Root() + out = out.Run(llb.Shlexf(`%s "exit 1"`, shellPrefix)).Root() + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.ErrorContains(t, err, "exit code: 1") + + // empty exit codes, equivalent to [0] + out = llb.Image(imgName) + out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix), llb.ValidExitCodes()).Root() + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // if we expect non-zero, those non-zero codes should succeed + out = llb.Image(imgName) + out = out.Run(llb.Shlexf(`%s "exit 1"`, shellPrefix), llb.ValidExitCodes(1)).Root() + out = out.Run(llb.Shlexf(`%s "exit 2"`, shellPrefix), llb.ValidExitCodes(2, 3)).Root() + out = out.Run(llb.Shlexf(`%s "exit 3"`, shellPrefix), llb.ValidExitCodes(2, 3)).Root() + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // if we expect non-zero, returning zero should fail + out = llb.Image(imgName) + out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix), llb.ValidExitCodes(1)).Root() + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.ErrorContains(t, err, "exit code: 0") +} + +func testSecurityMode(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) + command := `sh -c 'cat /proc/self/status | grep CapEff | cut -f 2 > /out'` + mode := llb.SecurityModeSandbox + var allowedEntitlements []string + var assertCaps func(caps uint64) + secMode := sb.Value("secmode") + if secMode == securitySandbox { + assertCaps = func(caps uint64) { + /* + $ capsh --decode=00000000a80425fb + 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap, + cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap + */ + require.Equal(t, uint64(0xa80425fb), caps) + } + allowedEntitlements = []string{} + } else { + assertCaps = func(caps uint64) { + /* + $ capsh --decode=0000003fffffffff + 0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid, + cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw, + cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin, + cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write, + cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read + */ + + // require that _at least_ minimum capabilities are granted + require.Equal(t, uint64(0x3fffffffff), caps&0x3fffffffff) + } + mode = llb.SecurityModeInsecure + allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(command), + llb.Security(mode)) + + 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, + }, + }, + AllowedEntitlements: allowedEntitlements, + }, nil) + + require.NoError(t, err) + + contents, err := os.ReadFile(filepath.Join(destDir, "out")) + require.NoError(t, err) + + caps, err := strconv.ParseUint(strings.TrimSpace(string(contents)), 16, 64) + require.NoError(t, err) + + t.Logf("Caps: %x", caps) + + assertCaps(caps) +} + +func testSecurityModeErrors(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + secMode := sb.Value("secmode") + if secMode == securitySandbox { + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'echo sandbox'`)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + AllowedEntitlements: []string{entitlements.EntitlementSecurityInsecure.String()}, + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "security.insecure is not allowed") + } + if secMode == securityInsecure { + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'echo insecure'`), llb.Security(llb.SecurityModeInsecure)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "security.insecure is not allowed") + } + + st := llb.Image("busybox:latest"). + Run(llb.Shlex("echo invalid")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + var foundExec bool + digestMap := map[digest.Digest]digest.Digest{} + for i, dt := range def.Def { + oldDigest := digest.FromBytes(dt) + var op pb.Op + require.NoError(t, op.UnmarshalVT(dt)) + changed := false + + for j, input := range op.Inputs { + if newDigest, ok := digestMap[digest.Digest(input.Digest)]; ok { + op.Inputs[j].Digest = string(newDigest) + changed = true + } + } + + if execOp := op.GetExec(); execOp != nil { + execOp.Security = pb.SecurityMode(2) + changed = true + foundExec = true + } + + if changed { + newDt, err := op.MarshalVT() + require.NoError(t, err) + def.Def[i] = newDt + + newDigest := digest.FromBytes(newDt) + digestMap[oldDigest] = newDigest + if meta, ok := def.Metadata[oldDigest]; ok { + delete(def.Metadata, oldDigest) + def.Metadata[newDigest] = meta + } + } + } + require.True(t, foundExec) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid security mode") +} + +func testSecurityModeSysfs(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) + if sb.Rootless() { + t.SkipNow() + } + + mode := llb.SecurityModeSandbox + var allowedEntitlements []string + secMode := sb.Value("secmode") + if secMode == securitySandbox { + allowedEntitlements = []string{} + } else { + mode = llb.SecurityModeInsecure + allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + cg := "/sys/fs/cgroup/cpuset/securitytest" // cgroup v1 + if _, err := os.Stat("/sys/fs/cgroup/cpuset"); errors.Is(err, os.ErrNotExist) { + cg = "/sys/fs/cgroup/securitytest" // cgroup v2 + } + + command := "mkdir " + cg + st := llb.Image("busybox:latest"). + Run(llb.Shlex(command), + llb.Security(mode)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + AllowedEntitlements: allowedEntitlements, + }, nil) + + if secMode == securitySandbox { + require.Error(t, err) + require.Contains(t, err.Error(), "did not complete successfully") + require.Contains(t, err.Error(), "mkdir "+cg) + } else { + require.NoError(t, err) + } +} + +func testShmSize(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest").Run( + llb.AddMount("/dev/shm", llb.Scratch(), llb.Tmpfs(llb.TmpfsSize(128*1024*1024))), + llb.Shlex(`sh -c 'mount | grep /dev/shm > /out/out'`), + ) + + out := st.AddMount("/out", llb.Scratch()) + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "out")) + require.NoError(t, err) + require.Contains(t, string(dt), `size=131072k`) +} + +// moby/buildkit#614 +func testStdinClosed(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + cmdStr := integration.UnixOrWindows("cat", "cmd /C more") + st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testUlimit(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string, ro ...llb.RunOption) { + st = busybox.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) + } + + run(`sh -c "ulimit -n > first"`, llb.AddUlimit(llb.UlimitNofile, 1062, 1062)) + run(`sh -c "ulimit -n > second"`) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "first")) + require.NoError(t, err) + require.Equal(t, `1062`, strings.TrimSpace(string(dt))) + + dt2, err := os.ReadFile(filepath.Join(destDir, "second")) + require.NoError(t, err) + require.NotEqual(t, `1062`, strings.TrimSpace(string(dt2))) +} + +func testUser(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest").Run(llb.Shlex(`sh -c "mkdir -m 0777 /wd"`)) + + run := func(user, cmd string) { + if user != "" { + st = st.Run(llb.Shlex(cmd), llb.Dir("/wd"), llb.User(user)) + } else { + st = st.Run(llb.Shlex(cmd), llb.Dir("/wd")) + } + } + + run("daemon", `sh -c "id -nu > user"`) + run("daemon:daemon", `sh -c "id -ng > group"`) + run("daemon:nobody", `sh -c "id -ng > nobody"`) + run("1:1", `sh -c "id -g > userone"`) + run("root", `sh -c "id -Gn > root_supplementary"`) + run("", `sh -c "id -Gn > default_supplementary"`) + run("", `rm /etc/passwd /etc/group`) // test that default user still works + run("", `sh -c "id -u > default_uid"`) + + st = st.Run(llb.Shlex("cp -a /wd/. /out/")) + out := st.AddMount("/out", llb.Scratch()) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "user")) + require.NoError(t, err) + require.Equal(t, "daemon", strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "group")) + require.NoError(t, err) + require.Equal(t, "daemon", strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "nobody")) + require.NoError(t, err) + require.Equal(t, "nobody", strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "userone")) + require.NoError(t, err) + require.Equal(t, "1", strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "root_supplementary")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(dt), "root ")) + require.Contains(t, string(dt), "wheel") + + dt2, err := os.ReadFile(filepath.Join(destDir, "default_supplementary")) + require.NoError(t, err) + require.Equal(t, string(dt), string(dt2)) + + dt, err = os.ReadFile(filepath.Join(destDir, "default_uid")) + require.NoError(t, err) + require.Equal(t, "0", strings.TrimSpace(string(dt))) + + checkAllReleasable(t, c, sb, true) +} + +func requireLocalFile(t *testing.T, dir, name, expected string) { + t.Helper() + + dt, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + require.Equal(t, expected, string(dt)) +} + +func solveStateToLocalDir(ctx context.Context, t *testing.T, c *Client, st llb.State) string { + t.Helper() + + def, err := st.Marshal(ctx) + require.NoError(t, err) + + outDir := t.TempDir() + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterLocal, + OutputDir: outDir, + }}, + }, nil) + require.NoError(t, err) + return outDir +} + +type secModeSandbox struct{} + +func (*secModeSandbox) UpdateConfigFile(in string) (string, func() error) { + return in, nil +} + +type secModeInsecure struct{} + +func (*secModeInsecure) UpdateConfigFile(in string) (string, func() error) { + return in + "\n\ninsecure-entitlements = [\"security.insecure\"]\n", nil +} + +var ( + securitySandbox integration.ConfigUpdater = &secModeSandbox{} + securityInsecure integration.ConfigUpdater = &secModeInsecure{} +) diff --git a/client/client_export_image_test.go b/client/client_export_image_test.go new file mode 100644 index 000000000..902e1dfae --- /dev/null +++ b/client/client_export_image_test.go @@ -0,0 +1,2005 @@ +package client + +import ( + "archive/tar" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + ctd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/content/proxy" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes/docker" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/containerd/v2/plugins/content/local" + cerrdefs "github.com/containerd/errdefs" + "github.com/containerd/platforms" + controlapi "github.com/moby/buildkit/api/services/control" + "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" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/testutil" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func testBuildExportScratch(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) + requiresLinux(t) + 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) + + makeFrontend := func(ps []string) func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + return func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch() + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + r, err := c.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() + if ps == nil { + res.SetRef(ref) + } else { + for _, p := range ps { + res.AddRef(p, ref) + } + + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(ps)), + } + for i, pk := range ps { + p := platforms.MustParse(pk) + + img := ocispecs.Image{ + Platform: p, + Config: ocispecs.ImageConfig{ + Labels: map[string]string{ + "foo": "i am platform " + platforms.Format(p), + }, + }, + } + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, pk), config) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: pk, + Platform: p, + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + } + + return res, nil + } + } + + target := registry + "/buildkit/build/exporter:withnocompressed" + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "unpack": "true", + "compression": "uncompressed", + "attest:provenance": "mode=max", + }, + }, + }, + }, "", makeFrontend(nil), nil) + require.NoError(t, err) + + targetMulti := registry + "/buildkit/build/exporter-multi:withnocompressed" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": targetMulti, + "push": "true", + "unpack": "true", + "compression": "uncompressed", + "attest:provenance": "mode=max", + }, + }, + }, + }, "", makeFrontend([]string{"linux/amd64", "linux/arm64"}), nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Len(t, imgs.Images, 1) + img := imgs.Images[0] + require.Empty(t, img.Layers) + require.True(t, platforms.Only(platforms.DefaultSpec()).Match(img.Img.Platform)) + + desc, provider, err = contentutil.ProviderFromRef(targetMulti) + require.NoError(t, err) + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Len(t, imgs.Images, 2) + img = imgs.Find("linux/amd64") + require.Empty(t, img.Layers) + require.Equal(t, "linux/amd64", platforms.Format(img.Img.Platform)) + require.Equal(t, "i am platform linux/amd64", img.Img.Config.Labels["foo"]) + img = imgs.Find("linux/arm64") + require.Empty(t, img.Layers) + require.Equal(t, "linux/arm64", platforms.Format(img.Img.Platform)) + require.Equal(t, "i am platform linux/arm64", img.Img.Config.Labels["foo"]) +} + +// testBuildExportWithForeignLayer verifies foreign (non-distributable) layer handling during +// image export. propagate=1 preserves foreign media type and URLs; propagate=0 converts to +// regular layers. Skipped on Windows: the test image is Linux-only and BuildKit's Windows +// exporter does not preserve foreign layer media types. +func testBuildExportWithForeignLayer(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows", "test image is Linux-only and BuildKit's Windows exporter strips foreign layer media types") + workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("cpuguy83/buildkit-foreign:latest") + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + t.Run("propagate=1", func(t *testing.T) { + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + target := registry + "/buildkit/build/exporter/foreign:latest" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "oci-mediatypes": "false", + "prefer-nondist-layers": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + resolver := docker.NewResolver(docker.ResolverOptions{PlainHTTP: true}) + name, desc, err := resolver.Resolve(ctx, target) + require.NoError(t, err) + + fetcher, err := resolver.Fetcher(ctx, name) + require.NoError(t, err) + mfst, err := images.Manifest(ctx, contentutil.FromFetcher(fetcher), desc, platforms.Any()) + require.NoError(t, err) + + require.Equal(t, 2, len(mfst.Layers)) + require.Equal(t, images.MediaTypeDockerSchema2LayerForeign, mfst.Layers[0].MediaType) + require.Len(t, mfst.Layers[0].URLs, 1) + require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[1].MediaType) + + rc, err := fetcher.Fetch(ctx, ocispecs.Descriptor{Digest: mfst.Layers[0].Digest, Size: mfst.Layers[0].Size}) + require.NoError(t, err) + defer rc.Close() + + // `Fetch` doesn't error (in the docker resolver), it just returns a reader immediately and does not make a request. + // The request is only made when we attempt to read from the reader. + buf := make([]byte, 1) + _, err = rc.Read(buf) + require.Truef(t, cerrdefs.IsNotFound(err), "expected error for blob that should not be in registry: %s, %v", mfst.Layers[0].Digest, err) + }) + t.Run("propagate=0", func(t *testing.T) { + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + target := registry + "/buildkit/build/exporter/noforeign:latest" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "oci-mediatypes": "false", + }, + }, + }, + }, nil) + require.NoError(t, err) + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + resolver := docker.NewResolver(docker.ResolverOptions{PlainHTTP: true}) + name, desc, err := resolver.Resolve(ctx, target) + require.NoError(t, err) + + fetcher, err := resolver.Fetcher(ctx, name) + require.NoError(t, err) + + mfst, err := images.Manifest(ctx, contentutil.FromFetcher(fetcher), desc, platforms.Any()) + require.NoError(t, err) + + require.Equal(t, 2, len(mfst.Layers)) + require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[0].MediaType) + require.Len(t, mfst.Layers[0].URLs, 0) + require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[1].MediaType) + + rc, err := fetcher.Fetch(ctx, ocispecs.Descriptor{Digest: mfst.Layers[0].Digest, Size: mfst.Layers[0].Size}) + require.NoError(t, err) + defer rc.Close() + + // `Fetch` doesn't error (in the docker resolver), it just returns a reader immediately and does not make a request. + // The request is only made when we attempt to read from the reader. + buf := make([]byte, 1) + _, err = rc.Read(buf) + require.NoError(t, err) + }) +} + +func testBuildExportWithUncompressed(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + cmd := `sh -e -c "echo -n uncompressed > data"` + + st := llb.Scratch() + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + + 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) + + target := registry + "/buildkit/build/exporter:withnocompressed" + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "compression": "uncompressed", + }, + }, + }, + }, 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() + + img, err := client.GetImage(ctx, target) + require.NoError(t, err) + mfst, err := images.Manifest(ctx, client.ContentStore(), img.Target(), nil) + require.NoError(t, err) + require.Equal(t, 1, len(mfst.Layers)) + require.Equal(t, ocispecs.MediaTypeImageLayer, mfst.Layers[0].MediaType) + } + + // new layer with gzip compression + targetImg := llb.Image(target) + cmd = `sh -e -c "echo -n gzip > data"` + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", targetImg) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + compressedTarget := registry + "/buildkit/build/exporter:withcompressed" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": compressedTarget, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + allCompressedTarget := registry + "/buildkit/build/exporter:withallcompressed" + _, err = c.Solve(context.TODO(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": allCompressedTarget, + "push": "true", + "compression": "gzip", + "force-compression": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + if cdAddress == "" { + t.Skip("rest of test requires containerd worker") + } + + err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) + require.NoError(t, err) + err = client.ImageService().Delete(ctx, compressedTarget, images.SynchronousDelete()) + require.NoError(t, err) + err = client.ImageService().Delete(ctx, allCompressedTarget, images.SynchronousDelete()) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) + + // check if the new layer is compressed with compression option + img, err := client.Pull(ctx, compressedTarget) + require.NoError(t, err) + + dt, err := content.ReadBlob(ctx, img.ContentStore(), img.Target()) + require.NoError(t, err) + + mfst := struct { + MediaType string `json:"mediaType,omitempty"` + ocispecs.Manifest + }{} + + err = json.Unmarshal(dt, &mfst) + require.NoError(t, err) + require.Equal(t, 2, len(mfst.Layers)) + require.Equal(t, ocispecs.MediaTypeImageLayer, mfst.Layers[0].MediaType) + require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + item, ok := m["data"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("uncompressed"), item.Data) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[1].Digest}) + require.NoError(t, err) + + m, err = testutil.ReadTarToMap(dt, true) + require.NoError(t, err) + + item, ok = m["data"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("gzip"), item.Data) + + err = client.ImageService().Delete(ctx, compressedTarget, images.SynchronousDelete()) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) + + // check if all layers are compressed with force-compressoin option + img, err = client.Pull(ctx, allCompressedTarget) + require.NoError(t, err) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), img.Target()) + require.NoError(t, err) + + mfst = struct { + MediaType string `json:"mediaType,omitempty"` + ocispecs.Manifest + }{} + + err = json.Unmarshal(dt, &mfst) + require.NoError(t, err) + require.Equal(t, 2, len(mfst.Layers)) + require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[0].MediaType) + require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) + require.NoError(t, err) + + m, err = testutil.ReadTarToMap(dt, true) + require.NoError(t, err) + + item, ok = m["data"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("uncompressed"), item.Data) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[1].Digest}) + require.NoError(t, err) + + m, err = testutil.ReadTarToMap(dt, true) + require.NoError(t, err) + + item, ok = m["data"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("gzip"), item.Data) +} + +// testBuildExportZstd verifies OCI export with Zstd-compressed layers. +// It builds a simple layer, exports it with Zstd compression, and validates both +// OCI and Docker schema 2 media types. +func testBuildExportZstd(t *testing.T, sb integration.Sandbox) { + // Skipped on Windows: + // 1. AddMount() fails because Windows snapshots allow only a single mount per layer. + // 2. OCI export fails because windowsLcowDiff lacks the Compare method needed to + // generate compressed layer diffs. + integration.SkipOnPlatform(t, "windows", "Windows container support incomplete: AddMount() fails with 'number of mounts should always be 1 for Windows layers', OCI export fails with 'windowsLcowDiff does not implement Compare method'") + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + cmd := `sh -e -c "echo -n zstd > data"` + + st := llb.Scratch() + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + 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), + Attrs: map[string]string{ + "compression": "zstd", + }, + }, + }, + // compression option should work even with inline cache exports + CacheExports: []CacheOptionsEntry{ + { + Type: "inline", + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + 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) + + lastLayer := mfst.Layers[len(mfst.Layers)-1] + require.Equal(t, ocispecs.MediaTypeImageLayerZstd, lastLayer.MediaType) + + zstdLayerDigest := lastLayer.Digest.Hex() + require.Equal(t, []byte{0x28, 0xb5, 0x2f, 0xfd}, m[ocispecs.ImageBlobsDir+"/sha256/"+zstdLayerDigest].Data[:4]) + + // repeat without oci mediatype + outW, err = os.Create(out) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Output: fixedWriteCloser(outW), + Attrs: map[string]string{ + "compression": "zstd", + "oci-mediatypes": "false", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(out) + 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, &mfst) + require.NoError(t, err) + + lastLayer = mfst.Layers[len(mfst.Layers)-1] + require.Equal(t, images.MediaTypeDockerSchema2LayerZstd, lastLayer.MediaType) + + require.Equal(t, lastLayer.Digest.Hex(), zstdLayerDigest) +} + +func testBuildPushAndValidate(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -e -c "mkdir -p foo/sub; echo -n first > foo/sub/bar; chmod 0741 foo;"`) + run(`true`) // this doesn't create a layer + run(`sh -c "echo -n second > foo/sub/baz"`) + + 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) + + target := registry + "/buildkit/testpush:latest" + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + // test existence of the image with next build + firstBuild := llb.Image(target) + + def, err = firstBuild.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo/sub/bar")) + require.NoError(t, err) + require.Equal(t, dt, []byte("first")) + + dt, err = os.ReadFile(filepath.Join(destDir, "foo/sub/baz")) + require.NoError(t, err) + require.Equal(t, dt, []byte("second")) + + fi, err := os.Stat(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, 0741, int(fi.Mode()&0777)) + + checkAllReleasable(t, c, sb, false) + + // examine contents of exported tars (requires containerd) + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.Skip("rest of test requires containerd worker") + } + + // TODO: make public pull helper function so this can be checked for standalone as well + + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + // check image in containerd + _, err = client.ImageService().Get(ctx, target) + require.NoError(t, err) + + // deleting image should release all content + err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) + + img, err := client.Pull(ctx, target) + require.NoError(t, err) + + desc, err := img.Config(ctx) + require.NoError(t, err) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), desc) + require.NoError(t, err) + + var ociimg ocispecs.Image + err = json.Unmarshal(dt, &ociimg) + require.NoError(t, err) + + require.NotEqual(t, "", ociimg.OS) + require.NotEqual(t, "", ociimg.Architecture) + require.NotEqual(t, "", ociimg.Config.WorkingDir) + require.Equal(t, "layers", ociimg.RootFS.Type) + require.Equal(t, 3, len(ociimg.RootFS.DiffIDs)) + require.NotNil(t, ociimg.Created) + require.Less(t, time.Since(*ociimg.Created), 2*time.Minute) + require.Condition(t, func() bool { + for _, env := range ociimg.Config.Env { + if strings.HasPrefix(env, "PATH=") { + return true + } + } + return false + }) + + require.Equal(t, 3, len(ociimg.History)) + require.Contains(t, ociimg.History[0].CreatedBy, "foo/sub/bar") + require.Contains(t, ociimg.History[1].CreatedBy, "true") + require.Contains(t, ociimg.History[2].CreatedBy, "foo/sub/baz") + require.False(t, ociimg.History[0].EmptyLayer) + require.False(t, ociimg.History[1].EmptyLayer) + require.False(t, ociimg.History[2].EmptyLayer) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), img.Target()) + require.NoError(t, err) + + mfst := struct { + MediaType string `json:"mediaType,omitempty"` + ocispecs.Manifest + }{} + + err = json.Unmarshal(dt, &mfst) + require.NoError(t, err) + + require.Equal(t, ocispecs.MediaTypeImageManifest, mfst.MediaType) + require.Equal(t, 3, len(mfst.Layers)) + require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[0].MediaType) + require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, true) + require.NoError(t, err) + + item, ok := m["foo/"] + require.True(t, ok) + require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) + require.Equal(t, 0741, int(item.Header.Mode&0777)) + + item, ok = m["foo/sub/"] + require.True(t, ok) + require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) + + item, ok = m["foo/sub/bar"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("first"), item.Data) + + _, ok = m["foo/sub/baz"] + require.False(t, ok) + + dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[2].Digest}) + require.NoError(t, err) + + m, err = testutil.ReadTarToMap(dt, true) + require.NoError(t, err) + + item, ok = m["foo/sub/baz"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + require.Equal(t, []byte("second"), item.Data) + + item, ok = m["foo/"] + require.True(t, ok) + require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) + require.Equal(t, 0741, int(item.Header.Mode&0777)) + + item, ok = m["foo/sub/"] + require.True(t, ok) + require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) + + _, ok = m["foo/sub/bar"] + require.False(t, ok) +} + +// #877 +func testExportBusyboxLocal(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + def, err := llb.Image("busybox").Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + fi, err := os.Stat(filepath.Join(destDir, "bin/busybox")) + require.NoError(t, err) + + fi2, err := os.Stat(filepath.Join(destDir, "bin/vi")) + require.NoError(t, err) + + require.True(t, os.SameFile(fi, fi2)) +} + +func testExportedImageLabels(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.Skip("only supported with containerd") + } + + ctx := sb.Context() + + imgName := integration.UnixOrWindows("busybox", "nanoserver") + prefix := integration.UnixOrWindows( + "", + "cmd /C ", // TODO(profnandaa): currently needs the shell prefix, to be fixed + ) + def, err := llb.Image(imgName). + Run(llb.Shlexf(fmt.Sprintf("%secho foo > /foo", prefix))). + Marshal(ctx) + require.NoError(t, err) + + target := "docker.io/buildkit/build/exporter:labels" + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + }, + }, + }, + }, nil) + require.NoError(t, err) + + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx = namespaces.WithNamespace(ctx, "buildkit") + + img, err := client.GetImage(ctx, target) + require.NoError(t, err) + + store := client.ContentStore() + + info, err := store.Info(ctx, img.Target().Digest) + require.NoError(t, err) + + dt, err := content.ReadBlob(ctx, store, img.Target()) + require.NoError(t, err) + + var mfst ocispecs.Manifest + err = json.Unmarshal(dt, &mfst) + require.NoError(t, err) + + require.Equal(t, 2, len(mfst.Layers)) + + hasLabel := func(dgst digest.Digest) bool { + for k, v := range info.Labels { + if strings.HasPrefix(k, "containerd.io/gc.ref.content.") && v == dgst.String() { + return true + } + } + return false + } + + // check that labels are set on all layers and config + for _, l := range mfst.Layers { + require.True(t, hasLabel(l.Digest)) + } + require.True(t, hasLabel(mfst.Config.Digest)) + + err = c.Prune(sb.Context(), nil, PruneAll) + require.NoError(t, err) + + // layer should not be deleted + _, err = store.Info(ctx, mfst.Layers[1].Digest) + require.NoError(t, err) + + err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) + require.NoError(t, err) + + // layers should be deleted + _, err = store.Info(ctx, mfst.Layers[1].Digest) + require.Error(t, err) + require.True(t, errors.Is(err, cerrdefs.ErrNotFound)) + + // config should be deleted + _, err = store.Info(ctx, mfst.Config.Digest) + require.Error(t, err) + require.True(t, errors.Is(err, cerrdefs.ErrNotFound)) + + // buildkit contentstore still has the layer because it is multi-ns + bkstore := proxy.NewContentStore(c.ContentClient()) + + // layer should be deleted as not kept by history + _, err = bkstore.Info(ctx, mfst.Layers[1].Digest) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + // config should still be there + _, err = bkstore.Info(ctx, img.Metadata().Target.Digest) + require.NoError(t, err) + + _, err = bkstore.Info(ctx, mfst.Config.Digest) + require.NoError(t, err) + + cl, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ + EarlyExit: true, + }) + require.NoError(t, err) + + for { + resp, err := cl.Recv() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ + Ref: resp.Record.Ref, + Delete: true, + }) + require.NoError(t, err) + } + + // now everything should be deleted + _, err = bkstore.Info(ctx, img.Metadata().Target.Digest) + require.Error(t, err) + + _, err = bkstore.Info(ctx, mfst.Config.Digest) + require.Error(t, err) +} + +func testImageResolveConfigDefaultLocalFallback(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) + + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.Skip("test requires containerd worker") + } + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + cdClient, err := newContainerd(cdAddress) + require.NoError(t, err) + defer cdClient.Close() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + target := registry + "/buildkit/default-local-fallback:" + identity.NewID() + st := integration.UnixOrWindows( + llb.Scratch(), + llb.Image("nanoserver:latest"), + ) + def, err := st.File(llb.Mkfile("/fallback", 0600, []byte("local"))).Marshal(ctx) + require.NoError(t, err) + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "store": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + imageService := cdClient.ImageService() + imageStoreCtx := namespaces.WithNamespace(ctx, "buildkit") + img, err := imageService.Get(imageStoreCtx, target) + require.NoError(t, err) + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + ref, dgst, config, err := c.ResolveImageConfig(ctx, target, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + ResolveMode: pb.AttrImageResolveModeDefault, + }, + }) + if err != nil { + return nil, err + } + require.Equal(t, target, ref) + require.Equal(t, img.Target.Digest, dgst) + require.NotEmpty(t, config) + + var ociimg ocispecs.Image + require.NoError(t, json.Unmarshal(config, &ociimg)) + require.NotEmpty(t, ociimg.OS) + require.NotEmpty(t, ociimg.Architecture) + + return gateway.NewResult(), nil + }, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testInvalidExporter(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + def, err := llb.Image(imgName).Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + target := "example.com/buildkit/testoci:latest" + attrs := map[string]string{ + "name": target, + } + for _, exp := range []string{ExporterOCI, ExporterDocker} { + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: attrs, + }, + }, + }, nil) + // output file writer is required + require.Error(t, err) + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: attrs, + OutputDir: destDir, + }, + }, + }, nil) + // output directory is not supported + require.Error(t, err) + } + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + Attrs: attrs, + }, + }, + }, nil) + // output directory is required + require.Error(t, err) + + f, err := os.Create(filepath.Join(destDir, "a")) + require.NoError(t, err) + defer f.Close() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + Attrs: attrs, + Output: fixedWriteCloser(f), + }, + }, + }, nil) + // output file writer is not supported + require.Error(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testLazyImagePush(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.Skip("test requires containerd worker") + } + + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + 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() + + // push the busybox image to the mutable registry + sourceImage := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + def, err := llb.Image(sourceImage).Marshal(sb.Context()) + require.NoError(t, err) + + targetNoTag := registry + "/buildkit/testlazyimage:" + target := targetNoTag + "latest" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + imageService := client.ImageService() + contentStore := client.ContentStore() + + img, err := imageService.Get(ctx, target) + require.NoError(t, err) + + manifest, err := images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.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) + + // retag the image we just pushed with no actual changes, which + // should not result in the image getting un-lazied + def, err = llb.Image(target).Marshal(sb.Context()) + require.NoError(t, err) + + target2 := targetNoTag + "newtag" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target2, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + img, err = imageService.Get(ctx, target2) + require.NoError(t, err) + + manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + // clear all local state out again + err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) + + // try a cross-repo push to same registry, which should still result in the + // image remaining lazy + target3 := registry + "/buildkit/testlazycrossrepo:latest" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target3, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + img, err = imageService.Get(ctx, target3) + require.NoError(t, err) + + manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + // check that a subsequent build can use the previously lazy image in an exec + cmdStr := integration.UnixOrWindows("true", "cmd /C exit 0") + def, err = llb.Image(target2).Run(llb.Args([]string{cmdStr})).Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testOCIExporter(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + target := "example.com/buildkit/testoci:latest" + for _, tc := range []struct { + name string + exporter string + attrs map[string]string + indexMediaType string + manifestMediaType string + configMediaType string + layerMediaType string + }{ + { + name: ExporterOCI, + exporter: ExporterOCI, + attrs: map[string]string{}, + indexMediaType: ocispecs.MediaTypeImageIndex, + manifestMediaType: ocispecs.MediaTypeImageManifest, + configMediaType: ocispecs.MediaTypeImageConfig, + layerMediaType: ocispecs.MediaTypeImageLayerGzip, + }, + { + name: ExporterDocker, + exporter: ExporterDocker, + attrs: map[string]string{ + "name": target, + }, + indexMediaType: ocispecs.MediaTypeImageIndex, + manifestMediaType: ocispecs.MediaTypeImageManifest, + configMediaType: ocispecs.MediaTypeImageConfig, + layerMediaType: ocispecs.MediaTypeImageLayerGzip, + }, + { + name: "docker-oci-mediatypes-false", + exporter: ExporterDocker, + attrs: map[string]string{ + "name": target, + "oci-mediatypes": "false", + }, + indexMediaType: ocispecs.MediaTypeImageIndex, + manifestMediaType: images.MediaTypeDockerSchema2Manifest, + configMediaType: images.MediaTypeDockerSchema2Config, + layerMediaType: images.MediaTypeDockerSchema2LayerGzip, + }, + } { + t.Run(tc.name, func(t *testing.T) { + 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: tc.exporter, + Attrs: tc.attrs, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + _, ok := m["oci-layout"] + require.True(t, ok) + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) + require.NoError(t, err) + require.Equal(t, 2, index.SchemaVersion) + require.Equal(t, tc.indexMediaType, index.MediaType) + require.Equal(t, 1, len(index.Manifests)) + require.Equal(t, tc.manifestMediaType, index.Manifests[0].MediaType) + + var mfst ocispecs.Manifest + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst) + require.NoError(t, err) + require.Equal(t, tc.manifestMediaType, mfst.MediaType) + require.Equal(t, tc.configMediaType, mfst.Config.MediaType) + require.Equal(t, 2, len(mfst.Layers)) + for _, layer := range mfst.Layers { + require.Equal(t, tc.layerMediaType, layer.MediaType) + } + + var ociimg ocispecs.Image + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Config.Digest.Hex()].Data, &ociimg) + require.NoError(t, err) + require.Equal(t, "layers", ociimg.RootFS.Type) + require.Equal(t, 2, len(ociimg.RootFS.DiffIDs)) + + _, ok = m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Layers[0].Digest.Hex()] + require.True(t, ok) + _, ok = m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Layers[1].Digest.Hex()] + require.True(t, ok) + + if tc.exporter != ExporterDocker { + return + } + + var dockerMfst []struct { + Config string + RepoTags []string + Layers []string + } + err = json.Unmarshal(m["manifest.json"].Data, &dockerMfst) + require.NoError(t, err) + require.Equal(t, 1, len(dockerMfst)) + + _, ok = m[dockerMfst[0].Config] + require.True(t, ok) + require.Equal(t, 2, len(dockerMfst[0].Layers)) + require.Equal(t, 1, len(dockerMfst[0].RepoTags)) + require.Equal(t, target, dockerMfst[0].RepoTags[0]) + + for _, l := range dockerMfst[0].Layers { + _, ok := m[l] + require.True(t, ok) + } + }) + } + + checkAllReleasable(t, c, sb, true) +} + +func testOCIExporterContentStore(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + for _, exp := range []string{ExporterOCI, ExporterDocker} { + destDir := t.TempDir() + target := "example.com/buildkit/testoci:latest" + + outTar := filepath.Join(destDir, "out.tar") + outW, err := os.Create(outTar) + require.NoError(t, err) + attrs := map[string]string{} + if exp == ExporterDocker { + attrs["name"] = target + } + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: attrs, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(outTar) + require.NoError(t, err) + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + checkStore := func(dir string) { + err = filepath.Walk(dir, func(filename string, fi os.FileInfo, err error) error { + filename = strings.TrimPrefix(filename, dir) + filename = strings.Trim(filename, "/") + if filename == "" || filename == "ingest" { + return nil + } + + if fi.IsDir() { + require.Contains(t, m, filename+"/") + } else { + require.Contains(t, m, filename) + if filename == ocispecs.ImageIndexFile { + // this file has a timestamp in it, so we can't compare + return nil + } + f, err := os.Open(path.Join(dir, filename)) + require.NoError(t, err) + data, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, m[filename].Data, data) + } + return nil + }) + require.NoError(t, err) + } + + outDir := filepath.Join(destDir, "out.d") + attrs = map[string]string{ + "tar": "false", + } + if exp == ExporterDocker { + attrs["name"] = target + } + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: attrs, + OutputDir: outDir, + }, + }, + }, nil) + require.NoError(t, err) + checkStore(outDir) + + outStoreDir := filepath.Join(destDir, "store.d") + store, err := local.NewStore(outStoreDir) + require.NoError(t, err) + attrs = map[string]string{ + "tar": "false", + } + if exp == ExporterDocker { + attrs["name"] = target + } + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: attrs, + OutputStore: store, + }, + }, + }, nil) + require.NoError(t, err) + checkStore(outDir) + } + + checkAllReleasable(t, c, sb, true) +} + +func testPullWithDigestCheck(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + 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) + + st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data1"))) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + name1 := registry + "/foo/bar:v1.0.0" + + resp, err := c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: "image", + Attrs: map[string]string{ + "name": name1, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dgst1Str := resp.ExporterResponse[exptypes.ExporterImageDigestKey] + dgst1, err := digest.Parse(dgst1Str) + require.NoError(t, err) + + st = llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data2"))) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + name2 := registry + "/foo/bar:v2.0.0" + + resp, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: "image", + Attrs: map[string]string{ + "name": name2, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dgst2str := resp.ExporterResponse[exptypes.ExporterImageDigestKey] + dgst2, err := digest.Parse(dgst2str) + require.NoError(t, err) + + require.NotEqual(t, dgst1, dgst2) + + // if digest is set in ref then pull happens only by the digest + st = llb.Image(name2 + "@" + dgst1.String()) + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, "data1", string(dt)) + + // if digest is set by checksum then pull happens by tag + st = llb.Image(name2, llb.WithImageChecksum(dgst2)) + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, "data2", string(dt)) + + // if checksum doesn't match then pull fails + st = llb.Image(name2, llb.WithImageChecksum(dgst1)) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), fmt.Sprintf("image digest %s for %s does not match expected checksum %s", dgst2, name2, dgst1)) +} + +// testPullZstdImage verifies pulling and re-exporting a Zstd-compressed image. +// It pushes a Zstd-compressed image to a registry, pulls it back, and validates +// the layer media types and Zstd magic bytes for both OCI and Docker schema 2. +func testPullZstdImage(t *testing.T, sb integration.Sandbox) { + // Skipped on Windows: + // 1. AddMount() fails because Windows snapshots allow only a single mount per layer. + // 2. Zstd-compressed push/export fails because windowsLcowDiff lacks the Compare + // method needed to generate compressed layer diffs. + integration.SkipOnPlatform(t, "windows", "Windows container support incomplete: AddMount() fails with 'number of mounts should always be 1 for Windows layers', Zstd export fails with 'windowsLcowDiff does not implement Compare method'") + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + for _, ociMediaTypes := range []bool{true, false} { + t.Run(t.Name()+fmt.Sprintf("/ociMediaTypes=%t", ociMediaTypes), func(t *testing.T) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + cmd := `sh -e -c "echo -n zstd > data"` + + st := llb.Scratch() + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + + 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) + + target := registry + "/buildkit/build/exporter:zstd" + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "compression": "zstd", + "oci-mediatypes": strconv.FormatBool(ociMediaTypes), + }, + }, + }, + }, nil) + require.NoError(t, err) + + ensurePruneAll(t, c, sb) + + st = llb.Image(target).File(llb.Copy(llb.Image(target), "/data", "/zdata")) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + 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), + Attrs: map[string]string{ + "oci-mediatypes": strconv.FormatBool(ociMediaTypes), + }, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + 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) + + firstLayer := mfst.Layers[0] + if ociMediaTypes { + require.Equal(t, ocispecs.MediaTypeImageLayerZstd, firstLayer.MediaType) + } else { + require.Equal(t, images.MediaTypeDockerSchema2LayerZstd, firstLayer.MediaType) + } + + zstdLayerDigest := firstLayer.Digest.Hex() + require.Equal(t, []byte{0x28, 0xb5, 0x2f, 0xfd}, m[ocispecs.ImageBlobsDir+"/sha256/"+zstdLayerDigest].Data[:4]) + }) + } +} + +func testPushByDigest(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + 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) + + st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data"))) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + name := registry + "/foo/bar" + + resp, err := c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: "image", + Attrs: map[string]string{ + "name": name, + "push": "true", + "push-by-digest": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + _, _, err = contentutil.ProviderFromRef(name + ":latest") + require.Error(t, err) + + desc, _, err := contentutil.ProviderFromRef(name + "@" + resp.ExporterResponse[exptypes.ExporterImageDigestKey]) + require.NoError(t, err) + + require.Equal(t, resp.ExporterResponse[exptypes.ExporterImageDigestKey], desc.Digest.String()) + require.Equal(t, ocispecs.MediaTypeImageManifest, desc.MediaType) + require.Greater(t, desc.Size, int64(0)) +} + +func testPushProgressSameVertex(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + 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) + + st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data"))) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + imageName := registry + "/foo/bar:latest" + + var vertexes []*Vertex + var statuses []*VertexStatus + ch := make(chan *SolveStatus) + eg, ctx := errgroup.WithContext(sb.Context()) + eg.Go(func() error { + for { + select { + case <-ctx.Done(): + return context.Cause(ctx) + case ss, ok := <-ch: + if !ok { + return nil + } + vertexes = append(vertexes, ss.Vertexes...) + statuses = append(statuses, ss.Statuses...) + } + } + }) + eg.Go(func() error { + _, err := c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: "image", + Attrs: map[string]string{ + "name": imageName, + "push": "true", + }, + }, + }, + }, ch) + return err + }) + require.NoError(t, eg.Wait()) + + exportVertexes := map[digest.Digest]struct{}{} + pushVertexes := map[digest.Digest]struct{}{} + pushManifestPrefix := "pushing manifest for " + imageName + "@" + + for _, v := range vertexes { + if v.Name == "exporting to image" { + exportVertexes[v.Digest] = struct{}{} + } + } + + for _, st := range statuses { + switch { + case st.ID == "pushing layers": + pushVertexes[st.Vertex] = struct{}{} + case strings.HasPrefix(st.ID, pushManifestPrefix): + pushVertexes[st.Vertex] = struct{}{} + } + } + + require.Len(t, exportVertexes, 1, "expected exactly one export vertex in status stream") + require.NotEmpty(t, pushVertexes, "expected at least one push status in status stream") + + var exportVertex digest.Digest + for v := range exportVertexes { + exportVertex = v + } + for v := range pushVertexes { + require.Equal(t, exportVertex, v, "push statuses should use the same vertex as exporting to image") + } + + hasPushManifest := false + for _, st := range statuses { + if strings.HasPrefix(st.ID, pushManifestPrefix) { + hasPushManifest = true + require.Equal(t, exportVertex, st.Vertex, "pushing manifest should use the same vertex as exporting to image") + } + } + require.True(t, hasPushManifest, "expected pushing manifest status in status stream") +} + +func testStargzLazyPull(t *testing.T, sb integration.Sandbox) { + 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 image + orgImage := "docker.io/library/alpine:latest" + sgzImage := registry + "/stargz/alpine:" + identity.NewID() + def, err := llb.Image(orgImage).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", + }, + }, + }, + }, 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 = llb.Image(sgzImage). + 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", + "oci-mediatypes": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "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 _, layer := range manifest.Layers[:len(manifest.Layers)-1] { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + 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", + }, + }, + }, + }, 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 + 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), + }, + }, + }, 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) +} diff --git a/client/client_export_local_test.go b/client/client_export_local_test.go new file mode 100644 index 000000000..1ad84d5de --- /dev/null +++ b/client/client_export_local_test.go @@ -0,0 +1,910 @@ +package client + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/platforms" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + gateway "github.com/moby/buildkit/frontend/gateway/client" + gatewaypb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/exporter" + "github.com/moby/buildkit/session/exporter/exporterprovider" + "github.com/moby/buildkit/session/filesync" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil" + "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" +) + +func testExportLocalForcePlatformSplit(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("foo", 0600, []byte("hello")), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + destDir := t.TempDir() + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "platform-split": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + fis, err := os.ReadDir(destDir) + require.NoError(t, err) + + require.Len(t, fis, 1, "expected one files in the output directory") + + expPlatform := strings.ReplaceAll(platforms.FormatAll(platforms.Normalize(platforms.DefaultSpec())), "/", "_") + _, err = os.Stat(filepath.Join(destDir, expPlatform+"/")) + require.NoError(t, err) + + fis, err = os.ReadDir(filepath.Join(destDir, expPlatform)) + require.NoError(t, err) + + require.Len(t, fis, 1, "expected one files in the output directory for platform "+expPlatform) + + dt, err := os.ReadFile(filepath.Join(destDir, expPlatform, "foo")) + require.NoError(t, err) + require.Equal(t, "hello", string(dt)) +} + +func testExportLocalModeCopyKeepsStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) + require.NoError(t, err) + require.Equal(t, "fresh", string(dt)) + + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "stale-dir", "old.txt")) + require.NoError(t, err) +} + +func testExportLocalModeCopyMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { + testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, false) +} + +func testExportLocalModeDeleteRemovesStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "mode": "delete", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) + require.NoError(t, err) + require.Equal(t, "fresh", string(dt)) + + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(destDir, "stale-dir")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { + testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, true) +} + +func testExportLocalModeInvalid(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + 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, + Attrs: map[string]string{ + "mode": "backup", + }, + }, + }, + }, nil) + require.Error(t, err) + require.ErrorContains(t, err, `invalid local exporter mode "backup"`) +} + +func testExportLocalModeMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox, deleteMode bool) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + const filesPerPlatform = 20 + platformsToTest := []string{"linux/amd64", "linux/arm64", "linux/arm/v7", "linux/s390x"} + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch() + for j := range filesPerPlatform { + st = st.File( + llb.Mkfile(fmt.Sprintf("file-%03d.txt", j), 0600, fmt.Appendf(nil, "%s-%d", platform, j)), + ) + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + destDir := t.TempDir() + + // Pre-populate dest with directories matching the platform-split naming + // convention. Delete mode must remove stale files from all platform + // directories while preserving every platform's fresh output. + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + for _, platform := range platformsToTest { + platDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) + err = os.MkdirAll(platDir, 0755) + require.NoError(t, err) + for j := range filesPerPlatform { + err = os.WriteFile(filepath.Join(platDir, fmt.Sprintf("old-%03d.txt", j)), []byte("old"), 0600) + require.NoError(t, err) + } + } + + attrs := map[string]string{} + if deleteMode { + attrs["mode"] = "delete" + } + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: attrs, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + if deleteMode { + // Stale top-level file must be gone. + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + } + + // Every platform's build output must survive (no cross-platform deletion). + for _, platform := range platformsToTest { + platformDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) + for j := range filesPerPlatform { + dt, err := os.ReadFile(filepath.Join(platformDir, fmt.Sprintf("file-%03d.txt", j))) + require.NoError(t, err, "missing build output file-%03d.txt for %s", j, platform) + require.Equal(t, fmt.Sprintf("%s-%d", platform, j), string(dt)) + } + if deleteMode { + // Pre-existing files within each platform dir must be cleaned up. + _, err = os.Stat(filepath.Join(platformDir, "old-000.txt")) + require.ErrorIs(t, err, os.ErrNotExist, "stale file not removed for %s", platform) + } + } +} + +func testExportLocalNoPlatformSplit(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + platformsToTest := []string{"linux/amd64", "linux/arm64"} + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch().File( + llb.Mkfile("hello-"+strings.ReplaceAll(platform, "/", "-"), 0600, []byte(platform)), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + destDir := t.TempDir() + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "platform-split": "false", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "hello-linux-amd64")) + require.NoError(t, err) + require.Equal(t, "linux/amd64", string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "hello-linux-arm64")) + require.NoError(t, err) + require.Equal(t, "linux/arm64", string(dt)) +} + +func testExportLocalNoPlatformSplitOverwrite(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + platformsToTest := []string{"linux/amd64", "linux/arm64"} + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch().File( + llb.Mkfile("hello-linux", 0600, []byte(platform)), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + destDir := t.TempDir() + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "platform-split": "false", + }, + }, + }, + }, "", frontend, nil) + require.Error(t, err) + require.ErrorContains(t, err, "cannot overwrite hello-linux from") + require.ErrorContains(t, err, "when split option is disabled") +} + +func testExportTarPlatformIDSanitized(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + const platformID = `..\buildkit-outside` + platform := platforms.DefaultSpec() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("payload.txt", 0600, []byte("payload")), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.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.AddRef(platformID, ref) + + dt, err := json.Marshal(&exptypes.Platforms{ + Platforms: []exptypes.Platform{{ + ID: platformID, + Platform: platform, + }}, + }) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + outW := bytes.NewBuffer(nil) + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(outW.Bytes(), false) + require.NoError(t, err) + + for name := range m { + require.Falsef(t, strings.HasPrefix(name, "../") || + strings.HasPrefix(name, `..\`) || + strings.Contains(name, `/../`) || + strings.Contains(name, `\..\`) || + strings.Contains(name, `\`) || + strings.Contains(name, ":"), + "tar exporter emitted unsafe platform path %q", name) + } + + tarPaths := make([]string, 0, len(m)) + for name := range m { + tarPaths = append(tarPaths, name) + } + sort.Strings(tarPaths) + + expectedPath := path.Join(".._buildkit-outside", integration.UnixOrWindows("payload.txt", "Files/payload.txt")) + item := m[expectedPath] + require.NotNilf(t, item, "expected sanitized tar path %q in %v", expectedPath, tarPaths) + require.Equal(t, "payload", string(item.Data)) +} + +func testExporterTargetExists(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + st := llb.Image(imgName) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + var mdDgst string + res, err := c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: map[string]string{}, + Output: func(m map[string]string) (io.WriteCloser, error) { + mdDgst = m[exptypes.ExporterImageDigestKey] + return nil, nil + }, + }, + }, + }, nil) + require.NoError(t, err) + dgst := res.ExporterResponse[exptypes.ExporterImageDigestKey] + + require.True(t, strings.HasPrefix(dgst, "sha256:")) + require.Equal(t, dgst, mdDgst) + + require.True(t, strings.HasPrefix(res.ExporterResponse[exptypes.ExporterImageConfigDigestKey], "sha256:")) +} + +func testMultipleExporters(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + def, err := llb.Scratch().File(llb.Mkfile("foo.txt", 0o755, nil)).Marshal(context.TODO()) + require.NoError(t, err) + + destDir, destDir2 := t.TempDir(), t.TempDir() + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + defer outW.Close() + + out2 := filepath.Join(destDir, "out2.tar") + outW2, err := os.Create(out2) + require.NoError(t, err) + defer outW2.Close() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + target1, target2 := registry+"/buildkit/build/exporter:image", + registry+"/buildkit/build/alternative:image" + + var exporters []ExportEntry + if workers.IsTestDockerd() { + exporters = append(exporters, ExportEntry{ + Type: "moby", + Attrs: map[string]string{ + "name": strings.Join([]string{target1, target2}, ","), + }, + }) + } else { + exporters = append(exporters, []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target1, + }, + }, + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target2, + "oci-mediatypes": "false", + }, + }, + }...) + } + + exporters = append(exporters, []ExportEntry{ + // Ensure that multiple local exporter destinations are written properly + { + Type: ExporterLocal, + OutputDir: destDir, + }, + { + Type: ExporterLocal, + OutputDir: destDir2, + }, + // Ensure that multiple instances of the same exporter are possible + { + Type: ExporterTar, + Output: fixedWriteCloser(outW), + }, + { + Type: ExporterTar, + Output: fixedWriteCloser(outW2), + }, + }...) + + ref := identity.NewID() + resp, err := c.Solve(sb.Context(), def, SolveOpt{ + Ref: ref, + Exports: exporters, + }, nil) + require.NoError(t, err) + + if workers.IsTestDockerd() { + require.Equal(t, target1+","+target2, resp.ExporterResponse[exptypes.ExporterImageNameKey]) + } else { + require.Equal(t, target2, resp.ExporterResponse[exptypes.ExporterImageNameKey]) + } + require.FileExists(t, filepath.Join(destDir, "out.tar")) + require.FileExists(t, filepath.Join(destDir, "out2.tar")) + require.FileExists(t, filepath.Join(destDir, "foo.txt")) + require.FileExists(t, filepath.Join(destDir2, "foo.txt")) + + history, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ + Ref: ref, + EarlyExit: true, + }) + require.NoError(t, err) + for { + ev, err := history.Recv() + if err != nil { + require.Equal(t, io.EOF, err) + break + } + require.Equal(t, ref, ev.Record.Ref) + + if workers.IsTestDockerd() { + require.Len(t, ev.Record.Result.Results, 1) + require.Len(t, ev.Record.Exporters, 5) + if workers.IsTestDockerdMoby(sb) { + require.Equal(t, ocispecs.MediaTypeImageConfig, ev.Record.Result.Results[0].MediaType) + } else { + require.Equal(t, ocispecs.MediaTypeImageManifest, ev.Record.Result.Results[0].MediaType) + } + } else { + require.Len(t, ev.Record.Result.Results, 2) + require.Len(t, ev.Record.Exporters, 6) + require.Equal(t, ocispecs.MediaTypeImageManifest, ev.Record.Result.Results[0].MediaType) + require.Equal(t, images.MediaTypeDockerSchema2Manifest, ev.Record.Result.Results[1].MediaType) + } + require.Equal(t, ev.Record.Result.Results[0], ev.Record.Result.ResultDeprecated) + } +} + +// TODO: Re-enable the skipped test on Windows (see issue #6296) +func testSessionExporter(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.FeatureOCIExporter, workers.FeatureOCILayout) + c, err := New(context.TODO(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // make an image that is exported there + 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) + } + + cmdPrefix := integration.UnixOrWindows( + `sh -c "echo -n`, + `cmd /C "echo`, + ) + run(fmt.Sprintf(`%s first > foo"`, cmdPrefix)) + run(fmt.Sprintf(`%s second > bar"`, cmdPrefix)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + var attachable []session.Attachable + target := filesync.NewFSSyncTarget() + + attachable = append(attachable, target) + + buildID := identity.NewID() + + outW := bytes.NewBuffer(nil) + exporterCalled := false + exporter := exporterprovider.New(func(ctx context.Context, md map[string][]byte, refs []string) ([]*exporter.ExporterRequest, error) { + require.Len(t, refs, 1) + g := c.GatewayClientForBuild(buildID) + resp, err := g.ReadDir(sb.Context(), &gatewaypb.ReadDirRequest{ + Ref: refs[0], + DirPath: "/", + }) + if err != nil { + return nil, err + } + t.Logf("readdir: %v", resp) + + require.Equal(t, 2, len(resp.Entries)) + require.Equal(t, "bar", resp.Entries[0].Path) + require.Equal(t, "foo", resp.Entries[1].Path) + + exporterCalled = true + target.Add(filesync.WithFSSync(0, fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}))) + return []*exporter.ExporterRequest{ + { + Type: ExporterOCI, + Attrs: map[string]string{ + "compression": "zstd", + }, + }, + }, nil + }) + require.NoError(t, err) + attachable = append(attachable, exporter) + + _, err = c.Build(sb.Context(), SolveOpt{ + EnableSessionExporter: true, + Session: attachable, + Ref: buildID, + }, "", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + require.True(t, exporterCalled) + + // extract the tar stream to the directory as OCI layout + m, err := testutil.ReadTarToMap(outW.Bytes(), false) + require.NoError(t, err) + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) + require.NoError(t, err) + require.Equal(t, 1, len(index.Manifests)) + digest := index.Manifests[0].Digest + + var mfst ocispecs.Manifest + err = json.Unmarshal(m[path.Join("blobs/sha256", digest.Hex())].Data, &mfst) + require.NoError(t, err) + + if runtime.GOOS == "windows" { + mfst.Layers = mfst.Layers[1:] // skip base layer + } + + require.Equal(t, 2, len(mfst.Layers)) + for _, layer := range mfst.Layers { + require.Contains(t, layer.MediaType, ocispecs.MediaTypeImageLayerZstd) + } +} + +// moby/buildkit#1418 +func testTarExporterSymlink(t *testing.T, sb integration.Sandbox) { + 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) + } + + cmdStr := integration.UnixOrWindows( + `sh -c "echo -n first > foo;ln -s foo bar"`, + `cmd /C "echo first> foo && mklink bar foo"`, + ) + run(cmdStr) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + var buf bytes.Buffer + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), + }, + }, + }, nil) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(buf.Bytes(), false) + require.NoError(t, err) + + item, ok := m["foo"] + require.True(t, ok) + require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) + newLine := integration.UnixOrWindows("", " \r\n") + require.Equal(t, []byte("first"+newLine), item.Data) + + item, ok = m["bar"] + require.True(t, ok) + require.Equal(t, tar.TypeSymlink, int32(item.Header.Typeflag)) + require.Equal(t, "foo", item.Header.Linkname) +} + +func testTarExporterWithSocket(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + alpine := llb.Image("docker.io/library/alpine:latest") + def, err := alpine.Run(llb.Args([]string{"sh", "-c", "nc -l -s local:/socket.sock & usleep 100000; kill %1"})).Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Attrs: map[string]string{}, + Output: func(m map[string]string) (io.WriteCloser, error) { + return &iohelper.NopWriteCloser{Writer: io.Discard}, nil + }, + }, + }, + }, nil) + require.NoError(t, err) +} + +func testTarExporterWithSocketCopy(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + alpine := llb.Image("docker.io/library/alpine:latest") + state := alpine.Run(llb.Args([]string{"sh", "-c", "nc -l -s local:/root/socket.sock & usleep 100000; kill %1"})).Root() + + fa := llb.Copy(state, "/root", "/roo2", &llb.CopyInfo{}) + + scratchCopy := llb.Scratch().File(fa) + + def, err := scratchCopy.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} diff --git a/client/client_export_metadata_test.go b/client/client_export_metadata_test.go new file mode 100644 index 000000000..819be230a --- /dev/null +++ b/client/client_export_metadata_test.go @@ -0,0 +1,2422 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + ctd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/platforms" + "github.com/distribution/reference" + 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" + gatewaypb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/solver/result" + "github.com/moby/buildkit/util/attestation" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/testutil" + containerdutil "github.com/moby/buildkit/util/testutil/containerd" + "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/spdx/tools-golang/spdx" + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" +) + +func testAttestationBundle(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + 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) + + ps := []ocispecs.Platform{ + platforms.MustParse("linux/amd64"), + } + + scratch := func() llb.State { + return integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) + } + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{} + + for _, p := range ps { + pk := platforms.Format(p) + expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) + + // build image + st := scratch().File( + llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + stmt := intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + Type: intoto.StatementInTotoV1, + PredicateType: "https://example.com/attestations/v1.0", + }, + Predicate: map[string]any{ + "foo": "1", + }, + } + buff := bytes.NewBuffer(nil) + enc := json.NewEncoder(buff) + require.NoError(t, enc.Encode(stmt)) + + // build attestations + st = scratch().File( + llb.Mkdir("/bundle", 0700), + ) + st = st.File( + llb.Mkfile("/bundle/attestation.json", 0600, buff.Bytes()), + ) + def, err = st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err = c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_Bundle, + Ref: refAttest, + Path: "/bundle", + }) + } + + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + target := registry + "/buildkit/testattestationsbundle:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, len(ps)*2, len(imgs.Images)) + + var bases []*testutil.ImageInfo + for _, p := range ps { + pk := platforms.Format(p) + bases = append(bases, imgs.Find(pk)) + } + + atts := imgs.Filter("unknown/unknown") + require.Equal(t, len(ps)*1, len(atts.Images)) + for i, att := range atts.Images { + require.Equal(t, 1, len(att.LayersRaw)) + var attest intoto.Statement + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]any{"foo": "1"}, attest.Predicate) + name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsbundle@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) + subjects := []intoto.Subject{{ + Name: name, + Digest: map[string]string{ + "sha256": bases[i].Desc.Digest.Encoded(), + }, + }} + require.Equal(t, subjects, attest.Subject) + } +} + +func testAttestationDefaultSubject(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + 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) + + ps := []ocispecs.Platform{ + platforms.MustParse("linux/amd64"), + } + + success := []byte(`{"success": true}`) + scratch := func() llb.State { + return integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) + } + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{} + + for _, p := range ps { + pk := platforms.Format(p) + expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) + + // build image + st := scratch().File( + llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + // build attestations + st = scratch().File(llb.Mkfile("/attestation.json", 0600, success)) + def, err = st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err = c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/attestation.json", + InToto: result.InTotoAttestation{ + PredicateType: "https://example.com/attestations/v1.0", + }, + }) + } + + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + target := registry + "/buildkit/testattestationsemptysubject:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, len(ps)*2, len(imgs.Images)) + + var bases []*testutil.ImageInfo + for _, p := range ps { + pk := platforms.Format(p) + bases = append(bases, imgs.Find(pk)) + } + + atts := imgs.Filter("unknown/unknown") + require.Equal(t, len(ps), len(atts.Images)) + for i, att := range atts.Images { + var attest intoto.Statement + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]any{"success": true}, attest.Predicate) + + name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsemptysubject@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) + subjects := []intoto.Subject{{ + Name: name, + Digest: map[string]string{ + "sha256": bases[i].Desc.Digest.Encoded(), + }, + }} + require.Equal(t, subjects, attest.Subject) + } +} + +func testExportAnnotations(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + 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) + + scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) + + amd64 := platforms.MustParse("linux/amd64") + arm64 := platforms.MustParse("linux/arm64") + ps := []ocispecs.Platform{amd64, arm64} + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(ps)), + } + for i, p := range ps { + st := scratch.File( + llb.Mkfile("platform", 0600, []byte(platforms.Format(p))), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + + k := platforms.Format(p) + res.AddRef(k, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: k, + Platform: p, + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + res.AddMeta(exptypes.AnnotationIndexKey("gi"), []byte("generic index")) + res.AddMeta(exptypes.AnnotationIndexDescriptorKey("gid"), []byte("generic index descriptor")) + res.AddMeta(exptypes.AnnotationManifestKey(nil, "gm"), []byte("generic manifest")) + res.AddMeta(exptypes.AnnotationManifestDescriptorKey(nil, "gmd"), []byte("generic manifest descriptor")) + res.AddMeta(exptypes.AnnotationManifestKey(&amd64, "m"), []byte("amd64 manifest")) + res.AddMeta(exptypes.AnnotationManifestKey(&arm64, "m"), []byte("arm64 manifest")) + res.AddMeta(exptypes.AnnotationManifestDescriptorKey(&amd64, "md"), []byte("amd64 manifest descriptor")) + res.AddMeta(exptypes.AnnotationManifestDescriptorKey(&arm64, "md"), []byte("arm64 manifest descriptor")) + res.AddMeta(exptypes.AnnotationKey{Key: "gd"}.String(), []byte("generic default")) + + return res, nil + } + + // testing for image exporter + + target := registry + "/buildkit/testannotations:latest" + + const created = "2022-01-23T12:34:56Z" + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "annotation-index.gio": "generic index opt", + "annotation-index." + ocispecs.AnnotationCreated: created, + "annotation-manifest.gmo": "generic manifest opt", + "annotation-manifest-descriptor.gmdo": "generic manifest descriptor opt", + "annotation-manifest[linux/amd64].mo": "amd64 manifest opt", + "annotation-manifest-descriptor[linux/amd64].mdo": "amd64 manifest descriptor opt", + "annotation-manifest[linux/arm64].mo": "arm64 manifest opt", + "annotation-manifest-descriptor[linux/arm64].mdo": "arm64 manifest descriptor opt", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + require.Equal(t, "generic index", imgs.Index.Annotations["gi"]) + require.Equal(t, "generic index opt", imgs.Index.Annotations["gio"]) + require.Equal(t, created, imgs.Index.Annotations[ocispecs.AnnotationCreated]) + for _, desc := range imgs.Index.Manifests { + require.Equal(t, "generic manifest descriptor", desc.Annotations["gmd"]) + require.Equal(t, "generic manifest descriptor opt", desc.Annotations["gmdo"]) + switch { + case platforms.Only(amd64).Match(*desc.Platform): + require.Equal(t, "amd64 manifest descriptor", desc.Annotations["md"]) + require.Equal(t, "amd64 manifest descriptor opt", desc.Annotations["mdo"]) + case platforms.Only(arm64).Match(*desc.Platform): + require.Equal(t, "arm64 manifest descriptor", desc.Annotations["md"]) + require.Equal(t, "arm64 manifest descriptor opt", desc.Annotations["mdo"]) + default: + require.Fail(t, "unrecognized platform") + } + } + + amdImage := imgs.Find(platforms.Format(amd64)) + require.Equal(t, "generic default", amdImage.Manifest.Annotations["gd"]) + require.Equal(t, "generic manifest", amdImage.Manifest.Annotations["gm"]) + require.Equal(t, "generic manifest opt", amdImage.Manifest.Annotations["gmo"]) + require.Equal(t, "amd64 manifest", amdImage.Manifest.Annotations["m"]) + require.Equal(t, "amd64 manifest opt", amdImage.Manifest.Annotations["mo"]) + + armImage := imgs.Find(platforms.Format(arm64)) + require.Equal(t, "generic default", armImage.Manifest.Annotations["gd"]) + require.Equal(t, "generic manifest", armImage.Manifest.Annotations["gm"]) + require.Equal(t, "generic manifest opt", armImage.Manifest.Annotations["gmo"]) + require.Equal(t, "arm64 manifest", armImage.Manifest.Annotations["m"]) + require.Equal(t, "arm64 manifest opt", armImage.Manifest.Annotations["mo"]) + + // testing for oci exporter + + destDir := t.TempDir() + + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Output: fixedWriteCloser(outW), + Attrs: map[string]string{ + "annotation-index.gio": "generic index opt", + "annotation-index-descriptor.gido": "generic index descriptor opt", + "annotation-index-descriptor." + ocispecs.AnnotationCreated: created, + "annotation-manifest.gmo": "generic manifest opt", + "annotation-manifest-descriptor.gmdo": "generic manifest descriptor opt", + "annotation-manifest[linux/amd64].mo": "amd64 manifest opt", + "annotation-manifest-descriptor[linux/amd64].mdo": "amd64 manifest descriptor opt", + "annotation-manifest[linux/arm64].mo": "arm64 manifest opt", + "annotation-manifest-descriptor[linux/arm64].mdo": "arm64 manifest descriptor opt", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + var layout ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &layout) + require.Equal(t, "generic index descriptor", layout.Manifests[0].Annotations["gid"]) + require.Equal(t, "generic index descriptor opt", layout.Manifests[0].Annotations["gido"]) + require.Equal(t, created, layout.Manifests[0].Annotations[ocispecs.AnnotationCreated]) + require.NoError(t, err) + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+layout.Manifests[0].Digest.Hex()].Data, &index) + require.Equal(t, "generic index", index.Annotations["gi"]) + require.Equal(t, "generic index opt", index.Annotations["gio"]) + require.NoError(t, err) + + for _, desc := range index.Manifests { + var mfst ocispecs.Manifest + err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+desc.Digest.Hex()].Data, &mfst) + require.NoError(t, err) + + require.Equal(t, "generic default", mfst.Annotations["gd"]) + require.Equal(t, "generic manifest", mfst.Annotations["gm"]) + require.Equal(t, "generic manifest descriptor", desc.Annotations["gmd"]) + require.Equal(t, "generic manifest opt", mfst.Annotations["gmo"]) + require.Equal(t, "generic manifest descriptor opt", desc.Annotations["gmdo"]) + + switch { + case platforms.Only(amd64).Match(*desc.Platform): + require.Equal(t, "amd64 manifest", mfst.Annotations["m"]) + require.Equal(t, "amd64 manifest descriptor", desc.Annotations["md"]) + require.Equal(t, "amd64 manifest opt", mfst.Annotations["mo"]) + require.Equal(t, "amd64 manifest descriptor opt", desc.Annotations["mdo"]) + case platforms.Only(arm64).Match(*desc.Platform): + require.Equal(t, "arm64 manifest", mfst.Annotations["m"]) + require.Equal(t, "arm64 manifest descriptor", desc.Annotations["md"]) + require.Equal(t, "arm64 manifest opt", mfst.Annotations["mo"]) + require.Equal(t, "arm64 manifest descriptor opt", desc.Annotations["mdo"]) + default: + require.Fail(t, "unrecognized platform") + } + } +} + +func testExportAnnotationsMediaTypes(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + 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) + + p := platforms.DefaultSpec() + ps := []ocispecs.Platform{p} + + scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(ps)), + } + for i, p := range ps { + st := scratch.File( + llb.Mkfile("platform", 0600, []byte(platforms.Format(p))), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + + k := platforms.Format(p) + res.AddRef(k, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: k, + Platform: p, + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + target := registry + "/buildkit/testannotationsmedia:1" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "annotation-manifest.a": "b", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 1, len(imgs.Images)) + + target2 := registry + "/buildkit/testannotationsmedia:2" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target2, + "push": "true", + "annotation-index.c": "d", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target2) + require.NoError(t, err) + imgs2, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 1, len(imgs2.Images)) + + require.Equal(t, "b", imgs.Images[0].Manifest.Annotations["a"]) + require.Equal(t, "d", imgs2.Index.Annotations["c"]) + + require.Equal(t, ocispecs.MediaTypeImageIndex, imgs.Index.MediaType) + require.Equal(t, ocispecs.MediaTypeImageIndex, imgs2.Index.MediaType) +} + +func testExportAttestations(t *testing.T, sb integration.Sandbox, ociArtifact bool, setOCIArtifact bool) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + 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) + + ps := []ocispecs.Platform{ + platforms.MustParse("linux/amd64"), + platforms.MustParse("linux/arm64"), + } + + success := []byte(`{"success": true}`) + successDigest := digest.SHA256.FromBytes(success) + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{} + + for _, p := range ps { + pk := platforms.Format(p) + expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + // build attestations + st = llb.Scratch(). + File(llb.Mkfile("/attestation.json", 0600, success)). + File(llb.Mkfile("/attestation2.json", 0600, []byte{})) + def, err = st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err = c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/attestation.json", + InToto: result.InTotoAttestation{ + PredicateType: "https://example.com/attestations/v1.0", + Subjects: []result.InTotoSubject{{ + Kind: gatewaypb.InTotoSubjectKind_Self, + }}, + }, + }) + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/attestation2.json", + InToto: result.InTotoAttestation{ + PredicateType: "https://example.com/attestations2/v1.0", + Subjects: []result.InTotoSubject{{ + Kind: gatewaypb.InTotoSubjectKind_Raw, + Name: "/attestation.json", + Digest: []digest.Digest{successDigest}, + }}, + }, + }) + } + + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + t.Run("image", func(t *testing.T) { + expectedOCIArtifact := !setOCIArtifact || ociArtifact + targets := []string{ + registry + "/buildkit/testattestationsfoo:latest", + registry + "/buildkit/testattestationsbar:latest", + } + attrs := map[string]string{ + "name": strings.Join(targets, ","), + "push": "true", + } + if setOCIArtifact { + attrs["oci-artifact"] = strconv.FormatBool(ociArtifact) + } + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: attrs, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(targets[0]) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, len(ps)*2, len(imgs.Images)) + + var bases []*testutil.ImageInfo + for _, p := range ps { + pk := platforms.Format(p) + img := imgs.Find(pk) + require.NotNil(t, img) + require.Equal(t, pk, platforms.Format(*img.Desc.Platform)) + require.Equal(t, 1, len(img.Layers)) + require.Equal(t, fmt.Appendf(nil, "hello %s!", pk), img.Layers[0]["greeting"].Data) + bases = append(bases, img) + } + + atts := imgs.Filter("unknown/unknown") + require.Equal(t, len(ps), len(atts.Images)) + for i, att := range atts.Images { + require.Equal(t, ocispecs.MediaTypeImageManifest, att.Desc.MediaType) + require.Equal(t, "unknown/unknown", platforms.Format(*att.Desc.Platform)) + require.Equal(t, attestation.DockerAnnotationReferenceTypeDefault, att.Desc.Annotations[attestation.DockerAnnotationReferenceType]) + require.Equal(t, bases[i].Desc.Digest.String(), att.Desc.Annotations[attestation.DockerAnnotationReferenceDigest]) + require.Equal(t, 2, len(att.Layers)) + + if expectedOCIArtifact { + subject := att.Manifest.Subject + require.NotNil(t, subject) + require.Equal(t, bases[i].Desc.MediaType, subject.MediaType) + require.Equal(t, bases[i].Desc.Digest, subject.Digest) + require.Equal(t, bases[i].Desc.Size, subject.Size) + require.Empty(t, subject.Annotations) + require.Nil(t, subject.Platform) + require.Equal(t, "application/vnd.docker.attestation.manifest.v1+json", att.Manifest.ArtifactType) + require.Equal(t, ocispecs.DescriptorEmptyJSON, att.Manifest.Config) + } else { + require.Nil(t, att.Manifest.Subject) + require.Empty(t, att.Manifest.ArtifactType) + + // image config is not included in the OCI artifact + require.Equal(t, "unknown/unknown", att.Img.OS+"/"+att.Img.Architecture) + require.Equal(t, len(att.Layers), len(att.Img.RootFS.DiffIDs)) + require.Equal(t, 0, len(att.Img.History)) + } + + var attest intoto.Statement + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + + purls := map[string]string{} + for _, k := range targets { + named, err := reference.ParseNormalizedNamed(k) + require.NoError(t, err) + name := reference.FamiliarName(named) + version := "" + if tagged, ok := named.(reference.Tagged); ok { + version = tagged.Tag() + } + p := fmt.Sprintf("pkg:docker/%s%s@%s?platform=%s", url.QueryEscape(registry), strings.TrimPrefix(name, registry), version, url.PathEscape(platforms.Format(ps[i]))) + purls[k] = p + } + + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]any{"success": true}, attest.Predicate) + subjects := []intoto.Subject{ + { + Name: purls[targets[0]], + Digest: map[string]string{ + "sha256": bases[i].Desc.Digest.Encoded(), + }, + }, + { + Name: purls[targets[1]], + Digest: map[string]string{ + "sha256": bases[i].Desc.Digest.Encoded(), + }, + }, + } + require.Equal(t, subjects, attest.Subject) + + var attest2 intoto.Statement + require.NoError(t, json.Unmarshal(att.LayersRaw[1], &attest2)) + + require.Equal(t, intoto.StatementInTotoV1, attest2.Type) + require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) + require.Nil(t, attest2.Predicate) + subjects = []intoto.Subject{{ + Name: "/attestation.json", + Digest: map[string]string{ + "sha256": successDigest.Encoded(), + }, + }} + require.Equal(t, subjects, attest2.Subject) + } + + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + return + } + client, err := ctd.New(cdAddress) + require.NoError(t, err) + defer client.Close() + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + for _, target := range targets { + err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) + require.NoError(t, err) + } + checkAllReleasable(t, c, sb, true) + }) + + t.Run("local", func(t *testing.T) { + dir := t.TempDir() + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dir, + Attrs: map[string]string{ + "attestation-prefix": "test.", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + for _, p := range ps { + var attest intoto.Statement + dt, err := os.ReadFile(path.Join(dir, strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(dt, &attest)) + + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]any{"success": true}, attest.Predicate) + + require.Equal(t, []intoto.Subject{{ + Name: "greeting", + Digest: result.ToDigestMap(digest.Canonical.FromString("hello " + platforms.Format(p) + "!")), + }}, attest.Subject) + + var attest2 intoto.Statement + dt, err = os.ReadFile(path.Join(dir, strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation2.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(dt, &attest2)) + + require.Equal(t, intoto.StatementInTotoV1, attest2.Type) + require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) + require.Nil(t, attest2.Predicate) + subjects := []intoto.Subject{{ + Name: "/attestation.json", + Digest: map[string]string{ + "sha256": successDigest.Encoded(), + }, + }} + require.Equal(t, subjects, attest2.Subject) + } + }) + + t.Run("tar", func(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(outW), + Attrs: map[string]string{ + "attestation-prefix": "test.", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + for _, p := range ps { + var attest intoto.Statement + item := m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation.json")] + require.NotNil(t, item) + require.NoError(t, json.Unmarshal(item.Data, &attest)) + + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]any{"success": true}, attest.Predicate) + + require.Equal(t, []intoto.Subject{{ + Name: "greeting", + Digest: result.ToDigestMap(digest.Canonical.FromString("hello " + platforms.Format(p) + "!")), + }}, attest.Subject) + + var attest2 intoto.Statement + item = m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation2.json")] + require.NotNil(t, item) + require.NoError(t, json.Unmarshal(item.Data, &attest2)) + + require.Equal(t, intoto.StatementInTotoV1, attest2.Type) + require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) + require.Nil(t, attest2.Predicate) + subjects := []intoto.Subject{{ + Name: "/attestation.json", + Digest: map[string]string{ + "sha256": successDigest.Encoded(), + }, + }} + require.Equal(t, subjects, attest2.Subject) + } + }) +} + +func testExportAttestationsDefaultOCIArtifact(t *testing.T, sb integration.Sandbox) { + testExportAttestations(t, sb, false, false) +} + +func testExportAttestationsImageManifest(t *testing.T, sb integration.Sandbox) { + testExportAttestations(t, sb, false, true) +} + +func testExportAttestationsOCIArtifact(t *testing.T, sb integration.Sandbox) { + testExportAttestations(t, sb, true, true) +} + +func testImageResolveAttestationChainLocal(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) + requiresLinux(t) + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + target, platform := buildProvenanceImage(ctx, t, c, sb) + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: "docker-image://" + target, + }, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + NoConfig: true, + AttestationChain: true, + Platform: &platform, + ResolveMode: pb.AttrImageResolveModeForcePull, + }, + }) + if err != nil { + return nil, err + } + require.NotNil(t, md.Image) + require.NotNil(t, md.Image.AttestationChain) + ac := md.Image.AttestationChain + require.NotEmpty(t, ac.AttestationManifest) + att := ac.Blobs[ac.AttestationManifest] + require.NotEmpty(t, att.Data) + + var manifest ocispecs.Manifest + require.NoError(t, json.Unmarshal(att.Data, &manifest)) + require.NotEmpty(t, manifest.Layers) + found := false + for _, layer := range manifest.Layers { + if isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { + found = true + break + } + } + require.True(t, found) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func testImageResolveAttestationChainRequiresNetwork(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureMultiPlatform, workers.FeatureProvenance) + // this test temporarily requires direct registry access as the integration test + // mirroring system does not support mirroring attestation chains yet. + // Support is coming in future buildx release. + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + amd64, err := platforms.Parse("linux/amd64") + require.NoError(t, err) + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + const rootDigest = "sha256:4e91099af134f4b1f509fecbd1a55981dfff18d8029d6782e28413210ef468b7" + const imageDigest = "sha256:d2f0b39234a66c3d58f24200d3fda9e4e3d2263c09f9b7286a826ab639713047" + const attestationDigest = "sha256:fb4c46b14f52d1bf790f593921c52dddc698fc6780792fb8469fc60efc0e609b" + const sigDigest = "sha256:bd0a6b088440ba9838e8eec79e736128fe52afce934578eae30ca8675f6d3142" + + id := "registry-1-stage.docker.io/docker/github-builder-test@" + rootDigest + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: "docker-image://" + id, + }, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + NoConfig: true, + AttestationChain: true, + Platform: &amd64, + }, + }) + if err != nil { + return nil, err + } + require.Equal(t, rootDigest, md.Image.Digest.String()) + require.Nil(t, md.Image.Config) + require.NotNil(t, md.Image) + require.NotNil(t, md.Image.AttestationChain) + ac := md.Image.AttestationChain + require.Equal(t, rootDigest, ac.Root.String()) + require.Equal(t, imageDigest, ac.ImageManifest.String()) + require.Equal(t, attestationDigest, ac.AttestationManifest.String()) + require.Len(t, ac.SignatureManifests, 1) + require.Equal(t, sigDigest, ac.SignatureManifests[0].String()) + + desc := ac.Blobs[ac.Root] + require.Equal(t, rootDigest, desc.Descriptor.Digest.String()) + require.Len(t, desc.Data, int(desc.Descriptor.Size)) + require.Equal(t, ocispecs.MediaTypeImageIndex, desc.Descriptor.MediaType) + chk := digest.FromBytes(desc.Data) + require.Equal(t, rootDigest, chk.String()) + + desc = ac.Blobs[ac.ImageManifest] + // image manifest is expected to be missing as content is not needed to verify attestation chain + _, ok := ac.Blobs[digest.Digest(imageDigest)] + require.False(t, ok) + + desc = ac.Blobs[ac.AttestationManifest] + require.Equal(t, attestationDigest, desc.Descriptor.Digest.String()) + require.Equal(t, ocispecs.MediaTypeImageManifest, desc.Descriptor.MediaType) + require.Len(t, desc.Data, int(desc.Descriptor.Size)) + chk = digest.FromBytes(desc.Data) + require.Equal(t, attestationDigest, chk.String()) + + desc = ac.Blobs[ac.SignatureManifests[0]] + require.Equal(t, sigDigest, desc.Descriptor.Digest.String()) + require.Equal(t, ocispecs.MediaTypeImageManifest, desc.Descriptor.MediaType) + require.Len(t, desc.Data, int(desc.Descriptor.Size)) + chk = digest.FromBytes(desc.Data) + require.Equal(t, sigDigest, chk.String()) + + var sigMfst ocispecs.Manifest + err = json.Unmarshal(ac.Blobs[ac.SignatureManifests[0]].Data, &sigMfst) + require.NoError(t, err) + require.Equal(t, 1, len(sigMfst.Layers)) + sigLayer := sigMfst.Layers[0] + sigDesc := ac.Blobs[digest.Digest(sigLayer.Digest)] + require.Equal(t, sigLayer.Digest, sigDesc.Descriptor.Digest) + require.Len(t, sigDesc.Data, int(sigDesc.Descriptor.Size)) + require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", sigDesc.Descriptor.MediaType) + chk = digest.FromBytes(sigDesc.Data) + require.Equal(t, sigLayer.Digest, chk) + + require.Len(t, md.Image.AttestationChain.Blobs, 4) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func testImageResolveProvenanceAttestation(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) + requiresLinux(t) + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + target, platform := buildProvenanceImage(ctx, t, c, sb) + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: "docker-image://" + target, + }, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + NoConfig: true, + ResolveAttestations: []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, + Platform: &platform, + ResolveMode: pb.AttrImageResolveModeForcePull, + }, + }) + if err != nil { + return nil, err + } + require.NotNil(t, md.Image) + require.NotNil(t, md.Image.AttestationChain) + ac := md.Image.AttestationChain + require.NotEmpty(t, ac.AttestationManifest) + att := ac.Blobs[ac.AttestationManifest] + require.NotEmpty(t, att.Data) + + var manifest ocispecs.Manifest + require.NoError(t, json.Unmarshal(att.Data, &manifest)) + require.NotEmpty(t, manifest.Layers) + var ( + stmtBytes []byte + foundLayer ocispecs.Descriptor + ) + for _, layer := range manifest.Layers { + if !isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { + continue + } + blob, ok := ac.Blobs[layer.Digest] + if !ok { + continue + } + stmtBytes = blob.Data + foundLayer = layer + break + } + require.NotEmpty(t, stmtBytes) + require.Contains(t, []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, foundLayer.Annotations["in-toto.io/predicate-type"]) + + var stmt intoto.Statement + require.NoError(t, json.Unmarshal(stmtBytes, &stmt)) + require.Equal(t, intoto.StatementInTotoV1, stmt.Type) + require.Contains(t, []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, stmt.PredicateType) + require.Equal(t, stmt.Subject[0].Digest["sha256"], ac.ImageManifest.Hex()) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func testSBOMScan(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) + requiresLinux(t) + 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) + + p := platforms.MustParse("linux/amd64") + pk := platforms.Format(p) + + scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Image("busybox", llb.Platform(p)) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.SetRef(ref) + + var img ocispecs.Image + cmd := ` +cat < $BUILDKIT_SCAN_DESTINATION/spdx.json +{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "predicate": { + "name": "fallback", + "extraParams": { + "ARG1": "$BUILDKIT_SCAN_ARG1", + "ARG2": "$BUILDKIT_SCAN_ARG2" + } + } +} +EOF +` + img.Config.Cmd = []string{"/bin/sh", "-c", cmd} + img.Platform = p + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(exptypes.ExporterImageConfigKey, config) + + return res, nil + } + + scannerTarget := registry + "/buildkit/testsbomscanner:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + "oci-mediatypes": "false", + }, + }, + }, + }, "", scannerFrontend, nil) + require.NoError(t, err) + + makeTargetFrontend := func(attest bool) func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + return func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello world!")), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + // build attestations + if attest { + st = llb.Scratch(). + File(llb.Mkfile("/result.spdx", 0600, []byte(`{"name": "frontend"}`))) + def, err = st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err = c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/result.spdx", + InToto: result.InTotoAttestation{ + PredicateType: intoto.PredicateSPDX, + }, + }) + } + + return res, nil + } + } + + // test the default fallback scanner + target := registry + "/buildkit/testsbom:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + // test the frontend builtin scanner + target = registry + "/buildkit/testsbom2:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(true), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att := imgs.Find("unknown/unknown") + attest := intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{"name": "frontend"}) + + // test the specified fallback scanner + target = registry + "/buildkit/testsbom3:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{"name": "fallback"}) + + // test the builtin frontend scanner and the specified fallback scanner together + target = registry + "/buildkit/testsbom3:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(true), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{"name": "frontend"}) + + // test configuring the scanner (simple) + target = registry + "/buildkit/testsbom4:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget + ",ARG1=foo,ARG2=bar", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{ + "extraParams": map[string]any{"ARG1": "foo", "ARG2": "bar"}, + }) + + // test configuring the scanner (complex) + target = registry + "/buildkit/testsbom4:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "\"generator=" + scannerTarget + "\",\"ARG1=foo\",\"ARG2=hello,world\"", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{ + "extraParams": map[string]any{"ARG1": "foo", "ARG2": "hello,world"}, + }) +} + +func testSBOMScanSingleRef(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) + requiresLinux(t) + 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) + + p := platforms.DefaultSpec() + pk := platforms.Format(p) + + scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Image("busybox", llb.Platform(p)) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.SetRef(ref) + + var img ocispecs.Image + cmd := ` +cat < $BUILDKIT_SCAN_DESTINATION/spdx.json +{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "predicate": {"name": "fallback"} +} +EOF +` + img.Config.Cmd = []string{"/bin/sh", "-c", cmd} + img.Platform = p + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(exptypes.ExporterImageConfigKey, config) + + return res, nil + } + + scannerTarget := registry + "/buildkit/testsbomscanner:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + "oci-mediatypes": "false", + }, + }, + }, + }, "", scannerFrontend, nil) + require.NoError(t, err) + + targetFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello world!")), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.SetRef(ref) + + var img ocispecs.Image + img.Config.Cmd = []string{"/bin/sh", "-c", "cat /greeting"} + img.Platform = p + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(exptypes.ExporterImageConfigKey, config) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + target := registry + "/buildkit/testsbomsingle:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", targetFrontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + var img *testutil.ImageInfo + for _, candidate := range imgs.Images { + if candidate.Desc.Platform != nil && platforms.Format(*candidate.Desc.Platform) == "unknown/unknown" { + continue + } + img = candidate + break + } + require.NotNil(t, img) + require.Equal(t, []string{"/bin/sh", "-c", "cat /greeting"}, img.Img.Config.Cmd) + + att := imgs.Find("unknown/unknown") + require.NotNil(t, att) + attest := intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Subset(t, attest.Predicate, map[string]any{"name": "fallback"}) +} + +func testSBOMSupplements(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) + requiresLinux(t) + 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) + + p := platforms.MustParse("linux/amd64") + pk := platforms.Format(p) + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + // build image + st := llb.Scratch().File( + llb.Mkfile("/foo", 0600, []byte{}), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + // build attestations + doc := spdx.Document{ + SPDXVersion: "SPDX-2.2", + SPDXIdentifier: "DOCUMENT", + Files: []*spdx.File{ + { + // foo exists... + FileSPDXIdentifier: "SPDXRef-File-foo", + FileName: "/foo", + }, + { + // ...but bar doesn't + FileSPDXIdentifier: "SPDXRef-File-bar", + FileName: "/bar", + }, + }, + } + docBytes, err := json.Marshal(doc) + if err != nil { + return nil, err + } + st = llb.Scratch(). + File(llb.Mkfile("/result.spdx", 0600, docBytes)) + def, err = st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err = c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/result.spdx", + InToto: result.InTotoAttestation{ + PredicateType: intoto.PredicateSPDX, + }, + Metadata: map[string][]byte{ + result.AttestationSBOMCore: []byte("result"), + }, + }) + + return res, nil + } + + // test the default fallback scanner + target := registry + "/buildkit/testsbom:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att := imgs.Find("unknown/unknown") + attest := struct { + intoto.StatementHeader + Predicate spdx.Document + }{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + + require.Equal(t, "DOCUMENT", string(attest.Predicate.SPDXIdentifier)) + require.Len(t, attest.Predicate.Files, 2) + require.Equal(t, "/foo", attest.Predicate.Files[0].FileName) + require.Regexp(t, "^layerID: sha256:", attest.Predicate.Files[0].FileComment) + require.Equal(t, "/bar", attest.Predicate.Files[1].FileName) + require.Empty(t, attest.Predicate.Files[1].FileComment) + + checkAllReleasable(t, c, sb, true) +} + +func testSourceDateEpochClamp(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + var bboxConfig []byte + _, err = c.Build(sb.Context(), SolveOpt{}, "", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + _, _, bboxConfig, err = c.ResolveImageConfig(ctx, "docker.io/library/busybox:latest", sourceresolver.Opt{}) + if err != nil { + return nil, err + } + return nil, nil + }, nil) + require.NoError(t, err) + + m := map[string]json.RawMessage{} + require.NoError(t, json.Unmarshal(bboxConfig, &m)) + delete(m, "created") + bboxConfig, err = json.Marshal(m) + require.NoError(t, err) + + busybox, err := llb.Image("busybox:latest").WithImageConfig(bboxConfig) + require.NoError(t, err) + + def, err := busybox.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + 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, + Attrs: map[string]string{ + exptypes.ExporterImageConfigKey: string(bboxConfig), + }, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + busyboxTmsX, err := readImageTimestamps(dt) + require.NoError(t, err) + busyboxTms := busyboxTmsX.FromImage + + require.Greater(t, len(busyboxTms), 1) + bboxLayerLen := len(busyboxTms) - 1 + + tm, err := time.Parse(time.RFC3339Nano, busyboxTms[1]) + require.NoError(t, err) + + next := tm.Add(time.Hour).Truncate(time.Second) + + st := busybox.Run(llb.Shlex("touch /foo")) + + def, err = st.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{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", next.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: map[string]string{ + exptypes.ExporterImageConfigKey: string(bboxConfig), + }, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(out) + require.NoError(t, err) + + tmsX, err := readImageTimestamps(dt) + require.NoError(t, err) + tms := tmsX.FromImage + + require.Equal(t, len(tms), bboxLayerLen+2) + + expected := next.UTC().Format(time.RFC3339Nano) + require.Equal(t, expected, tms[0]) + require.Equal(t, busyboxTms[1], tms[1]) + require.Equal(t, expected, tms[bboxLayerLen+1]) + require.Equal(t, expected, tmsX.FromAnnotation) + + checkAllReleasable(t, c, sb, true) +} + +func testSourceDateEpochImageExporter(t *testing.T, sb integration.Sandbox) { + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.SkipNow() + } + + // https://github.com/containerd/containerd/commit/133ddce7cf18a1db175150e7a69470dea1bb3132 + minVer := "v1.7.0-beta.1" + cdVersion := containerdutil.GetVersion(t, cdAddress) + // Normalize containerd version to semver format (add v prefix if missing) + normalizedCdVersion := cdVersion + if !strings.HasPrefix(cdVersion, "v") { + normalizedCdVersion = "v" + cdVersion + } + if semver.Compare(normalizedCdVersion, minVer) < 0 { + t.Skipf("containerd version %q does not satisfy minimal version %q", cdVersion, minVer) + } + + workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + + type testVars struct { + baseImgName string + st llb.State + cmdPrefix string + } + + baseImg := llb.Image(integration.UnixOrWindows("busybox:latest", "nanoserver:latest")) + v := integration.UnixOrWindows( + testVars{ + baseImgName: "busybox:latest", + st: llb.Scratch(), + cmdPrefix: `sh -c "echo -n`, + }, + testVars{ + baseImgName: "nanoserver:latest", + st: baseImg, + cmdPrefix: `cmd /C "echo`, + }, + ) + + st := v.st + run := func(cmd string) { + st = baseImg.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(fmt.Sprintf(`%s first> foo"`, v.cmdPrefix)) + run(fmt.Sprintf(`%s second> bar"`, v.cmdPrefix)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + name := strings.ToLower(path.Base(t.Name())) + tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": name, + }, + }, + }, + }, nil) + require.NoError(t, err) + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + img, err := client.GetImage(ctx, name) + require.NoError(t, err) + require.Equal(t, tm, img.Metadata().CreatedAt) + + err = client.ImageService().Delete(ctx, name, images.SynchronousDelete()) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testSourceDateEpochLayerTimestamps(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + tmsX, err := readImageTimestamps(dt) + require.NoError(t, err) + tms := tmsX.FromImage + + require.Equal(t, 3, len(tms)) + + expected := tm.UTC().Format(time.RFC3339Nano) + require.Equal(t, expected, tms[0]) + require.Equal(t, expected, tms[1]) + require.Equal(t, expected, tms[2]) + + checkAllReleasable(t, c, sb, true) +} + +func testSourceDateEpochLocalExporter(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + fi, err := os.Stat(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, fi.ModTime().Format(time.RFC3339), tm.UTC().Format(time.RFC3339)) + + fi, err = os.Stat(filepath.Join(destDir, "bar")) + require.NoError(t, err) + require.Equal(t, fi.ModTime().Format(time.RFC3339), tm.UTC().Format(time.RFC3339)) + + checkAllReleasable(t, c, sb, true) +} + +// testSourceDateEpochReset tests that the SOURCE_DATE_EPOCH is reset if exporter option is set +func testSourceDateEpochReset(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: map[string]string{"source-date-epoch": ""}, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + tmsX, err := readImageTimestamps(dt) + require.NoError(t, err) + tms := tmsX.FromImage + + require.Equal(t, 3, len(tms)) + + expected := tm.UTC().Format(time.RFC3339Nano) + require.NotEqual(t, expected, tms[0]) + require.NotEqual(t, expected, tms[1]) + require.NotEqual(t, expected, tms[2]) + + require.Equal(t, tms[0], tms[2]) + require.NotEqual(t, tms[2], tms[1]) + + checkAllReleasable(t, c, sb, true) +} + +func testSourceDateEpochTarExporter(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "echo -n first > foo"`) + run(`sh -c "echo -n second > bar"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + + tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), + }, + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + require.Equal(t, 2, len(m)) + + require.Equal(t, tm.Format(time.RFC3339), m["foo"].Header.ModTime.Format(time.RFC3339)) + require.Equal(t, tm.Format(time.RFC3339), m["bar"].Header.ModTime.Format(time.RFC3339)) + + checkAllReleasable(t, c, sb, true) +} + +func buildProvenanceImage(ctx context.Context, t *testing.T, c *Client, sb integration.Sandbox) (string, ocispecs.Platform) { + t.Helper() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + platform := platforms.Normalize(platforms.DefaultSpec()) + platformKey := platforms.Format(platform) + target := registry + "/buildkit/testprovenance:latest" + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello provenance")), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platformKey, ref) + + img := ocispecs.Image{ + Platform: platform, + } + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, platformKey), config) + + 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 + } + + _, err = c.Build(ctx, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "mode=max,version=v1", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + return target, platform +} + +// isSLSAPredicateType reports whether the predicate type represents SLSA provenance. +func isSLSAPredicateType(v string) bool { + switch v { + case policyimage.SLSAProvenancePredicateType02, policyimage.SLSAProvenancePredicateType1: + return true + default: + return false + } +} diff --git a/client/client_fileop_test.go b/client/client_fileop_test.go new file mode 100644 index 000000000..b94c5987b --- /dev/null +++ b/client/client_fileop_test.go @@ -0,0 +1,936 @@ +package client + +import ( + "archive/tar" + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/containerd/continuity/fs/fstest" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil" + "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/stretchr/testify/require" + "github.com/tonistiigi/fsutil" +) + +func testCopyFromEmptyImage(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // On Windows, the error messages are different for the Scratch image + // (which is coming from the OS). While the one for the no-layers image + // is being returned by Buildkit (in unix style). + winErrMsgs := []string{ + "foo: The system cannot find the file specified", // for llb.Scratch() + "/foo: no such file or directory", // for tonistiigi/test:nolayers + } + + for i, image := range []llb.State{llb.Scratch(), llb.Image("tonistiigi/test:nolayers")} { + st := llb.Scratch().File(llb.Copy(image, "/", "/")) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + st = llb.Scratch().File(llb.Copy(image, "/foo", "/")) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + errMsg := integration.UnixOrWindows( + "/foo: no such file or directory", + winErrMsgs[i], + ) + require.Contains(t, err.Error(), errMsg) + + imgName := integration.UnixOrWindows( + "busybox:latest", + "nanoserver:latest", + ) + busybox := llb.Image(imgName) + + cmdStr := integration.UnixOrWindows( + `sh -e -c '[ $(ls /scratch | wc -l) = '0' ]'`, + `cmd /C dir \scratch`, + ) + out := busybox.Run(llb.Shlex(cmdStr)) + out.AddMount("/scratch", image, llb.Readonly) + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + } +} + +// containerd/containerd#2119 +func testDuplicateWhiteouts(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -e -c "mkdir -p d0 d1; echo -n first > d1/bar;"`) + run(`sh -c "rm -rf d0 d1"`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + 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), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + 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) + + lastLayer := mfst.Layers[len(mfst.Layers)-1] + + layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] + require.True(t, ok) + + m, err = testutil.ReadTarToMap(layer.Data, true) + require.NoError(t, err) + + _, ok = m[".wh.d0"] + require.True(t, ok) + + _, ok = m[".wh.d1"] + require.True(t, ok) + + // check for a bug that added whiteout for subfile + _, ok = m["d1/.wh.bar"] + require.True(t, !ok) +} + +func testFileOpCopyAlwaysReplaceExistingDestPaths(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + destDirHostPath := integration.Tmpdir(t, + fstest.CreateDir("root", 0755), + fstest.CreateDir("root/overwritedir", 0755), + fstest.CreateFile("root/overwritedir/subfile", nil, 0755), + fstest.CreateFile("root/overwritefile", nil, 0755), + fstest.Symlink("dir", "root/overwritesymlink"), + fstest.CreateDir("root/dir", 0755), + fstest.CreateFile("root/dir/dirfile1", nil, 0755), + fstest.CreateDir("root/dir/overwritesubdir", 0755), + fstest.CreateFile("root/dir/overwritesubfile", nil, 0755), + fstest.Symlink("dirfile1", "root/dir/overwritesymlink"), + ) + destDir := llb.Local("destDir") + + srcDirHostPath := integration.Tmpdir(t, + fstest.CreateDir("root", 0755), + fstest.CreateFile("root/overwritedir", nil, 0755), + fstest.CreateDir("root/overwritefile", 0755), + fstest.CreateFile("root/overwritefile/foo", nil, 0755), + fstest.CreateDir("root/overwritesymlink", 0755), + fstest.CreateDir("root/dir", 0755), + fstest.CreateFile("root/dir/dirfile2", nil, 0755), + fstest.CreateFile("root/dir/overwritesubdir", nil, 0755), + fstest.CreateDir("root/dir/overwritesubfile", 0755), + fstest.CreateDir("root/dir/overwritesymlink", 0755), + ) + srcDir := llb.Local("srcDir") + + resultDir := destDir.File(llb.Copy(srcDir, "/", "/", &llb.CopyInfo{ + CopyDirContentsOnly: true, + AlwaysReplaceExistingDestPaths: true, + })) + + def, err := resultDir.Marshal(sb.Context()) + require.NoError(t, err) + + resultDirHostPath := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: resultDirHostPath, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "destDir": destDirHostPath, + "srcDir": srcDirHostPath, + }, + }, nil) + require.NoError(t, err) + + err = fstest.CheckDirectoryEqualWithApplier(resultDirHostPath, fstest.Apply( + fstest.CreateDir("root", 0755), + fstest.CreateFile("root/overwritedir", nil, 0755), + fstest.CreateDir("root/overwritefile", 0755), + fstest.CreateFile("root/overwritefile/foo", nil, 0755), + fstest.CreateDir("root/overwritesymlink", 0755), + fstest.CreateDir("root/dir", 0755), + fstest.CreateFile("root/dir/dirfile1", nil, 0755), + fstest.CreateFile("root/dir/dirfile2", nil, 0755), + fstest.CreateFile("root/dir/overwritesubdir", nil, 0755), + fstest.CreateDir("root/dir/overwritesubfile", 0755), + fstest.CreateDir("root/dir/overwritesymlink", 0755), + )) + require.NoError(t, err) +} + +func testFileOpCopyChmodText(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + tcases := []struct { + src string + dest string + mode string + }{ + {"file", "f1", "go-w"}, + {"file", "f2", "o-rwx,g+x"}, + {"file", "f3", "u=rwx,g=,o="}, + {"file", "f4", "u+rw,g+r,o-x,o+w"}, + {"dir", "d1", "a+X"}, + {"dir", "d2", "g+rw,o+rw"}, + {"dir", "d3", "u=rwX,go=rX"}, + } + + st := llb.Image("alpine"). + Run(llb.Shlex(`sh -c "mkdir /input && touch /input/file && mkdir /input/dir && chmod 0500 /input/dir && mkdir /expected"`)) + + for _, tc := range tcases { + st = st.Run(llb.Shlex(`sh -c "cp -a /input/` + tc.src + ` /expected/` + tc.dest + ` && chmod ` + tc.mode + ` /expected/` + tc.dest + `"`)) + } + cp := llb.Scratch() + + for _, tc := range tcases { + cp = cp.File(llb.Copy(st.Root(), "/input/"+tc.src, "/"+tc.dest, llb.ChmodOpt{ModeStr: tc.mode})) + } + + for _, tc := range tcases { + st = st.Run(llb.Shlex(`sh -c '[ "$(stat -c '%A' /expected/`+tc.dest+`)" == "$(stat -c '%A' /actual/`+tc.dest+`)" ]'`), llb.AddMount("/actual", cp, llb.Readonly)) + } + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testFileOpCopyIncludeExclude(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateFile("myfile", []byte("data0"), 0600), + fstest.CreateDir("sub", 0700), + fstest.CreateFile("sub/foo", []byte("foo0"), 0600), + fstest.CreateFile("sub/bar", []byte("bar0"), 0600), + ) + + st := llb.Scratch().File( + llb.Copy( + llb.Local("mylocal"), "/", "/", &llb.CopyInfo{ + IncludePatterns: []string{"sub/*"}, + ExcludePatterns: []string{"sub/bar"}, + }, + ), + ) + + busybox := llb.Image("busybox:latest") + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + run(`sh -c "cat /dev/urandom | head -c 100 | sha256sum > 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, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "sub", "foo")) + require.NoError(t, err) + require.Equal(t, []byte("foo0"), dt) + + for _, name := range []string{"myfile", "sub/bar"} { + _, err = os.Stat(filepath.Join(destDir, name)) + require.ErrorIs(t, err, os.ErrNotExist) + } + + randBytes, err := os.ReadFile(filepath.Join(destDir, "unique")) + require.NoError(t, err) + + // Create additional file which doesn't match the include pattern, and make + // sure this doesn't invalidate the cache. + + err = fstest.Apply(fstest.CreateFile("unmatchedfile", []byte("data1"), 0600)).Apply(dir.Name) + require.NoError(t, err) + + st = llb.Scratch().File( + llb.Copy( + llb.Local("mylocal"), "/", "/", &llb.CopyInfo{ + IncludePatterns: []string{"sub/*"}, + ExcludePatterns: []string{"sub/bar"}, + }, + ), + ) + + run(`sh -c "cat /dev/urandom | head -c 100 | sha256sum > 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, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) + + randBytes2, err := os.ReadFile(filepath.Join(destDir, "unique")) + require.NoError(t, err) + + require.Equal(t, randBytes, randBytes2) +} + +func testFileOpCopyRm(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateFile("myfile", []byte("data0"), 0600), + fstest.CreateDir("sub", 0700), + fstest.CreateFile("sub/foo", []byte("foo0"), 0600), + fstest.CreateFile("sub/bar", []byte("bar0"), 0600), + ) + dir2 := integration.Tmpdir( + t, + fstest.CreateFile("file2", []byte("file2"), 0600), + ) + + st := llb.Scratch(). + File( + llb.Copy(llb.Local("mylocal"), "myfile", "myfile2"). + Copy(llb.Local("mylocal"), "sub", "out"). + Rm("out/foo"). + Copy(llb.Local("mylocal2"), "file2", "/")) + + 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, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + "mylocal2": dir2, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "myfile2")) + require.NoError(t, err) + require.Equal(t, []byte("data0"), dt) + + fi, err := os.Stat(filepath.Join(destDir, "out")) + require.NoError(t, err) + require.Equal(t, true, fi.IsDir()) + + dt, err = os.ReadFile(filepath.Join(destDir, "out/bar")) + require.NoError(t, err) + require.Equal(t, []byte("bar0"), dt) + + _, err = os.Stat(filepath.Join(destDir, "out/foo")) + require.ErrorIs(t, err, os.ErrNotExist) + + dt, err = os.ReadFile(filepath.Join(destDir, "file2")) + require.NoError(t, err) + require.Equal(t, []byte("file2"), dt) +} + +// moby/buildkit#3291 +func testFileOpCopyUIDCache(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Copy(llb.Image("alpine").Run(llb.Shlex(`sh -c 'echo 123 > /foo && chown 1000:1000 /foo'`)).Root(), "foo", "foo")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + var buf bytes.Buffer + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), + }, + }, + }, nil) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(buf.Bytes(), false) + require.NoError(t, err) + + fi, ok := m["foo"] + require.True(t, ok) + require.Equal(t, 1000, fi.Header.Uid) + require.Equal(t, 1000, fi.Header.Gid) + + // repeat to check cache does not apply for different uid + st = llb.Scratch().File( + llb.Copy(llb.Image("alpine").Run(llb.Shlex(`sh -c 'echo 123 > /foo'`)).Root(), "foo", "foo")) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + buf = bytes.Buffer{} + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), + }, + }, + }, nil) + require.NoError(t, err) + + m, err = testutil.ReadTarToMap(buf.Bytes(), false) + require.NoError(t, err) + + fi, ok = m["foo"] + require.True(t, ok) + require.Equal(t, 0, fi.Header.Uid) + require.Equal(t, 0, fi.Header.Gid) +} + +// testFileOpInputSwap is a regression test that cache is invalidated when subset of fileop is built +func testFileOpInputSwap(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + base := llb.Scratch().File(llb.Mkfile("/foo", 0600, []byte("foo"))) + + src := llb.Scratch().File(llb.Mkfile("/bar", 0600, []byte("bar"))) + + st := base.File(llb.Copy(src, "/bar", "/baz")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // bar does not exist in base but index of all inputs remains the same + st = base.File(llb.Copy(base, "/bar", "/baz")) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + errStr := integration.UnixOrWindows( + "bar: no such file", + "bar: The system cannot find the file specified", + ) + require.Contains(t, err.Error(), errStr) +} + +func testFileOpMkdirMkfile(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch(). + File(llb.Mkdir("/foo", 0700).Mkfile("bar", 0600, []byte("contents"))) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + fi, err := os.Stat(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, true, fi.IsDir()) + + dt, err := os.ReadFile(filepath.Join(destDir, "bar")) + require.NoError(t, err) + require.Equal(t, []byte("contents"), dt) +} + +func testFileOpRmWildcard(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateDir("foo", 0700), + fstest.CreateDir("bar", 0700), + fstest.CreateFile("foo/target", []byte("foo0"), 0600), + fstest.CreateFile("bar/target", []byte("bar0"), 0600), + fstest.CreateFile("bar/remaining", []byte("bar1"), 0600), + ) + + st := llb.Scratch().File( + llb.Copy(llb.Local("mylocal"), "foo", "foo"). + Copy(llb.Local("mylocal"), "bar", "bar"), + ).File( + llb.Rm("*/target", llb.WithAllowWildcard(true)), + ) + 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, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "bar/remaining")) + require.NoError(t, err) + require.Equal(t, []byte("bar1"), dt) + + fi, err := os.Stat(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, true, fi.IsDir()) + + _, err = os.Stat(filepath.Join(destDir, "foo/target")) + require.ErrorIs(t, err, os.ErrNotExist) + + _, err = os.Stat(filepath.Join(destDir, "bar/target")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func testFileOpSymlink(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + const ( + fileOwner = 7777 + fileGroup = 8888 + linkOwner = 1111 + linkGroup = 2222 + + dummyTimestamp = 42 + ) + + dummyTime := time.Unix(dummyTimestamp, 0) + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch(). + File(llb.Mkdir("/foo", 0700).Mkfile("bar", 0600, []byte("contents"), llb.ChownOpt{ + User: &llb.UserOpt{ + UID: fileOwner, + }, + Group: &llb.UserOpt{ + UID: fileGroup, + }, + })). + File(llb.Symlink("bar", "/baz", llb.WithCreatedTime(dummyTime), llb.ChownOpt{ + User: &llb.UserOpt{ + UID: linkOwner, + }, + Group: &llb.UserOpt{ + UID: linkGroup, + }, + })) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + out := filepath.Join(destDir, "out.tar") + outW, err := os.Create(out) + require.NoError(t, err) + defer outW.Close() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(outW), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + entry, ok := m["bar"] + require.True(t, ok) + + dt = entry.Data + header := entry.Header + require.NoError(t, err) + + require.Equal(t, []byte("contents"), dt) + require.Equal(t, fileOwner, header.Uid) + require.Equal(t, fileGroup, header.Gid) + + entry, ok = m["baz"] + require.Equal(t, true, ok) + + header = entry.Header + // ensure it is a symlink + require.Equal(t, tar.TypeSymlink, rune(header.Typeflag)) + // ensure it is a symlink to the proper location + require.Equal(t, "bar", header.Linkname) + + // make sure it was chowned properly + require.Equal(t, linkOwner, header.Uid) + require.Equal(t, linkGroup, header.Gid) + + // ensure it was timestamped properly + require.Equal(t, dummyTime, header.ModTime) +} + +// #2490 +func testMoveParentDir(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + 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 := llb.Scratch() + + switch imgName { + case "nanoserver:latest": + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).Root() + } + + run(`cmd /c "mkdir foo && echo first > foo/bar && move foo foo2"`) + case "busybox:latest": + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "mkdir -p foo; echo -n first > foo/bar;"`) + run(`mv foo foo2`) + } + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + 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), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + 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) + + lastLayer := mfst.Layers[len(mfst.Layers)-1] + + layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] + require.True(t, ok) + + m, err = testutil.ReadTarToMap(layer.Data, true) + require.NoError(t, err) + + switch imgName { + case "nanoserver:latest": + _, ok = m["/wd/foo/bar"] // Should be false, else move didn't happen + require.False(t, ok) + + _, ok = m["Files/wd/foo/bar"] // Should be false, else move didn't happen + require.False(t, ok) + + _, ok = m["Files/wd/foo2/bar"] + require.True(t, ok) + + _, ok = m["Files/wd/foo2"] + require.True(t, ok) + + _, ok = m["Files/wd"] + require.True(t, ok) + + for key := range m { + if err == nil && strings.Contains(key, "/wd") { + ok = true + break + } else { + ok = false + } + } + require.True(t, ok) + + for key := range m { + if err == nil && strings.Contains(key, "/foo2/bar") { + ok = true + break + } else { + ok = false + } + } + require.True(t, ok) + + for key := range m { + if err == nil && strings.Contains(key, "/foo2") { + ok = true + break + } else { + ok = false + } + } + require.True(t, ok) + case "busybox:latest": + _, ok = m[".wh.foo"] + require.True(t, ok) + + _, ok = m["foo2/"] + require.True(t, ok) + + _, ok = m["foo2/bar"] + require.True(t, ok) + } +} + +func testRmSymlink(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // Test that if FileOp.Rm is called on a symlink, then + // the symlink is removed rather than the target + type testVars struct { + imgName string + touchCmd string + symlinkCmd string + } + + v := integration.UnixOrWindows( + testVars{ + imgName: "alpine", + touchCmd: "touch /mnt/target", + symlinkCmd: "ln -s target /mnt/link", + }, + testVars{ + imgName: "nanoserver:latest", + touchCmd: "cmd /C type nul > /mnt/target", + symlinkCmd: "cmd /c cd /d c:/mnt && mklink link target", + }, + ) + + mnt := llb.Image(v.imgName). + Run(llb.Shlex(v.touchCmd)). + AddMount("/mnt", llb.Scratch()) + + mnt = llb.Image(v.imgName). + Run(llb.Shlex(v.symlinkCmd)). + AddMount("/mnt", mnt) + + def, err := mnt.File(llb.Rm("link")).Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.CreateFile("target", nil, 0644))) +} + +// #276 +func testWhiteoutParentDir(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "mkdir -p foo; echo -n first > foo/bar;"`) + run(`rm foo/bar`) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + 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), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + 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) + + lastLayer := mfst.Layers[len(mfst.Layers)-1] + + layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] + require.True(t, ok) + + m, err = testutil.ReadTarToMap(layer.Data, true) + require.NoError(t, err) + + _, ok = m["foo/.wh.bar"] + require.True(t, ok) + + _, ok = m["foo/"] + require.True(t, ok) +} diff --git a/client/client_frontend_test.go b/client/client_frontend_test.go new file mode 100644 index 000000000..2c500acb9 --- /dev/null +++ b/client/client_frontend_test.go @@ -0,0 +1,431 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + ctd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/platforms" + "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/solver/pb" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil" + "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" +) + +func testFrontendImageNaming(t *testing.T, sb integration.Sandbox) { + 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) + + checkImageName := map[string]func(out, imageName string, exporterResponse map[string]string){ + ExporterOCI: func(out, imageName string, exporterResponse map[string]string) { + // Nothing to check + }, + ExporterDocker: func(out, imageName string, exporterResponse map[string]string) { + require.Contains(t, exporterResponse, exptypes.ExporterImageNameKey) + require.Equal(t, exporterResponse[exptypes.ExporterImageNameKey], "docker.io/library/"+imageName) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + _, ok := m["oci-layout"] + require.True(t, ok) + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) + require.NoError(t, err) + require.Equal(t, 2, index.SchemaVersion) + require.Equal(t, 1, len(index.Manifests)) + + var dockerMfst []struct { + RepoTags []string + } + err = json.Unmarshal(m["manifest.json"].Data, &dockerMfst) + require.NoError(t, err) + require.Equal(t, 1, len(dockerMfst)) + require.Equal(t, 1, len(dockerMfst[0].RepoTags)) + require.Equal(t, imageName, dockerMfst[0].RepoTags[0]) + }, + ExporterImage: func(_, imageName string, exporterResponse map[string]string) { + require.Contains(t, exporterResponse, exptypes.ExporterImageNameKey) + require.Equal(t, exporterResponse[exptypes.ExporterImageNameKey], imageName) + + // check if we can pull (requires containerd) + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + return + } + + // TODO: make public pull helper function so this can be checked for standalone as well + + client, err := ctd.New(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + // check image in containerd + _, err = client.ImageService().Get(ctx, imageName) + require.NoError(t, err) + + // deleting image should release all content + err = client.ImageService().Delete(ctx, imageName, images.SynchronousDelete()) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) + + _, err = client.Pull(ctx, imageName) + require.NoError(t, err) + + err = client.ImageService().Delete(ctx, imageName, images.SynchronousDelete()) + require.NoError(t, err) + }, + } + + // A caller provided name takes precedence over one returned by the frontend. Iterate over both options. + for _, winner := range []string{"frontend", "caller"} { + // The double layer of `t.Run` here is required so + // that the inner-most tests (with the actual + // functionality) have definitely completed before the + // sandbox and registry cleanups (defered above) are run. + t.Run(winner, func(t *testing.T) { + for _, exp := range []string{ExporterOCI, ExporterDocker, ExporterImage} { + t.Run(exp, func(t *testing.T) { + destDir := t.TempDir() + + so := SolveOpt{ + Exports: []ExportEntry{ + { + Type: exp, + Attrs: map[string]string{}, + }, + }, + } + + out := filepath.Join(destDir, "out.tar") + + imageName := "image-" + exp + "-fe:latest" + + switch exp { + case ExporterOCI: + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + t.Skip("oci exporter does not support named images") + case ExporterDocker: + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + outW, err := os.Create(out) + require.NoError(t, err) + so.Exports[0].Output = fixedWriteCloser(outW) + case ExporterImage: + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + imageName = registry + "/" + imageName + so.Exports[0].Attrs["push"] = "true" + } + + feName := imageName + switch winner { + case "caller": + feName = "loser:latest" + so.Exports[0].Attrs["name"] = imageName + case "frontend": + so.Exports[0].Attrs["name"] = "*" + } + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + res.AddMeta("image.name", []byte(feName)) + return res, nil + } + + resp, err := c.Build(sb.Context(), so, "", frontend, nil) + require.NoError(t, err) + + checkImageName[exp](out, imageName, resp.ExporterResponse) + }) + } + }) + } + + checkAllReleasable(t, c, sb, true) +} + +func testFrontendLintSkipVerifyPlatforms(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + dockerfile := ` +FROM scratch +COPY foo /foo + ` + st := llb.Scratch().File( + llb.Mkfile("Dockerfile", 0600, []byte(dockerfile)). + Mkfile("foo", 0600, []byte("data"))) + + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + FrontendInputs: map[string]*pb.Definition{ + "context": def.ToPB(), + "dockerfile": def.ToPB(), + }, + FrontendOpt: map[string]string{ + "requestid": "frontend.lint", + "frontend.caps": "moby.buildkit.frontend.subrequests", + }, + }) + } + + wc := newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "platform": "linux/amd64,linux/arm64", + }, + }, "", frontend, wc.status) + require.NoError(t, err) + warnings := wc.wait() + + for _, w := range warnings { + t.Logf("warning: %s", string(w.Short)) + } + require.Len(t, warnings, 0) +} + +func testFrontendMetadataReturn(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + res.AddMeta("frontend.returned", []byte("true")) + res.AddMeta("not-frontend.not-returned", []byte("false")) + res.AddMeta("frontendnot.returned.either", []byte("false")) + return res, nil + } + + var exports []ExportEntry + if workers.IsTestDockerdMoby(sb) { + exports = []ExportEntry{{ + Type: "moby", + Attrs: map[string]string{ + "name": "reg.dummy:5000/buildkit/test:latest", + }, + }} + } else { + exports = []ExportEntry{{ + Type: ExporterOCI, + Attrs: map[string]string{}, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: io.Discard}), + }} + } + + res, err := c.Build(sb.Context(), SolveOpt{ + Exports: exports, + }, "", frontend, nil) + require.NoError(t, err) + require.Contains(t, res.ExporterResponse, "frontend.returned") + require.Equal(t, "true", res.ExporterResponse["frontend.returned"]) + require.NotContains(t, res.ExporterResponse, "not-frontend.not-returned") + require.NotContains(t, res.ExporterResponse, "frontendnot.returned.either") + checkAllReleasable(t, c, sb, true) +} + +func testFrontendUseSolveResults(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("foo", 0600, []byte("data")), + ) + + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + res, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := res.SingleRef() + if err != nil { + return nil, err + } + + st2, err := ref.ToState() + if err != nil { + return nil, err + } + + st = llb.Scratch().File( + llb.Copy(st2, "foo", "foo2"), + ) + + def, err = st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + destDir := t.TempDir() + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo2")) + require.NoError(t, err) + require.Equal(t, dt, []byte("data")) +} + +func testFrontendVerifyPlatforms(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("foo", 0600, []byte("data")), + ) + + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + wc := newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "platform": "linux/amd64,linux/arm64", + }, + }, "", frontend, wc.status) + require.NoError(t, err) + warnings := wc.wait() + + require.Len(t, warnings, 1) + require.Contains(t, string(warnings[0].Short), "Multiple platforms requested but result is not multi-platform") + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{}, + }, "", frontend, wc.status) + require.NoError(t, err) + + warnings = wc.wait() + require.Len(t, warnings, 0, warningsListOutput(warnings)) + + frontend = func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + platformsToTest := []string{"linux/amd64", "linux/arm64"} + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch().File( + llb.Mkfile("platform", 0600, []byte(platform)), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "platform": "linux/amd64,linux/arm64", + }, + }, "", frontend, wc.status) + require.NoError(t, err) + warnings = wc.wait() + + require.Len(t, warnings, 0) + + wc = newWarningsCapture() + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{}, + }, "", frontend, wc.status) + require.NoError(t, err) + + warnings = wc.wait() + require.Len(t, warnings, 1) + require.Contains(t, string(warnings[0].Short), "do not match result platforms linux/amd64,linux/arm64") +} diff --git a/client/client_git_source_test.go b/client/client_git_source_test.go new file mode 100644 index 000000000..f757c0f2e --- /dev/null +++ b/client/client_git_source_test.go @@ -0,0 +1,706 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/plugins/content/local" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/client/llb/sourceresolver" + gateway "github.com/moby/buildkit/frontend/gateway/client" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/gitutil/gitobject" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +// testGitBundleRoundTrip exercises the bundle round-trip end to end: +// +// 1. Seed a git repo served over HTTP. +// 2. Build 1 uses GitCheckoutBundle() to export a single-file git bundle. +// 3. Build 2 imports that bundle via GitBundleURL("oci-layout+blob://...") +// against a client-side OCI layout store, once with the sha pinned as ref +// and once with a symbolic ref ("master"). Both checkouts must succeed +// and produce a worktree matching Build 0 (plain git clone). +// 4. A KeepGitDir subtest exercises llb.KeepGitDir() + bundle and asserts +// no fake refs/buildkit/* namespace leaks into the checkout's .git dir. +// 5. A provenance subtest enables attest:provenance and asserts both the +// git-commit material and the bundle-blob material are recorded. +func testGitBundleRoundTrip(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCILayout) + integration.SkipOnPlatform(t, "windows") + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + gitDir := t.TempDir() + err = runInDir(gitDir, + "git init -b master", + "git config --local user.email test@example.com", + "git config --local user.name test", + "echo hello > file.txt", + "git add file.txt", + "git commit -m initial", + "git update-server-info", + ) + require.NoError(t, err) + + cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") + cmd.Dir = gitDir + out, err := cmd.Output() + require.NoError(t, err) + headSha := strings.TrimSpace(string(out)) + + server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) + defer server.Close() + repoURL := server.URL + "/.git" + + // Build 1: export a bundle via GitCheckoutBundle. + bundleDir := t.TempDir() + st := llb.Git(repoURL, "master", llb.GitChecksum(headSha), llb.GitCheckoutBundle()) + def, err := st.Marshal(ctx) + require.NoError(t, err) + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: bundleDir}}, + }, nil) + require.NoError(t, err) + + bundleBytes, err := os.ReadFile(filepath.Join(bundleDir, "bundle")) + require.NoError(t, err) + require.NotEmpty(t, bundleBytes) + bundleDgst := digest.FromBytes(bundleBytes) + + // Sanity-check the bundle locally: it must contain the commit we pinned, + // landing in the natural ref namespace (refs/heads/master). + localBare := t.TempDir() + err = runInDir(localBare, "git init --bare") + require.NoError(t, err) + bundleRoot := t.TempDir() + root, err := os.OpenRoot(bundleRoot) + require.NoError(t, err) + require.NoError(t, root.WriteFile("bundle.pack", bundleBytes, 0644)) + require.NoError(t, root.Close()) + bundlePath := filepath.Join(bundleRoot, "bundle.pack") + err = runInDir(localBare, fmt.Sprintf("git fetch %s +refs/*:refs/*", bundlePath)) + require.NoError(t, err) + //nolint:gosec // Test-controlled temp dir and commit SHA are not attacker input. + cmd = exec.CommandContext(context.TODO(), "git", "--git-dir="+localBare, "cat-file", "-e", headSha+"^{commit}") + require.NoError(t, cmd.Run(), "bundle does not contain expected commit") + //nolint:gosec // Test-controlled temp dir is not attacker input. + cmd = exec.CommandContext(context.TODO(), "git", "--git-dir="+localBare, "rev-parse", "--verify", "refs/heads/master") + masterOut, err := cmd.CombinedOutput() + require.NoError(t, err, "bundle should carry user's ref refs/heads/master; output=%q", string(masterOut)) + require.Equal(t, headSha, strings.TrimSpace(string(masterOut))) + + // Stage the bundle in a client-side OCI layout store so Build 2 can + // resolve it through an oci-layout+blob:// locator. + storeDir := t.TempDir() + store, err := local.NewStore(storeDir) + require.NoError(t, err) + err = content.WriteBlob(ctx, store, "bundle-"+bundleDgst.String(), bytes.NewReader(bundleBytes), + ocispecs.Descriptor{Digest: bundleDgst, Size: int64(len(bundleBytes))}) + require.NoError(t, err) + + csID := "git-bundle-oci-store" + bundleLocator := fmt.Sprintf("oci-layout+blob://not/real@%s", bundleDgst.String()) + + cases := []struct { + name string + ref string // value passed as the fragment/ref to llb.Git + }{ + {"sha-ref", headSha}, + {"symbolic-ref", "master"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + destDir := t.TempDir() + st := llb.Git( + repoURL, + tc.ref, + llb.GitChecksum(headSha), + llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) + require.NoError(t, err) + require.Equal(t, "hello\n", string(dt)) + }) + } + + // KeepGitDir + bundle: the checkout's .git must not contain any leaked + // refs/buildkit/* namespace. `.git/refs/heads/*` and `.git/refs/tags/*` + // are populated by the plain-git checkout path (it runs its own fetch + // into a freshly inited repo), so we only assert no refs/buildkit/ + // leak — the whole point of Item A is that the bundle no longer uses + // that namespace. + t.Run("keepgitdir-no-namespace-leak", func(t *testing.T) { + destDir := t.TempDir() + st := llb.Git( + repoURL, + "master", + llb.GitChecksum(headSha), + llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), + llb.KeepGitDir(), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, nil) + require.NoError(t, err) + + // .git dir should be present. + st2, err := os.Stat(filepath.Join(destDir, ".git")) + require.NoError(t, err) + require.True(t, st2.IsDir(), ".git should be a directory") + + // No fake internal namespace anywhere under the checkout's .git. + leakRoot := filepath.Join(destDir, ".git", "refs", "buildkit") + _, err = os.Stat(leakRoot) + require.True(t, os.IsNotExist(err), ".git/refs/buildkit should not exist: %v", err) + + // Also scan packed-refs, which is where cloned/fetched refs may + // live on disk after git gc / pack-refs. + if pr, err := os.ReadFile(filepath.Join(destDir, ".git", "packed-refs")); err == nil { + require.NotContains(t, string(pr), "refs/buildkit/", + "packed-refs should not carry refs/buildkit/* entries") + } + + // The user's ref name must survive into .git: passing "master" to + // llb.Git should produce a checkout whose .git carries + // refs/heads/master, either loose or packed. + //nolint:gosec // Test-controlled export dir is not attacker input. + cmd := exec.CommandContext(context.TODO(), "git", + "--git-dir="+filepath.Join(destDir, ".git"), + "rev-parse", "--verify", "refs/heads/master") + refOut, err := cmd.CombinedOutput() + require.NoError(t, err, ".git should carry refs/heads/master; output=%q", string(refOut)) + require.Equal(t, headSha, strings.TrimSpace(string(refOut))) + + // HEAD must point at the pinned commit. + //nolint:gosec // Test-controlled export dir is not attacker input. + cmd = exec.CommandContext(context.TODO(), "git", + "--git-dir="+filepath.Join(destDir, ".git"), + "rev-parse", "HEAD") + gotOut, err := cmd.CombinedOutput() + require.NoError(t, err, "output=%q", string(gotOut)) + require.Equal(t, headSha, strings.TrimSpace(string(gotOut))) + + // file.txt content matches. + dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) + require.NoError(t, err) + require.Equal(t, "hello\n", string(dt)) + }) + + // Provenance subtest: assert that the git-commit material and the + // bundle-blob material are both recorded. + t.Run("provenance", func(t *testing.T) { + workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance) + destDir := t.TempDir() + st := llb.Scratch().File( + llb.Copy( + llb.Git( + repoURL, + "master", + llb.GitChecksum(headSha), + llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), + ), + "file.txt", + "file.txt", + ), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "", + }, + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, nil) + require.NoError(t, err) + + provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) + require.NoError(t, err) + + var stmt struct { + intoto.StatementHeader + Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` + } + require.NoError(t, json.Unmarshal(provDt, &stmt)) + + // Bundle-backed git sources emit two materials: the git source + // at its raw repository URL (same shape as a non-bundle git + // source), and the bundle blob as a purl with a vcs_url + // qualifier linking back to the git URL. + expectedGitURI := repoURL + "#master" + var foundCommit, foundBundle bool + for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { + if m.URI == expectedGitURI && m.Digest["sha1"] == headSha { + foundCommit = true + } + if strings.HasPrefix(m.URI, "pkg:oci/") && strings.Contains(m.URI, "ref_type=bundle") && strings.Contains(m.URI, "vcs_url=") && m.Digest["sha256"] == bundleDgst.Hex() { + foundBundle = true + } + } + require.True(t, foundCommit, "expected git commit material %q with sha1=%s in %+v", expectedGitURI, headSha, stmt.Predicate.BuildDefinition.ResolvedDependencies) + require.True(t, foundBundle, "expected bundle blob material (pkg:oci/...?ref_type=bundle) with sha256=%s in %+v", bundleDgst.Hex(), stmt.Predicate.BuildDefinition.ResolvedDependencies) + }) + + checkAllReleasable(t, c, sb, false) +} + +// testGitBundleRoundTripRegistry mirrors testGitBundleRoundTrip but resolves +// the bundle blob through a registry using the docker-image+blob:// scheme. +// The bundle bytes are uploaded directly to the sandbox registry by digest +// and then re-imported in Build 2. +func testGitBundleRoundTripRegistry(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + integration.SkipOnPlatform(t, "windows") + requiresLinux(t) + ctx := sb.Context() + c, err := New(ctx, 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) + + gitDir := t.TempDir() + err = runInDir(gitDir, + "git init -b master", + "git config --local user.email test@example.com", + "git config --local user.name test", + "echo hello > file.txt", + "git add file.txt", + "git commit -m initial", + "git update-server-info", + ) + require.NoError(t, err) + + cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") + cmd.Dir = gitDir + out, err := cmd.Output() + require.NoError(t, err) + headSha := strings.TrimSpace(string(out)) + + server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) + defer server.Close() + repoURL := server.URL + "/.git" + + // Build 1: export a bundle via GitCheckoutBundle. + bundleDir := t.TempDir() + st := llb.Git(repoURL, "master", llb.GitChecksum(headSha), llb.GitCheckoutBundle()) + def, err := st.Marshal(ctx) + require.NoError(t, err) + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: bundleDir}}, + }, nil) + require.NoError(t, err) + + bundleBytes, err := os.ReadFile(filepath.Join(bundleDir, "bundle")) + require.NoError(t, err) + require.NotEmpty(t, bundleBytes) + bundleDgst := digest.FromBytes(bundleBytes) + + // Push the bundle as a raw blob into the sandbox registry under a + // repository name. The docker registry accepts arbitrary blobs by + // digest; the repository exists implicitly once any blob is uploaded + // under its name. + bundleRepoRef := registry + "/foo/bundle@" + bundleDgst.String() + ingester, err := contentutil.IngesterFromRef(bundleRepoRef) + require.NoError(t, err) + err = content.WriteBlob(ctx, ingester, "bundle-"+bundleDgst.String(), bytes.NewReader(bundleBytes), + ocispecs.Descriptor{Digest: bundleDgst, Size: int64(len(bundleBytes))}) + require.NoError(t, err) + + bundleLocator := "docker-image+blob://" + registry + "/foo/bundle@" + bundleDgst.String() + + t.Run("checkout", func(t *testing.T) { + destDir := t.TempDir() + st := llb.Git( + repoURL, + "master", + llb.GitChecksum(headSha), + llb.GitBundleURL(bundleLocator), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) + require.NoError(t, err) + require.Equal(t, "hello\n", string(dt)) + }) + + t.Run("provenance", func(t *testing.T) { + workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance) + destDir := t.TempDir() + st := llb.Scratch().File( + llb.Copy( + llb.Git( + repoURL, + "master", + llb.GitChecksum(headSha), + llb.GitBundleURL(bundleLocator), + ), + "file.txt", + "file.txt", + ), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "", + }, + Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, + }, nil) + require.NoError(t, err) + + provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) + require.NoError(t, err) + + var stmt struct { + intoto.StatementHeader + Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` + } + require.NoError(t, json.Unmarshal(provDt, &stmt)) + + expectedGitURI := repoURL + "#master" + var foundCommit, foundBundle bool + for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { + if m.URI == expectedGitURI && m.Digest["sha1"] == headSha { + foundCommit = true + } + if strings.HasPrefix(m.URI, "pkg:docker/") && strings.Contains(m.URI, "ref_type=bundle") && strings.Contains(m.URI, "vcs_url=") && m.Digest["sha256"] == bundleDgst.Hex() { + foundBundle = true + } + } + require.True(t, foundCommit, "expected git commit material %q with sha1=%s in %+v", expectedGitURI, headSha, stmt.Predicate.BuildDefinition.ResolvedDependencies) + require.True(t, foundBundle, "expected bundle blob material (pkg:docker/...?ref_type=bundle) with sha256=%s in %+v", bundleDgst.Hex(), stmt.Predicate.BuildDefinition.ResolvedDependencies) + }) + + checkAllReleasable(t, c, sb, false) +} + +func testGitResolveMutatedSource(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + gitDir := t.TempDir() + gitCommands := []string{ + "git init", + "git config --local user.email test", + "git config --local user.name test", + "echo a > a", + "git add a", + "git commit -m a", + "git tag -a v0.1 -m v0.1", + "echo b > b", + "git add b", + "git commit -m b", + "git checkout -B v2", + "git update-server-info", + } + err = runInDir(gitDir, gitCommands...) + require.NoError(t, err) + + cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1") + cmd.Dir = gitDir + out, err := cmd.Output() + require.NoError(t, err) + commitTag := strings.TrimSpace(string(out)) + + cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1^{commit}") + cmd.Dir = gitDir + out, err = cmd.Output() + require.NoError(t, err) + commitTagCommit := strings.TrimSpace(string(out)) + + server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) + defer server.Close() + + dest := t.TempDir() + + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dest, + }, + }, + }, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + id := "git://" + strings.TrimPrefix(server.URL, "http://") + "/.git#v0.1" + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + Attrs: map[string]string{ + "git.fullurl": server.URL + "/.git", + }, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.Git) + require.Equal(t, "refs/tags/v0.1", md.Git.Ref) + require.Equal(t, commitTag, md.Git.Checksum) + require.Equal(t, commitTagCommit, md.Git.CommitChecksum) + require.Equal(t, id, md.Op.Identifier) + require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) + + // update the tag to point to a different commit + err = runInDir(gitDir, []string{ + "git tag -f v0.1", + "git update-server-info", + }...) + require.NoError(t, err) + + md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + Attrs: map[string]string{ + "git.fullurl": server.URL + "/.git", + }, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.Git) + require.Equal(t, "refs/tags/v0.1", md.Git.Ref) + require.Equal(t, commitTag, md.Git.Checksum) + require.Equal(t, commitTagCommit, md.Git.CommitChecksum) + require.Equal(t, id, md.Op.Identifier) + require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) + + st := llb.Git(server.URL+"/.git", "", llb.GitRef("v0.1")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + _, err = os.ReadFile(filepath.Join(dest, "b")) + require.Error(t, err) + require.True(t, os.IsNotExist(err), "expected file b to not exist") + + dt, err := os.ReadFile(filepath.Join(dest, "a")) + require.NoError(t, err) + require.Equal(t, "a\n", string(dt)) + + checkAllReleasable(t, c, sb, false) +} + +func testGitResolveSourceMetadata(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + gitDir := t.TempDir() + gitCommands := []string{ + "git init", + "git config --local user.email test@example.com", + "git config --local user.name test", + "touch a", + "git add a", + "git commit -m msg", + "git tag -a v0.1 -m v0.1release", + "echo b > b", + "git add b", + "git commit -m b", + "git checkout -B v2", + "git update-server-info", + } + err = runInDir(gitDir, gitCommands...) + require.NoError(t, err) + + cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") + cmd.Dir = gitDir + out, err := cmd.Output() + require.NoError(t, err) + commitHEAD := strings.TrimSpace(string(out)) + + cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1") + cmd.Dir = gitDir + out, err = cmd.Output() + require.NoError(t, err) + commitTag := strings.TrimSpace(string(out)) + + cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1^{commit}") + cmd.Dir = gitDir + out, err = cmd.Output() + require.NoError(t, err) + commitTagCommit := strings.TrimSpace(string(out)) + + server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) + defer server.Close() + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + id := "git://" + strings.TrimPrefix(server.URL, "http://") + "/.git" + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + Attrs: map[string]string{ + "git.fullurl": server.URL + "/.git", + }, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.Git) + require.Equal(t, "refs/heads/v2", md.Git.Ref) // default to branch head + require.Equal(t, commitHEAD, md.Git.Checksum) + require.Equal(t, "", md.Git.CommitChecksum) // not annotated tag + require.Equal(t, id, md.Op.Identifier) + require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) + require.Nil(t, md.Git.CommitObject) + require.Nil(t, md.Git.TagObject) + + id += "#v0.1" + md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + Attrs: map[string]string{ + "git.fullurl": server.URL + "/.git", + }, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.Git) + require.Equal(t, "refs/tags/v0.1", md.Git.Ref) + require.Equal(t, commitTag, md.Git.Checksum) // annotated tag + require.Equal(t, commitTagCommit, md.Git.CommitChecksum) + + require.Equal(t, id, md.Op.Identifier) + require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) + require.Nil(t, md.Git.CommitObject) + require.Nil(t, md.Git.TagObject) + + md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + Attrs: map[string]string{ + "git.fullurl": server.URL + "/.git", + }, + }, sourceresolver.Opt{ + GitOpt: &sourceresolver.ResolveGitOpt{ + ReturnObject: true, + }, + }) + if err != nil { + return nil, err + } + require.NotNil(t, md.Git) + require.Equal(t, "refs/tags/v0.1", md.Git.Ref) + require.Equal(t, commitTag, md.Git.Checksum) // annotated tag + require.Equal(t, commitTagCommit, md.Git.CommitChecksum) + + require.Equal(t, id, md.Op.Identifier) + require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) + require.NotNil(t, md.Git.CommitObject) + require.NotNil(t, md.Git.TagObject) + + commitObj, err := gitobject.Parse(md.Git.CommitObject) + require.NoError(t, err) + require.NoError(t, commitObj.VerifyChecksum(md.Git.CommitChecksum)) + + commit, err := commitObj.ToCommit() + require.NoError(t, err) + require.Equal(t, "msg", commit.Message) + require.Equal(t, "test", commit.Author.Name) + require.Equal(t, "test@example.com", commit.Author.Email) + require.Equal(t, "test", commit.Committer.Name) + require.Equal(t, "test@example.com", commit.Committer.Email) + commitTime := commit.Committer.When + require.NotNil(t, commitTime) + require.WithinDuration(t, time.Now(), *commitTime, 2*time.Minute) + + tagObj, err := gitobject.Parse(md.Git.TagObject) + require.NoError(t, err) + require.NoError(t, tagObj.VerifyChecksum(md.Git.Checksum)) + + tag, err := tagObj.ToTag() + require.NoError(t, err) + require.Equal(t, "v0.1release", tag.Message) + require.Equal(t, "v0.1", tag.Tag) + require.Equal(t, "test", tag.Tagger.Name) + require.Equal(t, "test@example.com", tag.Tagger.Email) + tagTime := tag.Tagger.When + require.NotNil(t, tagTime) + require.WithinDuration(t, time.Now(), *tagTime, 2*time.Minute) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func runInDir(dir string, cmds ...string) error { + return runInDirEnv(dir, nil, cmds...) +} + +func runInDirEnv(dir string, env []string, cmds ...string) error { + for _, args := range cmds { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(context.TODO(), "powershell", "-command", args) + } else { + cmd = exec.CommandContext(context.TODO(), "sh", "-c", args) + } + cmd.Env = append(os.Environ(), env...) + cmd.Dir = dir + if err := cmd.Run(); err != nil { + return errors.Wrapf(err, "error running %v", args) + } + } + return nil +} diff --git a/client/client_http_source_test.go b/client/client_http_source_test.go new file mode 100644 index 000000000..1dbb3a464 --- /dev/null +++ b/client/client_http_source_test.go @@ -0,0 +1,877 @@ +package client + +import ( + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/client/llb/sourceresolver" + gateway "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/secrets/secretsprovider" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/testutil/httpserver" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + digest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testBuildHTTPSource(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + LastModified: &modTime, + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + // invalid URL first + st := llb.HTTP(server.URL + "/bar") + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid response status 404") + + // first correct request + st = llb.HTTP(server.URL + "/foo") + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + require.Equal(t, 1, server.Stats("/foo").AllRequests) + require.Equal(t, 0, server.Stats("/foo").CachedRequests) + + tmpdir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: tmpdir, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 2, server.Stats("/foo").AllRequests) + require.Equal(t, 1, server.Stats("/foo").CachedRequests) + + dt, err := os.ReadFile(filepath.Join(tmpdir, "foo")) + require.NoError(t, err) + require.Equal(t, []byte("content1"), dt) + + allReqs := server.Stats("/foo").Requests + require.Equal(t, 2, len(allReqs)) + require.Equal(t, http.MethodGet, allReqs[0].Method) + require.Equal(t, "gzip", allReqs[0].Header.Get("Accept-Encoding")) + require.Equal(t, http.MethodHead, allReqs[1].Method) + require.Equal(t, "gzip", allReqs[1].Header.Get("Accept-Encoding")) + + require.NoError(t, os.RemoveAll(filepath.Join(tmpdir, "foo"))) + + // update the content at the url to be gzipped now, the final output + // should remain the same + modTime = time.Now().Add(-23 * time.Hour) + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + _, err = gw.Write(resp.Content) + require.NoError(t, err) + require.NoError(t, gw.Close()) + gzipBytes := buf.Bytes() + respGzip := &httpserver.Response{ + Etag: identity.NewID(), + Content: gzipBytes, + LastModified: &modTime, + ContentEncoding: "gzip", + } + server.SetRoute("/foo", respGzip) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: tmpdir, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 4, server.Stats("/foo").AllRequests) + require.Equal(t, 1, server.Stats("/foo").CachedRequests) + + dt, err = os.ReadFile(filepath.Join(tmpdir, "foo")) + require.NoError(t, err) + require.Equal(t, resp.Content, dt) + + allReqs = server.Stats("/foo").Requests + require.Equal(t, 4, len(allReqs)) + require.Equal(t, http.MethodHead, allReqs[2].Method) + require.Equal(t, "gzip", allReqs[2].Header.Get("Accept-Encoding")) + require.Equal(t, http.MethodGet, allReqs[3].Method) + require.Equal(t, "gzip", allReqs[3].Header.Get("Accept-Encoding")) + + // test extra options + // llb.Chown not supported on Windows + st = integration.UnixOrWindows( + llb.HTTP(server.URL+"/foo", llb.Filename("bar"), llb.Chmod(0741), llb.Chown(1000, 1000)), + llb.HTTP(server.URL+"/foo", llb.Filename("bar"), llb.Chmod(0741)), + ) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: tmpdir, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 5, server.Stats("/foo").AllRequests) + require.Equal(t, 1, server.Stats("/foo").CachedRequests) + + dt, err = os.ReadFile(filepath.Join(tmpdir, "bar")) + require.NoError(t, err) + require.Equal(t, []byte("content1"), dt) + + fi, err := os.Stat(filepath.Join(tmpdir, "bar")) + require.NoError(t, err) + require.Equal(t, fi.ModTime().Format(http.TimeFormat), modTime.Format(http.TimeFormat)) + + // no support for llb.Chmod on Windows, default is returned + fMode := integration.UnixOrWindows(0741, 0666) + require.Equal(t, fMode, int(fi.Mode()&0777)) + + checkAllReleasable(t, c, sb, true) + + // TODO: check that second request was marked as cached +} + +func testBuildHTTPSourceAuthHeaderSecret(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid false positive with current time + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + LastModified: &modTime, + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + st := llb.HTTP(server.URL+"/foo", llb.AuthHeaderSecret("http-secret")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve( + sb.Context(), + def, + SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "http-secret": []byte("Bearer foo"), + })}, + }, + nil, + ) + require.NoError(t, err) + + allReqs := server.Stats("/foo").Requests + require.Equal(t, 1, len(allReqs)) + require.Equal(t, http.MethodGet, allReqs[0].Method) + require.Equal(t, "Bearer foo", allReqs[0].Header.Get("Authorization")) +} + +// docker/buildx#2803 +func testBuildHTTPSourceEtagScope(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time + + sharedEtag := identity.NewID() + resp := &httpserver.Response{ + Etag: sharedEtag, + Content: []byte("content1"), + LastModified: &modTime, + } + resp2 := &httpserver.Response{ + Etag: sharedEtag, + Content: []byte("another"), + LastModified: &modTime, + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/one/foo": resp, + "/two/foo": resp2, + }) + defer server.Close() + + // first correct request + st := llb.HTTP(server.URL + "/one/foo") + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + out1 := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: out1, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 1, server.Stats("/one/foo").AllRequests) + require.Equal(t, 0, server.Stats("/one/foo").CachedRequests) + + dt, err := os.ReadFile(filepath.Join(out1, "foo")) + require.NoError(t, err) + require.Equal(t, []byte("content1"), dt) + + st = llb.HTTP(server.URL + "/two/foo") + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + out2 := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: out2, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 1, server.Stats("/two/foo").AllRequests) + require.Equal(t, 0, server.Stats("/two/foo").CachedRequests) + + dt, err = os.ReadFile(filepath.Join(out2, "foo")) + require.NoError(t, err) + require.Equal(t, []byte("another"), dt) + + out2 = t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: out2, + }, + }, + }, nil) + require.NoError(t, err) + + require.Equal(t, 2, server.Stats("/two/foo").AllRequests) + require.Equal(t, 1, server.Stats("/two/foo").CachedRequests) + + allReqs := server.Stats("/two/foo").Requests + require.Equal(t, 2, len(allReqs)) + require.Equal(t, http.MethodGet, allReqs[0].Method) + require.Equal(t, "gzip", allReqs[0].Header.Get("Accept-Encoding")) + require.Equal(t, http.MethodHead, allReqs[1].Method) + require.Equal(t, "gzip", allReqs[1].Header.Get("Accept-Encoding")) + + require.NoError(t, os.RemoveAll(filepath.Join(out2, "foo"))) +} + +func testBuildHTTPSourceHeader(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + LastModified: &modTime, + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + st := llb.HTTP( + server.URL+"/foo", + llb.Header(llb.HTTPHeader{ + Accept: "application/vnd.foo", + UserAgent: "fooagent", + }), + ) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + allReqs := server.Stats("/foo").Requests + require.Equal(t, 1, len(allReqs)) + require.Equal(t, http.MethodGet, allReqs[0].Method) + require.Equal(t, "application/vnd.foo", allReqs[0].Header.Get("accept")) + require.Equal(t, "fooagent", allReqs[0].Header.Get("user-agent")) +} + +func testBuildHTTPSourceHostTokenSecret(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid false positive with current time + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + LastModified: &modTime, + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + st := llb.HTTP(server.URL + "/foo") + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve( + sb.Context(), + def, + SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "HTTP_AUTH_TOKEN_127.0.0.1": []byte("123456"), + })}, + }, + nil, + ) + require.NoError(t, err) + + allReqs := server.Stats("/foo").Requests + require.Equal(t, 1, len(allReqs)) + require.Equal(t, http.MethodGet, allReqs[0].Method) + require.Equal(t, "Bearer 123456", allReqs[0].Header.Get("Authorization")) +} + +func testBuildHTTPSourcePGPSignatureVerify(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + signFixturesPath, ok := os.LookupEnv("BUILDKIT_TEST_SIGN_FIXTURES") + if !ok { + t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES") + } + + payload, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact")) + require.NoError(t, err) + sigData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact.asc")) + require.NoError(t, err) + pubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub")) + require.NoError(t, err) + wrongPubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user2.gpg.pub")) + require.NoError(t, err) + + httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/artifact.txt" { + http.NotFound(w, r) + return + } + _, _ = w.Write(payload) + })) + defer httpSrv.Close() + + solve := func(t *testing.T, state llb.State) error { + def, err := state.Marshal(sb.Context()) + require.NoError(t, err) + tmpdir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: tmpdir, + }, + }, + }, nil) + if err != nil { + return err + } + dt, err := os.ReadFile(filepath.Join(tmpdir, "artifact.txt")) + require.NoError(t, err) + require.Equal(t, payload, dt) + return nil + } + + t.Run("valid-signature", func(t *testing.T) { + validState := llb.HTTP( + httpSrv.URL+"/artifact.txt", + llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ + PubKey: pubKeyData, + Signature: sigData, + }), + ) + require.NoError(t, solve(t, validState)) + }) + + t.Run("wrong-pubkey", func(t *testing.T) { + invalidState := llb.HTTP( + httpSrv.URL+"/artifact.txt", + llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ + PubKey: wrongPubKeyData, + Signature: sigData, + }), + ) + err = solve(t, invalidState) + require.Error(t, err) + require.ErrorContains(t, err, "failed to verify pgp signature") + }) + + t.Run("concatenated-pubkeys-right-key-second", func(t *testing.T) { + mergedPubKeys := append(append([]byte{}, wrongPubKeyData...), '\n') + mergedPubKeys = append(mergedPubKeys, pubKeyData...) + state := llb.HTTP( + httpSrv.URL+"/artifact.txt", + llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ + PubKey: mergedPubKeys, + Signature: sigData, + }), + ) + require.NoError(t, solve(t, state)) + }) +} + +func testBuildHTTPSourceUnauthorizedChecksumRace(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + time.Sleep(200 * time.Millisecond) + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + url := server.URL + "/test.txt" + lower := llb.Scratch() + // Reproduce the race between same-URL HTTP sources with and without + // checksum sharing the resolver cache while the server returns 401. + withoutChecksum := lower.File(llb.Copy(llb.HTTP(url), "test.txt", "/a.txt")) + withChecksum := lower.File(llb.Copy(llb.HTTP(url, llb.Checksum(digest.FromBytes(nil))), "test.txt", "/b.txt")) + st := llb.Merge([]llb.State{ + llb.Diff(lower, withoutChecksum), + llb.Diff(lower, withChecksum), + }) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + for range 10 { + requests.Store(0) + _, err := c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: t.TempDir(), + }, + }, + }, nil) + require.Error(t, err) + require.ErrorContains(t, err, "invalid response status 401") + } +} + +func testHTTPPruneAfterCacheKey(t *testing.T, sb integration.Sandbox) { + // this test depends on hitting race condition in internal functions. + // If debugging and expecting failure you can add small sleep in beginning of source/http.Exec() to hit reliably + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + done := make(chan struct{}) + + startScan := make(chan struct{}) + stopScan := make(chan struct{}) + pauseScan := make(chan struct{}) + + go func() { + // attempt to prune the HTTP record in between cachekey and snapshot + defer close(done) + for { + select { + case <-startScan: + scan: + for { + select { + case <-pauseScan: + break scan + default: + du, err := c.DiskUsage(ctx) + assert.NoError(t, err) + for _, entry := range du { + if entry.Description == "http url "+server.URL+"/foo" { + if !entry.InUse { + t.Logf("entry no longer in use, pruning") + err = c.Prune(ctx, nil) + assert.NoError(t, err) + + resp.Etag = identity.NewID() + resp.Content = []byte("content2") + } + } + } + } + } + case <-stopScan: + return + } + } + }() + + const iterations = 10 + for range iterations { + startScan <- struct{}{} + resp.Etag = identity.NewID() + resp.Content = []byte("content1") + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File(llb.Copy(llb.HTTP(server.URL+"/foo"), "foo", "bar")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + resp, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + return resp, nil + }, nil) + require.NoError(t, err) + + pauseScan <- struct{}{} + + err = c.Prune(ctx, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, false) + } + close(stopScan) + <-done +} + +// testHTTPPruneAfterResolveMeta ensures that pruning after ResolveSourceMetadata +// doesn't pull in new data for same build. Once URL has been resolved once for a specific +// build, the data should be considered immutable and remote changes don't affect ongoing build. +func testHTTPPruneAfterResolveMeta(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + dest := t.TempDir() + + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dest, + }, + }, + }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { + id := server.URL + "/foo" + md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + + // prune all + err = c.Prune(ctx, nil) + require.NoError(t, err) + + resp.Content = []byte("content2") // etag is same so should hit cache if record not pruned + + st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return gc.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(dest, "bar")) + require.NoError(t, err) + require.Equal(t, "content1", string(dt)) + + checkAllReleasable(t, c, sb, false) +} + +func testHTTPResolveMetaReuse(t *testing.T, sb integration.Sandbox) { + // the difference with testHTTPPruneAfterResolveMeta is that here we change content with the etag on the server + // but because the URL was already resolved once, the new content should not be seen + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + dest := t.TempDir() + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dest, + }, + }, + }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { + id := server.URL + "/foo" + md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + + resp.Etag = identity.NewID() + resp.Content = []byte("content2") // etag changed so new content would be returned if re-resolving + + st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return gc.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(dest, "bar")) + require.NoError(t, err) + require.Equal(t, "content1", string(dt)) +} + +// testHTTPResolveMultiBuild is a negative test for testHTTPResolveMetaReuse to ensure that +// URLs are resolved in between separate builds +func testHTTPResolveMultiBuild(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + } + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + }) + defer server.Close() + + dest := t.TempDir() + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dest, + }, + }, + }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { + id := server.URL + "/foo" + md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) + + st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return gc.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(dest, "bar")) + require.NoError(t, err) + require.Equal(t, "content1", string(dt)) + + resp.Etag = identity.NewID() + resp.Content = []byte("content2") + + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: dest, + }, + }, + }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { + id := server.URL + "/foo" + md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) + st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return gc.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(dest, "bar")) + require.NoError(t, err) + require.Equal(t, "content2", string(dt)) +} + +func testHTTPResolveSourceMetadata(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time + + resp := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content1"), + LastModified: &modTime, + } + + resp2 := &httpserver.Response{ + Etag: identity.NewID(), + Content: []byte("content2"), + ContentDisposition: "attachment; filename=\"my img.jpg\"", + } + + server := httpserver.NewTestServer(map[string]*httpserver.Response{ + "/foo": resp, + "/bar": resp2, + }) + defer server.Close() + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + id := server.URL + "/foo" + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) + require.Equal(t, "foo", md.HTTP.Filename) + require.NotNil(t, md.HTTP.LastModified) + require.Equal(t, modTime.Unix(), md.HTTP.LastModified.Unix()) + require.Equal(t, id, md.Op.Identifier) + + id = server.URL + "/bar" + md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: id, + }, sourceresolver.Opt{}) + if err != nil { + return nil, err + } + require.NotNil(t, md.HTTP) + require.Equal(t, digest.FromBytes(resp2.Content), md.HTTP.Digest) + require.Equal(t, "my img.jpg", md.HTTP.Filename) + require.Nil(t, md.HTTP.LastModified) + require.Equal(t, id, md.Op.Identifier) + return nil, nil + }, nil) + require.NoError(t, err) +} diff --git a/client/client_image_source_test.go b/client/client_image_source_test.go new file mode 100644 index 000000000..1c680b445 --- /dev/null +++ b/client/client_image_source_test.go @@ -0,0 +1,365 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/client/llb/sourceresolver" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/session" + sessionauth "github.com/moby/buildkit/session/auth" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func testClientGatewayCanceledCredentialsCallbackReturns(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, 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) + + username := "buildkit-user" + password := "buildkit-pass" + repo := "buildkit/auth-session-" + identity.NewID() + backendRef := registry + "/" + repo + ":latest" + + st := llb.Scratch().File(llb.Mkfile("hello", 0o644, []byte("world"))) + def, err := st.Marshal(ctx) + require.NoError(t, err) + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": backendRef, + "push": "true", + }, + }}, + }, nil) + require.NoError(t, err) + + target, err := url.Parse("http://" + registry) + require.NoError(t, err) + + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(req *http.Request) { + director(req) + req.Host = target.Host + } + + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, secret, ok := r.BasicAuth() + if !ok || user != username || secret != password { + w.Header().Set("WWW-Authenticate", `Basic realm="buildkit-test"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + proxy.ServeHTTP(w, r) + })) + t.Cleanup(proxyServer.Close) + + ref := strings.TrimPrefix(proxyServer.URL, "http://") + "/" + repo + ":latest" + host, _, _ := strings.Cut(ref, "/") + + started := make(chan struct{}) + release := make(chan struct{}) + var startedOnce sync.Once + + provider := &blockingAuthProvider{ + t: t, + host: host, + username: username, + password: password, + started: started, + startedOnce: &startedOnce, + release: release, + } + + _, err = c.Build(ctx, SolveOpt{ + Session: []session.Attachable{provider}, + }, "buildkit_test", func(ctx context.Context, gw client.Client) (*client.Result, error) { + reqCtx, cancel := context.WithCancelCause(ctx) + defer cancel(context.Canceled) + + errCh := make(chan error, 1) + go func() { + _, _, _, err := gw.ResolveImageConfig(reqCtx, ref, sourceresolver.Opt{}) + errCh <- err + }() + + select { + case <-started: + case <-time.After(15 * time.Second): + return nil, errors.New("timed out waiting for registry credential callback") + } + + cancel(context.Canceled) + + select { + case err := <-errCh: + require.Error(t, err) + return client.NewResult(), nil + case <-time.After(3 * time.Second): + close(release) + } + + select { + case err := <-errCh: + require.Error(t, err) + case <-time.After(15 * time.Second): + return nil, errors.New("timed out draining canceled image config resolution") + } + + return nil, errors.New("canceled image config resolution stayed blocked until the credentials callback was released") + }, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testPullWithLayerLimit(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch(). + File(llb.Mkfile("/first", 0644, []byte("first"))). + File(llb.Mkfile("/second", 0644, []byte("second"))). + File(llb.Mkfile("/third", 0644, []byte("third"))) + + 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) + + target := registry + "/buildkit/testlayers:latest" + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + // pull 2 first layers + st = llb.Image(target, llb.WithLayerLimit(2)). + File(llb.Mkfile("/forth", 0644, []byte("forth"))) + + 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, + }}, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "first")) + require.NoError(t, err) + require.Equal(t, "first", string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "second")) + require.NoError(t, err) + require.Equal(t, "second", string(dt)) + + _, err = os.ReadFile(filepath.Join(destDir, "third")) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + + dt, err = os.ReadFile(filepath.Join(destDir, "forth")) + require.NoError(t, err) + require.Equal(t, "forth", string(dt)) + + // pull 3rd layer only + st = llb.Diff( + llb.Image(target, llb.WithLayerLimit(2)), + llb.Image(target)). + File(llb.Mkfile("/forth", 0644, []byte("forth"))) + + 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, + }}, + }, nil) + require.NoError(t, err) + + _, err = os.ReadFile(filepath.Join(destDir, "first")) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + + _, err = os.ReadFile(filepath.Join(destDir, "second")) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + + dt, err = os.ReadFile(filepath.Join(destDir, "third")) + require.NoError(t, err) + require.Equal(t, "third", string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "forth")) + require.NoError(t, err) + require.Equal(t, "forth", string(dt)) + + // zero limit errors cleanly + st = llb.Image(target, llb.WithLayerLimit(0)) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid layer limit") +} + +func testValidateDigestOrigin(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) + + cmdStr := integration.UnixOrWindows( + "touch foo", + "cmd /C echo a > foo", + ) + st := llb.Image(imgName). + Run(llb.Shlex(cmdStr), llb.Dir("/wd")). + AddMount("/wd", 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) + + target := registry + "/buildkit/testdigest:latest" + + resp, err := c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dgst, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey] + require.True(t, ok) + + err = c.Prune(sb.Context(), nil, PruneAll) + require.NoError(t, err) + + st = llb.Image(target + "@" + dgst) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // accessing the digest from invalid names should fail + st = llb.Image("example.invalid/nosuchrepo@" + dgst) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + + // also check repo that does exists but not digest + st = llb.Image("docker.io/library/ubuntu@" + dgst) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) +} + +type blockingAuthProvider struct { + sessionauth.UnimplementedAuthServer + + t *testing.T + host string + username string + password string + started chan struct{} + startedOnce *sync.Once + release chan struct{} +} + +func (p *blockingAuthProvider) Register(server *grpc.Server) { + sessionauth.RegisterAuthServer(server, p) +} + +func (p *blockingAuthProvider) Credentials(ctx context.Context, req *sessionauth.CredentialsRequest) (*sessionauth.CredentialsResponse, error) { + require.Equal(p.t, p.host, req.Host) + p.startedOnce.Do(func() { + close(p.started) + }) + + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-p.release: + return &sessionauth.CredentialsResponse{ + Username: p.username, + Secret: p.password, + }, nil + } +} diff --git a/client/client_local_source_test.go b/client/client_local_source_test.go new file mode 100644 index 000000000..04fded9d8 --- /dev/null +++ b/client/client_local_source_test.go @@ -0,0 +1,400 @@ +package client + +import ( + "context" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/containerd/continuity/fs/fstest" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/tonistiigi/fsutil" + "golang.org/x/sync/errgroup" +) + +func testLocalSourceDiffer(t *testing.T, sb integration.Sandbox) { + for _, d := range []llb.DiffType{llb.DiffNone, llb.DiffMetadata} { + t.Run(fmt.Sprintf("differ=%s", d), func(t *testing.T) { + testLocalSourceWithDiffer(t, sb, d) + }) + } +} + +func testLocalSourceWithDiffer(t *testing.T, sb integration.Sandbox, d llb.DiffType) { + c, err := New(context.TODO(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateFile("foo", []byte("foo"), 0600), + ) + + tv := syscall.NsecToTimespec(time.Now().UnixNano()) + + err = syscall.UtimesNano(filepath.Join(dir.Name, "foo"), []syscall.Timespec{tv, tv}) + require.NoError(t, err) + + st := llb.Local("mylocal"+string(d), llb.Differ(d, false)) + + def, err := st.Marshal(context.TODO()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(context.TODO(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal" + string(d): dir, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, []byte("foo"), dt) + + // Give some extra time to ensure the resources from first solve are released + time.Sleep(500 * time.Millisecond) + + err = os.WriteFile(filepath.Join(dir.Name, "foo"), []byte("bar"), 0600) + require.NoError(t, err) + + err = syscall.UtimesNano(filepath.Join(dir.Name, "foo"), []syscall.Timespec{tv, tv}) + require.NoError(t, err) + + _, err = c.Solve(context.TODO(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal" + string(d): dir, + }, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + if d == llb.DiffMetadata { + require.Equal(t, []byte("foo"), dt) + } + if d == llb.DiffNone { + require.Equal(t, []byte("bar"), dt) + } +} + +// moby/buildkit#4831 +func testLocalSourceWithHardlinksFilter(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(context.TODO(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := integration.Tmpdir( + t, + fstest.CreateFile("bar", []byte("bar"), 0600), + fstest.Link("bar", "foo1"), + fstest.Link("bar", "foo2"), + ) + + st := llb.Local("mylocal", llb.FollowPaths([]string{"foo*"})) + + def, err := st.Marshal(context.TODO()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(context.TODO(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) + + _, err = os.ReadFile(filepath.Join(destDir, "bar")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo1")) + require.NoError(t, err) + require.Equal(t, []byte("bar"), dt) + + st1, err := os.Stat(filepath.Join(destDir, "foo1")) + require.NoError(t, err) + + st2, err := os.Stat(filepath.Join(destDir, "foo2")) + require.NoError(t, err) + + require.True(t, os.SameFile(st1, st2)) +} + +func testLocalSymlinkEscape(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + test := []byte(`set -ex +[[ -L /mount/foo ]] +[[ -L /mount/sub/bar ]] +[[ -L /mount/bax ]] +[[ -f /mount/bay ]] +[[ -f /mount/sub/sub2/file ]] +[[ ! -f /mount/baz ]] +[[ ! -f /mount/etc/passwd ]] +[[ ! -f /mount/etc/group ]] +[[ $(readlink /mount/foo) == "/etc/passwd" ]] +[[ $(readlink /mount/sub/bar) == "../../../etc/group" ]] +`) + + dir := integration.Tmpdir( + t, + // point to absolute path that is not part of dir + fstest.Symlink("/etc/passwd", "foo"), + fstest.CreateDir("sub", 0700), + // point outside of the dir + fstest.Symlink("../../../etc/group", "sub/bar"), + // regular valid symlink + fstest.Symlink("bay", "bax"), + // target for symlink (not requested) + fstest.CreateFile("bay", []byte{}, 0600), + // file with many subdirs + fstest.CreateDir("sub/sub2", 0700), + fstest.CreateFile("sub/sub2/file", []byte{}, 0600), + // unused file that shouldn't be included + fstest.CreateFile("baz", []byte{}, 0600), + fstest.CreateFile("test.sh", test, 0700), + ) + + local := llb.Local("mylocal", llb.FollowPaths([]string{ + "test.sh", "foo", "sub/bar", "bax", "sub/sub2/file", + })) + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh /mount/test.sh`), llb.AddMount("/mount", local, llb.Readonly)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + LocalMounts: map[string]fsutil.FS{ + "mylocal": dir, + }, + }, nil) + require.NoError(t, err) +} + +func testMetadataOnlyLocal(t *testing.T, sb integration.Sandbox) { + ctx, cancel := context.WithCancelCause(sb.Context()) + defer func() { cancel(errors.WithStack(context.Canceled)) }() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + srcDir := integration.Tmpdir( + t, + fstest.CreateFile("data", []byte("contents"), 0600), + fstest.CreateDir("dir", 0700), + fstest.CreateFile("dir/file1", []byte("file1"), 0600), + fstest.CreateFile("dir/file2", []byte("file2"), 0600), + fstest.CreateDir("dir/subdir", 0700), + fstest.CreateDir("dir/subdir2", 0700), + fstest.CreateFile("dir/subdir/bar1", []byte("bar1"), 0600), + fstest.CreateFile("dir/subdir/bar2", []byte("bar2"), 0600), + fstest.CreateFile("dir/subdir2/bar3", []byte("bar3"), 0600), + fstest.CreateFile("foo", []byte("foo"), 0602), + ) + + def, err := llb.Local("source", llb.MetadataOnlyTransfer([]string{"dir/**/*1", "foo"})).Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "source": srcDir, + }, + }, nil) + require.NoError(t, err) + + _, err = os.ReadFile(filepath.Join(destDir, "data")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + act, err := os.ReadFile(filepath.Join(destDir, "dir/file1")) + require.NoError(t, err) + require.Equal(t, "file1", string(act)) + + act, err = os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, "foo", string(act)) + + _, err = os.ReadFile(filepath.Join(destDir, "dir/file2")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + act, err = os.ReadFile(filepath.Join(destDir, "dir/subdir/bar1")) + require.NoError(t, err) + require.Equal(t, "bar1", string(act)) + + _, err = os.Stat(filepath.Join(destDir, "dir/subdir2")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + _, err = os.ReadFile(filepath.Join(destDir, "dir/subdir/bar2")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + dt, err := os.ReadFile(filepath.Join(destDir, ".fsutil-metadata")) + require.NoError(t, err) + + stats := parseFSMetadata(t, dt) + require.Equal(t, 10, len(stats)) + + require.Equal(t, "data", stats[0].Path) + require.Equal(t, "dir", stats[1].Path) + require.Equal(t, "dir/file1", stats[2].Path) + require.Equal(t, "dir/file2", stats[3].Path) + require.Equal(t, "dir/subdir", stats[4].Path) + require.Equal(t, "dir/subdir/bar1", stats[5].Path) + require.Equal(t, "dir/subdir/bar2", stats[6].Path) + require.Equal(t, "dir/subdir2", stats[7].Path) + require.Equal(t, "dir/subdir2/bar3", stats[8].Path) + require.Equal(t, "foo", stats[9].Path) + + err = os.RemoveAll(filepath.Join(srcDir.Name, "dir/subdir")) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcDir.Name, "dir/file1"), []byte("file1-updated"), 0600) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcDir.Name, "dir/bar1"), []byte("bar1"), 0600) + require.NoError(t, err) + + def, err = llb.Local("source", llb.MetadataOnlyTransfer([]string{"dir/**/*1", "foo"})).Marshal(sb.Context()) + require.NoError(t, err) + + destDir = t.TempDir() + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "source": srcDir, + }, + }, nil) + require.NoError(t, err) + + _, err = os.ReadFile(filepath.Join(destDir, "data")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + act, err = os.ReadFile(filepath.Join(destDir, "dir/file1")) + require.NoError(t, err) + require.Equal(t, "file1-updated", string(act)) + + act, err = os.ReadFile(filepath.Join(destDir, "dir/bar1")) + require.NoError(t, err) + require.Equal(t, "bar1", string(act)) + + _, err = os.Stat(filepath.Join(destDir, "dir/subdir")) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + + dt, err = os.ReadFile(filepath.Join(destDir, ".fsutil-metadata")) + require.NoError(t, err) + + stats = parseFSMetadata(t, dt) + require.Equal(t, 8, len(stats)) + + require.Equal(t, "data", stats[0].Path) + require.Equal(t, "dir", stats[1].Path) + require.Equal(t, "dir/bar1", stats[2].Path) + require.Equal(t, "dir/file1", stats[3].Path) + require.Equal(t, "dir/file2", stats[4].Path) + require.Equal(t, "dir/subdir2", stats[5].Path) + require.Equal(t, "dir/subdir2/bar3", stats[6].Path) + require.Equal(t, "foo", stats[7].Path) +} + +// moby/buildkit#492 +func testParallelLocalBuilds(t *testing.T, sb integration.Sandbox) { + ctx, cancel := context.WithCancelCause(sb.Context()) + defer func() { cancel(errors.WithStack(context.Canceled)) }() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + eg, ctx := errgroup.WithContext(ctx) + + for i := range 3 { + func(i int) { + eg.Go(func() error { + fn := fmt.Sprintf("test%d", i) + srcDir := integration.Tmpdir( + t, + fstest.CreateFile(fn, []byte("contents"), 0600), + ) + + def, err := llb.Local("source").Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + LocalMounts: map[string]fsutil.FS{ + "source": srcDir, + }, + }, nil) + require.NoError(t, err) + + act, err := os.ReadFile(filepath.Join(destDir, fn)) + require.NoError(t, err) + + require.Equal(t, "contents", string(act)) + return nil + }) + }(i) + } + + err = eg.Wait() + require.NoError(t, err) +} diff --git a/client/client_mergeop_test.go b/client/client_mergeop_test.go new file mode 100644 index 000000000..b5c8be6ee --- /dev/null +++ b/client/client_mergeop_test.go @@ -0,0 +1,590 @@ +package client + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/pkg/namespaces" + "github.com/containerd/continuity/fs/fstest" + cerrdefs "github.com/containerd/errdefs" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testMergeOp(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) + requiresLinux(t) + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + ctx := sb.Context() + registry, err := sb.NewRegistry() + if !errors.Is(err, integration.ErrRequirements) { + require.NoError(t, err) + } + + var imageTarget string + if registry != "" { + imageTarget = registry + "/buildkit/testmergeop:latest" + } + + stateA := llb.Scratch(). + File(llb.Mkfile("/foo", 0755, []byte("A"))). + File(llb.Mkfile("/a", 0755, []byte("A"))). + File(llb.Mkdir("/bar", 0700)). + File(llb.Mkfile("/bar/A", 0755, []byte("A"))) + stateB := stateA. + File(llb.Rm("/foo")). + File(llb.Mkfile("/b", 0755, []byte("B"))). + File(llb.Mkfile("/bar/B", 0754, []byte("B"))) + stateC := llb.Scratch(). + File(llb.Mkfile("/foo", 0755, []byte("C"))). + File(llb.Mkfile("/c", 0755, []byte("C"))). + File(llb.Mkdir("/bar", 0755)). + File(llb.Mkfile("/bar/A", 0400, []byte("C"))) + + mergeA := llb.Merge([]llb.State{stateA, stateC}) + requireContents(ctx, t, c, sb, mergeA, nil, nil, imageTarget, + fstest.CreateFile("foo", []byte("C"), 0755), + fstest.CreateFile("c", []byte("C"), 0755), + fstest.CreateDir("bar", 0755), + fstest.CreateFile("bar/A", []byte("C"), 0400), + fstest.CreateFile("a", []byte("A"), 0755), + ) + + mergeB := llb.Merge([]llb.State{stateC, stateB}) + requireContents(ctx, t, c, sb, mergeB, nil, nil, imageTarget, + fstest.CreateFile("a", []byte("A"), 0755), + fstest.CreateFile("b", []byte("B"), 0755), + fstest.CreateFile("c", []byte("C"), 0755), + fstest.CreateDir("bar", 0700), + fstest.CreateFile("bar/A", []byte("A"), 0755), + fstest.CreateFile("bar/B", []byte("B"), 0754), + ) + + stateD := llb.Scratch().File(llb.Mkdir("/qaz", 0755)) + mergeC := llb.Merge([]llb.State{mergeA, mergeB, stateD}) + requireContents(ctx, t, c, sb, mergeC, nil, nil, imageTarget, + fstest.CreateFile("a", []byte("A"), 0755), + fstest.CreateFile("b", []byte("B"), 0755), + fstest.CreateFile("c", []byte("C"), 0755), + fstest.CreateDir("bar", 0700), + fstest.CreateFile("bar/A", []byte("A"), 0755), + fstest.CreateFile("bar/B", []byte("B"), 0754), + fstest.CreateDir("qaz", 0755), + ) + + runA := runShell(llb.Merge([]llb.State{llb.Image("alpine"), mergeC}), + // turn /a file into a dir, mv b and c into it + "rm /a", + "mkdir /a", + "mv /b /c /a/", + // remove+recreate /bar to make it opaque on overlay snapshotters + "rm -rf /bar", + "mkdir -m 0755 /bar", + "echo -n D > /bar/D", + // turn /qaz dir into a file + "rm -rf /qaz", + "touch /qaz", + ) + stateE := llb.Scratch(). + File(llb.Mkfile("/foo", 0755, []byte("E"))). + File(llb.Mkdir("/bar", 0755)). + File(llb.Mkfile("/bar/A", 0755, []byte("A"))). + File(llb.Mkfile("/bar/E", 0755, nil)) + mergeD := llb.Merge([]llb.State{stateE, runA}) + requireEqualContents(ctx, t, c, mergeD, llb.Image("alpine"). + File(llb.Mkdir("a", 0755)). + File(llb.Mkfile("a/b", 0755, []byte("B"))). + File(llb.Mkfile("a/c", 0755, []byte("C"))). + File(llb.Mkdir("bar", 0755)). + File(llb.Mkfile("bar/D", 0644, []byte("D"))). + File(llb.Mkfile("bar/E", 0755, nil)). + File(llb.Mkfile("qaz", 0644, nil)), + ) + // /foo from stateE is not here because it is deleted in stateB, which is part of a submerge of mergeD +} + +func testMergeOpCache(t *testing.T, sb integration.Sandbox, mode string) { + t.Helper() + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureMergeDiff) + requiresLinux(t) + + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + t.Skip("test requires containerd worker") + } + + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + ctx := namespaces.WithNamespace(sb.Context(), "buildkit") + + 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() + + // push the busybox image to the mutable registry + sourceImage := "busybox:latest" + def, err := llb.Image(sourceImage).Marshal(sb.Context()) + require.NoError(t, err) + + busyboxTargetNoTag := registry + "/buildkit/testlazyimage:" + busyboxTarget := busyboxTargetNoTag + "latest" + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": busyboxTarget, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + imageService := client.ImageService() + contentStore := client.ContentStore() + + busyboxImg, err := imageService.Get(ctx, busyboxTarget) + require.NoError(t, err) + + busyboxManifest, err := images.Manifest(ctx, contentStore, busyboxImg.Target, nil) + require.NoError(t, err) + + for _, layer := range busyboxManifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.NoError(t, err) + } + + // clear all local state out + err = imageService.Delete(ctx, busyboxImg.Name, images.SynchronousDelete()) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) + + for _, layer := range busyboxManifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + // make a new merge that includes the lazy busybox as a base and exports inline cache + input1 := llb.Scratch(). + File(llb.Mkdir("/dir", 0777)). + File(llb.Mkfile("/dir/1", 0777, nil)) + input1Copy := llb.Scratch().File(llb.Copy(input1, "/dir/1", "/foo/1", &llb.CopyInfo{CreateDestPath: true})) + + // put random contents in the file to ensure it's not re-run later + input2 := runShell(llb.Image("alpine:latest"), + "mkdir /dir", + "cat /dev/urandom | head -c 100 | sha256sum > /dir/2") + input2Copy := llb.Scratch().File(llb.Copy(input2, "/dir/2", "/bar/2", &llb.CopyInfo{CreateDestPath: true})) + + merge := llb.Merge([]llb.State{llb.Image(busyboxTarget), input1Copy, input2Copy}) + + def, err = merge.Marshal(sb.Context()) + require.NoError(t, err) + + target := registry + "/buildkit/testmerge:latest" + cacheTarget := registry + "/buildkit/testmergecache:latest" + + var cacheExports []CacheOptionsEntry + var cacheImports []CacheOptionsEntry + switch mode { + case "inline": + cacheExports = []CacheOptionsEntry{{ + Type: "inline", + }} + cacheImports = []CacheOptionsEntry{{ + Type: "registry", + Attrs: map[string]string{ + "ref": target, + }, + }} + case "min": + cacheExports = []CacheOptionsEntry{{ + Type: "registry", + Attrs: map[string]string{ + "ref": cacheTarget, + }, + }} + cacheImports = []CacheOptionsEntry{{ + Type: "registry", + Attrs: map[string]string{ + "ref": cacheTarget, + }, + }} + case "max": + cacheExports = []CacheOptionsEntry{{ + Type: "registry", + Attrs: map[string]string{ + "ref": cacheTarget, + "mode": "max", + }, + }} + cacheImports = []CacheOptionsEntry{{ + Type: "registry", + Attrs: map[string]string{ + "ref": cacheTarget, + }, + }} + default: + require.Fail(t, fmt.Sprintf("unknown cache mode: %s", mode)) + } + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }, + }, + CacheExports: cacheExports, + }, nil) + require.NoError(t, err) + + // verify that the busybox image stayed lazy + for _, layer := range busyboxManifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + // get the random value at /bar/2 + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + bar2Contents, err := os.ReadFile(filepath.Join(destDir, "bar", "2")) + require.NoError(t, err) + + // clear all local state out + img, err := imageService.Get(ctx, target) + require.NoError(t, err) + + manifest, err := images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + // re-run the same build with cache imports and verify everything stays lazy + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }}, + CacheImports: cacheImports, + CacheExports: cacheExports, + }, nil) + require.NoError(t, err) + + // verify everything from before stayed lazy + img, err = imageService.Get(ctx, target) + require.NoError(t, err) + + manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + for i, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d (%s)", err, i, layer.Digest) + } + + // re-run the build with a change only to input1 using the remote cache + input1 = llb.Scratch(). + File(llb.Mkdir("/dir", 0777)). + File(llb.Mkfile("/dir/1", 0444, nil)) + input1Copy = llb.Scratch().File(llb.Copy(input1, "/dir/1", "/foo/1", &llb.CopyInfo{CreateDestPath: true})) + + merge = llb.Merge([]llb.State{llb.Image(busyboxTarget), input1Copy, input2Copy}) + + def, err = merge.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }}, + CacheExports: cacheExports, + CacheImports: cacheImports, + }, nil) + require.NoError(t, err) + + // verify everything from before stayed lazy except the middle layer for input1Copy + img, err = imageService.Get(ctx, target) + require.NoError(t, err) + + manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) + require.NoError(t, err) + + for i, layer := range manifest.Layers { + switch i { + case 0, 2: + // bottom and top layer should stay lazy as they didn't change + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d", err, i) + case 1: + // middle layer had to be rebuilt, should exist locally + _, err = contentStore.Info(ctx, layer.Digest) + require.NoError(t, err) + default: + require.Fail(t, fmt.Sprintf("unexpected layer index %d", i)) + } + } + + // check the random value at /bar/2 didn't change + destDir = t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + CacheImports: cacheImports, + }, nil) + require.NoError(t, err) + + newBar2Contents, err := os.ReadFile(filepath.Join(destDir, "bar", "2")) + require.NoError(t, err) + + require.Equalf(t, bar2Contents, newBar2Contents, "bar/2 contents changed") + + // Now test the case with a layer on top of a merge. + err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + mergePlusLayer := merge.File(llb.Mkfile("/3", 0444, nil)) + + def, err = mergePlusLayer.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }}, + CacheExports: cacheExports, + CacheImports: cacheImports, + }, nil) + require.NoError(t, err) + + // check the random value at /bar/2 didn't change + destDir = t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + CacheImports: cacheImports, + }, nil) + require.NoError(t, err) + + newBar2Contents, err = os.ReadFile(filepath.Join(destDir, "bar", "2")) + require.NoError(t, err) + + require.Equalf(t, bar2Contents, newBar2Contents, "bar/2 contents changed") + + // clear local state, repeat the build, verify everything stays lazy + err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) + + for _, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) + } + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + "store": "true", + "unsafe-internal-store-allow-incomplete": "true", + }, + }}, + CacheImports: cacheImports, + CacheExports: cacheExports, + }, 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) + + for i, layer := range manifest.Layers { + _, err = contentStore.Info(ctx, layer.Digest) + require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d", err, i) + } +} + +func testMergeOpCacheInline(t *testing.T, sb integration.Sandbox) { + testMergeOpCache(t, sb, "inline") +} + +func testMergeOpCacheMax(t *testing.T, sb integration.Sandbox) { + testMergeOpCache(t, sb, "max") +} + +func testMergeOpCacheMin(t *testing.T, sb integration.Sandbox) { + testMergeOpCache(t, sb, "min") +} + +func chainRunShells(base llb.State, cmdss ...[]string) llb.State { + for _, cmds := range cmdss { + base = runShell(base, cmds...) + } + return base +} + +func requireContents(ctx context.Context, t *testing.T, c *Client, sb integration.Sandbox, state llb.State, cacheImports, cacheExports []CacheOptionsEntry, imageTarget string, files ...fstest.Applier) { + t.Helper() + + def, err := state.Marshal(ctx) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + CacheImports: cacheImports, + CacheExports: cacheExports, + }, nil) + require.NoError(t, err) + + require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.Apply(files...))) + + if imageTarget != "" { + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{{ + Type: ExporterImage, + Attrs: map[string]string{ + "name": imageTarget, + "push": "true", + }, + }}, + CacheImports: cacheImports, + CacheExports: cacheExports, + }, nil) + require.NoError(t, err) + resetState(t, c, sb) + requireContents(ctx, t, c, sb, llb.Image(imageTarget, llb.ResolveModePreferLocal), cacheImports, nil, "", files...) + } +} + +func requireEqualContents(ctx context.Context, t *testing.T, c *Client, stateA, stateB llb.State) { + t.Helper() + + defA, err := stateA.Marshal(ctx) + require.NoError(t, err) + + destDirA := t.TempDir() + + _, err = c.Solve(ctx, defA, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDirA, + }, + }, + }, nil) + require.NoError(t, err) + + defB, err := stateB.Marshal(ctx) + require.NoError(t, err) + + destDirB := t.TempDir() + + _, err = c.Solve(ctx, defB, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDirB, + }, + }, + }, nil) + require.NoError(t, err) + + require.NoError(t, fstest.CheckDirectoryEqual(destDirA, destDirB)) +} + +func runShell(base llb.State, cmds ...string) llb.State { + return runShellExecState(base, cmds...).Root() +} + +func runShellExecState(base llb.State, cmds ...string) llb.ExecState { + return base.Run(llb.Args([]string{"sh", "-c", strings.Join(cmds, " && ")})) +} diff --git a/client/client_mount_test.go b/client/client_mount_test.go new file mode 100644 index 000000000..c5deb2300 --- /dev/null +++ b/client/client_mount_test.go @@ -0,0 +1,1080 @@ +package client + +import ( + "archive/tar" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/secrets/secretsprovider" + "github.com/moby/buildkit/session/sshforward/sshprovider" + "github.com/moby/buildkit/util/testutil" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh/agent" +) + +func testBuildMultiMount(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgAlpine := integration.UnixOrWindows( + "docker.io/library/alpine:latest", + "nanoserver", + ) + imgBusybox := integration.UnixOrWindows( + "docker.io/library/busybox:latest", + "nanoserver", + ) + cmdStr1 := integration.UnixOrWindows( + "/bin/ls -l", + "cmd /C dir", + ) + cmdStr2 := integration.UnixOrWindows( + "/bin/cp -a /busybox/etc/passwd baz", + `cmd /C copy C:\\busybox\\License.txt baz`, + ) + + alpine := llb.Image(imgAlpine) + ls := alpine.Run(llb.Shlex(cmdStr1)) + busybox := llb.Image(imgBusybox) + cp := ls.Run(llb.Shlex(cmdStr2)) + cp.AddMount("/busybox", busybox) + + def, err := cp.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testCachedMounts(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + scratch := func() llb.State { + return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) + } + + busybox := llb.Image(imgName) + // setup base for one of the cache sources + cmdPrefix := integration.UnixOrWindows( + `sh -c "echo -n`, + `cmd /C "echo`, + ) + st := busybox.Run(llb.Shlexf(`%s base > baz"`, cmdPrefix), llb.Dir("/wd")) + base := st.AddMount("/wd", scratch()) + + st = busybox.Run(llb.Shlexf(`%s first > foo"`, cmdPrefix), llb.Dir("/wd")) + + st.AddMount("/wd", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + cmdStr := integration.UnixOrWindows( + `sh -c "cat foo && echo -n second > /wd2/bar"`, + `cmd /C type foo && echo second > C:\\wd2\\bar`, + ) + st = st.Run(llb.Shlex(cmdStr), llb.Dir("/wd")) + st.AddMount("/wd", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + st.AddMount("/wd2", base, llb.AsPersistentCacheDir("mycache2", llb.CacheMountShared)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // repeat to make sure cache works + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // second build using cache directories + cmdStr = integration.UnixOrWindows( + `sh -c "cp /src0/foo . && cp /src1/bar . && cp /src1/baz ."`, + `cmd /C copy C:\\src0\\foo . && copy C:\\src1\\bar . && copy C:\\src1\\baz .`, + ) + st = busybox.Run(llb.Shlex(cmdStr), llb.Dir("/wd")) + out := st.AddMount("/wd", scratch()) + st.AddMount("/src0", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + st.AddMount("/src1", base, llb.AsPersistentCacheDir("mycache2", llb.CacheMountShared)) + + destDir := t.TempDir() + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + newLine := integration.UnixOrWindows("", " \r\n") + dt, err := os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + require.Equal(t, "first"+newLine, string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "bar")) + require.NoError(t, err) + require.Equal(t, "second"+newLine, string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "baz")) + require.NoError(t, err) + require.Equal(t, "base"+newLine, string(dt)) + + checkAllReleasable(t, c, sb, true) +} + +func testCacheMountNoCache(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + + out := busybox.Run(llb.Shlex(`sh -e -c "touch /m1/foo; touch /m2/bar"`)) + out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + out.AddMount("/m2", llb.Scratch(), llb.AsPersistentCacheDir("mycache2", llb.CacheMountLocked)) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + out = busybox.Run(llb.Shlex(`sh -e -c "[[ ! -f /m1/foo ]]; touch /m1/foo2;"`), llb.IgnoreCache) + out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + out = busybox.Run(llb.Shlex(`sh -e -c "[[ -f /m1/foo2 ]]; [[ -f /m2/bar ]];"`)) + out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + out.AddMount("/m2", llb.Scratch(), llb.AsPersistentCacheDir("mycache2", llb.CacheMountLocked)) + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testDuplicateCacheMount(t *testing.T, sb integration.Sandbox) { + 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) + scratch := func() llb.State { + return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) + } + + cmdStr := integration.UnixOrWindows( + `sh -e -c "[[ ! -f /m2/foo ]]; touch /m1/foo; [[ -f /m2/foo ]];"`, + `cmd /C echo a > \\m1\\foo && dir \\m2\\foo`, + ) + out := busybox.Run(llb.Shlex(cmdStr)) + out.AddMount("/m1", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + out.AddMount("/m2", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testLayerLimitOnMounts(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + base := llb.Image("busybox:latest") + + const numLayers = 110 + + for range numLayers { + base = base.Run(llb.Shlex("sh -c 'echo hello >> /hello'")).Root() + } + + def, err := base.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(ctx, def, SolveOpt{}, nil) + require.NoError(t, err) + + ls := llb.Image("busybox:latest"). + Run(llb.Shlexf("ls -l /base/hello")) + ls.AddMount("/base", base, llb.Readonly) + + def, err = ls.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(ctx, def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testLLBMountPerformance(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + srcPath := integration.UnixOrWindows("/bin", "/Windows") + + mntInput := llb.Image(imgName) + st := llb.Image(imgName) + var mnts []llb.State + // Reduce iterations on Windows due to significantly slower container operations + numIterations := integration.UnixOrWindows(20, 5) + for range numIterations { + execSt := st.Run( + llb.Args(integration.UnixOrWindows( + []string{"true"}, + []string{"cmd", "/C", "exit 0"}, + )), + ) + mnts = append(mnts, mntInput) + for j := range mnts { + mnts[j] = execSt.AddMount(fmt.Sprintf("/tmp/bin%d", j), mnts[j], llb.SourcePath(srcPath)) + } + st = execSt.Root() + } + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + // Windows images take longer time, especially on CI systems + // With reduced iterations (5 vs 20), use generous timeout + timeout := integration.UnixOrWindows(time.Minute, 10*time.Minute) + timeoutCtx, cancel := context.WithTimeoutCause(sb.Context(), timeout, nil) + defer cancel() + _, err = c.Solve(timeoutCtx, def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testLockedCacheMounts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := busybox.Run(llb.Shlex(`sh -e -c "touch one; if [[ -f two ]]; then exit 0; fi; for i in $(seq 10); do if [[ -f two ]]; then exit 1; fi; usleep 200000; done"`), llb.Dir("/wd")) + st.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + + st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; if [[ -f one ]]; then exit 0; fi; for i in $(seq 10); do if [[ -f one ]]; then exit 1; fi; usleep 200000; done"`), llb.Dir("/wd")) + st2.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) + + out := busybox.Run(llb.Shlex("true")) + out.AddMount("/m1", st.Root()) + out.AddMount("/m2", st2.Root()) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +// testMountStubsDirectory verifies that BuildKit cleans up stub directories created for tmpfs +// mounts after a Run step completes. Empty stubs should be removed, stubs with user content +// should be kept, and pre-existing directories used as mount targets should be preserved. +func testMountStubsDirectory(t *testing.T, sb integration.Sandbox) { + // Skipped on Windows because the test relies on tmpfs mounts, multiple simultaneous AddMount + // calls, and llb.Scratch() — none of which are supported on Windows containers. The stub + // cleanup behavior is also Linux-specific (tied to overlayfs mount point handling). + integration.SkipOnPlatform(t, "windows", "requires tmpfs, multiple mounts, and Scratch — all unsupported on Windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + File(llb.Mkdir("/test", 0700)). + File(llb.Mkdir("/test/qux/", 0700)). + Run( + llb.Args([]string{"touch", "/test/baz/keep"}), + // check stubs directory is removed + llb.AddMount("/test/foo", llb.Scratch(), llb.Tmpfs()), + // check that stubs directory are recursively removed + llb.AddMount("/test/bar/x/y", llb.Scratch(), llb.Tmpfs()), + // check that only empty stubs directories are removed + llb.AddMount("/test/baz/x", llb.Scratch(), llb.Tmpfs()), + // check that previously existing directory are not removed + llb.AddMount("/test/qux", llb.Scratch(), llb.Tmpfs()), + ).Root() + st = llb.Scratch().File(llb.Copy(st, "/test", "/", &llb.CopyInfo{CopyDirContentsOnly: true})) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + tmpDir := t.TempDir() + tarFile := filepath.Join(tmpDir, "out.tar") + tarFileW, err := os.Create(tarFile) + require.NoError(t, err) + defer tarFileW.Close() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(tarFileW), + }, + }, + }, nil) + require.NoError(t, err) + tarFileW.Close() + + dt, err := os.ReadFile(tarFile) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + require.ElementsMatch(t, []string{ + "baz/", + "baz/keep", + "qux/", + }, keys) +} + +// testMountStubsTimestamp verifies that timestamps set on directories used as mount points +// (and their parents) are preserved after the mount is removed. +// https://github.com/moby/buildkit/issues/3148 +func testMountStubsTimestamp(t *testing.T, sb integration.Sandbox) { + // Skipped on Windows because the test relies on tmpfs mounts, multiple AddMount calls, + // Scratch, and Linux-specific commands — all unsupported on Windows containers. + integration.SkipOnPlatform(t, "windows", "requires tmpfs, multiple mounts, and Scratch — all unsupported on Windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + const sourceDateEpoch = int64(1234567890) // Fri Feb 13 11:31:30 PM UTC 2009 + st := llb.Image("busybox:latest").Run( + llb.Args([]string{ + "/bin/touch", fmt.Sprintf("--date=@%d", sourceDateEpoch), + "/bin", + "/etc", + "/var", + "/var/foo", + "/tmp", + "/tmp/foo2", + "/tmp/foo2/bar", + }), + llb.AddMount("/var/foo", llb.Scratch(), llb.Tmpfs()), + llb.AddMount("/tmp/foo2/bar", llb.Scratch(), llb.Tmpfs()), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + tmpDir := t.TempDir() + tarFile := filepath.Join(tmpDir, "out.tar") + tarFileW, err := os.Create(tarFile) + require.NoError(t, err) + defer tarFileW.Close() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(tarFileW), + }, + }, + }, nil) + require.NoError(t, err) + tarFileW.Close() + + tarFileR, err := os.Open(tarFile) + require.NoError(t, err) + defer tarFileR.Close() + tarR := tar.NewReader(tarFileR) + touched := map[string]*tar.Header{ + "bin/": nil, // Regular dir + "etc/": nil, // Parent of file mounts (etc/{resolv.conf, hosts}) + "var/": nil, // Parent of dir mount (var/foo/) + "tmp/": nil, // Grandparent of dir mount (tmp/foo2/bar/) + // No support for reproducing the timestamps of mount point directories such as var/foo/ and tmp/foo2/bar/, + // because the touched timestamp value is lost when the mount is unmounted. + } + for { + hd, err := tarR.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if x, ok := touched[hd.Name]; ok && x == nil { + touched[hd.Name] = hd + } + } + for name, hd := range touched { + t.Logf("Verifying %q (%+v)", name, hd) + require.NotNil(t, hd, name) + require.Equal(t, sourceDateEpoch, hd.ModTime.Unix(), name) + } +} + +// #319 +func testMountWithNoSource(t *testing.T, sb integration.Sandbox) { + 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)) + + var nilState llb.State + + // This should never actually be run, but we want to succeed + // if it was, because we expect an error below, or a daemon + // panic if the issue has regressed. + cmdArgs := integration.UnixOrWindows( + []string{"/bin/true"}, + []string{"cmd", "/C", "exit 0"}, + ) + run := busybox.Run( + llb.Args(cmdArgs), + llb.AddMount("/nil", nilState, llb.SourcePath("/"), llb.Readonly)) + + st = run.AddMount("/mnt", st) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testRawSocketMount(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dir := t.TempDir() + sockPath := filepath.Join(dir, "test.sock") + listener := net.ListenConfig{} + l, err := listener.Listen(context.TODO(), "unix", sockPath) + require.NoError(t, err) + defer l.Close() + + var called atomic.Bool + srv := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called.Store(true) + w.WriteHeader(http.StatusOK) + }), + } + go srv.Serve(l) + defer srv.Close() + + st := llb.Image("alpine:latest"). + Run(llb.Shlex(`apk add --no-cache curl`)). + Run(llb.Shlex(`curl --unix-socket /tmp/test.sock http://./foo`), + llb.AddSSHSocket(llb.SSHSocketTarget("/tmp/test.sock")), + ).Root() + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + raw, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ + Paths: []string{sockPath}, + Raw: true, + }}) + require.NoError(t, err) + + ch := make(chan *SolveStatus) + go func() { + for status := range ch { + for _, l := range status.Logs { + t.Log(string(l.Data)) + } + } + }() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{raw}, + }, ch) + require.NoError(t, err) + require.True(t, called.Load(), "server should have been called") +} + +// #324 +func testReadonlyRootFS(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("docker.io/library/busybox:latest") + st := llb.Scratch() + + // The path /foo should be unwriteable. + run := busybox.Run( + llb.ReadonlyRootFS(), + llb.Args([]string{"/bin/touch", "/foo"})) + st = run.AddMount("/mnt", st) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + // Would prefer to detect more specifically "Read-only file + // system" but that isn't exposed here (it is on the stdio + // which we don't see). + require.Contains(t, err.Error(), "process \"/bin/touch /foo\" did not complete successfully") + + checkAllReleasable(t, c, sb, true) +} + +func testRunCacheWithMounts(t *testing.T, sb integration.Sandbox) { + 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) + + imgAlpine := integration.UnixOrWindows("alpine:latest", "nanoserver:plus") + alpineImage := llb.Image(imgAlpine) + + // On Windows ARM64, nanoserver:plus and nanoserver:latest map to the same + // image, so we create a marker file to distinguish the mounted image. + mountSource := alpineImage + if runtime.GOOS == "windows" { + mountSource = alpineImage.Run( + llb.Shlex(`cmd /C echo 1> C:/marker`), + ).Root() + } + + cmdStr := integration.UnixOrWindows( + `sh -e -c "[[ -f /m1/sbin/apk ]]"`, + `cmd /C if exist C:/m1/marker (exit 0) else (exit 1)`, + ) + out := busybox.Run(llb.Shlex(cmdStr)) + out.AddMount("/m1", mountSource, llb.Readonly) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + cmdStr = integration.UnixOrWindows( + `sh -e -c "[[ ! -f /m1/sbin/apk ]]"`, + `cmd /C if exist C:/m1/marker (exit 1) else (exit 0)`, + ) + out = busybox.Run(llb.Shlex(cmdStr)) + out.AddMount("/m1", llb.Image(imgName), llb.Readonly) + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +/* +testSecretEnv verifies that secrets can be injected as environment variables during a build. +It tests four scenarios: (1) a provided secret is accessible via env var, (2) an optional +secret that is not provided results in an unset/empty env var, (3) a required secret that +is not provided causes an error, and (4) multiple secrets with custom IDs are resolved correctly. +Works on both Linux (busybox) and Windows (nanoserver). +*/ +func testSecretEnv(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + var st llb.ExecState + + // Test 1: Verify secret value is accessible as environment variable + switch imgName { + case "nanoserver:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`cmd /C "if "%MY_SECRET%"=="foo-secret" (exit 0) else (exit 1)"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) + case "busybox:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`sh -c '[ "$(echo ${MY_SECRET})" = 'foo-secret' ]'`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) + } + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "MY_SECRET": []byte("foo-secret"), + })}, + }, nil) + require.NoError(t, err) + + // Test 2: Optional secret not provided should be unset/empty + switch imgName { + case "nanoserver:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`cmd /C "if not defined MY_SECRET (exit 0) else (exit 1)"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true), llb.SecretOptional)) + case "busybox:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`sh -c '[ -z "${MY_SECRET}" ]'`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true), llb.SecretOptional)) + } + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, + }, nil) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // Test 3: Required secret not provided should error + switch imgName { + case "nanoserver:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`cmd /C "echo foo"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) + case "busybox:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`echo foo`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) + } + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, + }, nil) + require.Error(t, err) + + // Test 4: Multiple secrets with custom IDs + switch imgName { + case "nanoserver:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`cmd /C "if "%MYPASSWORD%-%MYTOKEN%"=="pw-token" (exit 0) else (exit 1)"`), + llb.AddSecret("MYPASSWORD", llb.SecretID("pass"), llb.SecretAsEnv(true)), + llb.AddSecret("MYTOKEN", llb.SecretAsEnv(true)), + ) + case "busybox:latest": + st = llb.Image(imgName). + Run(llb.Shlex(`sh -c '[ "$(echo ${MYPASSWORD}-${MYTOKEN})" = "pw-token" ]' `), + llb.AddSecret("MYPASSWORD", llb.SecretID("pass"), llb.SecretAsEnv(true)), + llb.AddSecret("MYTOKEN", llb.SecretAsEnv(true)), + ) + } + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "pass": []byte("pw"), + "MYTOKEN": []byte("token"), + })}, + }, nil) + require.NoError(t, err) +} + +/* +testSecretMounts verifies file-based secret mounts: content readability, optional/required +behavior, custom permissions, and empty secrets. Skipped on Windows because BuildKit uses +tmpfs for secret mounts, which Windows does not support. See testSecretEnv for a +cross-platform alternative using environment-based secrets. +*/ +func testSecretMounts(t *testing.T, sb integration.Sandbox) { + // Windows vs Linux secret implementation differences: + // + // Linux: Secrets are mounted as files using tmpfs at /run/secrets/ (RAM-based, encrypted) + // + // Windows: Secrets are stored in C:\ProgramData\Docker\internal\secrets (clear text on disk) + // Symbolic links point to the desired target (default: C:\ProgramData\Docker\secrets) + // Windows does NOT support tmpfs or non-directory file bind-mounts + // UID/GID/mode options are NOT supported on Windows + // Recommend BitLocker for at-rest encryption + // + // BuildKit Issue: The "invalid windows mount type: 'tmpfs'" error occurs because BuildKit + // currently tries to use tmpfs for all secret mounts. This needs to be fixed in BuildKit's + // secret mount implementation to use the Windows symlink approach instead. + + // For now, this test is Linux-only until BuildKit properly implements Windows secret mounts + // without tmpfs. Use testSecretEnv for Windows-compatible environment-based secrets. + integration.SkipOnPlatform(t, "windows", "Windows does not support tmpfs for secret mounts") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // Test 1: Basic secret mount with content verification (Linux only) + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'mount | grep mysecret | grep "type tmpfs" && [ "$(cat /run/secrets/mysecret)" = 'foo-secret' ]'`), llb.AddSecret("/run/secrets/mysecret")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "/run/secrets/mysecret": []byte("foo-secret"), + })}, + }, nil) + require.NoError(t, err) + + // Test 2: Optional secret - mount should not exist when secret not present in SolveOpt + st = llb.Image("busybox:latest"). + Run(llb.Shlex(`test ! -f /run/secrets/mysecret2`), llb.AddSecret("/run/secrets/mysecret2", llb.SecretOptional)) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, + }, nil) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + // Test 3: Required secret missing - should error + st = llb.Image("busybox:latest"). + Run(llb.Shlex(`echo secret3`), llb.AddSecret("/run/secrets/mysecret3")) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, + }, nil) + require.Error(t, err) + + // Test 4: Secret with custom ID and file permissions + st = llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c '[ "$(stat -c "%u %g %f" /run/secrets/mysecret4)" = "1 1 81ff" ]' `), llb.AddSecret("/run/secrets/mysecret4", llb.SecretID("mysecret"), llb.SecretFileOpt(1, 1, 0777))) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "mysecret": []byte("pw"), + })}, + }, nil) + require.NoError(t, err) + + // Test 5: Empty secret still creates secret file + st = llb.Image("busybox:latest"). + Run(llb.Shlex(`test -f /run/secrets/mysecret5`), llb.AddSecret("/run/secrets/mysecret5", llb.SecretID("mysecret"))) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ + "mysecret": []byte(""), + })}, + }, nil) + require.NoError(t, err) +} + +func testSharedCacheMounts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := busybox.Run(llb.Shlex(`sh -e -c "touch one; while [[ ! -f two ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) + st.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + + st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; while [[ ! -f one ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) + st2.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + + out := busybox.Run(llb.Shlex("true")) + out.AddMount("/m1", st.Root()) + out.AddMount("/m2", st2.Root()) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +// #2334 +func testSharedCacheMountsNoScratch(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := busybox.Run(llb.Shlex(`sh -e -c "touch one; while [[ ! -f two ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) + st.AddMount("/wd", llb.Image("busybox:latest"), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + + st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; while [[ ! -f one ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) + st2.AddMount("/wd", llb.Image("busybox:latest"), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) + + out := busybox.Run(llb.Shlex("true")) + out.AddMount("/m1", st.Root()) + out.AddMount("/m2", st2.Root()) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testSSHMount(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + a := agent.NewKeyring() + + k, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + err = a.Add(agent.AddedKey{PrivateKey: k}) + require.NoError(t, err) + + sockPath, err := makeSSHAgentSock(t, a) + require.NoError(t, err) + + ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ + Paths: []string{sockPath}, + }}) + require.NoError(t, err) + + // no ssh exposed + st := llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket()) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "no SSH key ") + + // custom ID not exposed + st = llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket(llb.SSHID("customID"))) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{ssh}, + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "unset ssh forward key customID") + + // missing custom ID ignored on optional + st = llb.Image("busybox:latest").Run(llb.Shlex(`ls`), llb.AddSSHSocket(llb.SSHID("customID"), llb.SSHOptional)) + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{ssh}, + }, nil) + require.NoError(t, err) + + // valid socket + st = llb.Image("alpine:latest"). + Run(llb.Shlex(`apk add --no-cache openssh`)). + Run(llb.Shlex(`sh -c 'echo -n $SSH_AUTH_SOCK > /out/sock && ssh-add -l > /out/out'`), + llb.AddSSHSocket()) + + out := st.AddMount("/out", llb.Scratch()) + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + Session: []session.Attachable{ssh}, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "sock")) + require.NoError(t, err) + require.Equal(t, "/run/buildkit/ssh_agent.0", string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "out")) + require.NoError(t, err) + require.Contains(t, string(dt), "2048") + require.Contains(t, string(dt), "(RSA)") + + // forbidden command + st = llb.Image("alpine:latest"). + Run(llb.Shlex(`apk add --no-cache openssh`)). + Run(llb.Shlex(`sh -c 'ssh-keygen -f /tmp/key -N "" && ssh-add -k /tmp/key 2> /out/out || true'`), + llb.AddSSHSocket()) + + out = st.AddMount("/out", llb.Scratch()) + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + Session: []session.Attachable{ssh}, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(destDir, "out")) + require.NoError(t, err) + require.Contains(t, string(dt), "agent refused operation") + + // valid socket from key on disk + st = llb.Image("alpine:latest"). + Run(llb.Shlex(`apk add --no-cache openssh`)). + Run(llb.Shlex(`sh -c 'ssh-add -l > /out/out'`), + llb.AddSSHSocket()) + + out = st.AddMount("/out", llb.Scratch()) + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + k, err = rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + dt = pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(k), + }, + ) + + tmpDir := t.TempDir() + + err = os.WriteFile(filepath.Join(tmpDir, "key"), dt, 0600) + require.NoError(t, err) + + ssh, err = sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ + Paths: []string{filepath.Join(tmpDir, "key")}, + }}) + require.NoError(t, err) + + destDir = t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + Session: []session.Attachable{ssh}, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(destDir, "out")) + require.NoError(t, err) + require.Contains(t, string(dt), "2048") + require.Contains(t, string(dt), "(RSA)") +} + +func testTmpfsMounts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'mount | grep /foobar | grep "type tmpfs" && touch /foobar/test'`), llb.AddMount("/foobar", llb.Scratch(), llb.Tmpfs())) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func cacheMountFile(base llb.State, cacheID, name string) llb.State { + check := base.Run(llb.Shlexf(`sh -e -c "echo %s >/dev/null; cp /cache/%s /out/%s"`, cacheID, name, name)) + check.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) + return check.AddMount("/out", llb.Scratch()) +} + +func makeSSHAgentSock(t *testing.T, agent agent.Agent) (p string, err error) { + tmpDir := integration.Tmpdir(t) + sockPath := filepath.Join(tmpDir.Name, "ssh_auth_sock") + + listener := net.ListenConfig{} + l, err := listener.Listen(context.TODO(), "unix", sockPath) + if err != nil { + return "", err + } + t.Cleanup(func() { + require.NoError(t, l.Close()) + }) + + s := &server{l: l} + go s.run(agent) + + return sockPath, nil +} + +type server struct { + l net.Listener +} + +func (s *server) run(a agent.Agent) error { + for { + c, err := s.l.Accept() + if err != nil { + return err + } + + go agent.ServeAgent(a, c) + } +} diff --git a/client/client_network_test.go b/client/client_network_test.go new file mode 100644 index 000000000..671e6bc1f --- /dev/null +++ b/client/client_network_test.go @@ -0,0 +1,437 @@ +package client + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/entitlements" + "github.com/moby/buildkit/util/testutil/echoserver" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func testBridgeNetworking(t *testing.T, sb integration.Sandbox) { + if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { + t.SkipNow() + } + if sb.Rootless() { // bridge is not used by default, even with detach-netns + t.SkipNow() + } + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + s, err := echoserver.NewTestServer("foo") + require.NoError(t, err) + addrParts := strings.Split(s.Addr().String(), ":") + + def, err := llb.Image("busybox").Run(llb.Shlexf("sh -c 'nc 127.0.0.1 %s | grep foo'", addrParts[len(addrParts)-1])).Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) +} + +func testBridgeNetworkingDNSNoRootless(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + workers.CheckFeatureCompat(t, sb, workers.FeatureCNINetwork) + if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { + t.SkipNow() + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + name := identity.NewID() + server, err := llb.Image("busybox"). + Run( + llb.Shlexf(`sh -c 'test "$(nc -l -p 1234)" = "foo"'`), + llb.Hostname(name), + ). + Marshal(sb.Context()) + require.NoError(t, err) + + client, err := llb.Image("busybox"). + Run( + llb.Shlexf("sh -c 'until echo foo | nc " + name + " 1234 -w0; do sleep 0.1; done'"), + ). + Marshal(sb.Context()) + require.NoError(t, err) + + eg, ctx := errgroup.WithContext(context.Background()) + eg.Go(func() error { + _, err := c.Solve(ctx, server, SolveOpt{}, nil) + return err + }) + eg.Go(func() error { + _, err := c.Solve(ctx, client, SolveOpt{}, nil) + return err + }) + err = eg.Wait() + require.NoError(t, err) +} + +/* +testExtraHosts verifies that custom host entries added via llb.AddExtraHost() are resolvable +during a RUN step. It adds "myhost" pointing to 1.2.3.4 and checks /etc/hosts for the entry. + +Skipped on Windows because BuildKit for Windows does not support llb.AddExtraHost() +at the moment due to fundamental differences in how Linux and Windows containers handle hosts file injections +*/ +func testExtraHosts(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows", "extra hosts not supported on BuildKit for Windows at the moment") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'cat /etc/hosts | grep myhost | grep 1.2.3.4'`), llb.AddExtraHost("myhost", net.ParseIP("1.2.3.4"))) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +func testHostNetworking(t *testing.T, sb integration.Sandbox) { + if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { + t.SkipNow() + } + netMode := sb.Value("netmode") + var allowedEntitlements []string + if netMode == hostNetwork { + allowedEntitlements = []string{entitlements.EntitlementNetworkHost.String()} + } + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + s, err := echoserver.NewTestServer("foo") + require.NoError(t, err) + addrParts := strings.Split(s.Addr().String(), ":") + + def, err := llb.Image("busybox").Run(llb.Shlexf("sh -c 'nc 127.0.0.1 %s | grep foo'", addrParts[len(addrParts)-1]), llb.Network(llb.NetModeHost)).Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + AllowedEntitlements: allowedEntitlements, + }, nil) + if netMode == hostNetwork { + require.NoError(t, err) + } else { + require.Error(t, err) + } +} + +func testHostnameLookup(t *testing.T, sb integration.Sandbox) { + if sb.Rootless() { // bridge is not used by default, even with detach-netns + t.SkipNow() + } + + 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 -c "ping -c 1 $(hostname)"`, + "cmd /C ping -n 1 %COMPUTERNAME%", + ) + st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) +} + +// moby/buildkit#1301 +func testHostnameSpecifying(t *testing.T, sb integration.Sandbox) { + if sb.Rootless() { // bridge is not used by default, even with detach-netns + t.SkipNow() + } + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + type testVars struct { + imgName string + cmdStr1 string + cmdStr2 string + } + + v := integration.UnixOrWindows( + testVars{ + imgName: "busybox:latest", + cmdStr1: "sh -c 'echo $HOSTNAME | grep %s'", + cmdStr2: "sh -c 'echo $(hostname) | grep %s'", + }, + testVars{ + imgName: "nanoserver:latest", + // NOTE: Windows capitalizes the hostname hence + // case insensitive findstr (findstr /I) + // testtest --> TESTTEST + cmdStr1: "cmd /C echo %%COMPUTERNAME%% | findstr /I %s", + cmdStr2: "cmd /C echo %%COMPUTERNAME%% | findstr /I %s", + }, + ) + + hostname := "testtest" + st := llb.Image(v.imgName).With(llb.Hostname(hostname)). + Run(llb.Shlexf(v.cmdStr1, hostname)). + Run(llb.Shlexf(v.cmdStr2, hostname)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{"hostname": hostname}, + }, nil) + require.NoError(t, err) +} + +func testNetworkMode(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows") + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c 'wget https://example.com 2>&1 | grep "wget: bad address"'`), llb.Network(llb.NetModeNone)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.NoError(t, err) + + st2 := llb.Image("busybox:latest"). + Run(llb.Shlex(`ifconfig`), llb.Network(llb.NetModeHost)) + + def, err = st2.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + // Currently disabled globally by default + // AllowedEntitlements: []entitlements.Entitlement{entitlements.EntitlementNetworkHost}, + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "network.host is not allowed") +} + +func testProxyEnv(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") + scratch := func() llb.State { + return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) + } + base := llb.Image(imgName).Dir("/out") + cmd := integration.UnixOrWindows( + `sh -c "echo -n $HTTP_PROXY-$HTTPS_PROXY-$NO_PROXY-$no_proxy-$ALL_PROXY-$all_proxy > env"`, + `cmd /C echo %HTTP_PROXY%-%HTTPS_PROXY%-%NO_PROXY%-%no_proxy%-%ALL_PROXY%-%all_proxy% > env`, + ) + + st := base.Run(llb.Shlex(cmd), llb.WithProxy(llb.ProxyEnv{ + HTTPProxy: "httpvalue", + HTTPSProxy: "httpsvalue", + NoProxy: "noproxyvalue", + AllProxy: "allproxyvalue", + })) + + out := st.AddMount("/out", scratch()) + + def, err := out.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "env")) + require.NoError(t, err) + newLine := integration.UnixOrWindows("", " \r\n") + require.Equal(t, "httpvalue-httpsvalue-noproxyvalue-noproxyvalue-allproxyvalue-allproxyvalue"+newLine, string(dt)) + + // repeat to make sure proxy doesn't change cache + st = base.Run(llb.Shlex(cmd), llb.WithProxy(llb.ProxyEnv{ + HTTPSProxy: "httpsvalue2", + NoProxy: "noproxyvalue2", + })) + out = st.AddMount("/out", scratch()) + + def, err = out.Marshal(sb.Context()) + require.NoError(t, err) + + destDir = t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err = os.ReadFile(filepath.Join(destDir, "env")) + require.NoError(t, err) + require.Equal(t, "httpvalue-httpsvalue-noproxyvalue-noproxyvalue-allproxyvalue-allproxyvalue"+newLine, string(dt)) +} + +func testResolveAndHosts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + busybox := llb.Image("busybox:latest") + st := llb.Scratch() + + run := func(cmd string) { + st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) + } + + run(`sh -c "cp /etc/resolv.conf ."`) + run(`sh -c "cp /etc/hosts ."`) + + 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, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "resolv.conf")) + require.NoError(t, err) + require.Contains(t, string(dt), "nameserver") + + dt, err = os.ReadFile(filepath.Join(destDir, "hosts")) + require.NoError(t, err) + require.Contains(t, string(dt), "127.0.0.1 localhost") +} + +type netModeHost struct{} + +func (*netModeHost) UpdateConfigFile(in string) (string, func() error) { + return in + "\n\ninsecure-entitlements = [\"network.host\"]\n", nil +} + +type netModeProxyBridge struct{} + +func (*netModeProxyBridge) UpdateConfigFile(in string) (string, func() error) { + return in + ` + +insecure-entitlements = ["network.host"] + +[worker.oci] +networkMode = "bridge" + +[worker.containerd] +networkMode = "bridge" + `, nil +} + +type netModeProxyDefault struct{} + +func (*netModeProxyDefault) UpdateConfigFile(in string) (string, func() error) { + return in + ` + +insecure-entitlements = ["network.host"] +`, nil +} + +type netModeProxyDefaultNoCNI struct{} + +func (*netModeProxyDefaultNoCNI) UpdateConfigFile(in string) (string, func() error) { + return in + ` + +insecure-entitlements = ["network.host"] + +[worker.oci] +cniConfigPath = "/tmp/buildkit-missing-cni.json" + +[worker.containerd] +cniConfigPath = "/tmp/buildkit-missing-cni.json" +`, nil +} + +type netModeProxyHost struct{} + +func (*netModeProxyHost) UpdateConfigFile(in string) (string, func() error) { + return in + ` + +insecure-entitlements = ["network.host"] + +[worker.oci] +networkMode = "host" + +[worker.containerd] +networkMode = "host" +`, nil +} + +type netModeDefault struct{} + +func (*netModeDefault) UpdateConfigFile(in string) (string, func() error) { + return in, nil +} + +type netModeBridgeDNS struct{} + +func (*netModeBridgeDNS) UpdateConfigFile(in string) (string, func() error) { + return in + ` +# configure bridge networking +[worker.oci] +networkMode = "cni" +cniConfigPath = "/etc/buildkit/dns-cni.conflist" + +[worker.containerd] +networkMode = "cni" +cniConfigPath = "/etc/buildkit/dns-cni.conflist" + +[dns] +nameservers = ["10.11.0.1"] +`, nil +} + +var ( + hostNetwork integration.ConfigUpdater = &netModeHost{} + defaultNetwork integration.ConfigUpdater = &netModeDefault{} + proxyDefaultNetwork integration.ConfigUpdater = &netModeProxyDefault{} + proxyDefaultNetworkNoCNI integration.ConfigUpdater = &netModeProxyDefaultNoCNI{} + proxyBridgeNetwork integration.ConfigUpdater = &netModeProxyBridge{} + proxyHostNetwork integration.ConfigUpdater = &netModeProxyHost{} + bridgeDNSNetwork integration.ConfigUpdater = &netModeBridgeDNS{} +) diff --git a/client/client_oci_source_test.go b/client/client_oci_source_test.go new file mode 100644 index 000000000..5714abc91 --- /dev/null +++ b/client/client_oci_source_test.go @@ -0,0 +1,606 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/platforms" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + gateway "github.com/moby/buildkit/frontend/gateway/client" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/purl" + "github.com/moby/buildkit/util/testutil" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + packageurl "github.com/package-url/packageurl-go" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testImageBlobSource(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + requiresLinux(t) + 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) + + st := llb.Image("alpine") + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + name := registry + "/foo/blobtest:img" + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: "image", + Attrs: map[string]string{ + "name": name, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(name) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + + require.Equal(t, 1, len(imgs.Images)) + mfst := imgs.Images[0].Manifest + require.GreaterOrEqual(t, len(mfst.Layers), 1) + + l := mfst.Layers[0] + + blob := llb.ImageBlob(registry+"/foo/blobtest@"+l.Digest.String(), llb.Filename("layer.tar.gz"), llb.Chown(123, 456)) + st = llb.Image("alpine").Run(llb.Shlex(`sh -c 'sha256sum /layers/layer.tar.gz | cut -d" " -f0 > /out/checksum && stat -c "%u-%g-%s" /layers/layer.tar.gz > /out/stat'`), llb.AddMount("/layers", blob, llb.Readonly)).AddMount("/out", llb.Scratch()) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "stat")) + require.NoError(t, err) + + require.Equal(t, "123-456-"+strconv.FormatInt(l.Size, 10), strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "checksum")) + require.NoError(t, err) + + require.Equal(t, l.Digest.Hex(), strings.TrimSpace(string(dt))) + + provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) + require.NoError(t, err) + + var stmt struct { + intoto.StatementHeader + Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` + } + require.NoError(t, json.Unmarshal(provDt, &stmt)) + + expectedName, err := purl.RefToPURL(packageurl.TypeDocker, registry+"/foo/blobtest@"+l.Digest.String(), nil) + require.NoError(t, err) + purlObj, err := packageurl.FromString(expectedName) + require.NoError(t, err) + purlObj.Qualifiers = append(purlObj.Qualifiers, packageurl.Qualifier{Key: "ref_type", Value: "blob"}) + expectedName = purlObj.ToString() + + found := false + for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { + if m.URI == expectedName { + found = true + require.Equal(t, l.Digest.Hex(), m.Digest["sha256"]) + break + } + } + require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.BuildDefinition.ResolvedDependencies) +} + +func testNoTarOCIIndexMediaType(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + 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 -c "echo -n hello > hello"`, + `cmd /C "echo hello> hello"`, + ) + st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + outDir := filepath.Join(destDir, "out.d") + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: map[string]string{ + "tar": "false", + }, + OutputDir: outDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(outDir, "index.json")) + require.NoError(t, err) + + var index ocispecs.Index + err = json.Unmarshal(dt, &index) + require.NoError(t, err) + + require.Equal(t, "application/vnd.oci.image.index.v1+json", index.MediaType) + + checkAllReleasable(t, c, sb, true) +} + +func testOCIIndexMediatype(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) + 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 -c "echo -n hello > hello"`, + `cmd /C "echo hello> hello"`, + ) + st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir, err := os.MkdirTemp("", "buildkit") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + 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), + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(out) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(dt, false) + require.NoError(t, err) + + indexDt, ok := m[ocispecs.ImageIndexFile] + require.True(t, ok) + + var index ocispecs.Index + err = json.Unmarshal(indexDt.Data, &index) + require.NoError(t, err) + + require.Equal(t, "application/vnd.oci.image.index.v1+json", index.MediaType) + + checkAllReleasable(t, c, sb, true) +} + +func testOCILayoutBlobSource(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("alpine") + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + ociDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: map[string]string{ + "tar": "false", + }, + OutputDir: ociDir, + }, + }, + }, nil) + require.NoError(t, err) + + indexDt, err := os.ReadFile(filepath.Join(ociDir, ocispecs.ImageIndexFile)) + require.NoError(t, err) + + var index ocispecs.Index + err = json.Unmarshal(indexDt, &index) + require.NoError(t, err) + require.Equal(t, 1, len(index.Manifests)) + + var mfst ocispecs.Manifest + mfstDt, err := os.ReadFile(filepath.Join(ociDir, "blobs/sha256", index.Manifests[0].Digest.Hex())) + require.NoError(t, err) + err = json.Unmarshal(mfstDt, &mfst) + require.NoError(t, err) + require.GreaterOrEqual(t, len(mfst.Layers), 1) + layer := mfst.Layers[0] + + store, err := local.NewStore(ociDir) + require.NoError(t, err) + csID := "my-blob-content-store" + + blob := llb.OCILayoutBlob("not/real@"+layer.Digest.String(), llb.ImageBlobOCIStore("", csID), llb.Filename("layer.tar.gz"), llb.Chown(123, 456)) + st = llb.Image("alpine").Run(llb.Shlex(`sh -c 'sha256sum /layers/layer.tar.gz | cut -d" " -f0 > /out/checksum && stat -c "%u-%g-%s" /layers/layer.tar.gz > /out/stat'`), llb.AddMount("/layers", blob, llb.Readonly)).AddMount("/out", llb.Scratch()) + + def, err = st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "stat")) + require.NoError(t, err) + require.Equal(t, "123-456-"+strconv.FormatInt(layer.Size, 10), strings.TrimSpace(string(dt))) + + dt, err = os.ReadFile(filepath.Join(destDir, "checksum")) + require.NoError(t, err) + require.Equal(t, layer.Digest.Hex(), strings.TrimSpace(string(dt))) + + provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) + require.NoError(t, err) + + var stmt struct { + intoto.StatementHeader + Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` + } + require.NoError(t, json.Unmarshal(provDt, &stmt)) + + expectedName, err := purl.RefToPURL(packageurl.TypeOCI, "not/real@"+layer.Digest.String(), nil) + require.NoError(t, err) + purlObj, err := packageurl.FromString(expectedName) + require.NoError(t, err) + purlObj.Qualifiers = append(purlObj.Qualifiers, packageurl.Qualifier{Key: "ref_type", Value: "blob"}) + expectedName = purlObj.ToString() + + found := false + for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { + if m.URI == expectedName { + found = true + require.Equal(t, layer.Digest.Hex(), m.Digest["sha256"]) + break + } + } + require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.BuildDefinition.ResolvedDependencies) +} + +func testOCILayoutPlatformSource(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) + requiresLinux(t) + c, err := New(context.TODO(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // create a tempdir where we will store the OCI layout + dir := t.TempDir() + + platformsToTest := []string{"linux/amd64", "linux/arm64"} + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch().File( + llb.Mkfile("platform", 0600, []byte(platform)), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + attrs := map[string]string{} + outW := bytes.NewBuffer(nil) + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: attrs, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + // extract the tar stream to the directory as OCI layout + m, err := testutil.ReadTarToMap(outW.Bytes(), false) + require.NoError(t, err) + + for filename, tarItem := range m { + fullFilename := path.Join(dir, filename) + err = os.MkdirAll(path.Dir(fullFilename), 0755) + require.NoError(t, err) + if tarItem.Header.FileInfo().IsDir() { + err = os.MkdirAll(fullFilename, 0755) + require.NoError(t, err) + } else { + err = os.WriteFile(fullFilename, tarItem.Data, 0644) + require.NoError(t, err) + } + } + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) + require.NoError(t, err) + require.Equal(t, 1, len(index.Manifests)) + digest := index.Manifests[0].Digest + + store, err := local.NewStore(dir) + require.NoError(t, err) + csID := "my-content-store" + + destDir := t.TempDir() + + frontendOCILayout := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.OCILayout(fmt.Sprintf("not/real@%s", digest), llb.OCIStore("", csID)) + + def, err := st.Marshal(ctx, llb.Platform(platforms.MustParse(platform))) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, "", frontendOCILayout, nil) + require.NoError(t, err) + + for _, platform := range platformsToTest { + dt, err := os.ReadFile(filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_"), "platform")) + require.NoError(t, err) + require.Equal(t, []byte(platform), dt) + } +} + +func testOCILayoutSource(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) + c, err := New(context.TODO(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + // create a tempdir where we will store the OCI layout + dir := t.TempDir() + + // make an image that is exported there + 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) + } + + cmdPrefix := integration.UnixOrWindows( + `sh -c "echo -n`, + `cmd /C "echo`, + ) + run(fmt.Sprintf(`%s first > foo"`, cmdPrefix)) + run(fmt.Sprintf(`%s second > bar"`, cmdPrefix)) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + outW := bytes.NewBuffer(nil) + attrs := map[string]string{} + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterOCI, + Attrs: attrs, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), + }, + }, + }, nil) + require.NoError(t, err) + + // extract the tar stream to the directory as OCI layout + m, err := testutil.ReadTarToMap(outW.Bytes(), false) + require.NoError(t, err) + + for filename, content := range m { + fullFilename := path.Join(dir, filename) + err = os.MkdirAll(path.Dir(fullFilename), 0755) + require.NoError(t, err) + if content.Header.FileInfo().IsDir() { + err = os.MkdirAll(fullFilename, 0755) + require.NoError(t, err) + } else { + err = os.WriteFile(fullFilename, content.Data, 0644) + require.NoError(t, err) + } + } + + var index ocispecs.Index + err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) + require.NoError(t, err) + require.Equal(t, 1, len(index.Manifests)) + digest := index.Manifests[0].Digest + + store, err := local.NewStore(dir) + require.NoError(t, err) + + // reference the OCI Layout in a build + // note that the key does not need to be the directory name, just something + // unique. since we are doing just one build with one remote here, we can + // give it any ID + csID := "my-content-store" + st = llb.OCILayout(fmt.Sprintf("not/real@%s", digest), llb.OCIStore("", csID)) + + def, err = st.Marshal(context.TODO()) + require.NoError(t, err) + + destDir := t.TempDir() + + _, err = c.Solve(context.TODO(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + OCIStores: map[string]content.Store{ + csID: store, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo")) + require.NoError(t, err) + newLine := integration.UnixOrWindows("", " \r\n") + require.Equal(t, []byte("first"+newLine), dt) + + dt, err = os.ReadFile(filepath.Join(destDir, "bar")) + require.NoError(t, err) + require.Equal(t, []byte("second"+newLine), dt) +} diff --git a/client/client_session_test.go b/client/client_session_test.go new file mode 100644 index 000000000..9dfa96a71 --- /dev/null +++ b/client/client_session_test.go @@ -0,0 +1,199 @@ +package client + +import ( + "context" + "io" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/session" + sessionsecrets "github.com/moby/buildkit/session/secrets" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +func testSessionHealthMonitorFailsBlockedSecretResponse(t *testing.T, sb integration.Sandbox) { + integration.SkipOnPlatform(t, "windows", "Windows secret mounts are covered separately") + + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Image("busybox:latest"). + Run(llb.Shlex(`sh -c '[ "$(cat /run/secrets/mysecret)" = "foo-secret" ]'`), llb.AddSecret("/run/secrets/mysecret")) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + provider := &gatedSecretProvider{ + t: t, + id: "/run/secrets/mysecret", + data: []byte("foo-secret"), + started: make(chan struct{}), + allowResponse: make(chan struct{}), + } + + baseDialer := c.Dialer() + proxyCh := make(chan *gatedSessionTunnelProxy, 1) + c.sessionDialer = func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { + meta["X-Buildkit-Session-Health-Custom-Timeout"] = []string{"2000"} + + conn, err := baseDialer(ctx, proto, meta) + if err != nil { + return nil, err + } + proxy, clientConn := newGatedSessionTunnelProxy(conn) + proxyCh <- proxy + return clientConn, nil + } + + done := make(chan error, 1) + go func() { + _, err := c.Solve(sb.Context(), def, SolveOpt{ + Session: []session.Attachable{provider}, + }, nil) + done <- err + }() + + var stalledConn *gatedSessionTunnelProxy + select { + case stalledConn = <-proxyCh: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for session connection") + } + + select { + case <-provider.started: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for secret request") + } + + stalledConn.blockClientToDaemon.Store(true) + close(provider.allowResponse) + + select { + case <-stalledConn.blockedClientToDaemon: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for blocked session traffic") + } + + select { + case err := <-done: + require.Error(t, err) + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for solve to fail after session healthcheck timeout") + } +} + +type gatedSecretProvider struct { + sessionsecrets.UnimplementedSecretsServer + + t *testing.T + id string + data []byte + started chan struct{} + allowResponse chan struct{} +} + +func (p *gatedSecretProvider) Register(server *grpc.Server) { + sessionsecrets.RegisterSecretsServer(server, p) +} + +func (p *gatedSecretProvider) GetSecret(ctx context.Context, req *sessionsecrets.GetSecretRequest) (*sessionsecrets.GetSecretResponse, error) { + require.Equal(p.t, p.id, req.ID) + select { + case <-p.started: + default: + close(p.started) + } + + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-p.allowResponse: + return &sessionsecrets.GetSecretResponse{Data: p.data}, nil + } +} + +type gatedSessionTunnelProxy struct { + clientConn net.Conn + proxyConn net.Conn + daemonConn net.Conn + + blockClientToDaemon atomic.Bool + blockedClientToDaemon chan struct{} + releaseClientToDaemon chan struct{} + closed chan struct{} + blockedOnce sync.Once + releaseOnce sync.Once + closeOnce sync.Once +} + +func (p *gatedSessionTunnelProxy) Close() error { + p.closeOnce.Do(func() { + close(p.closed) + _ = p.proxyConn.Close() + _ = p.clientConn.Close() + _ = p.daemonConn.Close() + }) + return nil +} + +func (p *gatedSessionTunnelProxy) Release() { + p.releaseOnce.Do(func() { + close(p.releaseClientToDaemon) + }) +} + +func (p *gatedSessionTunnelProxy) copyClientToDaemon() { + defer p.Close() + + buf := make([]byte, 32*1024) + for { + n, err := p.proxyConn.Read(buf) + if n > 0 { + data := append([]byte(nil), buf[:n]...) + if p.blockClientToDaemon.Load() { + p.blockedOnce.Do(func() { + close(p.blockedClientToDaemon) + }) + select { + case <-p.releaseClientToDaemon: + case <-p.closed: + return + } + } + if _, writeErr := p.daemonConn.Write(data); writeErr != nil { + return + } + } + if err != nil { + return + } + } +} + +func (p *gatedSessionTunnelProxy) copyDaemonToClient() { + defer p.Close() + _, _ = io.Copy(p.proxyConn, p.daemonConn) +} + +func newGatedSessionTunnelProxy(daemonConn net.Conn) (*gatedSessionTunnelProxy, net.Conn) { + clientConn, proxyConn := net.Pipe() + p := &gatedSessionTunnelProxy{ + clientConn: clientConn, + proxyConn: proxyConn, + daemonConn: daemonConn, + blockedClientToDaemon: make(chan struct{}), + releaseClientToDaemon: make(chan struct{}), + closed: make(chan struct{}), + } + go p.copyClientToDaemon() + go p.copyDaemonToClient() + return p, clientConn +} diff --git a/client/client_source_map_test.go b/client/client_source_map_test.go new file mode 100644 index 000000000..fac51e235 --- /dev/null +++ b/client/client_source_map_test.go @@ -0,0 +1,132 @@ +package client + +import ( + "context" + "testing" + + "github.com/moby/buildkit/client/llb" + gateway "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/solver/errdefs" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/stretchr/testify/require" +) + +func testSourceMap(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + sm1 := llb.NewSourceMap(nil, "foo", "", []byte("data1")) + sm2 := llb.NewSourceMap(nil, "bar", "", []byte("data2")) + + st := llb.Scratch().Run( + llb.Shlex("not-exist"), + sm1.Location([]*pb.Range{{Start: &pb.Position{Line: 7}}}), + sm2.Location([]*pb.Range{{Start: &pb.Position{Line: 8}}}), + sm1.Location([]*pb.Range{{Start: &pb.Position{Line: 9}}}), + ) + + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) + require.Error(t, err) + + srcs := errdefs.Sources(err) + require.Equal(t, 3, len(srcs)) + + // Source errors are wrapped in the order provided as llb.ConstraintOpts, so + // when they are unwrapped, the first unwrapped error is the last location + // provided. + require.Equal(t, "foo", srcs[0].Info.Filename) + require.Equal(t, []byte("data1"), srcs[0].Info.Data) + require.Nil(t, srcs[0].Info.Definition) + + require.Equal(t, 1, len(srcs[0].Ranges)) + require.Equal(t, int32(9), srcs[0].Ranges[0].Start.Line) + require.Equal(t, int32(0), srcs[0].Ranges[0].Start.Character) + + require.Equal(t, "bar", srcs[1].Info.Filename) + require.Equal(t, []byte("data2"), srcs[1].Info.Data) + require.Nil(t, srcs[1].Info.Definition) + + require.Equal(t, 1, len(srcs[1].Ranges)) + require.Equal(t, int32(8), srcs[1].Ranges[0].Start.Line) + require.Equal(t, int32(0), srcs[1].Ranges[0].Start.Character) + + require.Equal(t, "foo", srcs[2].Info.Filename) + require.Equal(t, []byte("data1"), srcs[2].Info.Data) + require.Nil(t, srcs[2].Info.Definition) + + require.Equal(t, 1, len(srcs[2].Ranges)) + require.Equal(t, int32(7), srcs[2].Ranges[0].Start.Line) + require.Equal(t, int32(0), srcs[2].Ranges[0].Start.Character) +} + +func testSourceMapFromRef(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + srcState := llb.Scratch().File( + llb.Mkfile("foo", 0600, []byte("data"))) + sm := llb.NewSourceMap(&srcState, "bar", "mylang", []byte("bardata")) + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkdir("foo/bar", 0600), // fails because /foo doesn't exist + sm.Location([]*pb.Range{{Start: &pb.Position{Line: 3, Character: 1}}}), + ) + + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + res, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := res.SingleRef() + if err != nil { + return nil, err + } + + st2, err := ref.ToState() + if err != nil { + return nil, err + } + + st = llb.Scratch().File( + llb.Copy(st2, "foo", "foo2"), + ) + + def, err = st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) + require.Error(t, err) + + srcs := errdefs.Sources(err) + require.Equal(t, 1, len(srcs)) + + require.Equal(t, "bar", srcs[0].Info.Filename) + require.Equal(t, "mylang", srcs[0].Info.Language) + require.Equal(t, []byte("bardata"), srcs[0].Info.Data) + require.NotNil(t, srcs[0].Info.Definition) + + require.Equal(t, 1, len(srcs[0].Ranges)) + require.Equal(t, int32(3), srcs[0].Ranges[0].Start.Line) + require.Equal(t, int32(1), srcs[0].Ranges[0].Start.Character) +} diff --git a/client/client_test.go b/client/client_test.go index d66046ac3..3a18a318c 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1,98 +1,10 @@ package client import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/pem" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "os" - "os/exec" - "path" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" "testing" - "time" - ctd "github.com/containerd/containerd/v2/client" - "github.com/containerd/containerd/v2/core/content" - "github.com/containerd/containerd/v2/core/content/proxy" - "github.com/containerd/containerd/v2/core/images" - "github.com/containerd/containerd/v2/core/remotes/docker" - "github.com/containerd/containerd/v2/core/snapshots" - "github.com/containerd/containerd/v2/pkg/namespaces" - "github.com/containerd/containerd/v2/plugins/content/local" - "github.com/containerd/continuity/fs/fstest" - cerrdefs "github.com/containerd/errdefs" - "github.com/containerd/platforms" - "github.com/distribution/reference" - intoto "github.com/in-toto/in-toto-golang/in_toto" - controlapi "github.com/moby/buildkit/api/services/control" - cacheimporttypes "github.com/moby/buildkit/cache/remotecache/v1/types" - "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" - gatewaypb "github.com/moby/buildkit/frontend/gateway/pb" - "github.com/moby/buildkit/identity" - "github.com/moby/buildkit/session" - "github.com/moby/buildkit/session/exporter" - "github.com/moby/buildkit/session/exporter/exporterprovider" - "github.com/moby/buildkit/session/filesync" - sessionsecrets "github.com/moby/buildkit/session/secrets" - "github.com/moby/buildkit/session/secrets/secretsprovider" - "github.com/moby/buildkit/session/sshforward/sshprovider" - "github.com/moby/buildkit/solver/errdefs" - provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" - "github.com/moby/buildkit/solver/pb" - "github.com/moby/buildkit/solver/result" - "github.com/moby/buildkit/sourcepolicy" - sourcepolicypb "github.com/moby/buildkit/sourcepolicy/pb" - "github.com/moby/buildkit/util/attestation" - "github.com/moby/buildkit/util/contentutil" - "github.com/moby/buildkit/util/entitlements" - "github.com/moby/buildkit/util/gitutil/gitobject" - "github.com/moby/buildkit/util/iohelper" - "github.com/moby/buildkit/util/purl" - "github.com/moby/buildkit/util/testutil" - containerdutil "github.com/moby/buildkit/util/testutil/containerd" - "github.com/moby/buildkit/util/testutil/echoserver" - "github.com/moby/buildkit/util/testutil/helpers" - "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" - packageurl "github.com/package-url/packageurl-go" - "github.com/pkg/errors" - "github.com/spdx/tools-golang/spdx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tonistiigi/fsutil" - fsutiltypes "github.com/tonistiigi/fsutil/types" - "golang.org/x/crypto/ssh/agent" - "golang.org/x/mod/semver" - "golang.org/x/sync/errgroup" - "google.golang.org/grpc" ) func init() { @@ -105,189 +17,296 @@ func init() { } var allTests = []func(t *testing.T, sb integration.Sandbox){ - testCacheExportCacheKeyLoop, - testRelativeWorkDir, - testFileOpMkdirMkfile, - testFileOpCopyRm, - testFileOpCopyIncludeExclude, - testFileOpCopyAlwaysReplaceExistingDestPaths, - testFileOpRmWildcard, - testFileOpCopyUIDCache, - testFileOpCopyChmodText, - testCallDiskUsage, - testBuildMultiMount, - testBuildHTTPSource, - testBuildHTTPSourceUnauthorizedChecksumRace, - testBuildHTTPSourceEtagScope, - testBuildHTTPSourceAuthHeaderSecret, - testBuildHTTPSourceHostTokenSecret, - testBuildHTTPSourceHeader, - testBuildHTTPSourcePGPSignatureVerify, - testBuildPushAndValidate, - testBuildExportWithUncompressed, - testBuildExportScratch, - testResolveAndHosts, - testUser, - testOCIExporter, - testOCIExporterContentStore, - testSessionExporter, - testWhiteoutParentDir, - testFrontendImageNaming, - testDuplicateWhiteouts, - testMountWithNoSource, - testInvalidExporter, - testReadonlyRootFS, - testBasicRegistryCacheImportExport, - testBasicLocalCacheImportExport, - testBasicS3CacheImportExport, + // client_cache_test.go testBasicAzblobCacheImportExport, - testCachedMounts, - testCopyFromEmptyImage, - testProxyEnv, - testLocalSymlinkEscape, - testTmpfsMounts, - testSharedCacheMounts, - testSharedCacheMountsNoScratch, - testLockedCacheMounts, - testDuplicateCacheMount, - testRunCacheWithMounts, - testParallelLocalBuilds, - testSecretEnv, - testSecretMounts, - testSessionHealthMonitorFailsBlockedSecretResponse, - testExtraHosts, - testShmSize, - testUlimit, - testCgroupParent, - testLinuxResources, - testNetworkMode, - testFrontendMetadataReturn, - testFrontendUseSolveResults, - testSSHMount, - testRawSocketMount, - testStdinClosed, - testHostnameLookup, - testHostnameSpecifying, - testPushByDigest, - testPushProgressSameVertex, - testPullWithDigestCheck, testBasicInlineCacheImportExport, - testExportBusyboxLocal, - testBridgeNetworking, - testCacheMountNoCache, - testExporterTargetExists, - testTarExporterWithSocket, - testTarExporterWithSocketCopy, - testTarExporterSymlink, - testMultipleRegistryCacheImportExport, - testMultipleExporters, - testSourceMap, - testSourceMapFromRef, - testLazyImagePush, - testStargzLazyPull, - testStargzLazyInlineCacheImportExport, - testFileOpInputSwap, - testRelativeMountpoint, - testLocalSourceDiffer, - testLocalSourceWithHardlinksFilter, - testNoTarOCIIndexMediaType, - testOCILayoutSource, - testOCILayoutPlatformSource, - testBuildExportZstd, - testPullZstdImage, - testMergeOp, - testPassthroughOp, - testMergeOpCacheInline, - testMergeOpCacheMin, - testMergeOpCacheMax, - testRmSymlink, - testMoveParentDir, - testBuildExportWithForeignLayer, - testZstdLocalCacheExport, - testCacheExportIgnoreError, + testBasicLocalCacheImportExport, + testBasicRegistryCacheImportExport, + testBasicS3CacheImportExport, testCacheExportCacheDeletedContent, + testCacheExportCacheKeyLoop, + testCacheExportIgnoreError, + testImageManifestRegistryCacheImportExport, testLocalCacheExportReset, - testZstdRegistryCacheImportExport, - testZstdLocalCacheImportExport, + testMultipleCacheExports, + testMultipleRecordsWithSameLayersCacheImportExport, + testMultipleRegistryCacheImportExport, + testRegistryEmptyCacheExport, + testSnapshotWithMultipleBlobs, testUncompressedLocalCacheImportExport, testUncompressedRegistryCacheImportExport, + testZstdLocalCacheExport, + testZstdLocalCacheImportExport, + testZstdRegistryCacheImportExport, + testStargzLazyInlineCacheImportExport, testStargzLazyRegistryCacheImportExport, - testValidateDigestOrigin, + + // client_control_test.go testCallInfo, - testPullWithLayerLimit, + testClientCustomGRPCOpts, + testListenBuildHistoryExcludesSoftDeletedRecords, + + // client_diskusage_test.go + testCallDiskUsage, + + // client_exec_test.go + testCgroupParent, + testLinuxResources, + testPassthroughOp, + testRelativeMountpoint, + testRelativeWorkDir, + testRunValidExitCodes, + testShmSize, + testStdinClosed, + testUlimit, + testUser, + + // client_export_image_test.go + testBuildExportScratch, + testBuildExportWithForeignLayer, + testBuildExportWithUncompressed, + testBuildExportZstd, + testBuildPushAndValidate, + testExportBusyboxLocal, + testExportedImageLabels, + testImageResolveConfigDefaultLocalFallback, + testInvalidExporter, + testLazyImagePush, + testOCIExporter, + testOCIExporterContentStore, + testPullWithDigestCheck, + testPullZstdImage, + testPushByDigest, + testPushProgressSameVertex, + testStargzLazyPull, + + // client_export_local_test.go + testExportLocalForcePlatformSplit, + testExportLocalModeCopyKeepsStaleDestinationFiles, + testExportLocalModeCopyMultiPlatformKeepsAllPlatforms, + testExportLocalModeDeleteRemovesStaleDestinationFiles, + testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms, + testExportLocalModeInvalid, + testExportLocalNoPlatformSplit, + testExportLocalNoPlatformSplitOverwrite, + testExportTarPlatformIDSanitized, + testExporterTargetExists, + testMultipleExporters, + testSessionExporter, + testTarExporterSymlink, + testTarExporterWithSocket, + testTarExporterWithSocketCopy, + + // client_export_metadata_test.go + testAttestationBundle, + testAttestationDefaultSubject, testExportAnnotations, testExportAnnotationsMediaTypes, testExportAttestationsDefaultOCIArtifact, - testExportAttestationsOCIArtifact, testExportAttestationsImageManifest, - testExportedImageLabels, - testAttestationDefaultSubject, - testSourceDateEpochLayerTimestamps, - testSourceDateEpochClamp, - testSourceDateEpochReset, - testSourceDateEpochLocalExporter, - testSourceDateEpochTarExporter, - testSourceDateEpochImageExporter, - testAttestationBundle, + testExportAttestationsOCIArtifact, + testImageResolveAttestationChainLocal, + testImageResolveAttestationChainRequiresNetwork, + testImageResolveProvenanceAttestation, testSBOMScan, testSBOMScanSingleRef, testSBOMSupplements, - testMultipleCacheExports, - testMountStubsDirectory, - testMountStubsTimestamp, - testSourcePolicy, - testImageManifestRegistryCacheImportExport, - testLLBMountPerformance, - testClientCustomGRPCOpts, - testMultipleRecordsWithSameLayersCacheImportExport, - testRegistryEmptyCacheExport, - testSnapshotWithMultipleBlobs, - testImageBlobSource, - testOCILayoutBlobSource, - testExportLocalNoPlatformSplit, - testExportLocalNoPlatformSplitOverwrite, - testExportLocalForcePlatformSplit, - testExportTarPlatformIDSanitized, - testExportLocalModeCopyKeepsStaleDestinationFiles, - testExportLocalModeDeleteRemovesStaleDestinationFiles, - testExportLocalModeCopyMultiPlatformKeepsAllPlatforms, - testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms, - testExportLocalModeInvalid, - testOCIIndexMediatype, - testLayerLimitOnMounts, - testFrontendVerifyPlatforms, - testFrontendLintSkipVerifyPlatforms, - testRunValidExitCodes, + testSourceDateEpochClamp, + testSourceDateEpochImageExporter, + testSourceDateEpochLayerTimestamps, + testSourceDateEpochLocalExporter, + testSourceDateEpochReset, + testSourceDateEpochTarExporter, + + // client_fileop_test.go + testCopyFromEmptyImage, + testDuplicateWhiteouts, + testFileOpCopyAlwaysReplaceExistingDestPaths, + testFileOpCopyChmodText, + testFileOpCopyIncludeExclude, + testFileOpCopyRm, + testFileOpCopyUIDCache, + testFileOpInputSwap, + testFileOpMkdirMkfile, + testFileOpRmWildcard, testFileOpSymlink, - testMetadataOnlyLocal, + testMoveParentDir, + testRmSymlink, + testWhiteoutParentDir, + + // client_frontend_test.go + testFrontendImageNaming, + testFrontendLintSkipVerifyPlatforms, + testFrontendMetadataReturn, + testFrontendUseSolveResults, + testFrontendVerifyPlatforms, + + // client_git_source_test.go + testGitBundleRoundTrip, + testGitBundleRoundTripRegistry, + testGitResolveMutatedSource, testGitResolveSourceMetadata, - testHTTPResolveSourceMetadata, + + // client_http_source_test.go + testBuildHTTPSource, + testBuildHTTPSourceAuthHeaderSecret, + testBuildHTTPSourceEtagScope, + testBuildHTTPSourceHeader, + testBuildHTTPSourceHostTokenSecret, + testBuildHTTPSourcePGPSignatureVerify, + testBuildHTTPSourceUnauthorizedChecksumRace, testHTTPPruneAfterCacheKey, testHTTPPruneAfterResolveMeta, testHTTPResolveMetaReuse, testHTTPResolveMultiBuild, - testImageResolveConfigDefaultLocalFallback, - testGitResolveMutatedSource, - testGitBundleRoundTrip, - testGitBundleRoundTripRegistry, - testImageResolveAttestationChainRequiresNetwork, - testImageResolveAttestationChainLocal, - testImageResolveProvenanceAttestation, + testHTTPResolveSourceMetadata, + + // client_image_source_test.go + testClientGatewayCanceledCredentialsCallbackReturns, + testPullWithLayerLimit, + testValidateDigestOrigin, + + // client_local_source_test.go + testLocalSourceDiffer, + testLocalSourceWithHardlinksFilter, + testLocalSymlinkEscape, + testMetadataOnlyLocal, + testParallelLocalBuilds, + + // client_mergeop_test.go + testMergeOp, + testMergeOpCacheInline, + testMergeOpCacheMax, + testMergeOpCacheMin, + + // client_mount_test.go + testBuildMultiMount, + testCachedMounts, + testCacheMountNoCache, + testDuplicateCacheMount, + testLayerLimitOnMounts, + testLLBMountPerformance, + testLockedCacheMounts, + testMountStubsDirectory, + testMountStubsTimestamp, + testMountWithNoSource, + testRawSocketMount, + testReadonlyRootFS, + testRunCacheWithMounts, + testSecretEnv, + testSecretMounts, + testSharedCacheMounts, + testSharedCacheMountsNoScratch, + testSSHMount, + testTmpfsMounts, + + // client_network_test.go + testBridgeNetworking, + testExtraHosts, + testHostnameLookup, + testHostnameSpecifying, + testNetworkMode, + testProxyEnv, + testResolveAndHosts, + + // client_oci_source_test.go + testImageBlobSource, + testNoTarOCIIndexMediaType, + testOCIIndexMediatype, + testOCILayoutBlobSource, + testOCILayoutPlatformSource, + testOCILayoutSource, + + // client_session_test.go + testSessionHealthMonitorFailsBlockedSecretResponse, + + // client_source_map_test.go + testSourceMap, + testSourceMapFromRef, + + // client_warnings_test.go + testWarnings, + + // policy_test.go testSourcePolicySession, testSourcePolicySessionDenyMessages, testSourceMetaPolicySession, testSourceMetaPolicySessionResolveAttestations, testSourcePolicyParallelSession, testSourcePolicySignedCommit, - testSourcePolicySessionHTTPChecksumAssist, testSourcePolicySessionConvert, - testListenBuildHistoryExcludesSoftDeletedRecords, + testSourcePolicySessionHTTPChecksumAssist, + testSourcePolicy, } func TestIntegration(t *testing.T) { testIntegration(t, append(allTests, validationTests...)...) } +func TestClientGatewayIntegration(t *testing.T) { + integration.Run(t, integration.TestFuncs( + // gateway_container_exec_test.go + testClientGatewayContainerCancelExecTty, + testClientGatewayContainerCancelOnRelease, + testClientGatewayContainerCancelPID1Tty, + testClientGatewayContainerExecPipe, + testClientGatewayContainerExecPipeRelease, + testClientGatewayContainerExecPipeSignalKill, + testClientGatewayContainerExecTty, + testClientGatewayContainerPID1Exit, + testClientGatewayContainerPID1Fail, + testClientGatewayContainerPID1Tty, + testClientGatewayContainerSignal, + testClientGatewayExecError, + testClientGatewayExecFileActionError, + testClientGatewaySlowCacheExecError, + + // gateway_container_mount_test.go + testClientGatewayContainerMounts, + testClientGatewayContainerPlatformPATH, + testClientGatewayContainerSecretEnv, + + // gateway_container_network_test.go + testClientGatewayContainerExtraHosts, + + // gateway_container_security_test.go + testClientGatewayContainerInvalidSecurityMode, + + // gateway_solve_test.go + testClientGatewayEmptyImageExec, + testClientGatewayEmptySolve, + testClientGatewayFailedSolve, + testClientGatewayNilResult, + testClientGatewaySolve, + testClientSlowCacheRootfsRef, + testNoBuildID, + testUnknownBuildID, + ), integration.WithMirroredImages(integration.OfficialImages("busybox:latest"))) + + integration.Run(t, integration.TestFuncs( + // gateway_container_security_test.go + testClientGatewayContainerSecurityModeCaps, + testClientGatewayContainerSecurityModeValidation, + ), integration.WithMirroredImages(integration.OfficialImages("busybox:latest")), + integration.WithMatrix("secmode", map[string]any{ + "sandbox": securitySandbox, + "insecure": securityInsecure, + }), + ) + + integration.Run(t, integration.TestFuncs( + // gateway_container_network_test.go + testClientGatewayContainerHostNetworkingAccess, + testClientGatewayContainerHostNetworkingValidation, + ), + integration.WithMirroredImages(integration.OfficialImages("busybox:latest")), + integration.WithMatrix("netmode", map[string]any{ + "default": defaultNetwork, + "host": hostNetwork, + }), + ) +} + func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sandbox)) { mirroredImagesUnix := integration.OfficialImages("busybox:latest", "alpine:latest") mirroredImagesUnix["tonistiigi/test:nolayers"] = "docker.io/tonistiigi/test:nolayers" @@ -305,6 +324,7 @@ func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sa integration.SkipOnPlatform(t, "windows") integration.Run(t, integration.TestFuncs( + // client_exec_test.go testSecurityMode, testSecurityModeSysfs, testSecurityModeErrors, @@ -317,6 +337,7 @@ func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sa ) integration.Run(t, integration.TestFuncs( + // client_network_test.go testHostNetworking, ), mirrors, @@ -327,6 +348,7 @@ func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sa ) integration.Run(t, integration.TestFuncs( + // policy_test.go testProxyNetworkNoRootless, testProxyNetworkModesNoRootless, testProxyNetworkDefaultEgressNoRootless, @@ -342,14711 +364,16 @@ func testIntegration(t *testing.T, funcs ...func(t *testing.T, sb integration.Sa integration.Run( t, - integration.TestFuncs(testBridgeNetworkingDNSNoRootless), + integration.TestFuncs( + // client_network_test.go + testBridgeNetworkingDNSNoRootless, + ), mirrors, integration.WithMatrix("netmode", map[string]any{ "dns": bridgeDNSNetwork, }), ) + // client_cdi_test.go integration.Run(t, integration.TestFuncs(cdiTests...), mirrors) } - -func newContainerd(cdAddress string) (*ctd.Client, error) { - return ctd.New(cdAddress, ctd.WithTimeout(60*time.Second)) -} - -// 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 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, 5, 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, 5, 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) - } -} - -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 testBridgeNetworking(t *testing.T, sb integration.Sandbox) { - if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { - t.SkipNow() - } - if sb.Rootless() { // bridge is not used by default, even with detach-netns - t.SkipNow() - } - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - s, err := echoserver.NewTestServer("foo") - require.NoError(t, err) - addrParts := strings.Split(s.Addr().String(), ":") - - def, err := llb.Image("busybox").Run(llb.Shlexf("sh -c 'nc 127.0.0.1 %s | grep foo'", addrParts[len(addrParts)-1])).Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) -} - -func testBridgeNetworkingDNSNoRootless(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - workers.CheckFeatureCompat(t, sb, workers.FeatureCNINetwork) - if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { - t.SkipNow() - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - name := identity.NewID() - server, err := llb.Image("busybox"). - Run( - llb.Shlexf(`sh -c 'test "$(nc -l -p 1234)" = "foo"'`), - llb.Hostname(name), - ). - Marshal(sb.Context()) - require.NoError(t, err) - - client, err := llb.Image("busybox"). - Run( - llb.Shlexf("sh -c 'until echo foo | nc " + name + " 1234 -w0; do sleep 0.1; done'"), - ). - Marshal(sb.Context()) - require.NoError(t, err) - - eg, ctx := errgroup.WithContext(context.Background()) - eg.Go(func() error { - _, err := c.Solve(ctx, server, SolveOpt{}, nil) - return err - }) - eg.Go(func() error { - _, err := c.Solve(ctx, client, SolveOpt{}, nil) - return err - }) - err = eg.Wait() - require.NoError(t, err) -} - -func testHostNetworking(t *testing.T, sb integration.Sandbox) { - if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { - t.SkipNow() - } - netMode := sb.Value("netmode") - var allowedEntitlements []string - if netMode == hostNetwork { - allowedEntitlements = []string{entitlements.EntitlementNetworkHost.String()} - } - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - s, err := echoserver.NewTestServer("foo") - require.NoError(t, err) - addrParts := strings.Split(s.Addr().String(), ":") - - def, err := llb.Image("busybox").Run(llb.Shlexf("sh -c 'nc 127.0.0.1 %s | grep foo'", addrParts[len(addrParts)-1]), llb.Network(llb.NetModeHost)).Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - AllowedEntitlements: allowedEntitlements, - }, nil) - if netMode == hostNetwork { - require.NoError(t, err) - } else { - require.Error(t, err) - } -} - -func testExportedImageLabels(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.Skip("only supported with containerd") - } - - ctx := sb.Context() - - imgName := integration.UnixOrWindows("busybox", "nanoserver") - prefix := integration.UnixOrWindows( - "", - "cmd /C ", // TODO(profnandaa): currently needs the shell prefix, to be fixed - ) - def, err := llb.Image(imgName). - Run(llb.Shlexf(fmt.Sprintf("%secho foo > /foo", prefix))). - Marshal(ctx) - require.NoError(t, err) - - target := "docker.io/buildkit/build/exporter:labels" - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - }, - }, - }, - }, nil) - require.NoError(t, err) - - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - ctx = namespaces.WithNamespace(ctx, "buildkit") - - img, err := client.GetImage(ctx, target) - require.NoError(t, err) - - store := client.ContentStore() - - info, err := store.Info(ctx, img.Target().Digest) - require.NoError(t, err) - - dt, err := content.ReadBlob(ctx, store, img.Target()) - require.NoError(t, err) - - var mfst ocispecs.Manifest - err = json.Unmarshal(dt, &mfst) - require.NoError(t, err) - - require.Equal(t, 2, len(mfst.Layers)) - - hasLabel := func(dgst digest.Digest) bool { - for k, v := range info.Labels { - if strings.HasPrefix(k, "containerd.io/gc.ref.content.") && v == dgst.String() { - return true - } - } - return false - } - - // check that labels are set on all layers and config - for _, l := range mfst.Layers { - require.True(t, hasLabel(l.Digest)) - } - require.True(t, hasLabel(mfst.Config.Digest)) - - err = c.Prune(sb.Context(), nil, PruneAll) - require.NoError(t, err) - - // layer should not be deleted - _, err = store.Info(ctx, mfst.Layers[1].Digest) - require.NoError(t, err) - - err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) - require.NoError(t, err) - - // layers should be deleted - _, err = store.Info(ctx, mfst.Layers[1].Digest) - require.Error(t, err) - require.True(t, errors.Is(err, cerrdefs.ErrNotFound)) - - // config should be deleted - _, err = store.Info(ctx, mfst.Config.Digest) - require.Error(t, err) - require.True(t, errors.Is(err, cerrdefs.ErrNotFound)) - - // buildkit contentstore still has the layer because it is multi-ns - bkstore := proxy.NewContentStore(c.ContentClient()) - - // layer should be deleted as not kept by history - _, err = bkstore.Info(ctx, mfst.Layers[1].Digest) - require.Error(t, err) - require.Contains(t, err.Error(), "not found") - - // config should still be there - _, err = bkstore.Info(ctx, img.Metadata().Target.Digest) - require.NoError(t, err) - - _, err = bkstore.Info(ctx, mfst.Config.Digest) - require.NoError(t, err) - - cl, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ - EarlyExit: true, - }) - require.NoError(t, err) - - for { - resp, err := cl.Recv() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ - Ref: resp.Record.Ref, - Delete: true, - }) - require.NoError(t, err) - } - - // now everything should be deleted - _, err = bkstore.Info(ctx, img.Metadata().Target.Digest) - require.Error(t, err) - - _, err = bkstore.Info(ctx, mfst.Config.Digest) - require.Error(t, err) -} - -// #877 -func testExportBusyboxLocal(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - def, err := llb.Image("busybox").Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - fi, err := os.Stat(filepath.Join(destDir, "bin/busybox")) - require.NoError(t, err) - - fi2, err := os.Stat(filepath.Join(destDir, "bin/vi")) - require.NoError(t, err) - - require.True(t, os.SameFile(fi, fi2)) -} - -func testHostnameLookup(t *testing.T, sb integration.Sandbox) { - if sb.Rootless() { // bridge is not used by default, even with detach-netns - t.SkipNow() - } - - 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 -c "ping -c 1 $(hostname)"`, - "cmd /C ping -n 1 %COMPUTERNAME%", - ) - st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -// moby/buildkit#1301 -func testHostnameSpecifying(t *testing.T, sb integration.Sandbox) { - if sb.Rootless() { // bridge is not used by default, even with detach-netns - t.SkipNow() - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - type testVars struct { - imgName string - cmdStr1 string - cmdStr2 string - } - - v := integration.UnixOrWindows( - testVars{ - imgName: "busybox:latest", - cmdStr1: "sh -c 'echo $HOSTNAME | grep %s'", - cmdStr2: "sh -c 'echo $(hostname) | grep %s'", - }, - testVars{ - imgName: "nanoserver:latest", - // NOTE: Windows capitalizes the hostname hence - // case insensitive findstr (findstr /I) - // testtest --> TESTTEST - cmdStr1: "cmd /C echo %%COMPUTERNAME%% | findstr /I %s", - cmdStr2: "cmd /C echo %%COMPUTERNAME%% | findstr /I %s", - }, - ) - - hostname := "testtest" - st := llb.Image(v.imgName).With(llb.Hostname(hostname)). - Run(llb.Shlexf(v.cmdStr1, hostname)). - Run(llb.Shlexf(v.cmdStr2, hostname)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{"hostname": hostname}, - }, nil) - require.NoError(t, err) -} - -// moby/buildkit#614 -func testStdinClosed(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - cmdStr := integration.UnixOrWindows("cat", "cmd /C more") - st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testSSHMount(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - a := agent.NewKeyring() - - k, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - err = a.Add(agent.AddedKey{PrivateKey: k}) - require.NoError(t, err) - - sockPath, err := makeSSHAgentSock(t, a) - require.NoError(t, err) - - ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ - Paths: []string{sockPath}, - }}) - require.NoError(t, err) - - // no ssh exposed - st := llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket()) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "no SSH key ") - - // custom ID not exposed - st = llb.Image("busybox:latest").Run(llb.Shlex(`nosuchcmd`), llb.AddSSHSocket(llb.SSHID("customID"))) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{ssh}, - }, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "unset ssh forward key customID") - - // missing custom ID ignored on optional - st = llb.Image("busybox:latest").Run(llb.Shlex(`ls`), llb.AddSSHSocket(llb.SSHID("customID"), llb.SSHOptional)) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{ssh}, - }, nil) - require.NoError(t, err) - - // valid socket - st = llb.Image("alpine:latest"). - Run(llb.Shlex(`apk add --no-cache openssh`)). - Run(llb.Shlex(`sh -c 'echo -n $SSH_AUTH_SOCK > /out/sock && ssh-add -l > /out/out'`), - llb.AddSSHSocket()) - - out := st.AddMount("/out", llb.Scratch()) - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - Session: []session.Attachable{ssh}, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "sock")) - require.NoError(t, err) - require.Equal(t, "/run/buildkit/ssh_agent.0", string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "out")) - require.NoError(t, err) - require.Contains(t, string(dt), "2048") - require.Contains(t, string(dt), "(RSA)") - - // forbidden command - st = llb.Image("alpine:latest"). - Run(llb.Shlex(`apk add --no-cache openssh`)). - Run(llb.Shlex(`sh -c 'ssh-keygen -f /tmp/key -N "" && ssh-add -k /tmp/key 2> /out/out || true'`), - llb.AddSSHSocket()) - - out = st.AddMount("/out", llb.Scratch()) - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - Session: []session.Attachable{ssh}, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(destDir, "out")) - require.NoError(t, err) - require.Contains(t, string(dt), "agent refused operation") - - // valid socket from key on disk - st = llb.Image("alpine:latest"). - Run(llb.Shlex(`apk add --no-cache openssh`)). - Run(llb.Shlex(`sh -c 'ssh-add -l > /out/out'`), - llb.AddSSHSocket()) - - out = st.AddMount("/out", llb.Scratch()) - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - k, err = rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - dt = pem.EncodeToMemory( - &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(k), - }, - ) - - tmpDir := t.TempDir() - - err = os.WriteFile(filepath.Join(tmpDir, "key"), dt, 0600) - require.NoError(t, err) - - ssh, err = sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ - Paths: []string{filepath.Join(tmpDir, "key")}, - }}) - require.NoError(t, err) - - destDir = t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - Session: []session.Attachable{ssh}, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(destDir, "out")) - require.NoError(t, err) - require.Contains(t, string(dt), "2048") - require.Contains(t, string(dt), "(RSA)") -} - -func testRawSocketMount(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := t.TempDir() - sockPath := filepath.Join(dir, "test.sock") - listener := net.ListenConfig{} - l, err := listener.Listen(context.TODO(), "unix", sockPath) - require.NoError(t, err) - defer l.Close() - - var called atomic.Bool - srv := &http.Server{ - ReadHeaderTimeout: 10 * time.Second, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called.Store(true) - w.WriteHeader(http.StatusOK) - }), - } - go srv.Serve(l) - defer srv.Close() - - st := llb.Image("alpine:latest"). - Run(llb.Shlex(`apk add --no-cache curl`)). - Run(llb.Shlex(`curl --unix-socket /tmp/test.sock http://./foo`), - llb.AddSSHSocket(llb.SSHSocketTarget("/tmp/test.sock")), - ).Root() - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - raw, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ - Paths: []string{sockPath}, - Raw: true, - }}) - require.NoError(t, err) - - ch := make(chan *SolveStatus) - go func() { - for status := range ch { - for _, l := range status.Logs { - t.Log(string(l.Data)) - } - } - }() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{raw}, - }, ch) - require.NoError(t, err) - require.True(t, called.Load(), "server should have been called") -} - -/* -testExtraHosts verifies that custom host entries added via llb.AddExtraHost() are resolvable -during a RUN step. It adds "myhost" pointing to 1.2.3.4 and checks /etc/hosts for the entry. - -Skipped on Windows because BuildKit for Windows does not support llb.AddExtraHost() -at the moment due to fundamental differences in how Linux and Windows containers handle hosts file injections -*/ -func testExtraHosts(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows", "extra hosts not supported on BuildKit for Windows at the moment") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'cat /etc/hosts | grep myhost | grep 1.2.3.4'`), llb.AddExtraHost("myhost", net.ParseIP("1.2.3.4"))) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testShmSize(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest").Run( - llb.AddMount("/dev/shm", llb.Scratch(), llb.Tmpfs(llb.TmpfsSize(128*1024*1024))), - llb.Shlex(`sh -c 'mount | grep /dev/shm > /out/out'`), - ) - - out := st.AddMount("/out", llb.Scratch()) - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "out")) - require.NoError(t, err) - require.Contains(t, string(dt), `size=131072k`) -} - -func testUlimit(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string, ro ...llb.RunOption) { - st = busybox.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) - } - - run(`sh -c "ulimit -n > first"`, llb.AddUlimit(llb.UlimitNofile, 1062, 1062)) - run(`sh -c "ulimit -n > second"`) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "first")) - require.NoError(t, err) - require.Equal(t, `1062`, strings.TrimSpace(string(dt))) - - dt2, err := os.ReadFile(filepath.Join(destDir, "second")) - require.NoError(t, err) - require.NotEqual(t, `1062`, strings.TrimSpace(string(dt2))) -} - -func testCgroupParent(t *testing.T, sb integration.Sandbox) { - if sb.Rootless() { - t.SkipNow() - } - - if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) { - t.Skipf("test requires cgroup v2") - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - img := llb.Image("alpine:latest") - st := llb.Scratch() - - run := func(cmd string, ro ...llb.RunOption) { - st = img.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) - } - - cgroupName := "test." + identity.NewID() - - err = os.MkdirAll(filepath.Join("/sys/fs/cgroup", cgroupName), 0755) - require.NoError(t, err) - - defer func() { - err := os.RemoveAll(filepath.Join("/sys/fs/cgroup", cgroupName)) - require.NoError(t, err) - }() - - err = os.WriteFile(filepath.Join("/sys/fs/cgroup", cgroupName, "pids.max"), []byte("10"), 0644) - require.NoError(t, err) - - run(`sh -c "(for i in $(seq 1 10); do sleep 1 & done 2>first.error); cat /proc/self/cgroup >> first"`, llb.WithCgroupParent(cgroupName)) - run(`sh -c "(for i in $(seq 1 10); do sleep 1 & done 2>second.error); cat /proc/self/cgroup >> second"`) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - // neither process leaks parent cgroup name inside container - dt, err := os.ReadFile(filepath.Join(destDir, "first")) - require.NoError(t, err) - require.NotContains(t, strings.TrimSpace(string(dt)), cgroupName) - - dt2, err := os.ReadFile(filepath.Join(destDir, "second")) - require.NoError(t, err) - require.NotContains(t, strings.TrimSpace(string(dt2)), cgroupName) - - dt, err = os.ReadFile(filepath.Join(destDir, "first.error")) - require.NoError(t, err) - require.Contains(t, strings.TrimSpace(string(dt)), "Resource temporarily unavailable") - - dt, err = os.ReadFile(filepath.Join(destDir, "second.error")) - require.NoError(t, err) - require.Equal(t, "", strings.TrimSpace(string(dt))) -} - -func testLinuxResources(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - if sb.Rootless() { - t.SkipNow() - } - - if _, err := os.Lstat("/sys/fs/cgroup/cgroup.subtree_control"); os.IsNotExist(err) { - t.Skipf("test requires cgroup v2") - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - img := llb.Image("alpine:latest") - st := llb.Scratch() - - run := func(cmd string, ro ...llb.RunOption) { - st = img.Run(append(ro, llb.Shlex(cmd), llb.Dir("/wd"))...).AddMount("/wd", st) - } - - // Test memory limit: set 64MiB and verify via cgroup - run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_limited"`, llb.MemoryLimit(64*1024*1024)) - run(`sh -c "cat /sys/fs/cgroup/memory.max > mem_default"`) - - // Test CPU quota: set quota=50000 period=100000 (50% CPU) and verify - run(`sh -c "cat /sys/fs/cgroup/cpu.max > cpu_limited"`, llb.CPUQuota(50000), llb.CPUPeriod(100000)) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "mem_limited")) - require.NoError(t, err) - require.Equal(t, "67108864", strings.TrimSpace(string(dt))) - - dt2, err := os.ReadFile(filepath.Join(destDir, "mem_default")) - require.NoError(t, err) - require.Equal(t, "max", strings.TrimSpace(string(dt2))) - - dt3, err := os.ReadFile(filepath.Join(destDir, "cpu_limited")) - require.NoError(t, err) - require.Equal(t, "50000 100000", strings.TrimSpace(string(dt3))) -} - -func testNetworkMode(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'wget https://example.com 2>&1 | grep "wget: bad address"'`), llb.Network(llb.NetModeNone)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - st2 := llb.Image("busybox:latest"). - Run(llb.Shlex(`ifconfig`), llb.Network(llb.NetModeHost)) - - def, err = st2.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - // Currently disabled globally by default - // AllowedEntitlements: []entitlements.Entitlement{entitlements.EntitlementNetworkHost}, - }, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "network.host is not allowed") -} - -func testPushByDigest(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - 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) - - st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data"))) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - name := registry + "/foo/bar" - - resp, err := c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: "image", - Attrs: map[string]string{ - "name": name, - "push": "true", - "push-by-digest": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - _, _, err = contentutil.ProviderFromRef(name + ":latest") - require.Error(t, err) - - desc, _, err := contentutil.ProviderFromRef(name + "@" + resp.ExporterResponse[exptypes.ExporterImageDigestKey]) - require.NoError(t, err) - - require.Equal(t, resp.ExporterResponse[exptypes.ExporterImageDigestKey], desc.Digest.String()) - require.Equal(t, ocispecs.MediaTypeImageManifest, desc.MediaType) - require.Greater(t, desc.Size, int64(0)) -} - -func testPushProgressSameVertex(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - 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) - - st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data"))) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - imageName := registry + "/foo/bar:latest" - - var vertexes []*Vertex - var statuses []*VertexStatus - ch := make(chan *SolveStatus) - eg, ctx := errgroup.WithContext(sb.Context()) - eg.Go(func() error { - for { - select { - case <-ctx.Done(): - return context.Cause(ctx) - case ss, ok := <-ch: - if !ok { - return nil - } - vertexes = append(vertexes, ss.Vertexes...) - statuses = append(statuses, ss.Statuses...) - } - } - }) - eg.Go(func() error { - _, err := c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: "image", - Attrs: map[string]string{ - "name": imageName, - "push": "true", - }, - }, - }, - }, ch) - return err - }) - require.NoError(t, eg.Wait()) - - exportVertexes := map[digest.Digest]struct{}{} - pushVertexes := map[digest.Digest]struct{}{} - pushManifestPrefix := "pushing manifest for " + imageName + "@" - - for _, v := range vertexes { - if v.Name == "exporting to image" { - exportVertexes[v.Digest] = struct{}{} - } - } - - for _, st := range statuses { - switch { - case st.ID == "pushing layers": - pushVertexes[st.Vertex] = struct{}{} - case strings.HasPrefix(st.ID, pushManifestPrefix): - pushVertexes[st.Vertex] = struct{}{} - } - } - - require.Len(t, exportVertexes, 1, "expected exactly one export vertex in status stream") - require.NotEmpty(t, pushVertexes, "expected at least one push status in status stream") - - var exportVertex digest.Digest - for v := range exportVertexes { - exportVertex = v - } - for v := range pushVertexes { - require.Equal(t, exportVertex, v, "push statuses should use the same vertex as exporting to image") - } - - hasPushManifest := false - for _, st := range statuses { - if strings.HasPrefix(st.ID, pushManifestPrefix) { - hasPushManifest = true - require.Equal(t, exportVertex, st.Vertex, "pushing manifest should use the same vertex as exporting to image") - } - } - require.True(t, hasPushManifest, "expected pushing manifest status in status stream") -} - -func testPullWithDigestCheck(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - 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) - - st := llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data1"))) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - name1 := registry + "/foo/bar:v1.0.0" - - resp, err := c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: "image", - Attrs: map[string]string{ - "name": name1, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - dgst1Str := resp.ExporterResponse[exptypes.ExporterImageDigestKey] - dgst1, err := digest.Parse(dgst1Str) - require.NoError(t, err) - - st = llb.Scratch().File(llb.Mkfile("foo", 0600, []byte("data2"))) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - name2 := registry + "/foo/bar:v2.0.0" - - resp, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: "image", - Attrs: map[string]string{ - "name": name2, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - dgst2str := resp.ExporterResponse[exptypes.ExporterImageDigestKey] - dgst2, err := digest.Parse(dgst2str) - require.NoError(t, err) - - require.NotEqual(t, dgst1, dgst2) - - // if digest is set in ref then pull happens only by the digest - st = llb.Image(name2 + "@" + dgst1.String()) - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, "data1", string(dt)) - - // if digest is set by checksum then pull happens by tag - st = llb.Image(name2, llb.WithImageChecksum(dgst2)) - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, "data2", string(dt)) - - // if checksum doesn't match then pull fails - st = llb.Image(name2, llb.WithImageChecksum(dgst1)) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), fmt.Sprintf("image digest %s for %s does not match expected checksum %s", dgst2, name2, dgst1)) -} - -func testSecurityMode(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) - command := `sh -c 'cat /proc/self/status | grep CapEff | cut -f 2 > /out'` - mode := llb.SecurityModeSandbox - var allowedEntitlements []string - var assertCaps func(caps uint64) - secMode := sb.Value("secmode") - if secMode == securitySandbox { - assertCaps = func(caps uint64) { - /* - $ capsh --decode=00000000a80425fb - 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap, - cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap - */ - require.Equal(t, uint64(0xa80425fb), caps) - } - allowedEntitlements = []string{} - } else { - assertCaps = func(caps uint64) { - /* - $ capsh --decode=0000003fffffffff - 0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid, - cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw, - cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin, - cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write, - cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read - */ - - // require that _at least_ minimum capabilities are granted - require.Equal(t, uint64(0x3fffffffff), caps&0x3fffffffff) - } - mode = llb.SecurityModeInsecure - allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(command), - llb.Security(mode)) - - 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, - }, - }, - AllowedEntitlements: allowedEntitlements, - }, nil) - - require.NoError(t, err) - - contents, err := os.ReadFile(filepath.Join(destDir, "out")) - require.NoError(t, err) - - caps, err := strconv.ParseUint(strings.TrimSpace(string(contents)), 16, 64) - require.NoError(t, err) - - t.Logf("Caps: %x", caps) - - assertCaps(caps) -} - -func testSecurityModeSysfs(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) - if sb.Rootless() { - t.SkipNow() - } - - mode := llb.SecurityModeSandbox - var allowedEntitlements []string - secMode := sb.Value("secmode") - if secMode == securitySandbox { - allowedEntitlements = []string{} - } else { - mode = llb.SecurityModeInsecure - allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} - } - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - cg := "/sys/fs/cgroup/cpuset/securitytest" // cgroup v1 - if _, err := os.Stat("/sys/fs/cgroup/cpuset"); errors.Is(err, os.ErrNotExist) { - cg = "/sys/fs/cgroup/securitytest" // cgroup v2 - } - - command := "mkdir " + cg - st := llb.Image("busybox:latest"). - Run(llb.Shlex(command), - llb.Security(mode)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - AllowedEntitlements: allowedEntitlements, - }, nil) - - if secMode == securitySandbox { - require.Error(t, err) - require.Contains(t, err.Error(), "did not complete successfully") - require.Contains(t, err.Error(), "mkdir "+cg) - } else { - require.NoError(t, err) - } -} - -func testSecurityModeErrors(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - secMode := sb.Value("secmode") - if secMode == securitySandbox { - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'echo sandbox'`)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - AllowedEntitlements: []string{entitlements.EntitlementSecurityInsecure.String()}, - }, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "security.insecure is not allowed") - } - if secMode == securityInsecure { - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'echo insecure'`), llb.Security(llb.SecurityModeInsecure)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "security.insecure is not allowed") - } - - st := llb.Image("busybox:latest"). - Run(llb.Shlex("echo invalid")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - var foundExec bool - digestMap := map[digest.Digest]digest.Digest{} - for i, dt := range def.Def { - oldDigest := digest.FromBytes(dt) - var op pb.Op - require.NoError(t, op.UnmarshalVT(dt)) - changed := false - - for j, input := range op.Inputs { - if newDigest, ok := digestMap[digest.Digest(input.Digest)]; ok { - op.Inputs[j].Digest = string(newDigest) - changed = true - } - } - - if execOp := op.GetExec(); execOp != nil { - execOp.Security = pb.SecurityMode(2) - changed = true - foundExec = true - } - - if changed { - newDt, err := op.MarshalVT() - require.NoError(t, err) - def.Def[i] = newDt - - newDigest := digest.FromBytes(newDt) - digestMap[oldDigest] = newDigest - if meta, ok := def.Metadata[oldDigest]; ok { - delete(def.Metadata, oldDigest) - def.Metadata[newDigest] = meta - } - } - } - require.True(t, foundExec) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid security mode") -} - -func testFrontendImageNaming(t *testing.T, sb integration.Sandbox) { - 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) - - checkImageName := map[string]func(out, imageName string, exporterResponse map[string]string){ - ExporterOCI: func(out, imageName string, exporterResponse map[string]string) { - // Nothing to check - }, - ExporterDocker: func(out, imageName string, exporterResponse map[string]string) { - require.Contains(t, exporterResponse, exptypes.ExporterImageNameKey) - require.Equal(t, exporterResponse[exptypes.ExporterImageNameKey], "docker.io/library/"+imageName) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - _, ok := m["oci-layout"] - require.True(t, ok) - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) - require.NoError(t, err) - require.Equal(t, 2, index.SchemaVersion) - require.Equal(t, 1, len(index.Manifests)) - - var dockerMfst []struct { - RepoTags []string - } - err = json.Unmarshal(m["manifest.json"].Data, &dockerMfst) - require.NoError(t, err) - require.Equal(t, 1, len(dockerMfst)) - require.Equal(t, 1, len(dockerMfst[0].RepoTags)) - require.Equal(t, imageName, dockerMfst[0].RepoTags[0]) - }, - ExporterImage: func(_, imageName string, exporterResponse map[string]string) { - require.Contains(t, exporterResponse, exptypes.ExporterImageNameKey) - require.Equal(t, exporterResponse[exptypes.ExporterImageNameKey], imageName) - - // check if we can pull (requires containerd) - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - return - } - - // TODO: make public pull helper function so this can be checked for standalone as well - - client, err := ctd.New(cdAddress) - require.NoError(t, err) - defer client.Close() - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - // check image in containerd - _, err = client.ImageService().Get(ctx, imageName) - require.NoError(t, err) - - // deleting image should release all content - err = client.ImageService().Delete(ctx, imageName, images.SynchronousDelete()) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) - - _, err = client.Pull(ctx, imageName) - require.NoError(t, err) - - err = client.ImageService().Delete(ctx, imageName, images.SynchronousDelete()) - require.NoError(t, err) - }, - } - - // A caller provided name takes precedence over one returned by the frontend. Iterate over both options. - for _, winner := range []string{"frontend", "caller"} { - // The double layer of `t.Run` here is required so - // that the inner-most tests (with the actual - // functionality) have definitely completed before the - // sandbox and registry cleanups (defered above) are run. - t.Run(winner, func(t *testing.T) { - for _, exp := range []string{ExporterOCI, ExporterDocker, ExporterImage} { - t.Run(exp, func(t *testing.T) { - destDir := t.TempDir() - - so := SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: map[string]string{}, - }, - }, - } - - out := filepath.Join(destDir, "out.tar") - - imageName := "image-" + exp + "-fe:latest" - - switch exp { - case ExporterOCI: - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - t.Skip("oci exporter does not support named images") - case ExporterDocker: - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - outW, err := os.Create(out) - require.NoError(t, err) - so.Exports[0].Output = fixedWriteCloser(outW) - case ExporterImage: - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - imageName = registry + "/" + imageName - so.Exports[0].Attrs["push"] = "true" - } - - feName := imageName - switch winner { - case "caller": - feName = "loser:latest" - so.Exports[0].Attrs["name"] = imageName - case "frontend": - so.Exports[0].Attrs["name"] = "*" - } - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - res.AddMeta("image.name", []byte(feName)) - return res, nil - } - - resp, err := c.Build(sb.Context(), so, "", frontend, nil) - require.NoError(t, err) - - checkImageName[exp](out, imageName, resp.ExporterResponse) - }) - } - }) - } - - checkAllReleasable(t, c, sb, true) -} - -/* -testSecretMounts verifies file-based secret mounts: content readability, optional/required -behavior, custom permissions, and empty secrets. Skipped on Windows because BuildKit uses -tmpfs for secret mounts, which Windows does not support. See testSecretEnv for a -cross-platform alternative using environment-based secrets. -*/ -func testSecretMounts(t *testing.T, sb integration.Sandbox) { - // Windows vs Linux secret implementation differences: - // - // Linux: Secrets are mounted as files using tmpfs at /run/secrets/ (RAM-based, encrypted) - // - // Windows: Secrets are stored in C:\ProgramData\Docker\internal\secrets (clear text on disk) - // Symbolic links point to the desired target (default: C:\ProgramData\Docker\secrets) - // Windows does NOT support tmpfs or non-directory file bind-mounts - // UID/GID/mode options are NOT supported on Windows - // Recommend BitLocker for at-rest encryption - // - // BuildKit Issue: The "invalid windows mount type: 'tmpfs'" error occurs because BuildKit - // currently tries to use tmpfs for all secret mounts. This needs to be fixed in BuildKit's - // secret mount implementation to use the Windows symlink approach instead. - - // For now, this test is Linux-only until BuildKit properly implements Windows secret mounts - // without tmpfs. Use testSecretEnv for Windows-compatible environment-based secrets. - integration.SkipOnPlatform(t, "windows", "Windows does not support tmpfs for secret mounts") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // Test 1: Basic secret mount with content verification (Linux only) - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'mount | grep mysecret | grep "type tmpfs" && [ "$(cat /run/secrets/mysecret)" = 'foo-secret' ]'`), llb.AddSecret("/run/secrets/mysecret")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "/run/secrets/mysecret": []byte("foo-secret"), - })}, - }, nil) - require.NoError(t, err) - - // Test 2: Optional secret - mount should not exist when secret not present in SolveOpt - st = llb.Image("busybox:latest"). - Run(llb.Shlex(`test ! -f /run/secrets/mysecret2`), llb.AddSecret("/run/secrets/mysecret2", llb.SecretOptional)) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, - }, nil) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // Test 3: Required secret missing - should error - st = llb.Image("busybox:latest"). - Run(llb.Shlex(`echo secret3`), llb.AddSecret("/run/secrets/mysecret3")) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, - }, nil) - require.Error(t, err) - - // Test 4: Secret with custom ID and file permissions - st = llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c '[ "$(stat -c "%u %g %f" /run/secrets/mysecret4)" = "1 1 81ff" ]' `), llb.AddSecret("/run/secrets/mysecret4", llb.SecretID("mysecret"), llb.SecretFileOpt(1, 1, 0777))) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "mysecret": []byte("pw"), - })}, - }, nil) - require.NoError(t, err) - - // Test 5: Empty secret still creates secret file - st = llb.Image("busybox:latest"). - Run(llb.Shlex(`test -f /run/secrets/mysecret5`), llb.AddSecret("/run/secrets/mysecret5", llb.SecretID("mysecret"))) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "mysecret": []byte(""), - })}, - }, nil) - require.NoError(t, err) -} - -func testSessionHealthMonitorFailsBlockedSecretResponse(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows", "Windows secret mounts are covered separately") - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c '[ "$(cat /run/secrets/mysecret)" = "foo-secret" ]'`), llb.AddSecret("/run/secrets/mysecret")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - provider := &gatedSecretProvider{ - t: t, - id: "/run/secrets/mysecret", - data: []byte("foo-secret"), - started: make(chan struct{}), - allowResponse: make(chan struct{}), - } - - baseDialer := c.Dialer() - proxyCh := make(chan *gatedSessionTunnelProxy, 1) - c.sessionDialer = func(ctx context.Context, proto string, meta map[string][]string) (net.Conn, error) { - meta["X-Buildkit-Session-Health-Custom-Timeout"] = []string{"2000"} - - conn, err := baseDialer(ctx, proto, meta) - if err != nil { - return nil, err - } - proxy, clientConn := newGatedSessionTunnelProxy(conn) - proxyCh <- proxy - return clientConn, nil - } - - done := make(chan error, 1) - go func() { - _, err := c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{provider}, - }, nil) - done <- err - }() - - var stalledConn *gatedSessionTunnelProxy - select { - case stalledConn = <-proxyCh: - case <-time.After(15 * time.Second): - t.Fatal("timed out waiting for session connection") - } - - select { - case <-provider.started: - case <-time.After(15 * time.Second): - t.Fatal("timed out waiting for secret request") - } - - stalledConn.blockClientToDaemon.Store(true) - close(provider.allowResponse) - - select { - case <-stalledConn.blockedClientToDaemon: - case <-time.After(15 * time.Second): - t.Fatal("timed out waiting for blocked session traffic") - } - - select { - case err := <-done: - require.Error(t, err) - case <-time.After(15 * time.Second): - t.Fatal("timed out waiting for solve to fail after session healthcheck timeout") - } -} - -type gatedSecretProvider struct { - sessionsecrets.UnimplementedSecretsServer - - t *testing.T - id string - data []byte - started chan struct{} - allowResponse chan struct{} -} - -func (p *gatedSecretProvider) Register(server *grpc.Server) { - sessionsecrets.RegisterSecretsServer(server, p) -} - -func (p *gatedSecretProvider) GetSecret(ctx context.Context, req *sessionsecrets.GetSecretRequest) (*sessionsecrets.GetSecretResponse, error) { - require.Equal(p.t, p.id, req.ID) - select { - case <-p.started: - default: - close(p.started) - } - - select { - case <-ctx.Done(): - return nil, context.Cause(ctx) - case <-p.allowResponse: - return &sessionsecrets.GetSecretResponse{Data: p.data}, nil - } -} - -type gatedSessionTunnelProxy struct { - clientConn net.Conn - proxyConn net.Conn - daemonConn net.Conn - - blockClientToDaemon atomic.Bool - blockedClientToDaemon chan struct{} - releaseClientToDaemon chan struct{} - closed chan struct{} - blockedOnce sync.Once - releaseOnce sync.Once - closeOnce sync.Once -} - -func newGatedSessionTunnelProxy(daemonConn net.Conn) (*gatedSessionTunnelProxy, net.Conn) { - clientConn, proxyConn := net.Pipe() - p := &gatedSessionTunnelProxy{ - clientConn: clientConn, - proxyConn: proxyConn, - daemonConn: daemonConn, - blockedClientToDaemon: make(chan struct{}), - releaseClientToDaemon: make(chan struct{}), - closed: make(chan struct{}), - } - go p.copyClientToDaemon() - go p.copyDaemonToClient() - return p, clientConn -} - -func (p *gatedSessionTunnelProxy) copyClientToDaemon() { - defer p.Close() - - buf := make([]byte, 32*1024) - for { - n, err := p.proxyConn.Read(buf) - if n > 0 { - data := append([]byte(nil), buf[:n]...) - if p.blockClientToDaemon.Load() { - p.blockedOnce.Do(func() { - close(p.blockedClientToDaemon) - }) - select { - case <-p.releaseClientToDaemon: - case <-p.closed: - return - } - } - if _, writeErr := p.daemonConn.Write(data); writeErr != nil { - return - } - } - if err != nil { - return - } - } -} - -func (p *gatedSessionTunnelProxy) copyDaemonToClient() { - defer p.Close() - _, _ = io.Copy(p.proxyConn, p.daemonConn) -} - -func (p *gatedSessionTunnelProxy) Close() error { - p.closeOnce.Do(func() { - close(p.closed) - _ = p.proxyConn.Close() - _ = p.clientConn.Close() - _ = p.daemonConn.Close() - }) - return nil -} - -func (p *gatedSessionTunnelProxy) Release() { - p.releaseOnce.Do(func() { - close(p.releaseClientToDaemon) - }) -} - -/* -testSecretEnv verifies that secrets can be injected as environment variables during a build. -It tests four scenarios: (1) a provided secret is accessible via env var, (2) an optional -secret that is not provided results in an unset/empty env var, (3) a required secret that -is not provided causes an error, and (4) multiple secrets with custom IDs are resolved correctly. -Works on both Linux (busybox) and Windows (nanoserver). -*/ -func testSecretEnv(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - var st llb.ExecState - - // Test 1: Verify secret value is accessible as environment variable - switch imgName { - case "nanoserver:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`cmd /C "if "%MY_SECRET%"=="foo-secret" (exit 0) else (exit 1)"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) - case "busybox:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`sh -c '[ "$(echo ${MY_SECRET})" = 'foo-secret' ]'`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) - } - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "MY_SECRET": []byte("foo-secret"), - })}, - }, nil) - require.NoError(t, err) - - // Test 2: Optional secret not provided should be unset/empty - switch imgName { - case "nanoserver:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`cmd /C "if not defined MY_SECRET (exit 0) else (exit 1)"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true), llb.SecretOptional)) - case "busybox:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`sh -c '[ -z "${MY_SECRET}" ]'`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true), llb.SecretOptional)) - } - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, - }, nil) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // Test 3: Required secret not provided should error - switch imgName { - case "nanoserver:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`cmd /C "echo foo"`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) - case "busybox:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`echo foo`), llb.AddSecret("MY_SECRET", llb.SecretAsEnv(true))) - } - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{})}, - }, nil) - require.Error(t, err) - - // Test 4: Multiple secrets with custom IDs - switch imgName { - case "nanoserver:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`cmd /C "if "%MYPASSWORD%-%MYTOKEN%"=="pw-token" (exit 0) else (exit 1)"`), - llb.AddSecret("MYPASSWORD", llb.SecretID("pass"), llb.SecretAsEnv(true)), - llb.AddSecret("MYTOKEN", llb.SecretAsEnv(true)), - ) - case "busybox:latest": - st = llb.Image(imgName). - Run(llb.Shlex(`sh -c '[ "$(echo ${MYPASSWORD}-${MYTOKEN})" = "pw-token" ]' `), - llb.AddSecret("MYPASSWORD", llb.SecretID("pass"), llb.SecretAsEnv(true)), - llb.AddSecret("MYTOKEN", llb.SecretAsEnv(true)), - ) - } - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "pass": []byte("pw"), - "MYTOKEN": []byte("token"), - })}, - }, nil) - require.NoError(t, err) -} - -func testTmpfsMounts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh -c 'mount | grep /foobar | grep "type tmpfs" && touch /foobar/test'`), llb.AddMount("/foobar", llb.Scratch(), llb.Tmpfs())) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testLocalSymlinkEscape(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - test := []byte(`set -ex -[[ -L /mount/foo ]] -[[ -L /mount/sub/bar ]] -[[ -L /mount/bax ]] -[[ -f /mount/bay ]] -[[ -f /mount/sub/sub2/file ]] -[[ ! -f /mount/baz ]] -[[ ! -f /mount/etc/passwd ]] -[[ ! -f /mount/etc/group ]] -[[ $(readlink /mount/foo) == "/etc/passwd" ]] -[[ $(readlink /mount/sub/bar) == "../../../etc/group" ]] -`) - - dir := integration.Tmpdir( - t, - // point to absolute path that is not part of dir - fstest.Symlink("/etc/passwd", "foo"), - fstest.CreateDir("sub", 0700), - // point outside of the dir - fstest.Symlink("../../../etc/group", "sub/bar"), - // regular valid symlink - fstest.Symlink("bay", "bax"), - // target for symlink (not requested) - fstest.CreateFile("bay", []byte{}, 0600), - // file with many subdirs - fstest.CreateDir("sub/sub2", 0700), - fstest.CreateFile("sub/sub2/file", []byte{}, 0600), - // unused file that shouldn't be included - fstest.CreateFile("baz", []byte{}, 0600), - fstest.CreateFile("test.sh", test, 0700), - ) - - local := llb.Local("mylocal", llb.FollowPaths([]string{ - "test.sh", "foo", "sub/bar", "bax", "sub/sub2/file", - })) - - st := llb.Image("busybox:latest"). - Run(llb.Shlex(`sh /mount/test.sh`), llb.AddMount("/mount", local, llb.Readonly)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - }, - }, nil) - require.NoError(t, err) -} - -func testRelativeWorkDir(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows( - "docker.io/library/busybox:latest", - "mcr.microsoft.com/windows/nanoserver:ltsc2022", - ) - cmdStr := integration.UnixOrWindows( - `sh -c "pwd > /out/pwd"`, - `cmd /C "cd > /out/pwd"`, - ) - pwd := llb.Image(imgName). - Dir("test1"). - Dir("test2"). - Run(llb.Shlex(cmdStr)). - AddMount("/out", llb.Scratch()) - - def, err := pwd.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "pwd")) - require.NoError(t, err) - pathStr := integration.UnixOrWindows( - "/test1/test2\n", - "C:\\test1\\test2\r\n", - ) - require.Equal(t, []byte(pathStr), dt) -} - -func testFileOpMkdirMkfile(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch(). - File(llb.Mkdir("/foo", 0700).Mkfile("bar", 0600, []byte("contents"))) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - fi, err := os.Stat(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, true, fi.IsDir()) - - dt, err := os.ReadFile(filepath.Join(destDir, "bar")) - require.NoError(t, err) - require.Equal(t, []byte("contents"), dt) -} - -func testFileOpCopyRm(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := integration.Tmpdir( - t, - fstest.CreateFile("myfile", []byte("data0"), 0600), - fstest.CreateDir("sub", 0700), - fstest.CreateFile("sub/foo", []byte("foo0"), 0600), - fstest.CreateFile("sub/bar", []byte("bar0"), 0600), - ) - dir2 := integration.Tmpdir( - t, - fstest.CreateFile("file2", []byte("file2"), 0600), - ) - - st := llb.Scratch(). - File( - llb.Copy(llb.Local("mylocal"), "myfile", "myfile2"). - Copy(llb.Local("mylocal"), "sub", "out"). - Rm("out/foo"). - Copy(llb.Local("mylocal2"), "file2", "/")) - - 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, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - "mylocal2": dir2, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "myfile2")) - require.NoError(t, err) - require.Equal(t, []byte("data0"), dt) - - fi, err := os.Stat(filepath.Join(destDir, "out")) - require.NoError(t, err) - require.Equal(t, true, fi.IsDir()) - - dt, err = os.ReadFile(filepath.Join(destDir, "out/bar")) - require.NoError(t, err) - require.Equal(t, []byte("bar0"), dt) - - _, err = os.Stat(filepath.Join(destDir, "out/foo")) - require.ErrorIs(t, err, os.ErrNotExist) - - dt, err = os.ReadFile(filepath.Join(destDir, "file2")) - require.NoError(t, err) - require.Equal(t, []byte("file2"), dt) -} - -func testFileOpCopyChmodText(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - tcases := []struct { - src string - dest string - mode string - }{ - {"file", "f1", "go-w"}, - {"file", "f2", "o-rwx,g+x"}, - {"file", "f3", "u=rwx,g=,o="}, - {"file", "f4", "u+rw,g+r,o-x,o+w"}, - {"dir", "d1", "a+X"}, - {"dir", "d2", "g+rw,o+rw"}, - {"dir", "d3", "u=rwX,go=rX"}, - } - - st := llb.Image("alpine"). - Run(llb.Shlex(`sh -c "mkdir /input && touch /input/file && mkdir /input/dir && chmod 0500 /input/dir && mkdir /expected"`)) - - for _, tc := range tcases { - st = st.Run(llb.Shlex(`sh -c "cp -a /input/` + tc.src + ` /expected/` + tc.dest + ` && chmod ` + tc.mode + ` /expected/` + tc.dest + `"`)) - } - cp := llb.Scratch() - - for _, tc := range tcases { - cp = cp.File(llb.Copy(st.Root(), "/input/"+tc.src, "/"+tc.dest, llb.ChmodOpt{ModeStr: tc.mode})) - } - - for _, tc := range tcases { - st = st.Run(llb.Shlex(`sh -c '[ "$(stat -c '%A' /expected/`+tc.dest+`)" == "$(stat -c '%A' /actual/`+tc.dest+`)" ]'`), llb.AddMount("/actual", cp, llb.Readonly)) - } - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -// moby/buildkit#3291 -func testFileOpCopyUIDCache(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch().File( - llb.Copy(llb.Image("alpine").Run(llb.Shlex(`sh -c 'echo 123 > /foo && chown 1000:1000 /foo'`)).Root(), "foo", "foo")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - var buf bytes.Buffer - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), - }, - }, - }, nil) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(buf.Bytes(), false) - require.NoError(t, err) - - fi, ok := m["foo"] - require.True(t, ok) - require.Equal(t, 1000, fi.Header.Uid) - require.Equal(t, 1000, fi.Header.Gid) - - // repeat to check cache does not apply for different uid - st = llb.Scratch().File( - llb.Copy(llb.Image("alpine").Run(llb.Shlex(`sh -c 'echo 123 > /foo'`)).Root(), "foo", "foo")) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - buf = bytes.Buffer{} - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), - }, - }, - }, nil) - require.NoError(t, err) - - m, err = testutil.ReadTarToMap(buf.Bytes(), false) - require.NoError(t, err) - - fi, ok = m["foo"] - require.True(t, ok) - require.Equal(t, 0, fi.Header.Uid) - require.Equal(t, 0, fi.Header.Gid) -} - -func testFileOpCopyIncludeExclude(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := integration.Tmpdir( - t, - fstest.CreateFile("myfile", []byte("data0"), 0600), - fstest.CreateDir("sub", 0700), - fstest.CreateFile("sub/foo", []byte("foo0"), 0600), - fstest.CreateFile("sub/bar", []byte("bar0"), 0600), - ) - - st := llb.Scratch().File( - llb.Copy( - llb.Local("mylocal"), "/", "/", &llb.CopyInfo{ - IncludePatterns: []string{"sub/*"}, - ExcludePatterns: []string{"sub/bar"}, - }, - ), - ) - - busybox := llb.Image("busybox:latest") - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - run(`sh -c "cat /dev/urandom | head -c 100 | sha256sum > 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, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "sub", "foo")) - require.NoError(t, err) - require.Equal(t, []byte("foo0"), dt) - - for _, name := range []string{"myfile", "sub/bar"} { - _, err = os.Stat(filepath.Join(destDir, name)) - require.ErrorIs(t, err, os.ErrNotExist) - } - - randBytes, err := os.ReadFile(filepath.Join(destDir, "unique")) - require.NoError(t, err) - - // Create additional file which doesn't match the include pattern, and make - // sure this doesn't invalidate the cache. - - err = fstest.Apply(fstest.CreateFile("unmatchedfile", []byte("data1"), 0600)).Apply(dir.Name) - require.NoError(t, err) - - st = llb.Scratch().File( - llb.Copy( - llb.Local("mylocal"), "/", "/", &llb.CopyInfo{ - IncludePatterns: []string{"sub/*"}, - ExcludePatterns: []string{"sub/bar"}, - }, - ), - ) - - run(`sh -c "cat /dev/urandom | head -c 100 | sha256sum > 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, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - }, - }, nil) - require.NoError(t, err) - - randBytes2, err := os.ReadFile(filepath.Join(destDir, "unique")) - require.NoError(t, err) - - require.Equal(t, randBytes, randBytes2) -} - -func testFileOpCopyAlwaysReplaceExistingDestPaths(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - destDirHostPath := integration.Tmpdir(t, - fstest.CreateDir("root", 0755), - fstest.CreateDir("root/overwritedir", 0755), - fstest.CreateFile("root/overwritedir/subfile", nil, 0755), - fstest.CreateFile("root/overwritefile", nil, 0755), - fstest.Symlink("dir", "root/overwritesymlink"), - fstest.CreateDir("root/dir", 0755), - fstest.CreateFile("root/dir/dirfile1", nil, 0755), - fstest.CreateDir("root/dir/overwritesubdir", 0755), - fstest.CreateFile("root/dir/overwritesubfile", nil, 0755), - fstest.Symlink("dirfile1", "root/dir/overwritesymlink"), - ) - destDir := llb.Local("destDir") - - srcDirHostPath := integration.Tmpdir(t, - fstest.CreateDir("root", 0755), - fstest.CreateFile("root/overwritedir", nil, 0755), - fstest.CreateDir("root/overwritefile", 0755), - fstest.CreateFile("root/overwritefile/foo", nil, 0755), - fstest.CreateDir("root/overwritesymlink", 0755), - fstest.CreateDir("root/dir", 0755), - fstest.CreateFile("root/dir/dirfile2", nil, 0755), - fstest.CreateFile("root/dir/overwritesubdir", nil, 0755), - fstest.CreateDir("root/dir/overwritesubfile", 0755), - fstest.CreateDir("root/dir/overwritesymlink", 0755), - ) - srcDir := llb.Local("srcDir") - - resultDir := destDir.File(llb.Copy(srcDir, "/", "/", &llb.CopyInfo{ - CopyDirContentsOnly: true, - AlwaysReplaceExistingDestPaths: true, - })) - - def, err := resultDir.Marshal(sb.Context()) - require.NoError(t, err) - - resultDirHostPath := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: resultDirHostPath, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "destDir": destDirHostPath, - "srcDir": srcDirHostPath, - }, - }, nil) - require.NoError(t, err) - - err = fstest.CheckDirectoryEqualWithApplier(resultDirHostPath, fstest.Apply( - fstest.CreateDir("root", 0755), - fstest.CreateFile("root/overwritedir", nil, 0755), - fstest.CreateDir("root/overwritefile", 0755), - fstest.CreateFile("root/overwritefile/foo", nil, 0755), - fstest.CreateDir("root/overwritesymlink", 0755), - fstest.CreateDir("root/dir", 0755), - fstest.CreateFile("root/dir/dirfile1", nil, 0755), - fstest.CreateFile("root/dir/dirfile2", nil, 0755), - fstest.CreateFile("root/dir/overwritesubdir", nil, 0755), - fstest.CreateDir("root/dir/overwritesubfile", 0755), - fstest.CreateDir("root/dir/overwritesymlink", 0755), - )) - require.NoError(t, err) -} - -// testFileOpInputSwap is a regression test that cache is invalidated when subset of fileop is built -func testFileOpInputSwap(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - base := llb.Scratch().File(llb.Mkfile("/foo", 0600, []byte("foo"))) - - src := llb.Scratch().File(llb.Mkfile("/bar", 0600, []byte("bar"))) - - st := base.File(llb.Copy(src, "/bar", "/baz")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // bar does not exist in base but index of all inputs remains the same - st = base.File(llb.Copy(base, "/bar", "/baz")) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - errStr := integration.UnixOrWindows( - "bar: no such file", - "bar: The system cannot find the file specified", - ) - require.Contains(t, err.Error(), errStr) -} - -func testLocalSourceDiffer(t *testing.T, sb integration.Sandbox) { - for _, d := range []llb.DiffType{llb.DiffNone, llb.DiffMetadata} { - t.Run(fmt.Sprintf("differ=%s", d), func(t *testing.T) { - testLocalSourceWithDiffer(t, sb, d) - }) - } -} - -func testLocalSourceWithDiffer(t *testing.T, sb integration.Sandbox, d llb.DiffType) { - c, err := New(context.TODO(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := integration.Tmpdir( - t, - fstest.CreateFile("foo", []byte("foo"), 0600), - ) - - tv := syscall.NsecToTimespec(time.Now().UnixNano()) - - err = syscall.UtimesNano(filepath.Join(dir.Name, "foo"), []syscall.Timespec{tv, tv}) - require.NoError(t, err) - - st := llb.Local("mylocal"+string(d), llb.Differ(d, false)) - - def, err := st.Marshal(context.TODO()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(context.TODO(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal" + string(d): dir, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, []byte("foo"), dt) - - // Give some extra time to ensure the resources from first solve are released - time.Sleep(500 * time.Millisecond) - - err = os.WriteFile(filepath.Join(dir.Name, "foo"), []byte("bar"), 0600) - require.NoError(t, err) - - err = syscall.UtimesNano(filepath.Join(dir.Name, "foo"), []syscall.Timespec{tv, tv}) - require.NoError(t, err) - - _, err = c.Solve(context.TODO(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal" + string(d): dir, - }, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - if d == llb.DiffMetadata { - require.Equal(t, []byte("foo"), dt) - } - if d == llb.DiffNone { - require.Equal(t, []byte("bar"), dt) - } -} - -// moby/buildkit#4831 -func testLocalSourceWithHardlinksFilter(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(context.TODO(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := integration.Tmpdir( - t, - fstest.CreateFile("bar", []byte("bar"), 0600), - fstest.Link("bar", "foo1"), - fstest.Link("bar", "foo2"), - ) - - st := llb.Local("mylocal", llb.FollowPaths([]string{"foo*"})) - - def, err := st.Marshal(context.TODO()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(context.TODO(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - }, - }, nil) - require.NoError(t, err) - - _, err = os.ReadFile(filepath.Join(destDir, "bar")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo1")) - require.NoError(t, err) - require.Equal(t, []byte("bar"), dt) - - st1, err := os.Stat(filepath.Join(destDir, "foo1")) - require.NoError(t, err) - - st2, err := os.Stat(filepath.Join(destDir, "foo2")) - require.NoError(t, err) - - require.True(t, os.SameFile(st1, st2)) -} - -func testOCILayoutSource(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) - c, err := New(context.TODO(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // create a tempdir where we will store the OCI layout - dir := t.TempDir() - - // make an image that is exported there - 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) - } - - cmdPrefix := integration.UnixOrWindows( - `sh -c "echo -n`, - `cmd /C "echo`, - ) - run(fmt.Sprintf(`%s first > foo"`, cmdPrefix)) - run(fmt.Sprintf(`%s second > bar"`, cmdPrefix)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - outW := bytes.NewBuffer(nil) - attrs := map[string]string{} - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: attrs, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), - }, - }, - }, nil) - require.NoError(t, err) - - // extract the tar stream to the directory as OCI layout - m, err := testutil.ReadTarToMap(outW.Bytes(), false) - require.NoError(t, err) - - for filename, content := range m { - fullFilename := path.Join(dir, filename) - err = os.MkdirAll(path.Dir(fullFilename), 0755) - require.NoError(t, err) - if content.Header.FileInfo().IsDir() { - err = os.MkdirAll(fullFilename, 0755) - require.NoError(t, err) - } else { - err = os.WriteFile(fullFilename, content.Data, 0644) - require.NoError(t, err) - } - } - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) - require.NoError(t, err) - require.Equal(t, 1, len(index.Manifests)) - digest := index.Manifests[0].Digest - - store, err := local.NewStore(dir) - require.NoError(t, err) - - // reference the OCI Layout in a build - // note that the key does not need to be the directory name, just something - // unique. since we are doing just one build with one remote here, we can - // give it any ID - csID := "my-content-store" - st = llb.OCILayout(fmt.Sprintf("not/real@%s", digest), llb.OCIStore("", csID)) - - def, err = st.Marshal(context.TODO()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(context.TODO(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - newLine := integration.UnixOrWindows("", " \r\n") - require.Equal(t, []byte("first"+newLine), dt) - - dt, err = os.ReadFile(filepath.Join(destDir, "bar")) - require.NoError(t, err) - require.Equal(t, []byte("second"+newLine), dt) -} - -// TODO: Re-enable the skipped test on Windows (see issue #6296) -func testSessionExporter(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.FeatureOCIExporter, workers.FeatureOCILayout) - c, err := New(context.TODO(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // make an image that is exported there - 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) - } - - cmdPrefix := integration.UnixOrWindows( - `sh -c "echo -n`, - `cmd /C "echo`, - ) - run(fmt.Sprintf(`%s first > foo"`, cmdPrefix)) - run(fmt.Sprintf(`%s second > bar"`, cmdPrefix)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - var attachable []session.Attachable - target := filesync.NewFSSyncTarget() - - attachable = append(attachable, target) - - buildID := identity.NewID() - - outW := bytes.NewBuffer(nil) - exporterCalled := false - exporter := exporterprovider.New(func(ctx context.Context, md map[string][]byte, refs []string) ([]*exporter.ExporterRequest, error) { - require.Len(t, refs, 1) - g := c.GatewayClientForBuild(buildID) - resp, err := g.ReadDir(sb.Context(), &gatewaypb.ReadDirRequest{ - Ref: refs[0], - DirPath: "/", - }) - if err != nil { - return nil, err - } - t.Logf("readdir: %v", resp) - - require.Equal(t, 2, len(resp.Entries)) - require.Equal(t, "bar", resp.Entries[0].Path) - require.Equal(t, "foo", resp.Entries[1].Path) - - exporterCalled = true - target.Add(filesync.WithFSSync(0, fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}))) - return []*exporter.ExporterRequest{ - { - Type: ExporterOCI, - Attrs: map[string]string{ - "compression": "zstd", - }, - }, - }, nil - }) - require.NoError(t, err) - attachable = append(attachable, exporter) - - _, err = c.Build(sb.Context(), SolveOpt{ - EnableSessionExporter: true, - Session: attachable, - Ref: buildID, - }, "", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - require.True(t, exporterCalled) - - // extract the tar stream to the directory as OCI layout - m, err := testutil.ReadTarToMap(outW.Bytes(), false) - require.NoError(t, err) - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) - require.NoError(t, err) - require.Equal(t, 1, len(index.Manifests)) - digest := index.Manifests[0].Digest - - var mfst ocispecs.Manifest - err = json.Unmarshal(m[path.Join("blobs/sha256", digest.Hex())].Data, &mfst) - require.NoError(t, err) - - if runtime.GOOS == "windows" { - mfst.Layers = mfst.Layers[1:] // skip base layer - } - - require.Equal(t, 2, len(mfst.Layers)) - for _, layer := range mfst.Layers { - require.Contains(t, layer.MediaType, ocispecs.MediaTypeImageLayerZstd) - } -} - -func testOCILayoutPlatformSource(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) - requiresLinux(t) - c, err := New(context.TODO(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // create a tempdir where we will store the OCI layout - dir := t.TempDir() - - platformsToTest := []string{"linux/amd64", "linux/arm64"} - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.Scratch().File( - llb.Mkfile("platform", 0600, []byte(platform)), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - attrs := map[string]string{} - outW := bytes.NewBuffer(nil) - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: attrs, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - // extract the tar stream to the directory as OCI layout - m, err := testutil.ReadTarToMap(outW.Bytes(), false) - require.NoError(t, err) - - for filename, tarItem := range m { - fullFilename := path.Join(dir, filename) - err = os.MkdirAll(path.Dir(fullFilename), 0755) - require.NoError(t, err) - if tarItem.Header.FileInfo().IsDir() { - err = os.MkdirAll(fullFilename, 0755) - require.NoError(t, err) - } else { - err = os.WriteFile(fullFilename, tarItem.Data, 0644) - require.NoError(t, err) - } - } - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) - require.NoError(t, err) - require.Equal(t, 1, len(index.Manifests)) - digest := index.Manifests[0].Digest - - store, err := local.NewStore(dir) - require.NoError(t, err) - csID := "my-content-store" - - destDir := t.TempDir() - - frontendOCILayout := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.OCILayout(fmt.Sprintf("not/real@%s", digest), llb.OCIStore("", csID)) - - def, err := st.Marshal(ctx, llb.Platform(platforms.MustParse(platform))) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, "", frontendOCILayout, nil) - require.NoError(t, err) - - for _, platform := range platformsToTest { - dt, err := os.ReadFile(filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_"), "platform")) - require.NoError(t, err) - require.Equal(t, []byte(platform), dt) - } -} - -func testFileOpSymlink(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - const ( - fileOwner = 7777 - fileGroup = 8888 - linkOwner = 1111 - linkGroup = 2222 - - dummyTimestamp = 42 - ) - - dummyTime := time.Unix(dummyTimestamp, 0) - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch(). - File(llb.Mkdir("/foo", 0700).Mkfile("bar", 0600, []byte("contents"), llb.ChownOpt{ - User: &llb.UserOpt{ - UID: fileOwner, - }, - Group: &llb.UserOpt{ - UID: fileGroup, - }, - })). - File(llb.Symlink("bar", "/baz", llb.WithCreatedTime(dummyTime), llb.ChownOpt{ - User: &llb.UserOpt{ - UID: linkOwner, - }, - Group: &llb.UserOpt{ - UID: linkGroup, - }, - })) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - defer outW.Close() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - entry, ok := m["bar"] - require.True(t, ok) - - dt = entry.Data - header := entry.Header - require.NoError(t, err) - - require.Equal(t, []byte("contents"), dt) - require.Equal(t, fileOwner, header.Uid) - require.Equal(t, fileGroup, header.Gid) - - entry, ok = m["baz"] - require.Equal(t, true, ok) - - header = entry.Header - // ensure it is a symlink - require.Equal(t, tar.TypeSymlink, rune(header.Typeflag)) - // ensure it is a symlink to the proper location - require.Equal(t, "bar", header.Linkname) - - // make sure it was chowned properly - require.Equal(t, linkOwner, header.Uid) - require.Equal(t, linkGroup, header.Gid) - - // ensure it was timestamped properly - require.Equal(t, dummyTime, header.ModTime) -} - -func testFileOpRmWildcard(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - dir := integration.Tmpdir( - t, - fstest.CreateDir("foo", 0700), - fstest.CreateDir("bar", 0700), - fstest.CreateFile("foo/target", []byte("foo0"), 0600), - fstest.CreateFile("bar/target", []byte("bar0"), 0600), - fstest.CreateFile("bar/remaining", []byte("bar1"), 0600), - ) - - st := llb.Scratch().File( - llb.Copy(llb.Local("mylocal"), "foo", "foo"). - Copy(llb.Local("mylocal"), "bar", "bar"), - ).File( - llb.Rm("*/target", llb.WithAllowWildcard(true)), - ) - 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, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "mylocal": dir, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "bar/remaining")) - require.NoError(t, err) - require.Equal(t, []byte("bar1"), dt) - - fi, err := os.Stat(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, true, fi.IsDir()) - - _, err = os.Stat(filepath.Join(destDir, "foo/target")) - require.ErrorIs(t, err, os.ErrNotExist) - - _, err = os.Stat(filepath.Join(destDir, "bar/target")) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func testCallDiskUsage(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - _, err = c.DiskUsage(sb.Context()) - require.NoError(t, err) -} - -func testBuildMultiMount(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgAlpine := integration.UnixOrWindows( - "docker.io/library/alpine:latest", - "nanoserver", - ) - imgBusybox := integration.UnixOrWindows( - "docker.io/library/busybox:latest", - "nanoserver", - ) - cmdStr1 := integration.UnixOrWindows( - "/bin/ls -l", - "cmd /C dir", - ) - cmdStr2 := integration.UnixOrWindows( - "/bin/cp -a /busybox/etc/passwd baz", - `cmd /C copy C:\\busybox\\License.txt baz`, - ) - - alpine := llb.Image(imgAlpine) - ls := alpine.Run(llb.Shlex(cmdStr1)) - busybox := llb.Image(imgBusybox) - cp := ls.Run(llb.Shlex(cmdStr2)) - cp.AddMount("/busybox", busybox) - - def, err := cp.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testBuildExportScratch(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) - requiresLinux(t) - 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) - - makeFrontend := func(ps []string) func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - return func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch() - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - r, err := c.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() - if ps == nil { - res.SetRef(ref) - } else { - for _, p := range ps { - res.AddRef(p, ref) - } - - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(ps)), - } - for i, pk := range ps { - p := platforms.MustParse(pk) - - img := ocispecs.Image{ - Platform: p, - Config: ocispecs.ImageConfig{ - Labels: map[string]string{ - "foo": "i am platform " + platforms.Format(p), - }, - }, - } - config, err := json.Marshal(img) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal image config") - } - res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, pk), config) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: pk, - Platform: p, - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - } - - return res, nil - } - } - - target := registry + "/buildkit/build/exporter:withnocompressed" - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "unpack": "true", - "compression": "uncompressed", - "attest:provenance": "mode=max", - }, - }, - }, - }, "", makeFrontend(nil), nil) - require.NoError(t, err) - - targetMulti := registry + "/buildkit/build/exporter-multi:withnocompressed" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": targetMulti, - "push": "true", - "unpack": "true", - "compression": "uncompressed", - "attest:provenance": "mode=max", - }, - }, - }, - }, "", makeFrontend([]string{"linux/amd64", "linux/arm64"}), nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Len(t, imgs.Images, 1) - img := imgs.Images[0] - require.Empty(t, img.Layers) - require.True(t, platforms.Only(platforms.DefaultSpec()).Match(img.Img.Platform)) - - desc, provider, err = contentutil.ProviderFromRef(targetMulti) - require.NoError(t, err) - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Len(t, imgs.Images, 2) - img = imgs.Find("linux/amd64") - require.Empty(t, img.Layers) - require.Equal(t, "linux/amd64", platforms.Format(img.Img.Platform)) - require.Equal(t, "i am platform linux/amd64", img.Img.Config.Labels["foo"]) - img = imgs.Find("linux/arm64") - require.Empty(t, img.Layers) - require.Equal(t, "linux/arm64", platforms.Format(img.Img.Platform)) - require.Equal(t, "i am platform linux/arm64", img.Img.Config.Labels["foo"]) -} - -func testBuildHTTPSource(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - LastModified: &modTime, - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - // invalid URL first - st := llb.HTTP(server.URL + "/bar") - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid response status 404") - - // first correct request - st = llb.HTTP(server.URL + "/foo") - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - require.Equal(t, 1, server.Stats("/foo").AllRequests) - require.Equal(t, 0, server.Stats("/foo").CachedRequests) - - tmpdir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: tmpdir, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 2, server.Stats("/foo").AllRequests) - require.Equal(t, 1, server.Stats("/foo").CachedRequests) - - dt, err := os.ReadFile(filepath.Join(tmpdir, "foo")) - require.NoError(t, err) - require.Equal(t, []byte("content1"), dt) - - allReqs := server.Stats("/foo").Requests - require.Equal(t, 2, len(allReqs)) - require.Equal(t, http.MethodGet, allReqs[0].Method) - require.Equal(t, "gzip", allReqs[0].Header.Get("Accept-Encoding")) - require.Equal(t, http.MethodHead, allReqs[1].Method) - require.Equal(t, "gzip", allReqs[1].Header.Get("Accept-Encoding")) - - require.NoError(t, os.RemoveAll(filepath.Join(tmpdir, "foo"))) - - // update the content at the url to be gzipped now, the final output - // should remain the same - modTime = time.Now().Add(-23 * time.Hour) - var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - _, err = gw.Write(resp.Content) - require.NoError(t, err) - require.NoError(t, gw.Close()) - gzipBytes := buf.Bytes() - respGzip := &httpserver.Response{ - Etag: identity.NewID(), - Content: gzipBytes, - LastModified: &modTime, - ContentEncoding: "gzip", - } - server.SetRoute("/foo", respGzip) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: tmpdir, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 4, server.Stats("/foo").AllRequests) - require.Equal(t, 1, server.Stats("/foo").CachedRequests) - - dt, err = os.ReadFile(filepath.Join(tmpdir, "foo")) - require.NoError(t, err) - require.Equal(t, resp.Content, dt) - - allReqs = server.Stats("/foo").Requests - require.Equal(t, 4, len(allReqs)) - require.Equal(t, http.MethodHead, allReqs[2].Method) - require.Equal(t, "gzip", allReqs[2].Header.Get("Accept-Encoding")) - require.Equal(t, http.MethodGet, allReqs[3].Method) - require.Equal(t, "gzip", allReqs[3].Header.Get("Accept-Encoding")) - - // test extra options - // llb.Chown not supported on Windows - st = integration.UnixOrWindows( - llb.HTTP(server.URL+"/foo", llb.Filename("bar"), llb.Chmod(0741), llb.Chown(1000, 1000)), - llb.HTTP(server.URL+"/foo", llb.Filename("bar"), llb.Chmod(0741)), - ) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: tmpdir, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 5, server.Stats("/foo").AllRequests) - require.Equal(t, 1, server.Stats("/foo").CachedRequests) - - dt, err = os.ReadFile(filepath.Join(tmpdir, "bar")) - require.NoError(t, err) - require.Equal(t, []byte("content1"), dt) - - fi, err := os.Stat(filepath.Join(tmpdir, "bar")) - require.NoError(t, err) - require.Equal(t, fi.ModTime().Format(http.TimeFormat), modTime.Format(http.TimeFormat)) - - // no support for llb.Chmod on Windows, default is returned - fMode := integration.UnixOrWindows(0741, 0666) - require.Equal(t, fMode, int(fi.Mode()&0777)) - - checkAllReleasable(t, c, sb, true) - - // TODO: check that second request was marked as cached -} - -func testBuildHTTPSourceUnauthorizedChecksumRace(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if requests.Add(1) == 1 { - time.Sleep(200 * time.Millisecond) - } - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - url := server.URL + "/test.txt" - lower := llb.Scratch() - // Reproduce the race between same-URL HTTP sources with and without - // checksum sharing the resolver cache while the server returns 401. - withoutChecksum := lower.File(llb.Copy(llb.HTTP(url), "test.txt", "/a.txt")) - withChecksum := lower.File(llb.Copy(llb.HTTP(url, llb.Checksum(digest.FromBytes(nil))), "test.txt", "/b.txt")) - st := llb.Merge([]llb.State{ - llb.Diff(lower, withoutChecksum), - llb.Diff(lower, withChecksum), - }) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - for range 10 { - requests.Store(0) - _, err := c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: t.TempDir(), - }, - }, - }, nil) - require.Error(t, err) - require.ErrorContains(t, err, "invalid response status 401") - } -} - -// docker/buildx#2803 -func testBuildHTTPSourceEtagScope(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time - - sharedEtag := identity.NewID() - resp := &httpserver.Response{ - Etag: sharedEtag, - Content: []byte("content1"), - LastModified: &modTime, - } - resp2 := &httpserver.Response{ - Etag: sharedEtag, - Content: []byte("another"), - LastModified: &modTime, - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/one/foo": resp, - "/two/foo": resp2, - }) - defer server.Close() - - // first correct request - st := llb.HTTP(server.URL + "/one/foo") - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - out1 := t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: out1, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 1, server.Stats("/one/foo").AllRequests) - require.Equal(t, 0, server.Stats("/one/foo").CachedRequests) - - dt, err := os.ReadFile(filepath.Join(out1, "foo")) - require.NoError(t, err) - require.Equal(t, []byte("content1"), dt) - - st = llb.HTTP(server.URL + "/two/foo") - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - out2 := t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: out2, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 1, server.Stats("/two/foo").AllRequests) - require.Equal(t, 0, server.Stats("/two/foo").CachedRequests) - - dt, err = os.ReadFile(filepath.Join(out2, "foo")) - require.NoError(t, err) - require.Equal(t, []byte("another"), dt) - - out2 = t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: out2, - }, - }, - }, nil) - require.NoError(t, err) - - require.Equal(t, 2, server.Stats("/two/foo").AllRequests) - require.Equal(t, 1, server.Stats("/two/foo").CachedRequests) - - allReqs := server.Stats("/two/foo").Requests - require.Equal(t, 2, len(allReqs)) - require.Equal(t, http.MethodGet, allReqs[0].Method) - require.Equal(t, "gzip", allReqs[0].Header.Get("Accept-Encoding")) - require.Equal(t, http.MethodHead, allReqs[1].Method) - require.Equal(t, "gzip", allReqs[1].Header.Get("Accept-Encoding")) - - require.NoError(t, os.RemoveAll(filepath.Join(out2, "foo"))) -} - -func testBuildHTTPSourceAuthHeaderSecret(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid false positive with current time - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - LastModified: &modTime, - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - st := llb.HTTP(server.URL+"/foo", llb.AuthHeaderSecret("http-secret")) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve( - sb.Context(), - def, - SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "http-secret": []byte("Bearer foo"), - })}, - }, - nil, - ) - require.NoError(t, err) - - allReqs := server.Stats("/foo").Requests - require.Equal(t, 1, len(allReqs)) - require.Equal(t, http.MethodGet, allReqs[0].Method) - require.Equal(t, "Bearer foo", allReqs[0].Header.Get("Authorization")) -} - -func testBuildHTTPSourceHostTokenSecret(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid false positive with current time - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - LastModified: &modTime, - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - st := llb.HTTP(server.URL + "/foo") - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve( - sb.Context(), - def, - SolveOpt{ - Session: []session.Attachable{secretsprovider.FromMap(map[string][]byte{ - "HTTP_AUTH_TOKEN_127.0.0.1": []byte("123456"), - })}, - }, - nil, - ) - require.NoError(t, err) - - allReqs := server.Stats("/foo").Requests - require.Equal(t, 1, len(allReqs)) - require.Equal(t, http.MethodGet, allReqs[0].Method) - require.Equal(t, "Bearer 123456", allReqs[0].Header.Get("Authorization")) -} - -func testBuildHTTPSourceHeader(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - LastModified: &modTime, - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - st := llb.HTTP( - server.URL+"/foo", - llb.Header(llb.HTTPHeader{ - Accept: "application/vnd.foo", - UserAgent: "fooagent", - }), - ) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - allReqs := server.Stats("/foo").Requests - require.Equal(t, 1, len(allReqs)) - require.Equal(t, http.MethodGet, allReqs[0].Method) - require.Equal(t, "application/vnd.foo", allReqs[0].Header.Get("accept")) - require.Equal(t, "fooagent", allReqs[0].Header.Get("user-agent")) -} - -func testBuildHTTPSourcePGPSignatureVerify(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - signFixturesPath, ok := os.LookupEnv("BUILDKIT_TEST_SIGN_FIXTURES") - if !ok { - t.Skip("missing BUILDKIT_TEST_SIGN_FIXTURES") - } - - payload, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact")) - require.NoError(t, err) - sigData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.http.artifact.asc")) - require.NoError(t, err) - pubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user1.gpg.pub")) - require.NoError(t, err) - wrongPubKeyData, err := os.ReadFile(filepath.Join(signFixturesPath, "user2.gpg.pub")) - require.NoError(t, err) - - httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/artifact.txt" { - http.NotFound(w, r) - return - } - _, _ = w.Write(payload) - })) - defer httpSrv.Close() - - solve := func(t *testing.T, state llb.State) error { - def, err := state.Marshal(sb.Context()) - require.NoError(t, err) - tmpdir := t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: tmpdir, - }, - }, - }, nil) - if err != nil { - return err - } - dt, err := os.ReadFile(filepath.Join(tmpdir, "artifact.txt")) - require.NoError(t, err) - require.Equal(t, payload, dt) - return nil - } - - t.Run("valid-signature", func(t *testing.T) { - validState := llb.HTTP( - httpSrv.URL+"/artifact.txt", - llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ - PubKey: pubKeyData, - Signature: sigData, - }), - ) - require.NoError(t, solve(t, validState)) - }) - - t.Run("wrong-pubkey", func(t *testing.T) { - invalidState := llb.HTTP( - httpSrv.URL+"/artifact.txt", - llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ - PubKey: wrongPubKeyData, - Signature: sigData, - }), - ) - err = solve(t, invalidState) - require.Error(t, err) - require.ErrorContains(t, err, "failed to verify pgp signature") - }) - - t.Run("concatenated-pubkeys-right-key-second", func(t *testing.T) { - mergedPubKeys := append(append([]byte{}, wrongPubKeyData...), '\n') - mergedPubKeys = append(mergedPubKeys, pubKeyData...) - state := llb.HTTP( - httpSrv.URL+"/artifact.txt", - llb.VerifyPGPSignature(llb.HTTPSignatureInfo{ - PubKey: mergedPubKeys, - Signature: sigData, - }), - ) - require.NoError(t, solve(t, state)) - }) -} - -func testResolveAndHosts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "cp /etc/resolv.conf ."`) - run(`sh -c "cp /etc/hosts ."`) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "resolv.conf")) - require.NoError(t, err) - require.Contains(t, string(dt), "nameserver") - - dt, err = os.ReadFile(filepath.Join(destDir, "hosts")) - require.NoError(t, err) - require.Contains(t, string(dt), "127.0.0.1 localhost") -} - -func testUser(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest").Run(llb.Shlex(`sh -c "mkdir -m 0777 /wd"`)) - - run := func(user, cmd string) { - if user != "" { - st = st.Run(llb.Shlex(cmd), llb.Dir("/wd"), llb.User(user)) - } else { - st = st.Run(llb.Shlex(cmd), llb.Dir("/wd")) - } - } - - run("daemon", `sh -c "id -nu > user"`) - run("daemon:daemon", `sh -c "id -ng > group"`) - run("daemon:nobody", `sh -c "id -ng > nobody"`) - run("1:1", `sh -c "id -g > userone"`) - run("root", `sh -c "id -Gn > root_supplementary"`) - run("", `sh -c "id -Gn > default_supplementary"`) - run("", `rm /etc/passwd /etc/group`) // test that default user still works - run("", `sh -c "id -u > default_uid"`) - - st = st.Run(llb.Shlex("cp -a /wd/. /out/")) - out := st.AddMount("/out", llb.Scratch()) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "user")) - require.NoError(t, err) - require.Equal(t, "daemon", strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "group")) - require.NoError(t, err) - require.Equal(t, "daemon", strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "nobody")) - require.NoError(t, err) - require.Equal(t, "nobody", strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "userone")) - require.NoError(t, err) - require.Equal(t, "1", strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "root_supplementary")) - require.NoError(t, err) - require.True(t, strings.HasPrefix(string(dt), "root ")) - require.Contains(t, string(dt), "wheel") - - dt2, err := os.ReadFile(filepath.Join(destDir, "default_supplementary")) - require.NoError(t, err) - require.Equal(t, string(dt), string(dt2)) - - dt, err = os.ReadFile(filepath.Join(destDir, "default_uid")) - require.NoError(t, err) - require.Equal(t, "0", strings.TrimSpace(string(dt))) - - checkAllReleasable(t, c, sb, true) -} - -func testMultipleExporters(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - def, err := llb.Scratch().File(llb.Mkfile("foo.txt", 0o755, nil)).Marshal(context.TODO()) - require.NoError(t, err) - - destDir, destDir2 := t.TempDir(), t.TempDir() - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - defer outW.Close() - - out2 := filepath.Join(destDir, "out2.tar") - outW2, err := os.Create(out2) - require.NoError(t, err) - defer outW2.Close() - - registry, err := sb.NewRegistry() - if errors.Is(err, integration.ErrRequirements) { - t.Skip(err.Error()) - } - require.NoError(t, err) - - target1, target2 := registry+"/buildkit/build/exporter:image", - registry+"/buildkit/build/alternative:image" - - var exporters []ExportEntry - if workers.IsTestDockerd() { - exporters = append(exporters, ExportEntry{ - Type: "moby", - Attrs: map[string]string{ - "name": strings.Join([]string{target1, target2}, ","), - }, - }) - } else { - exporters = append(exporters, []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target1, - }, - }, - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target2, - "oci-mediatypes": "false", - }, - }, - }...) - } - - exporters = append(exporters, []ExportEntry{ - // Ensure that multiple local exporter destinations are written properly - { - Type: ExporterLocal, - OutputDir: destDir, - }, - { - Type: ExporterLocal, - OutputDir: destDir2, - }, - // Ensure that multiple instances of the same exporter are possible - { - Type: ExporterTar, - Output: fixedWriteCloser(outW), - }, - { - Type: ExporterTar, - Output: fixedWriteCloser(outW2), - }, - }...) - - ref := identity.NewID() - resp, err := c.Solve(sb.Context(), def, SolveOpt{ - Ref: ref, - Exports: exporters, - }, nil) - require.NoError(t, err) - - if workers.IsTestDockerd() { - require.Equal(t, target1+","+target2, resp.ExporterResponse[exptypes.ExporterImageNameKey]) - } else { - require.Equal(t, target2, resp.ExporterResponse[exptypes.ExporterImageNameKey]) - } - require.FileExists(t, filepath.Join(destDir, "out.tar")) - require.FileExists(t, filepath.Join(destDir, "out2.tar")) - require.FileExists(t, filepath.Join(destDir, "foo.txt")) - require.FileExists(t, filepath.Join(destDir2, "foo.txt")) - - history, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ - Ref: ref, - EarlyExit: true, - }) - require.NoError(t, err) - for { - ev, err := history.Recv() - if err != nil { - require.Equal(t, io.EOF, err) - break - } - require.Equal(t, ref, ev.Record.Ref) - - if workers.IsTestDockerd() { - require.Len(t, ev.Record.Result.Results, 1) - require.Len(t, ev.Record.Exporters, 5) - if workers.IsTestDockerdMoby(sb) { - require.Equal(t, ocispecs.MediaTypeImageConfig, ev.Record.Result.Results[0].MediaType) - } else { - require.Equal(t, ocispecs.MediaTypeImageManifest, ev.Record.Result.Results[0].MediaType) - } - } else { - require.Len(t, ev.Record.Result.Results, 2) - require.Len(t, ev.Record.Exporters, 6) - require.Equal(t, ocispecs.MediaTypeImageManifest, ev.Record.Result.Results[0].MediaType) - require.Equal(t, images.MediaTypeDockerSchema2Manifest, ev.Record.Result.Results[1].MediaType) - } - require.Equal(t, ev.Record.Result.Results[0], ev.Record.Result.ResultDeprecated) - } -} - -func testOCIExporter(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - target := "example.com/buildkit/testoci:latest" - for _, tc := range []struct { - name string - exporter string - attrs map[string]string - indexMediaType string - manifestMediaType string - configMediaType string - layerMediaType string - }{ - { - name: ExporterOCI, - exporter: ExporterOCI, - attrs: map[string]string{}, - indexMediaType: ocispecs.MediaTypeImageIndex, - manifestMediaType: ocispecs.MediaTypeImageManifest, - configMediaType: ocispecs.MediaTypeImageConfig, - layerMediaType: ocispecs.MediaTypeImageLayerGzip, - }, - { - name: ExporterDocker, - exporter: ExporterDocker, - attrs: map[string]string{ - "name": target, - }, - indexMediaType: ocispecs.MediaTypeImageIndex, - manifestMediaType: ocispecs.MediaTypeImageManifest, - configMediaType: ocispecs.MediaTypeImageConfig, - layerMediaType: ocispecs.MediaTypeImageLayerGzip, - }, - { - name: "docker-oci-mediatypes-false", - exporter: ExporterDocker, - attrs: map[string]string{ - "name": target, - "oci-mediatypes": "false", - }, - indexMediaType: ocispecs.MediaTypeImageIndex, - manifestMediaType: images.MediaTypeDockerSchema2Manifest, - configMediaType: images.MediaTypeDockerSchema2Config, - layerMediaType: images.MediaTypeDockerSchema2LayerGzip, - }, - } { - t.Run(tc.name, func(t *testing.T) { - 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: tc.exporter, - Attrs: tc.attrs, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - _, ok := m["oci-layout"] - require.True(t, ok) - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index) - require.NoError(t, err) - require.Equal(t, 2, index.SchemaVersion) - require.Equal(t, tc.indexMediaType, index.MediaType) - require.Equal(t, 1, len(index.Manifests)) - require.Equal(t, tc.manifestMediaType, index.Manifests[0].MediaType) - - var mfst ocispecs.Manifest - err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst) - require.NoError(t, err) - require.Equal(t, tc.manifestMediaType, mfst.MediaType) - require.Equal(t, tc.configMediaType, mfst.Config.MediaType) - require.Equal(t, 2, len(mfst.Layers)) - for _, layer := range mfst.Layers { - require.Equal(t, tc.layerMediaType, layer.MediaType) - } - - var ociimg ocispecs.Image - err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Config.Digest.Hex()].Data, &ociimg) - require.NoError(t, err) - require.Equal(t, "layers", ociimg.RootFS.Type) - require.Equal(t, 2, len(ociimg.RootFS.DiffIDs)) - - _, ok = m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Layers[0].Digest.Hex()] - require.True(t, ok) - _, ok = m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Layers[1].Digest.Hex()] - require.True(t, ok) - - if tc.exporter != ExporterDocker { - return - } - - var dockerMfst []struct { - Config string - RepoTags []string - Layers []string - } - err = json.Unmarshal(m["manifest.json"].Data, &dockerMfst) - require.NoError(t, err) - require.Equal(t, 1, len(dockerMfst)) - - _, ok = m[dockerMfst[0].Config] - require.True(t, ok) - require.Equal(t, 2, len(dockerMfst[0].Layers)) - require.Equal(t, 1, len(dockerMfst[0].RepoTags)) - require.Equal(t, target, dockerMfst[0].RepoTags[0]) - - for _, l := range dockerMfst[0].Layers { - _, ok := m[l] - require.True(t, ok) - } - }) - } - - checkAllReleasable(t, c, sb, true) -} - -func testOCIExporterContentStore(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - for _, exp := range []string{ExporterOCI, ExporterDocker} { - destDir := t.TempDir() - target := "example.com/buildkit/testoci:latest" - - outTar := filepath.Join(destDir, "out.tar") - outW, err := os.Create(outTar) - require.NoError(t, err) - attrs := map[string]string{} - if exp == ExporterDocker { - attrs["name"] = target - } - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: attrs, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(outTar) - require.NoError(t, err) - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - checkStore := func(dir string) { - err = filepath.Walk(dir, func(filename string, fi os.FileInfo, err error) error { - filename = strings.TrimPrefix(filename, dir) - filename = strings.Trim(filename, "/") - if filename == "" || filename == "ingest" { - return nil - } - - if fi.IsDir() { - require.Contains(t, m, filename+"/") - } else { - require.Contains(t, m, filename) - if filename == ocispecs.ImageIndexFile { - // this file has a timestamp in it, so we can't compare - return nil - } - f, err := os.Open(path.Join(dir, filename)) - require.NoError(t, err) - data, err := io.ReadAll(f) - require.NoError(t, err) - require.Equal(t, m[filename].Data, data) - } - return nil - }) - require.NoError(t, err) - } - - outDir := filepath.Join(destDir, "out.d") - attrs = map[string]string{ - "tar": "false", - } - if exp == ExporterDocker { - attrs["name"] = target - } - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: attrs, - OutputDir: outDir, - }, - }, - }, nil) - require.NoError(t, err) - checkStore(outDir) - - outStoreDir := filepath.Join(destDir, "store.d") - store, err := local.NewStore(outStoreDir) - require.NoError(t, err) - attrs = map[string]string{ - "tar": "false", - } - if exp == ExporterDocker { - attrs["name"] = target - } - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: attrs, - OutputStore: store, - }, - }, - }, nil) - require.NoError(t, err) - checkStore(outDir) - } - - checkAllReleasable(t, c, sb, true) -} - -func testNoTarOCIIndexMediaType(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - 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 -c "echo -n hello > hello"`, - `cmd /C "echo hello> hello"`, - ) - st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - outDir := filepath.Join(destDir, "out.d") - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: map[string]string{ - "tar": "false", - }, - OutputDir: outDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(outDir, "index.json")) - require.NoError(t, err) - - var index ocispecs.Index - err = json.Unmarshal(dt, &index) - require.NoError(t, err) - - require.Equal(t, "application/vnd.oci.image.index.v1+json", index.MediaType) - - checkAllReleasable(t, c, sb, true) -} - -func testOCIIndexMediatype(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - 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 -c "echo -n hello > hello"`, - `cmd /C "echo hello> hello"`, - ) - st := llb.Image(imgName).Run(llb.Shlex(cmdStr)) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - 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), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - indexDt, ok := m[ocispecs.ImageIndexFile] - require.True(t, ok) - - var index ocispecs.Index - err = json.Unmarshal(indexDt.Data, &index) - require.NoError(t, err) - - require.Equal(t, "application/vnd.oci.image.index.v1+json", index.MediaType) - - checkAllReleasable(t, c, sb, true) -} - -func testSourceDateEpochLayerTimestamps(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - - tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - tmsX, err := readImageTimestamps(dt) - require.NoError(t, err) - tms := tmsX.FromImage - - require.Equal(t, 3, len(tms)) - - expected := tm.UTC().Format(time.RFC3339Nano) - require.Equal(t, expected, tms[0]) - require.Equal(t, expected, tms[1]) - require.Equal(t, expected, tms[2]) - - checkAllReleasable(t, c, sb, true) -} - -func testSourceDateEpochClamp(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - var bboxConfig []byte - _, err = c.Build(sb.Context(), SolveOpt{}, "", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - _, _, bboxConfig, err = c.ResolveImageConfig(ctx, "docker.io/library/busybox:latest", sourceresolver.Opt{}) - if err != nil { - return nil, err - } - return nil, nil - }, nil) - require.NoError(t, err) - - m := map[string]json.RawMessage{} - require.NoError(t, json.Unmarshal(bboxConfig, &m)) - delete(m, "created") - bboxConfig, err = json.Marshal(m) - require.NoError(t, err) - - busybox, err := llb.Image("busybox:latest").WithImageConfig(bboxConfig) - require.NoError(t, err) - - def, err := busybox.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - 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, - Attrs: map[string]string{ - exptypes.ExporterImageConfigKey: string(bboxConfig), - }, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - busyboxTmsX, err := readImageTimestamps(dt) - require.NoError(t, err) - busyboxTms := busyboxTmsX.FromImage - - require.Greater(t, len(busyboxTms), 1) - bboxLayerLen := len(busyboxTms) - 1 - - tm, err := time.Parse(time.RFC3339Nano, busyboxTms[1]) - require.NoError(t, err) - - next := tm.Add(time.Hour).Truncate(time.Second) - - st := busybox.Run(llb.Shlex("touch /foo")) - - def, err = st.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{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", next.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: map[string]string{ - exptypes.ExporterImageConfigKey: string(bboxConfig), - }, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(out) - require.NoError(t, err) - - tmsX, err := readImageTimestamps(dt) - require.NoError(t, err) - tms := tmsX.FromImage - - require.Equal(t, len(tms), bboxLayerLen+2) - - expected := next.UTC().Format(time.RFC3339Nano) - require.Equal(t, expected, tms[0]) - require.Equal(t, busyboxTms[1], tms[1]) - require.Equal(t, expected, tms[bboxLayerLen+1]) - require.Equal(t, expected, tmsX.FromAnnotation) - - checkAllReleasable(t, c, sb, true) -} - -// testSourceDateEpochReset tests that the SOURCE_DATE_EPOCH is reset if exporter option is set -func testSourceDateEpochReset(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureSourceDateEpoch) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - - tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: map[string]string{"source-date-epoch": ""}, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - tmsX, err := readImageTimestamps(dt) - require.NoError(t, err) - tms := tmsX.FromImage - - require.Equal(t, 3, len(tms)) - - expected := tm.UTC().Format(time.RFC3339Nano) - require.NotEqual(t, expected, tms[0]) - require.NotEqual(t, expected, tms[1]) - require.NotEqual(t, expected, tms[2]) - - require.Equal(t, tms[0], tms[2]) - require.NotEqual(t, tms[2], tms[1]) - - checkAllReleasable(t, c, sb, true) -} - -func testSourceDateEpochLocalExporter(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - fi, err := os.Stat(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, fi.ModTime().Format(time.RFC3339), tm.UTC().Format(time.RFC3339)) - - fi, err = os.Stat(filepath.Join(destDir, "bar")) - require.NoError(t, err) - require.Equal(t, fi.ModTime().Format(time.RFC3339), tm.UTC().Format(time.RFC3339)) - - checkAllReleasable(t, c, sb, true) -} - -func testSourceDateEpochTarExporter(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "echo -n first > foo"`) - run(`sh -c "echo -n second > bar"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir, err := os.MkdirTemp("", "buildkit") - require.NoError(t, err) - defer os.RemoveAll(destDir) - - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - - tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(outW), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - require.Equal(t, 2, len(m)) - - require.Equal(t, tm.Format(time.RFC3339), m["foo"].Header.ModTime.Format(time.RFC3339)) - require.Equal(t, tm.Format(time.RFC3339), m["bar"].Header.ModTime.Format(time.RFC3339)) - - checkAllReleasable(t, c, sb, true) -} - -func testSourceDateEpochImageExporter(t *testing.T, sb integration.Sandbox) { - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.SkipNow() - } - - // https://github.com/containerd/containerd/commit/133ddce7cf18a1db175150e7a69470dea1bb3132 - minVer := "v1.7.0-beta.1" - cdVersion := containerdutil.GetVersion(t, cdAddress) - // Normalize containerd version to semver format (add v prefix if missing) - normalizedCdVersion := cdVersion - if !strings.HasPrefix(cdVersion, "v") { - normalizedCdVersion = "v" + cdVersion - } - if semver.Compare(normalizedCdVersion, minVer) < 0 { - t.Skipf("containerd version %q does not satisfy minimal version %q", cdVersion, minVer) - } - - workers.CheckFeatureCompat(t, sb, workers.FeatureSourceDateEpoch) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - - type testVars struct { - baseImgName string - st llb.State - cmdPrefix string - } - - baseImg := llb.Image(integration.UnixOrWindows("busybox:latest", "nanoserver:latest")) - v := integration.UnixOrWindows( - testVars{ - baseImgName: "busybox:latest", - st: llb.Scratch(), - cmdPrefix: `sh -c "echo -n`, - }, - testVars{ - baseImgName: "nanoserver:latest", - st: baseImg, - cmdPrefix: `cmd /C "echo`, - }, - ) - - st := v.st - run := func(cmd string) { - st = baseImg.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(fmt.Sprintf(`%s first> foo"`, v.cmdPrefix)) - run(fmt.Sprintf(`%s second> bar"`, v.cmdPrefix)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - name := strings.ToLower(path.Base(t.Name())) - tm := time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "build-arg:SOURCE_DATE_EPOCH": fmt.Sprintf("%d", tm.Unix()), - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": name, - }, - }, - }, - }, nil) - require.NoError(t, err) - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - img, err := client.GetImage(ctx, name) - require.NoError(t, err) - require.Equal(t, tm, img.Metadata().CreatedAt) - - err = client.ImageService().Delete(ctx, name, images.SynchronousDelete()) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testFrontendMetadataReturn(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - res.AddMeta("frontend.returned", []byte("true")) - res.AddMeta("not-frontend.not-returned", []byte("false")) - res.AddMeta("frontendnot.returned.either", []byte("false")) - return res, nil - } - - var exports []ExportEntry - if workers.IsTestDockerdMoby(sb) { - exports = []ExportEntry{{ - Type: "moby", - Attrs: map[string]string{ - "name": "reg.dummy:5000/buildkit/test:latest", - }, - }} - } else { - exports = []ExportEntry{{ - Type: ExporterOCI, - Attrs: map[string]string{}, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: io.Discard}), - }} - } - - res, err := c.Build(sb.Context(), SolveOpt{ - Exports: exports, - }, "", frontend, nil) - require.NoError(t, err) - require.Contains(t, res.ExporterResponse, "frontend.returned") - require.Equal(t, "true", res.ExporterResponse["frontend.returned"]) - require.NotContains(t, res.ExporterResponse, "not-frontend.not-returned") - require.NotContains(t, res.ExporterResponse, "frontendnot.returned.either") - checkAllReleasable(t, c, sb, true) -} - -func testFrontendUseSolveResults(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File( - llb.Mkfile("foo", 0600, []byte("data")), - ) - - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - res, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := res.SingleRef() - if err != nil { - return nil, err - } - - st2, err := ref.ToState() - if err != nil { - return nil, err - } - - st = llb.Scratch().File( - llb.Copy(st2, "foo", "foo2"), - ) - - def, err = st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - } - - destDir := t.TempDir() - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo2")) - require.NoError(t, err) - require.Equal(t, dt, []byte("data")) -} - -func testExporterTargetExists(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - st := llb.Image(imgName) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - var mdDgst string - res, err := c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: map[string]string{}, - Output: func(m map[string]string) (io.WriteCloser, error) { - mdDgst = m[exptypes.ExporterImageDigestKey] - return nil, nil - }, - }, - }, - }, nil) - require.NoError(t, err) - dgst := res.ExporterResponse[exptypes.ExporterImageDigestKey] - - require.True(t, strings.HasPrefix(dgst, "sha256:")) - require.Equal(t, dgst, mdDgst) - - require.True(t, strings.HasPrefix(res.ExporterResponse[exptypes.ExporterImageConfigDigestKey], "sha256:")) -} - -func testTarExporterWithSocket(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - alpine := llb.Image("docker.io/library/alpine:latest") - def, err := alpine.Run(llb.Args([]string{"sh", "-c", "nc -l -s local:/socket.sock & usleep 100000; kill %1"})).Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Attrs: map[string]string{}, - Output: func(m map[string]string) (io.WriteCloser, error) { - return &iohelper.NopWriteCloser{Writer: io.Discard}, nil - }, - }, - }, - }, nil) - require.NoError(t, err) -} - -func testTarExporterWithSocketCopy(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - alpine := llb.Image("docker.io/library/alpine:latest") - state := alpine.Run(llb.Args([]string{"sh", "-c", "nc -l -s local:/root/socket.sock & usleep 100000; kill %1"})).Root() - - fa := llb.Copy(state, "/root", "/roo2", &llb.CopyInfo{}) - - scratchCopy := llb.Scratch().File(fa) - - def, err := scratchCopy.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -// moby/buildkit#1418 -func testTarExporterSymlink(t *testing.T, sb integration.Sandbox) { - 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) - } - - cmdStr := integration.UnixOrWindows( - `sh -c "echo -n first > foo;ln -s foo bar"`, - `cmd /C "echo first> foo && mklink bar foo"`, - ) - run(cmdStr) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - var buf bytes.Buffer - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), - }, - }, - }, nil) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(buf.Bytes(), false) - require.NoError(t, err) - - item, ok := m["foo"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - newLine := integration.UnixOrWindows("", " \r\n") - require.Equal(t, []byte("first"+newLine), item.Data) - - item, ok = m["bar"] - require.True(t, ok) - require.Equal(t, tar.TypeSymlink, int32(item.Header.Typeflag)) - require.Equal(t, "foo", item.Header.Linkname) -} - -// testBuildExportWithForeignLayer verifies foreign (non-distributable) layer handling during -// image export. propagate=1 preserves foreign media type and URLs; propagate=0 converts to -// regular layers. Skipped on Windows: the test image is Linux-only and BuildKit's Windows -// exporter does not preserve foreign layer media types. -func testBuildExportWithForeignLayer(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows", "test image is Linux-only and BuildKit's Windows exporter strips foreign layer media types") - workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("cpuguy83/buildkit-foreign:latest") - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - t.Run("propagate=1", func(t *testing.T) { - registry, err := sb.NewRegistry() - if errors.Is(err, integration.ErrRequirements) { - t.Skip(err.Error()) - } - require.NoError(t, err) - - target := registry + "/buildkit/build/exporter/foreign:latest" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "oci-mediatypes": "false", - "prefer-nondist-layers": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - resolver := docker.NewResolver(docker.ResolverOptions{PlainHTTP: true}) - name, desc, err := resolver.Resolve(ctx, target) - require.NoError(t, err) - - fetcher, err := resolver.Fetcher(ctx, name) - require.NoError(t, err) - mfst, err := images.Manifest(ctx, contentutil.FromFetcher(fetcher), desc, platforms.Any()) - require.NoError(t, err) - - require.Equal(t, 2, len(mfst.Layers)) - require.Equal(t, images.MediaTypeDockerSchema2LayerForeign, mfst.Layers[0].MediaType) - require.Len(t, mfst.Layers[0].URLs, 1) - require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[1].MediaType) - - rc, err := fetcher.Fetch(ctx, ocispecs.Descriptor{Digest: mfst.Layers[0].Digest, Size: mfst.Layers[0].Size}) - require.NoError(t, err) - defer rc.Close() - - // `Fetch` doesn't error (in the docker resolver), it just returns a reader immediately and does not make a request. - // The request is only made when we attempt to read from the reader. - buf := make([]byte, 1) - _, err = rc.Read(buf) - require.Truef(t, cerrdefs.IsNotFound(err), "expected error for blob that should not be in registry: %s, %v", mfst.Layers[0].Digest, err) - }) - t.Run("propagate=0", func(t *testing.T) { - registry, err := sb.NewRegistry() - if errors.Is(err, integration.ErrRequirements) { - t.Skip(err.Error()) - } - require.NoError(t, err) - target := registry + "/buildkit/build/exporter/noforeign:latest" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "oci-mediatypes": "false", - }, - }, - }, - }, nil) - require.NoError(t, err) - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - resolver := docker.NewResolver(docker.ResolverOptions{PlainHTTP: true}) - name, desc, err := resolver.Resolve(ctx, target) - require.NoError(t, err) - - fetcher, err := resolver.Fetcher(ctx, name) - require.NoError(t, err) - - mfst, err := images.Manifest(ctx, contentutil.FromFetcher(fetcher), desc, platforms.Any()) - require.NoError(t, err) - - require.Equal(t, 2, len(mfst.Layers)) - require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[0].MediaType) - require.Len(t, mfst.Layers[0].URLs, 0) - require.Equal(t, images.MediaTypeDockerSchema2Layer, mfst.Layers[1].MediaType) - - rc, err := fetcher.Fetch(ctx, ocispecs.Descriptor{Digest: mfst.Layers[0].Digest, Size: mfst.Layers[0].Size}) - require.NoError(t, err) - defer rc.Close() - - // `Fetch` doesn't error (in the docker resolver), it just returns a reader immediately and does not make a request. - // The request is only made when we attempt to read from the reader. - buf := make([]byte, 1) - _, err = rc.Read(buf) - require.NoError(t, err) - }) -} - -func testBuildExportWithUncompressed(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - cmd := `sh -e -c "echo -n uncompressed > data"` - - st := llb.Scratch() - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - - 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) - - target := registry + "/buildkit/build/exporter:withnocompressed" - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "compression": "uncompressed", - }, - }, - }, - }, 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() - - img, err := client.GetImage(ctx, target) - require.NoError(t, err) - mfst, err := images.Manifest(ctx, client.ContentStore(), img.Target(), nil) - require.NoError(t, err) - require.Equal(t, 1, len(mfst.Layers)) - require.Equal(t, ocispecs.MediaTypeImageLayer, mfst.Layers[0].MediaType) - } - - // new layer with gzip compression - targetImg := llb.Image(target) - cmd = `sh -e -c "echo -n gzip > data"` - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", targetImg) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - compressedTarget := registry + "/buildkit/build/exporter:withcompressed" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": compressedTarget, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - allCompressedTarget := registry + "/buildkit/build/exporter:withallcompressed" - _, err = c.Solve(context.TODO(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": allCompressedTarget, - "push": "true", - "compression": "gzip", - "force-compression": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - if cdAddress == "" { - t.Skip("rest of test requires containerd worker") - } - - err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) - require.NoError(t, err) - err = client.ImageService().Delete(ctx, compressedTarget, images.SynchronousDelete()) - require.NoError(t, err) - err = client.ImageService().Delete(ctx, allCompressedTarget, images.SynchronousDelete()) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) - - // check if the new layer is compressed with compression option - img, err := client.Pull(ctx, compressedTarget) - require.NoError(t, err) - - dt, err := content.ReadBlob(ctx, img.ContentStore(), img.Target()) - require.NoError(t, err) - - mfst := struct { - MediaType string `json:"mediaType,omitempty"` - ocispecs.Manifest - }{} - - err = json.Unmarshal(dt, &mfst) - require.NoError(t, err) - require.Equal(t, 2, len(mfst.Layers)) - require.Equal(t, ocispecs.MediaTypeImageLayer, mfst.Layers[0].MediaType) - require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - item, ok := m["data"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("uncompressed"), item.Data) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[1].Digest}) - require.NoError(t, err) - - m, err = testutil.ReadTarToMap(dt, true) - require.NoError(t, err) - - item, ok = m["data"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("gzip"), item.Data) - - err = client.ImageService().Delete(ctx, compressedTarget, images.SynchronousDelete()) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) - - // check if all layers are compressed with force-compressoin option - img, err = client.Pull(ctx, allCompressedTarget) - require.NoError(t, err) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), img.Target()) - require.NoError(t, err) - - mfst = struct { - MediaType string `json:"mediaType,omitempty"` - ocispecs.Manifest - }{} - - err = json.Unmarshal(dt, &mfst) - require.NoError(t, err) - require.Equal(t, 2, len(mfst.Layers)) - require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[0].MediaType) - require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) - require.NoError(t, err) - - m, err = testutil.ReadTarToMap(dt, true) - require.NoError(t, err) - - item, ok = m["data"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("uncompressed"), item.Data) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[1].Digest}) - require.NoError(t, err) - - m, err = testutil.ReadTarToMap(dt, true) - require.NoError(t, err) - - item, ok = m["data"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("gzip"), item.Data) -} - -// testBuildExportZstd verifies OCI export with Zstd-compressed layers. -// It builds a simple layer, exports it with Zstd compression, and validates both -// OCI and Docker schema 2 media types. -func testBuildExportZstd(t *testing.T, sb integration.Sandbox) { - // Skipped on Windows: - // 1. AddMount() fails because Windows snapshots allow only a single mount per layer. - // 2. OCI export fails because windowsLcowDiff lacks the Compare method needed to - // generate compressed layer diffs. - integration.SkipOnPlatform(t, "windows", "Windows container support incomplete: AddMount() fails with 'number of mounts should always be 1 for Windows layers', OCI export fails with 'windowsLcowDiff does not implement Compare method'") - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - cmd := `sh -e -c "echo -n zstd > data"` - - st := llb.Scratch() - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - 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), - Attrs: map[string]string{ - "compression": "zstd", - }, - }, - }, - // compression option should work even with inline cache exports - CacheExports: []CacheOptionsEntry{ - { - Type: "inline", - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - 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) - - lastLayer := mfst.Layers[len(mfst.Layers)-1] - require.Equal(t, ocispecs.MediaTypeImageLayerZstd, lastLayer.MediaType) - - zstdLayerDigest := lastLayer.Digest.Hex() - require.Equal(t, []byte{0x28, 0xb5, 0x2f, 0xfd}, m[ocispecs.ImageBlobsDir+"/sha256/"+zstdLayerDigest].Data[:4]) - - // repeat without oci mediatype - outW, err = os.Create(out) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Output: fixedWriteCloser(outW), - Attrs: map[string]string{ - "compression": "zstd", - "oci-mediatypes": "false", - }, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(out) - 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, &mfst) - require.NoError(t, err) - - lastLayer = mfst.Layers[len(mfst.Layers)-1] - require.Equal(t, images.MediaTypeDockerSchema2LayerZstd, lastLayer.MediaType) - - require.Equal(t, lastLayer.Digest.Hex(), zstdLayerDigest) -} - -// testPullZstdImage verifies pulling and re-exporting a Zstd-compressed image. -// It pushes a Zstd-compressed image to a registry, pulls it back, and validates -// the layer media types and Zstd magic bytes for both OCI and Docker schema 2. -func testPullZstdImage(t *testing.T, sb integration.Sandbox) { - // Skipped on Windows: - // 1. AddMount() fails because Windows snapshots allow only a single mount per layer. - // 2. Zstd-compressed push/export fails because windowsLcowDiff lacks the Compare - // method needed to generate compressed layer diffs. - integration.SkipOnPlatform(t, "windows", "Windows container support incomplete: AddMount() fails with 'number of mounts should always be 1 for Windows layers', Zstd export fails with 'windowsLcowDiff does not implement Compare method'") - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - for _, ociMediaTypes := range []bool{true, false} { - t.Run(t.Name()+fmt.Sprintf("/ociMediaTypes=%t", ociMediaTypes), func(t *testing.T) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - cmd := `sh -e -c "echo -n zstd > data"` - - st := llb.Scratch() - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - - 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) - - target := registry + "/buildkit/build/exporter:zstd" - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "compression": "zstd", - "oci-mediatypes": strconv.FormatBool(ociMediaTypes), - }, - }, - }, - }, nil) - require.NoError(t, err) - - ensurePruneAll(t, c, sb) - - st = llb.Image(target).File(llb.Copy(llb.Image(target), "/data", "/zdata")) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - 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), - Attrs: map[string]string{ - "oci-mediatypes": strconv.FormatBool(ociMediaTypes), - }, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - 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) - - firstLayer := mfst.Layers[0] - if ociMediaTypes { - require.Equal(t, ocispecs.MediaTypeImageLayerZstd, firstLayer.MediaType) - } else { - require.Equal(t, images.MediaTypeDockerSchema2LayerZstd, firstLayer.MediaType) - } - - zstdLayerDigest := firstLayer.Digest.Hex() - require.Equal(t, []byte{0x28, 0xb5, 0x2f, 0xfd}, m[ocispecs.ImageBlobsDir+"/sha256/"+zstdLayerDigest].Data[:4]) - }) - } -} - -func testBuildPushAndValidate(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -e -c "mkdir -p foo/sub; echo -n first > foo/sub/bar; chmod 0741 foo;"`) - run(`true`) // this doesn't create a layer - run(`sh -c "echo -n second > foo/sub/baz"`) - - 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) - - target := registry + "/buildkit/testpush:latest" - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - // test existence of the image with next build - firstBuild := llb.Image(target) - - def, err = firstBuild.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "foo/sub/bar")) - require.NoError(t, err) - require.Equal(t, dt, []byte("first")) - - dt, err = os.ReadFile(filepath.Join(destDir, "foo/sub/baz")) - require.NoError(t, err) - require.Equal(t, dt, []byte("second")) - - fi, err := os.Stat(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, 0741, int(fi.Mode()&0777)) - - checkAllReleasable(t, c, sb, false) - - // examine contents of exported tars (requires containerd) - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.Skip("rest of test requires containerd worker") - } - - // TODO: make public pull helper function so this can be checked for standalone as well - - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - // check image in containerd - _, err = client.ImageService().Get(ctx, target) - require.NoError(t, err) - - // deleting image should release all content - err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) - - img, err := client.Pull(ctx, target) - require.NoError(t, err) - - desc, err := img.Config(ctx) - require.NoError(t, err) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), desc) - require.NoError(t, err) - - var ociimg ocispecs.Image - err = json.Unmarshal(dt, &ociimg) - require.NoError(t, err) - - require.NotEqual(t, "", ociimg.OS) - require.NotEqual(t, "", ociimg.Architecture) - require.NotEqual(t, "", ociimg.Config.WorkingDir) - require.Equal(t, "layers", ociimg.RootFS.Type) - require.Equal(t, 3, len(ociimg.RootFS.DiffIDs)) - require.NotNil(t, ociimg.Created) - require.Less(t, time.Since(*ociimg.Created), 2*time.Minute) - require.Condition(t, func() bool { - for _, env := range ociimg.Config.Env { - if strings.HasPrefix(env, "PATH=") { - return true - } - } - return false - }) - - require.Equal(t, 3, len(ociimg.History)) - require.Contains(t, ociimg.History[0].CreatedBy, "foo/sub/bar") - require.Contains(t, ociimg.History[1].CreatedBy, "true") - require.Contains(t, ociimg.History[2].CreatedBy, "foo/sub/baz") - require.False(t, ociimg.History[0].EmptyLayer) - require.False(t, ociimg.History[1].EmptyLayer) - require.False(t, ociimg.History[2].EmptyLayer) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), img.Target()) - require.NoError(t, err) - - mfst := struct { - MediaType string `json:"mediaType,omitempty"` - ocispecs.Manifest - }{} - - err = json.Unmarshal(dt, &mfst) - require.NoError(t, err) - - require.Equal(t, ocispecs.MediaTypeImageManifest, mfst.MediaType) - require.Equal(t, 3, len(mfst.Layers)) - require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[0].MediaType) - require.Equal(t, ocispecs.MediaTypeImageLayerGzip, mfst.Layers[1].MediaType) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[0].Digest}) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, true) - require.NoError(t, err) - - item, ok := m["foo/"] - require.True(t, ok) - require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) - require.Equal(t, 0741, int(item.Header.Mode&0777)) - - item, ok = m["foo/sub/"] - require.True(t, ok) - require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) - - item, ok = m["foo/sub/bar"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("first"), item.Data) - - _, ok = m["foo/sub/baz"] - require.False(t, ok) - - dt, err = content.ReadBlob(ctx, img.ContentStore(), ocispecs.Descriptor{Digest: mfst.Layers[2].Digest}) - require.NoError(t, err) - - m, err = testutil.ReadTarToMap(dt, true) - require.NoError(t, err) - - item, ok = m["foo/sub/baz"] - require.True(t, ok) - require.Equal(t, tar.TypeReg, int32(item.Header.Typeflag)) - require.Equal(t, []byte("second"), item.Data) - - item, ok = m["foo/"] - require.True(t, ok) - require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) - require.Equal(t, 0741, int(item.Header.Mode&0777)) - - item, ok = m["foo/sub/"] - require.True(t, ok) - require.Equal(t, tar.TypeDir, int32(item.Header.Typeflag)) - - _, ok = m["foo/sub/bar"] - require.False(t, ok) -} - -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) -} - -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 testStargzLazyPull(t *testing.T, sb integration.Sandbox) { - 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 image - orgImage := "docker.io/library/alpine:latest" - sgzImage := registry + "/stargz/alpine:" + identity.NewID() - def, err := llb.Image(orgImage).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", - }, - }, - }, - }, 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 = llb.Image(sgzImage). - 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", - "oci-mediatypes": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "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 _, layer := range manifest.Layers[:len(manifest.Layers)-1] { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - 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", - }, - }, - }, - }, 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 - 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), - }, - }, - }, 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 testLazyImagePush(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.Skip("test requires containerd worker") - } - - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - 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() - - // push the busybox image to the mutable registry - sourceImage := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - def, err := llb.Image(sourceImage).Marshal(sb.Context()) - require.NoError(t, err) - - targetNoTag := registry + "/buildkit/testlazyimage:" - target := targetNoTag + "latest" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - imageService := client.ImageService() - contentStore := client.ContentStore() - - img, err := imageService.Get(ctx, target) - require.NoError(t, err) - - manifest, err := images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.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) - - // retag the image we just pushed with no actual changes, which - // should not result in the image getting un-lazied - def, err = llb.Image(target).Marshal(sb.Context()) - require.NoError(t, err) - - target2 := targetNoTag + "newtag" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target2, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - img, err = imageService.Get(ctx, target2) - require.NoError(t, err) - - manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - // clear all local state out again - err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) - - // try a cross-repo push to same registry, which should still result in the - // image remaining lazy - target3 := registry + "/buildkit/testlazycrossrepo:latest" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target3, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - img, err = imageService.Get(ctx, target3) - require.NoError(t, err) - - manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - // check that a subsequent build can use the previously lazy image in an exec - cmdStr := integration.UnixOrWindows("true", "cmd /C exit 0") - def, err = llb.Image(target2).Run(llb.Args([]string{cmdStr})).Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -// 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 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 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}) -} - -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}) -} - -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}) -} - -// 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 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 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 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 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}) -} - -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", - } - - s3Addr, s3Bucket, cleanup, err := helpers.NewMinioServer(t, sb, opts) - require.NoError(t, err) - defer cleanup() - - im := CacheOptionsEntry{ - Type: "s3", - Attrs: map[string]string{ - "region": opts.Region, - "access_key_id": opts.AccessKeyID, - "secret_access_key": opts.SecretAccessKey, - "bucket": s3Bucket, - "endpoint_url": s3Addr, - "use_path_style": "true", - }, - } - ex := CacheOptionsEntry{ - Type: "s3", - Attrs: map[string]string{ - "region": opts.Region, - "access_key_id": opts.AccessKeyID, - "secret_access_key": opts.SecretAccessKey, - "bucket": s3Bucket, - "endpoint_url": s3Addr, - "use_path_style": "true", - }, - } - testBasicCacheImportExport(t, sb, []CacheOptionsEntry{im}, []CacheOptionsEntry{ex}) -} - -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 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 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) - } - }) - } - } -} - -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) -} - -// 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 testExportLocalNoPlatformSplit(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - platformsToTest := []string{"linux/amd64", "linux/arm64"} - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.Scratch().File( - llb.Mkfile("hello-"+strings.ReplaceAll(platform, "/", "-"), 0600, []byte(platform)), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - destDir := t.TempDir() - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - Attrs: map[string]string{ - "platform-split": "false", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "hello-linux-amd64")) - require.NoError(t, err) - require.Equal(t, "linux/amd64", string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "hello-linux-arm64")) - require.NoError(t, err) - require.Equal(t, "linux/arm64", string(dt)) -} - -func testExportLocalNoPlatformSplitOverwrite(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - platformsToTest := []string{"linux/amd64", "linux/arm64"} - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.Scratch().File( - llb.Mkfile("hello-linux", 0600, []byte(platform)), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - destDir := t.TempDir() - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - Attrs: map[string]string{ - "platform-split": "false", - }, - }, - }, - }, "", frontend, nil) - require.Error(t, err) - require.ErrorContains(t, err, "cannot overwrite hello-linux from") - require.ErrorContains(t, err, "when split option is disabled") -} - -func testExportLocalForcePlatformSplit(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File( - llb.Mkfile("foo", 0600, []byte("hello")), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - } - - destDir := t.TempDir() - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - Attrs: map[string]string{ - "platform-split": "true", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - fis, err := os.ReadDir(destDir) - require.NoError(t, err) - - require.Len(t, fis, 1, "expected one files in the output directory") - - expPlatform := strings.ReplaceAll(platforms.FormatAll(platforms.Normalize(platforms.DefaultSpec())), "/", "_") - _, err = os.Stat(filepath.Join(destDir, expPlatform+"/")) - require.NoError(t, err) - - fis, err = os.ReadDir(filepath.Join(destDir, expPlatform)) - require.NoError(t, err) - - require.Len(t, fis, 1, "expected one files in the output directory for platform "+expPlatform) - - dt, err := os.ReadFile(filepath.Join(destDir, expPlatform, "foo")) - require.NoError(t, err) - require.Equal(t, "hello", string(dt)) -} - -func testExportTarPlatformIDSanitized(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - const platformID = `..\buildkit-outside` - platform := platforms.DefaultSpec() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File( - llb.Mkfile("payload.txt", 0600, []byte("payload")), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.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.AddRef(platformID, ref) - - dt, err := json.Marshal(&exptypes.Platforms{ - Platforms: []exptypes.Platform{{ - ID: platformID, - Platform: platform, - }}, - }) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - outW := bytes.NewBuffer(nil) - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(outW.Bytes(), false) - require.NoError(t, err) - - for name := range m { - require.Falsef(t, strings.HasPrefix(name, "../") || - strings.HasPrefix(name, `..\`) || - strings.Contains(name, `/../`) || - strings.Contains(name, `\..\`) || - strings.Contains(name, `\`) || - strings.Contains(name, ":"), - "tar exporter emitted unsafe platform path %q", name) - } - - tarPaths := make([]string, 0, len(m)) - for name := range m { - tarPaths = append(tarPaths, name) - } - sort.Strings(tarPaths) - - expectedPath := path.Join(".._buildkit-outside", integration.UnixOrWindows("payload.txt", "Files/payload.txt")) - item := m[expectedPath] - require.NotNilf(t, item, "expected sanitized tar path %q in %v", expectedPath, tarPaths) - require.Equal(t, "payload", string(item.Data)) -} - -func testExportLocalModeCopyKeepsStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch().File( - llb.Mkfile("fresh.txt", 0600, []byte("fresh")), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) - require.NoError(t, err) - err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) - require.NoError(t, err) - require.Equal(t, "fresh", string(dt)) - - _, err = os.Stat(filepath.Join(destDir, "stale.txt")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(destDir, "stale-dir", "old.txt")) - require.NoError(t, err) -} - -func testExportLocalModeDeleteRemovesStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch().File( - llb.Mkfile("fresh.txt", 0600, []byte("fresh")), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) - require.NoError(t, err) - err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - Attrs: map[string]string{ - "mode": "delete", - }, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) - require.NoError(t, err) - require.Equal(t, "fresh", string(dt)) - - _, err = os.Stat(filepath.Join(destDir, "stale.txt")) - require.ErrorIs(t, err, os.ErrNotExist) - _, err = os.Stat(filepath.Join(destDir, "stale-dir")) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func testExportLocalModeCopyMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { - testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, false) -} - -func testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { - testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, true) -} - -func testExportLocalModeMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox, deleteMode bool) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - const filesPerPlatform = 20 - platformsToTest := []string{"linux/amd64", "linux/arm64", "linux/arm/v7", "linux/s390x"} - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.Scratch() - for j := range filesPerPlatform { - st = st.File( - llb.Mkfile(fmt.Sprintf("file-%03d.txt", j), 0600, fmt.Appendf(nil, "%s-%d", platform, j)), - ) - } - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - destDir := t.TempDir() - - // Pre-populate dest with directories matching the platform-split naming - // convention. Delete mode must remove stale files from all platform - // directories while preserving every platform's fresh output. - err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) - require.NoError(t, err) - for _, platform := range platformsToTest { - platDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) - err = os.MkdirAll(platDir, 0755) - require.NoError(t, err) - for j := range filesPerPlatform { - err = os.WriteFile(filepath.Join(platDir, fmt.Sprintf("old-%03d.txt", j)), []byte("old"), 0600) - require.NoError(t, err) - } - } - - attrs := map[string]string{} - if deleteMode { - attrs["mode"] = "delete" - } - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - Attrs: attrs, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - if deleteMode { - // Stale top-level file must be gone. - _, err = os.Stat(filepath.Join(destDir, "stale.txt")) - require.ErrorIs(t, err, os.ErrNotExist) - } - - // Every platform's build output must survive (no cross-platform deletion). - for _, platform := range platformsToTest { - platformDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) - for j := range filesPerPlatform { - dt, err := os.ReadFile(filepath.Join(platformDir, fmt.Sprintf("file-%03d.txt", j))) - require.NoError(t, err, "missing build output file-%03d.txt for %s", j, platform) - require.Equal(t, fmt.Sprintf("%s-%d", platform, j), string(dt)) - } - if deleteMode { - // Pre-existing files within each platform dir must be cleaned up. - _, err = os.Stat(filepath.Join(platformDir, "old-000.txt")) - require.ErrorIs(t, err, os.ErrNotExist, "stale file not removed for %s", platform) - } - } -} - -func testExportLocalModeInvalid(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch().File( - llb.Mkfile("fresh.txt", 0600, []byte("fresh")), - ) - 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, - Attrs: map[string]string{ - "mode": "backup", - }, - }, - }, - }, nil) - require.Error(t, err) - require.ErrorContains(t, err, `invalid local exporter mode "backup"`) -} - -func readFileInImage(ctx context.Context, t *testing.T, c *Client, ref, path string) ([]byte, error) { - def, err := llb.Image(ref).Marshal(ctx) - if err != nil { - return nil, err - } - destDir := t.TempDir() - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - if err != nil { - return nil, err - } - return os.ReadFile(filepath.Join(destDir, filepath.Clean(path))) -} - -func testCachedMounts(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - scratch := func() llb.State { - return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) - } - - busybox := llb.Image(imgName) - // setup base for one of the cache sources - cmdPrefix := integration.UnixOrWindows( - `sh -c "echo -n`, - `cmd /C "echo`, - ) - st := busybox.Run(llb.Shlexf(`%s base > baz"`, cmdPrefix), llb.Dir("/wd")) - base := st.AddMount("/wd", scratch()) - - st = busybox.Run(llb.Shlexf(`%s first > foo"`, cmdPrefix), llb.Dir("/wd")) - - st.AddMount("/wd", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - cmdStr := integration.UnixOrWindows( - `sh -c "cat foo && echo -n second > /wd2/bar"`, - `cmd /C type foo && echo second > C:\\wd2\\bar`, - ) - st = st.Run(llb.Shlex(cmdStr), llb.Dir("/wd")) - st.AddMount("/wd", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - st.AddMount("/wd2", base, llb.AsPersistentCacheDir("mycache2", llb.CacheMountShared)) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // repeat to make sure cache works - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // second build using cache directories - cmdStr = integration.UnixOrWindows( - `sh -c "cp /src0/foo . && cp /src1/bar . && cp /src1/baz ."`, - `cmd /C copy C:\\src0\\foo . && copy C:\\src1\\bar . && copy C:\\src1\\baz .`, - ) - st = busybox.Run(llb.Shlex(cmdStr), llb.Dir("/wd")) - out := st.AddMount("/wd", scratch()) - st.AddMount("/src0", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - st.AddMount("/src1", base, llb.AsPersistentCacheDir("mycache2", llb.CacheMountShared)) - - destDir := t.TempDir() - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - newLine := integration.UnixOrWindows("", " \r\n") - dt, err := os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, "first"+newLine, string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "bar")) - require.NoError(t, err) - require.Equal(t, "second"+newLine, string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "baz")) - require.NoError(t, err) - require.Equal(t, "base"+newLine, string(dt)) - - checkAllReleasable(t, c, sb, true) -} - -func testSharedCacheMounts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := busybox.Run(llb.Shlex(`sh -e -c "touch one; while [[ ! -f two ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) - st.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - - st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; while [[ ! -f one ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) - st2.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - - out := busybox.Run(llb.Shlex("true")) - out.AddMount("/m1", st.Root()) - out.AddMount("/m2", st2.Root()) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -// #2334 -func testSharedCacheMountsNoScratch(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := busybox.Run(llb.Shlex(`sh -e -c "touch one; while [[ ! -f two ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) - st.AddMount("/wd", llb.Image("busybox:latest"), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - - st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; while [[ ! -f one ]]; do ls -l; usleep 500000; done"`), llb.Dir("/wd")) - st2.AddMount("/wd", llb.Image("busybox:latest"), llb.AsPersistentCacheDir("mycache1", llb.CacheMountShared)) - - out := busybox.Run(llb.Shlex("true")) - out.AddMount("/m1", st.Root()) - out.AddMount("/m2", st2.Root()) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testLockedCacheMounts(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := busybox.Run(llb.Shlex(`sh -e -c "touch one; if [[ -f two ]]; then exit 0; fi; for i in $(seq 10); do if [[ -f two ]]; then exit 1; fi; usleep 200000; done"`), llb.Dir("/wd")) - st.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - - st2 := busybox.Run(llb.Shlex(`sh -e -c "touch two; if [[ -f one ]]; then exit 0; fi; for i in $(seq 10); do if [[ -f one ]]; then exit 1; fi; usleep 200000; done"`), llb.Dir("/wd")) - st2.AddMount("/wd", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - - out := busybox.Run(llb.Shlex("true")) - out.AddMount("/m1", st.Root()) - out.AddMount("/m2", st2.Root()) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testDuplicateCacheMount(t *testing.T, sb integration.Sandbox) { - 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) - scratch := func() llb.State { - return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) - } - - cmdStr := integration.UnixOrWindows( - `sh -e -c "[[ ! -f /m2/foo ]]; touch /m1/foo; [[ -f /m2/foo ]];"`, - `cmd /C echo a > \\m1\\foo && dir \\m2\\foo`, - ) - out := busybox.Run(llb.Shlex(cmdStr)) - out.AddMount("/m1", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - out.AddMount("/m2", scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testRunCacheWithMounts(t *testing.T, sb integration.Sandbox) { - 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) - - imgAlpine := integration.UnixOrWindows("alpine:latest", "nanoserver:plus") - alpineImage := llb.Image(imgAlpine) - - // On Windows ARM64, nanoserver:plus and nanoserver:latest map to the same - // image, so we create a marker file to distinguish the mounted image. - mountSource := alpineImage - if runtime.GOOS == "windows" { - mountSource = alpineImage.Run( - llb.Shlex(`cmd /C echo 1> C:/marker`), - ).Root() - } - - cmdStr := integration.UnixOrWindows( - `sh -e -c "[[ -f /m1/sbin/apk ]]"`, - `cmd /C if exist C:/m1/marker (exit 0) else (exit 1)`, - ) - out := busybox.Run(llb.Shlex(cmdStr)) - out.AddMount("/m1", mountSource, llb.Readonly) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - cmdStr = integration.UnixOrWindows( - `sh -e -c "[[ ! -f /m1/sbin/apk ]]"`, - `cmd /C if exist C:/m1/marker (exit 1) else (exit 0)`, - ) - out = busybox.Run(llb.Shlex(cmdStr)) - out.AddMount("/m1", llb.Image(imgName), llb.Readonly) - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testCacheMountNoCache(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - - out := busybox.Run(llb.Shlex(`sh -e -c "touch /m1/foo; touch /m2/bar"`)) - out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - out.AddMount("/m2", llb.Scratch(), llb.AsPersistentCacheDir("mycache2", llb.CacheMountLocked)) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - out = busybox.Run(llb.Shlex(`sh -e -c "[[ ! -f /m1/foo ]]; touch /m1/foo2;"`), llb.IgnoreCache) - out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - out = busybox.Run(llb.Shlex(`sh -e -c "[[ -f /m1/foo2 ]]; [[ -f /m2/bar ]];"`)) - out.AddMount("/m1", llb.Scratch(), llb.AsPersistentCacheDir("mycache1", llb.CacheMountLocked)) - out.AddMount("/m2", llb.Scratch(), llb.AsPersistentCacheDir("mycache2", llb.CacheMountLocked)) - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testCopyFromEmptyImage(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // On Windows, the error messages are different for the Scratch image - // (which is coming from the OS). While the one for the no-layers image - // is being returned by Buildkit (in unix style). - winErrMsgs := []string{ - "foo: The system cannot find the file specified", // for llb.Scratch() - "/foo: no such file or directory", // for tonistiigi/test:nolayers - } - - for i, image := range []llb.State{llb.Scratch(), llb.Image("tonistiigi/test:nolayers")} { - st := llb.Scratch().File(llb.Copy(image, "/", "/")) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - st = llb.Scratch().File(llb.Copy(image, "/foo", "/")) - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - errMsg := integration.UnixOrWindows( - "/foo: no such file or directory", - winErrMsgs[i], - ) - require.Contains(t, err.Error(), errMsg) - - imgName := integration.UnixOrWindows( - "busybox:latest", - "nanoserver:latest", - ) - busybox := llb.Image(imgName) - - cmdStr := integration.UnixOrWindows( - `sh -e -c '[ $(ls /scratch | wc -l) = '0' ]'`, - `cmd /C dir \scratch`, - ) - out := busybox.Run(llb.Shlex(cmdStr)) - out.AddMount("/scratch", image, llb.Readonly) - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - } -} - -// containerd/containerd#2119 -func testDuplicateWhiteouts(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -e -c "mkdir -p d0 d1; echo -n first > d1/bar;"`) - run(`sh -c "rm -rf d0 d1"`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - 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), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - 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) - - lastLayer := mfst.Layers[len(mfst.Layers)-1] - - layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] - require.True(t, ok) - - m, err = testutil.ReadTarToMap(layer.Data, true) - require.NoError(t, err) - - _, ok = m[".wh.d0"] - require.True(t, ok) - - _, ok = m[".wh.d1"] - require.True(t, ok) - - // check for a bug that added whiteout for subfile - _, ok = m["d1/.wh.bar"] - require.True(t, !ok) -} - -// #276 -func testWhiteoutParentDir(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("busybox:latest") - st := llb.Scratch() - - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "mkdir -p foo; echo -n first > foo/bar;"`) - run(`rm foo/bar`) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - 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), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - 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) - - lastLayer := mfst.Layers[len(mfst.Layers)-1] - - layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] - require.True(t, ok) - - m, err = testutil.ReadTarToMap(layer.Data, true) - require.NoError(t, err) - - _, ok = m["foo/.wh.bar"] - require.True(t, ok) - - _, ok = m["foo/"] - require.True(t, ok) -} - -// #2490 -func testMoveParentDir(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - 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 := llb.Scratch() - - switch imgName { - case "nanoserver:latest": - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).Root() - } - - run(`cmd /c "mkdir foo && echo first > foo/bar && move foo foo2"`) - case "busybox:latest": - run := func(cmd string) { - st = busybox.Run(llb.Shlex(cmd), llb.Dir("/wd")).AddMount("/wd", st) - } - - run(`sh -c "mkdir -p foo; echo -n first > foo/bar;"`) - run(`mv foo foo2`) - } - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - 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), - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - 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) - - lastLayer := mfst.Layers[len(mfst.Layers)-1] - - layer, ok := m[ocispecs.ImageBlobsDir+"/sha256/"+lastLayer.Digest.Hex()] - require.True(t, ok) - - m, err = testutil.ReadTarToMap(layer.Data, true) - require.NoError(t, err) - - switch imgName { - case "nanoserver:latest": - _, ok = m["/wd/foo/bar"] // Should be false, else move didn't happen - require.False(t, ok) - - _, ok = m["Files/wd/foo/bar"] // Should be false, else move didn't happen - require.False(t, ok) - - _, ok = m["Files/wd/foo2/bar"] - require.True(t, ok) - - _, ok = m["Files/wd/foo2"] - require.True(t, ok) - - _, ok = m["Files/wd"] - require.True(t, ok) - - for key := range m { - if err == nil && strings.Contains(key, "/wd") { - ok = true - break - } else { - ok = false - } - } - require.True(t, ok) - - for key := range m { - if err == nil && strings.Contains(key, "/foo2/bar") { - ok = true - break - } else { - ok = false - } - } - require.True(t, ok) - - for key := range m { - if err == nil && strings.Contains(key, "/foo2") { - ok = true - break - } else { - ok = false - } - } - require.True(t, ok) - case "busybox:latest": - _, ok = m[".wh.foo"] - require.True(t, ok) - - _, ok = m["foo2/"] - require.True(t, ok) - - _, ok = m["foo2/bar"] - require.True(t, ok) - } -} - -// #319 -func testMountWithNoSource(t *testing.T, sb integration.Sandbox) { - 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)) - - var nilState llb.State - - // This should never actually be run, but we want to succeed - // if it was, because we expect an error below, or a daemon - // panic if the issue has regressed. - cmdArgs := integration.UnixOrWindows( - []string{"/bin/true"}, - []string{"cmd", "/C", "exit 0"}, - ) - run := busybox.Run( - llb.Args(cmdArgs), - llb.AddMount("/nil", nilState, llb.SourcePath("/"), llb.Readonly)) - - st = run.AddMount("/mnt", st) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// #324 -func testReadonlyRootFS(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - busybox := llb.Image("docker.io/library/busybox:latest") - st := llb.Scratch() - - // The path /foo should be unwriteable. - run := busybox.Run( - llb.ReadonlyRootFS(), - llb.Args([]string{"/bin/touch", "/foo"})) - st = run.AddMount("/mnt", st) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - // Would prefer to detect more specifically "Read-only file - // system" but that isn't exposed here (it is on the stdio - // which we don't see). - require.Contains(t, err.Error(), "process \"/bin/touch /foo\" did not complete successfully") - - checkAllReleasable(t, c, sb, true) -} - -func testSourceMap(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - sm1 := llb.NewSourceMap(nil, "foo", "", []byte("data1")) - sm2 := llb.NewSourceMap(nil, "bar", "", []byte("data2")) - - st := llb.Scratch().Run( - llb.Shlex("not-exist"), - sm1.Location([]*pb.Range{{Start: &pb.Position{Line: 7}}}), - sm2.Location([]*pb.Range{{Start: &pb.Position{Line: 8}}}), - sm1.Location([]*pb.Range{{Start: &pb.Position{Line: 9}}}), - ) - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - - srcs := errdefs.Sources(err) - require.Equal(t, 3, len(srcs)) - - // Source errors are wrapped in the order provided as llb.ConstraintOpts, so - // when they are unwrapped, the first unwrapped error is the last location - // provided. - require.Equal(t, "foo", srcs[0].Info.Filename) - require.Equal(t, []byte("data1"), srcs[0].Info.Data) - require.Nil(t, srcs[0].Info.Definition) - - require.Equal(t, 1, len(srcs[0].Ranges)) - require.Equal(t, int32(9), srcs[0].Ranges[0].Start.Line) - require.Equal(t, int32(0), srcs[0].Ranges[0].Start.Character) - - require.Equal(t, "bar", srcs[1].Info.Filename) - require.Equal(t, []byte("data2"), srcs[1].Info.Data) - require.Nil(t, srcs[1].Info.Definition) - - require.Equal(t, 1, len(srcs[1].Ranges)) - require.Equal(t, int32(8), srcs[1].Ranges[0].Start.Line) - require.Equal(t, int32(0), srcs[1].Ranges[0].Start.Character) - - require.Equal(t, "foo", srcs[2].Info.Filename) - require.Equal(t, []byte("data1"), srcs[2].Info.Data) - require.Nil(t, srcs[2].Info.Definition) - - require.Equal(t, 1, len(srcs[2].Ranges)) - require.Equal(t, int32(7), srcs[2].Ranges[0].Start.Line) - require.Equal(t, int32(0), srcs[2].Ranges[0].Start.Character) -} - -func testSourceMapFromRef(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - srcState := llb.Scratch().File( - llb.Mkfile("foo", 0600, []byte("data"))) - sm := llb.NewSourceMap(&srcState, "bar", "mylang", []byte("bardata")) - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File( - llb.Mkdir("foo/bar", 0600), // fails because /foo doesn't exist - sm.Location([]*pb.Range{{Start: &pb.Position{Line: 3, Character: 1}}}), - ) - - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - res, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := res.SingleRef() - if err != nil { - return nil, err - } - - st2, err := ref.ToState() - if err != nil { - return nil, err - } - - st = llb.Scratch().File( - llb.Copy(st2, "foo", "foo2"), - ) - - def, err = st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - } - - _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) - require.Error(t, err) - - srcs := errdefs.Sources(err) - require.Equal(t, 1, len(srcs)) - - require.Equal(t, "bar", srcs[0].Info.Filename) - require.Equal(t, "mylang", srcs[0].Info.Language) - require.Equal(t, []byte("bardata"), srcs[0].Info.Data) - require.NotNil(t, srcs[0].Info.Definition) - - require.Equal(t, 1, len(srcs[0].Ranges)) - require.Equal(t, int32(3), srcs[0].Ranges[0].Start.Line) - require.Equal(t, int32(1), srcs[0].Ranges[0].Start.Character) -} - -func testRmSymlink(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // Test that if FileOp.Rm is called on a symlink, then - // the symlink is removed rather than the target - type testVars struct { - imgName string - touchCmd string - symlinkCmd string - } - - v := integration.UnixOrWindows( - testVars{ - imgName: "alpine", - touchCmd: "touch /mnt/target", - symlinkCmd: "ln -s target /mnt/link", - }, - testVars{ - imgName: "nanoserver:latest", - touchCmd: "cmd /C type nul > /mnt/target", - symlinkCmd: "cmd /c cd /d c:/mnt && mklink link target", - }, - ) - - mnt := llb.Image(v.imgName). - Run(llb.Shlex(v.touchCmd)). - AddMount("/mnt", llb.Scratch()) - - mnt = llb.Image(v.imgName). - Run(llb.Shlex(v.symlinkCmd)). - AddMount("/mnt", mnt) - - def, err := mnt.File(llb.Rm("link")).Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.CreateFile("target", nil, 0644))) -} - -func testProxyEnv(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - scratch := func() llb.State { - return integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) - } - base := llb.Image(imgName).Dir("/out") - cmd := integration.UnixOrWindows( - `sh -c "echo -n $HTTP_PROXY-$HTTPS_PROXY-$NO_PROXY-$no_proxy-$ALL_PROXY-$all_proxy > env"`, - `cmd /C echo %HTTP_PROXY%-%HTTPS_PROXY%-%NO_PROXY%-%no_proxy%-%ALL_PROXY%-%all_proxy% > env`, - ) - - st := base.Run(llb.Shlex(cmd), llb.WithProxy(llb.ProxyEnv{ - HTTPProxy: "httpvalue", - HTTPSProxy: "httpsvalue", - NoProxy: "noproxyvalue", - AllProxy: "allproxyvalue", - })) - - out := st.AddMount("/out", scratch()) - - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "env")) - require.NoError(t, err) - newLine := integration.UnixOrWindows("", " \r\n") - require.Equal(t, "httpvalue-httpsvalue-noproxyvalue-noproxyvalue-allproxyvalue-allproxyvalue"+newLine, string(dt)) - - // repeat to make sure proxy doesn't change cache - st = base.Run(llb.Shlex(cmd), llb.WithProxy(llb.ProxyEnv{ - HTTPSProxy: "httpsvalue2", - NoProxy: "noproxyvalue2", - })) - out = st.AddMount("/out", scratch()) - - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - - destDir = t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(destDir, "env")) - require.NoError(t, err) - require.Equal(t, "httpvalue-httpsvalue-noproxyvalue-noproxyvalue-allproxyvalue-allproxyvalue"+newLine, string(dt)) -} - -func testMergeOp(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) - requiresLinux(t) - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - ctx := sb.Context() - registry, err := sb.NewRegistry() - if !errors.Is(err, integration.ErrRequirements) { - require.NoError(t, err) - } - - var imageTarget string - if registry != "" { - imageTarget = registry + "/buildkit/testmergeop:latest" - } - - stateA := llb.Scratch(). - File(llb.Mkfile("/foo", 0755, []byte("A"))). - File(llb.Mkfile("/a", 0755, []byte("A"))). - File(llb.Mkdir("/bar", 0700)). - File(llb.Mkfile("/bar/A", 0755, []byte("A"))) - stateB := stateA. - File(llb.Rm("/foo")). - File(llb.Mkfile("/b", 0755, []byte("B"))). - File(llb.Mkfile("/bar/B", 0754, []byte("B"))) - stateC := llb.Scratch(). - File(llb.Mkfile("/foo", 0755, []byte("C"))). - File(llb.Mkfile("/c", 0755, []byte("C"))). - File(llb.Mkdir("/bar", 0755)). - File(llb.Mkfile("/bar/A", 0400, []byte("C"))) - - mergeA := llb.Merge([]llb.State{stateA, stateC}) - requireContents(ctx, t, c, sb, mergeA, nil, nil, imageTarget, - fstest.CreateFile("foo", []byte("C"), 0755), - fstest.CreateFile("c", []byte("C"), 0755), - fstest.CreateDir("bar", 0755), - fstest.CreateFile("bar/A", []byte("C"), 0400), - fstest.CreateFile("a", []byte("A"), 0755), - ) - - mergeB := llb.Merge([]llb.State{stateC, stateB}) - requireContents(ctx, t, c, sb, mergeB, nil, nil, imageTarget, - fstest.CreateFile("a", []byte("A"), 0755), - fstest.CreateFile("b", []byte("B"), 0755), - fstest.CreateFile("c", []byte("C"), 0755), - fstest.CreateDir("bar", 0700), - fstest.CreateFile("bar/A", []byte("A"), 0755), - fstest.CreateFile("bar/B", []byte("B"), 0754), - ) - - stateD := llb.Scratch().File(llb.Mkdir("/qaz", 0755)) - mergeC := llb.Merge([]llb.State{mergeA, mergeB, stateD}) - requireContents(ctx, t, c, sb, mergeC, nil, nil, imageTarget, - fstest.CreateFile("a", []byte("A"), 0755), - fstest.CreateFile("b", []byte("B"), 0755), - fstest.CreateFile("c", []byte("C"), 0755), - fstest.CreateDir("bar", 0700), - fstest.CreateFile("bar/A", []byte("A"), 0755), - fstest.CreateFile("bar/B", []byte("B"), 0754), - fstest.CreateDir("qaz", 0755), - ) - - runA := runShell(llb.Merge([]llb.State{llb.Image("alpine"), mergeC}), - // turn /a file into a dir, mv b and c into it - "rm /a", - "mkdir /a", - "mv /b /c /a/", - // remove+recreate /bar to make it opaque on overlay snapshotters - "rm -rf /bar", - "mkdir -m 0755 /bar", - "echo -n D > /bar/D", - // turn /qaz dir into a file - "rm -rf /qaz", - "touch /qaz", - ) - stateE := llb.Scratch(). - File(llb.Mkfile("/foo", 0755, []byte("E"))). - File(llb.Mkdir("/bar", 0755)). - File(llb.Mkfile("/bar/A", 0755, []byte("A"))). - File(llb.Mkfile("/bar/E", 0755, nil)) - mergeD := llb.Merge([]llb.State{stateE, runA}) - requireEqualContents(ctx, t, c, mergeD, llb.Image("alpine"). - File(llb.Mkdir("a", 0755)). - File(llb.Mkfile("a/b", 0755, []byte("B"))). - File(llb.Mkfile("a/c", 0755, []byte("C"))). - File(llb.Mkdir("bar", 0755)). - File(llb.Mkfile("bar/D", 0644, []byte("D"))). - File(llb.Mkfile("bar/E", 0755, nil)). - File(llb.Mkfile("qaz", 0644, nil)), - ) - // /foo from stateE is not here because it is deleted in stateB, which is part of a submerge of mergeD -} - -func testPassthroughOp(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - ctx := sb.Context() - busybox := llb.Image("busybox:latest") - base := llb.Scratch().File(llb.Mkfile("/base", 0644, []byte("base"))) - - t.Run("requires returns receiver and builds non-output dependency", func(t *testing.T) { - cacheID := "passthrough-requires-" + identity.NewID() - nonOutputDep := busybox.Run(llb.Shlex(`sh -e -c "echo requires-dep-built > /cache/marker; echo should-not-export > /not-output"`)) - nonOutputDep.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) - - outDir := solveStateToLocalDir(ctx, t, c, base.Requires("test.passthrough.requires", nonOutputDep.Root())) - requireLocalFile(t, outDir, "base", "base") - require.NoFileExists(t, filepath.Join(outDir, "not-output")) - - markerDir := solveStateToLocalDir(ctx, t, c, cacheMountFile(busybox, cacheID, "marker")) - requireLocalFile(t, markerDir, "marker", "requires-dep-built\n") - }) - - t.Run("direct passthrough returns selected outputs and builds non-output input", func(t *testing.T) { - cacheID := "passthrough-direct-" + identity.NewID() - nonOutputInput := busybox.Run(llb.Shlex(`sh -e -c "echo direct-input-built > /cache/marker; echo should-not-export > /not-output"`)) - nonOutputInput.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) - - pass := llb.NewPassthroughOp("test.passthrough.direct", []llb.PassthroughInput{ - {State: llb.Scratch().File(llb.Mkfile("/a", 0644, []byte("a"))), Output: true}, - {State: nonOutputInput.Root()}, - {State: llb.Scratch().File(llb.Mkfile("/c", 0644, []byte("c"))), Output: true}, - }) - - outA := llb.NewState(pass.Output()) - outC := llb.NewState(pass.OutputAt(1)) - out := llb.Scratch(). - File(llb.Copy(outA, "/a", "/a")). - File(llb.Copy(outC, "/c", "/c")) - - outDir := solveStateToLocalDir(ctx, t, c, out) - requireLocalFile(t, outDir, "a", "a") - requireLocalFile(t, outDir, "c", "c") - require.NoFileExists(t, filepath.Join(outDir, "not-output")) - - markerDir := solveStateToLocalDir(ctx, t, c, cacheMountFile(busybox, cacheID, "marker")) - requireLocalFile(t, markerDir, "marker", "direct-input-built\n") - }) - - t.Run("failing non-output dependency fails solve", func(t *testing.T) { - fail := busybox.Run(llb.Shlex(`sh -e -c "exit 42"`)).Root() - def, err := base.Requires("test.passthrough.fail", fail).Marshal(ctx) - require.NoError(t, err) - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterLocal, - OutputDir: t.TempDir(), - }}, - }, nil) - require.Error(t, err) - }) -} - -func solveStateToLocalDir(ctx context.Context, t *testing.T, c *Client, st llb.State) string { - t.Helper() - - def, err := st.Marshal(ctx) - require.NoError(t, err) - - outDir := t.TempDir() - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterLocal, - OutputDir: outDir, - }}, - }, nil) - require.NoError(t, err) - return outDir -} - -func cacheMountFile(base llb.State, cacheID, name string) llb.State { - check := base.Run(llb.Shlexf(`sh -e -c "echo %s >/dev/null; cp /cache/%s /out/%s"`, cacheID, name, name)) - check.AddMount("/cache", llb.Scratch(), llb.AsPersistentCacheDir(cacheID, llb.CacheMountShared)) - return check.AddMount("/out", llb.Scratch()) -} - -func requireLocalFile(t *testing.T, dir, name, expected string) { - t.Helper() - - dt, err := os.ReadFile(filepath.Join(dir, name)) - require.NoError(t, err) - require.Equal(t, expected, string(dt)) -} - -func testMergeOpCacheInline(t *testing.T, sb integration.Sandbox) { - testMergeOpCache(t, sb, "inline") -} - -func testMergeOpCacheMin(t *testing.T, sb integration.Sandbox) { - testMergeOpCache(t, sb, "min") -} - -func testMergeOpCacheMax(t *testing.T, sb integration.Sandbox) { - testMergeOpCache(t, sb, "max") -} - -func testMergeOpCache(t *testing.T, sb integration.Sandbox, mode string) { - t.Helper() - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureMergeDiff) - requiresLinux(t) - - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.Skip("test requires containerd worker") - } - - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - 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() - - // push the busybox image to the mutable registry - sourceImage := "busybox:latest" - def, err := llb.Image(sourceImage).Marshal(sb.Context()) - require.NoError(t, err) - - busyboxTargetNoTag := registry + "/buildkit/testlazyimage:" - busyboxTarget := busyboxTargetNoTag + "latest" - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": busyboxTarget, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - imageService := client.ImageService() - contentStore := client.ContentStore() - - busyboxImg, err := imageService.Get(ctx, busyboxTarget) - require.NoError(t, err) - - busyboxManifest, err := images.Manifest(ctx, contentStore, busyboxImg.Target, nil) - require.NoError(t, err) - - for _, layer := range busyboxManifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.NoError(t, err) - } - - // clear all local state out - err = imageService.Delete(ctx, busyboxImg.Name, images.SynchronousDelete()) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) - - for _, layer := range busyboxManifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - // make a new merge that includes the lazy busybox as a base and exports inline cache - input1 := llb.Scratch(). - File(llb.Mkdir("/dir", 0777)). - File(llb.Mkfile("/dir/1", 0777, nil)) - input1Copy := llb.Scratch().File(llb.Copy(input1, "/dir/1", "/foo/1", &llb.CopyInfo{CreateDestPath: true})) - - // put random contents in the file to ensure it's not re-run later - input2 := runShell(llb.Image("alpine:latest"), - "mkdir /dir", - "cat /dev/urandom | head -c 100 | sha256sum > /dir/2") - input2Copy := llb.Scratch().File(llb.Copy(input2, "/dir/2", "/bar/2", &llb.CopyInfo{CreateDestPath: true})) - - merge := llb.Merge([]llb.State{llb.Image(busyboxTarget), input1Copy, input2Copy}) - - def, err = merge.Marshal(sb.Context()) - require.NoError(t, err) - - target := registry + "/buildkit/testmerge:latest" - cacheTarget := registry + "/buildkit/testmergecache:latest" - - var cacheExports []CacheOptionsEntry - var cacheImports []CacheOptionsEntry - switch mode { - case "inline": - cacheExports = []CacheOptionsEntry{{ - Type: "inline", - }} - cacheImports = []CacheOptionsEntry{{ - Type: "registry", - Attrs: map[string]string{ - "ref": target, - }, - }} - case "min": - cacheExports = []CacheOptionsEntry{{ - Type: "registry", - Attrs: map[string]string{ - "ref": cacheTarget, - }, - }} - cacheImports = []CacheOptionsEntry{{ - Type: "registry", - Attrs: map[string]string{ - "ref": cacheTarget, - }, - }} - case "max": - cacheExports = []CacheOptionsEntry{{ - Type: "registry", - Attrs: map[string]string{ - "ref": cacheTarget, - "mode": "max", - }, - }} - cacheImports = []CacheOptionsEntry{{ - Type: "registry", - Attrs: map[string]string{ - "ref": cacheTarget, - }, - }} - default: - require.Fail(t, fmt.Sprintf("unknown cache mode: %s", mode)) - } - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }, - }, - CacheExports: cacheExports, - }, nil) - require.NoError(t, err) - - // verify that the busybox image stayed lazy - for _, layer := range busyboxManifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - // get the random value at /bar/2 - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - bar2Contents, err := os.ReadFile(filepath.Join(destDir, "bar", "2")) - require.NoError(t, err) - - // clear all local state out - img, err := imageService.Get(ctx, target) - require.NoError(t, err) - - manifest, err := images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - // re-run the same build with cache imports and verify everything stays lazy - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }}, - CacheImports: cacheImports, - CacheExports: cacheExports, - }, nil) - require.NoError(t, err) - - // verify everything from before stayed lazy - img, err = imageService.Get(ctx, target) - require.NoError(t, err) - - manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - for i, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d (%s)", err, i, layer.Digest) - } - - // re-run the build with a change only to input1 using the remote cache - input1 = llb.Scratch(). - File(llb.Mkdir("/dir", 0777)). - File(llb.Mkfile("/dir/1", 0444, nil)) - input1Copy = llb.Scratch().File(llb.Copy(input1, "/dir/1", "/foo/1", &llb.CopyInfo{CreateDestPath: true})) - - merge = llb.Merge([]llb.State{llb.Image(busyboxTarget), input1Copy, input2Copy}) - - def, err = merge.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }}, - CacheExports: cacheExports, - CacheImports: cacheImports, - }, nil) - require.NoError(t, err) - - // verify everything from before stayed lazy except the middle layer for input1Copy - img, err = imageService.Get(ctx, target) - require.NoError(t, err) - - manifest, err = images.Manifest(ctx, contentStore, img.Target, nil) - require.NoError(t, err) - - for i, layer := range manifest.Layers { - switch i { - case 0, 2: - // bottom and top layer should stay lazy as they didn't change - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d", err, i) - case 1: - // middle layer had to be rebuilt, should exist locally - _, err = contentStore.Info(ctx, layer.Digest) - require.NoError(t, err) - default: - require.Fail(t, fmt.Sprintf("unexpected layer index %d", i)) - } - } - - // check the random value at /bar/2 didn't change - destDir = t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - CacheImports: cacheImports, - }, nil) - require.NoError(t, err) - - newBar2Contents, err := os.ReadFile(filepath.Join(destDir, "bar", "2")) - require.NoError(t, err) - - require.Equalf(t, bar2Contents, newBar2Contents, "bar/2 contents changed") - - // Now test the case with a layer on top of a merge. - err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - mergePlusLayer := merge.File(llb.Mkfile("/3", 0444, nil)) - - def, err = mergePlusLayer.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }}, - CacheExports: cacheExports, - CacheImports: cacheImports, - }, nil) - require.NoError(t, err) - - // check the random value at /bar/2 didn't change - destDir = t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - CacheImports: cacheImports, - }, nil) - require.NoError(t, err) - - newBar2Contents, err = os.ReadFile(filepath.Join(destDir, "bar", "2")) - require.NoError(t, err) - - require.Equalf(t, bar2Contents, newBar2Contents, "bar/2 contents changed") - - // clear local state, repeat the build, verify everything stays lazy - err = imageService.Delete(ctx, img.Name, images.SynchronousDelete()) - require.NoError(t, err) - checkAllReleasable(t, c, sb, true) - - for _, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v", err) - } - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "store": "true", - "unsafe-internal-store-allow-incomplete": "true", - }, - }}, - CacheImports: cacheImports, - CacheExports: cacheExports, - }, 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) - - for i, layer := range manifest.Layers { - _, err = contentStore.Info(ctx, layer.Digest) - require.ErrorIs(t, err, cerrdefs.ErrNotFound, "unexpected error %v for index %d", err, i) - } -} - -func requireContents(ctx context.Context, t *testing.T, c *Client, sb integration.Sandbox, state llb.State, cacheImports, cacheExports []CacheOptionsEntry, imageTarget string, files ...fstest.Applier) { - t.Helper() - - def, err := state.Marshal(ctx) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - CacheImports: cacheImports, - CacheExports: cacheExports, - }, nil) - require.NoError(t, err) - - require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.Apply(files...))) - - if imageTarget != "" { - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{ - Type: ExporterImage, - Attrs: map[string]string{ - "name": imageTarget, - "push": "true", - }, - }}, - CacheImports: cacheImports, - CacheExports: cacheExports, - }, nil) - require.NoError(t, err) - resetState(t, c, sb) - requireContents(ctx, t, c, sb, llb.Image(imageTarget, llb.ResolveModePreferLocal), cacheImports, nil, "", files...) - } -} - -func requireEqualContents(ctx context.Context, t *testing.T, c *Client, stateA, stateB llb.State) { - t.Helper() - - defA, err := stateA.Marshal(ctx) - require.NoError(t, err) - - destDirA := t.TempDir() - - _, err = c.Solve(ctx, defA, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDirA, - }, - }, - }, nil) - require.NoError(t, err) - - defB, err := stateB.Marshal(ctx) - require.NoError(t, err) - - destDirB := t.TempDir() - - _, err = c.Solve(ctx, defB, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDirB, - }, - }, - }, nil) - require.NoError(t, err) - - require.NoError(t, fstest.CheckDirectoryEqual(destDirA, destDirB)) -} - -func runShellExecState(base llb.State, cmds ...string) llb.ExecState { - return base.Run(llb.Args([]string{"sh", "-c", strings.Join(cmds, " && ")})) -} - -func runShell(base llb.State, cmds ...string) llb.State { - return runShellExecState(base, cmds...).Root() -} - -func chainRunShells(base llb.State, cmdss ...[]string) llb.State { - for _, cmds := range cmdss { - base = runShell(base, cmds...) - } - return base -} - -func requiresLinux(t *testing.T) { - integration.SkipOnPlatform(t, "!linux") -} - -// ensurePruneAll tries to ensure Prune completes with retries. -// Current cache implementation defers release-related logic using goroutine so -// there can be situation where a build has finished but the following prune doesn't -// cleanup cache because some records still haven't been released. -// This function tries to ensure prune by retrying it. -func ensurePruneAll(t *testing.T, c *Client, sb integration.Sandbox) { - for i := range 2 { - require.NoError(t, c.Prune(sb.Context(), nil, PruneAll)) - for range 20 { - du, err := c.DiskUsage(sb.Context()) - require.NoError(t, err) - if len(du) == 0 { - return - } - time.Sleep(500 * time.Millisecond) - } - t.Logf("retrying prune(%d)", i) - } - t.Fatalf("failed to ensure prune") -} - -func checkAllReleasable(t *testing.T, c *Client, sb integration.Sandbox, checkContent bool) { - cl, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ - EarlyExit: true, - }) - require.NoError(t, err) - - for { - resp, err := cl.Recv() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ - Ref: resp.Record.Ref, - Delete: true, - }) - require.NoError(t, err) - } - - retries := 0 -loop0: - for { - require.Greater(t, 20, retries) - retries++ - du, err := c.DiskUsage(sb.Context()) - require.NoError(t, err) - for _, d := range du { - if d.InUse { - time.Sleep(500 * time.Millisecond) - continue loop0 - } - } - break - } - - err = c.Prune(sb.Context(), nil, PruneAll) - require.NoError(t, err) - - du, err := c.DiskUsage(sb.Context()) - require.NoError(t, err) - require.Equal(t, 0, len(du)) - - // examine contents of exported tars (requires containerd) - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - if checkContent { - if err := workers.HasFeatureCompat(t, sb, workers.FeatureContentCheck); err == nil { - store := proxy.NewContentStore(c.ContentClient()) - count := 0 - err := store.Walk(sb.Context(), func(info content.Info) error { - count++ - return nil - }) - require.NoError(t, err) - require.Equal(t, 0, count) - } - } - t.Logf("checkAllReleasable: skipping check for exported tars in non-containerd test") - return - } - - // TODO: make public pull helper function so this can be checked for standalone as well - - client, err := newContainerd(cdAddress) - require.NoError(t, err) - defer client.Close() - - for _, ns := range []string{"buildkit", "buildkit_history"} { - ctx := namespaces.WithNamespace(sb.Context(), ns) - snapshotterName := sb.Snapshotter() - snapshotService := client.SnapshotService(snapshotterName) - - checkLeases := func() { - leases, err := client.LeasesService().List(ctx) - require.NoError(t, err) - count := 0 - for _, l := range leases { - _, isTemp := l.Labels["buildkit/lease.temporary"] - _, isExpire := l.Labels["containerd.io/gc.expire"] - if isTemp && isExpire { - continue - } - resources, err := client.LeasesService().ListResources(ctx, l) - require.NoError(t, err) - if len(resources) == 0 { - continue - } - count++ - t.Logf("lease: %v", l) - for _, r := range resources { - t.Logf("lease resource: %v", r) - } - } - require.Equal(t, 0, count) - } - - if checkContent { - images, err := client.ImageService().List(ctx) - require.NoError(t, err) - for _, img := range images { - err := client.ImageService().Delete(ctx, img.Name) - require.NoError(t, err) - } - } - - retries = 0 - for { - count := 0 - err = snapshotService.Walk(ctx, func(context.Context, snapshots.Info) error { - count++ - return nil - }) - require.NoError(t, err) - if count == 0 { - break - } - require.Less(t, retries, 20) - retries++ - time.Sleep(500 * time.Millisecond) - } - - if !checkContent { - checkLeases() - return - } - - retries = 0 - for { - count := 0 - var infos []content.Info - err = client.ContentStore().Walk(ctx, func(info content.Info) error { - count++ - infos = append(infos, info) - return nil - }) - require.NoError(t, err) - if count == 0 { - break - } - if retries >= 50 { - for _, info := range infos { - t.Logf("content: %v %v %+v", info.Digest, info.Size, info.Labels) - ra, err := client.ContentStore().ReaderAt(ctx, ocispecs.Descriptor{ - Digest: info.Digest, - Size: info.Size, - }) - if err == nil { - dt := make([]byte, 1024) - n, err := ra.ReadAt(dt, 0) - t.Logf("data: %+v %q", err, string(dt[:n])) - } - } - require.FailNowf(t, "content still exists", "%+v", infos) - } - retries++ - time.Sleep(500 * time.Millisecond) - } - - checkLeases() - } -} - -func testInvalidExporter(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - def, err := llb.Image(imgName).Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - target := "example.com/buildkit/testoci:latest" - attrs := map[string]string{ - "name": target, - } - for _, exp := range []string{ExporterOCI, ExporterDocker} { - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: attrs, - }, - }, - }, nil) - // output file writer is required - require.Error(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: exp, - Attrs: attrs, - OutputDir: destDir, - }, - }, - }, nil) - // output directory is not supported - require.Error(t, err) - } - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - Attrs: attrs, - }, - }, - }, nil) - // output directory is required - require.Error(t, err) - - f, err := os.Create(filepath.Join(destDir, "a")) - require.NoError(t, err) - defer f.Close() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - Attrs: attrs, - Output: fixedWriteCloser(f), - }, - }, - }, nil) - // output file writer is not supported - require.Error(t, err) - - checkAllReleasable(t, c, sb, true) -} - -// moby/buildkit#492 -func testParallelLocalBuilds(t *testing.T, sb integration.Sandbox) { - ctx, cancel := context.WithCancelCause(sb.Context()) - defer func() { cancel(errors.WithStack(context.Canceled)) }() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - eg, ctx := errgroup.WithContext(ctx) - - for i := range 3 { - func(i int) { - eg.Go(func() error { - fn := fmt.Sprintf("test%d", i) - srcDir := integration.Tmpdir( - t, - fstest.CreateFile(fn, []byte("contents"), 0600), - ) - - def, err := llb.Local("source").Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "source": srcDir, - }, - }, nil) - require.NoError(t, err) - - act, err := os.ReadFile(filepath.Join(destDir, fn)) - require.NoError(t, err) - - require.Equal(t, "contents", string(act)) - return nil - }) - }(i) - } - - err = eg.Wait() - require.NoError(t, err) -} - -func testMetadataOnlyLocal(t *testing.T, sb integration.Sandbox) { - ctx, cancel := context.WithCancelCause(sb.Context()) - defer func() { cancel(errors.WithStack(context.Canceled)) }() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - srcDir := integration.Tmpdir( - t, - fstest.CreateFile("data", []byte("contents"), 0600), - fstest.CreateDir("dir", 0700), - fstest.CreateFile("dir/file1", []byte("file1"), 0600), - fstest.CreateFile("dir/file2", []byte("file2"), 0600), - fstest.CreateDir("dir/subdir", 0700), - fstest.CreateDir("dir/subdir2", 0700), - fstest.CreateFile("dir/subdir/bar1", []byte("bar1"), 0600), - fstest.CreateFile("dir/subdir/bar2", []byte("bar2"), 0600), - fstest.CreateFile("dir/subdir2/bar3", []byte("bar3"), 0600), - fstest.CreateFile("foo", []byte("foo"), 0602), - ) - - def, err := llb.Local("source", llb.MetadataOnlyTransfer([]string{"dir/**/*1", "foo"})).Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "source": srcDir, - }, - }, nil) - require.NoError(t, err) - - _, err = os.ReadFile(filepath.Join(destDir, "data")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - act, err := os.ReadFile(filepath.Join(destDir, "dir/file1")) - require.NoError(t, err) - require.Equal(t, "file1", string(act)) - - act, err = os.ReadFile(filepath.Join(destDir, "foo")) - require.NoError(t, err) - require.Equal(t, "foo", string(act)) - - _, err = os.ReadFile(filepath.Join(destDir, "dir/file2")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - act, err = os.ReadFile(filepath.Join(destDir, "dir/subdir/bar1")) - require.NoError(t, err) - require.Equal(t, "bar1", string(act)) - - _, err = os.Stat(filepath.Join(destDir, "dir/subdir2")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - _, err = os.ReadFile(filepath.Join(destDir, "dir/subdir/bar2")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - dt, err := os.ReadFile(filepath.Join(destDir, ".fsutil-metadata")) - require.NoError(t, err) - - stats := parseFSMetadata(t, dt) - require.Equal(t, 10, len(stats)) - - require.Equal(t, "data", stats[0].Path) - require.Equal(t, "dir", stats[1].Path) - require.Equal(t, "dir/file1", stats[2].Path) - require.Equal(t, "dir/file2", stats[3].Path) - require.Equal(t, "dir/subdir", stats[4].Path) - require.Equal(t, "dir/subdir/bar1", stats[5].Path) - require.Equal(t, "dir/subdir/bar2", stats[6].Path) - require.Equal(t, "dir/subdir2", stats[7].Path) - require.Equal(t, "dir/subdir2/bar3", stats[8].Path) - require.Equal(t, "foo", stats[9].Path) - - err = os.RemoveAll(filepath.Join(srcDir.Name, "dir/subdir")) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(srcDir.Name, "dir/file1"), []byte("file1-updated"), 0600) - require.NoError(t, err) - - err = os.WriteFile(filepath.Join(srcDir.Name, "dir/bar1"), []byte("bar1"), 0600) - require.NoError(t, err) - - def, err = llb.Local("source", llb.MetadataOnlyTransfer([]string{"dir/**/*1", "foo"})).Marshal(sb.Context()) - require.NoError(t, err) - - destDir = t.TempDir() - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - LocalMounts: map[string]fsutil.FS{ - "source": srcDir, - }, - }, nil) - require.NoError(t, err) - - _, err = os.ReadFile(filepath.Join(destDir, "data")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - act, err = os.ReadFile(filepath.Join(destDir, "dir/file1")) - require.NoError(t, err) - require.Equal(t, "file1-updated", string(act)) - - act, err = os.ReadFile(filepath.Join(destDir, "dir/bar1")) - require.NoError(t, err) - require.Equal(t, "bar1", string(act)) - - _, err = os.Stat(filepath.Join(destDir, "dir/subdir")) - require.Error(t, err) - require.True(t, os.IsNotExist(err)) - - dt, err = os.ReadFile(filepath.Join(destDir, ".fsutil-metadata")) - require.NoError(t, err) - - stats = parseFSMetadata(t, dt) - require.Equal(t, 8, len(stats)) - - require.Equal(t, "data", stats[0].Path) - require.Equal(t, "dir", stats[1].Path) - require.Equal(t, "dir/bar1", stats[2].Path) - require.Equal(t, "dir/file1", stats[3].Path) - require.Equal(t, "dir/file2", stats[4].Path) - require.Equal(t, "dir/subdir2", stats[5].Path) - require.Equal(t, "dir/subdir2/bar3", stats[6].Path) - require.Equal(t, "foo", stats[7].Path) -} - -// testRelativeMountpoint is a test that relative paths for mountpoints don't -// fail when runc is upgraded to at least rc95, which introduces an error when -// mountpoints are not absolute. Relative paths should be transformed to -// absolute points based on the llb.State's current working directory. -func testRelativeMountpoint(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - id := identity.NewID() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) - cmdStr := integration.UnixOrWindows( - "sh -c 'echo -n %s > /root/relpath/data'", - `cmd /C echo %s > \\root\\relpath\\data`, - ) - st := llb.Image(imgName).Dir("/root").Run( - llb.Shlexf(cmdStr, id), - ).AddMount("relpath", scratch) - - 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, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "data")) - require.NoError(t, err) - newLine := integration.UnixOrWindows("", " \r\n") - require.Equal(t, dt, []byte(id+newLine)) -} - -func testPullWithLayerLimit(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Scratch(). - File(llb.Mkfile("/first", 0644, []byte("first"))). - File(llb.Mkfile("/second", 0644, []byte("second"))). - File(llb.Mkfile("/third", 0644, []byte("third"))) - - 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) - - target := registry + "/buildkit/testlayers:latest" - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - // pull 2 first layers - st = llb.Image(target, llb.WithLayerLimit(2)). - File(llb.Mkfile("/forth", 0644, []byte("forth"))) - - 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, - }}, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "first")) - require.NoError(t, err) - require.Equal(t, "first", string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "second")) - require.NoError(t, err) - require.Equal(t, "second", string(dt)) - - _, err = os.ReadFile(filepath.Join(destDir, "third")) - require.Error(t, err) - require.True(t, errors.Is(err, os.ErrNotExist)) - - dt, err = os.ReadFile(filepath.Join(destDir, "forth")) - require.NoError(t, err) - require.Equal(t, "forth", string(dt)) - - // pull 3rd layer only - st = llb.Diff( - llb.Image(target, llb.WithLayerLimit(2)), - llb.Image(target)). - File(llb.Mkfile("/forth", 0644, []byte("forth"))) - - 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, - }}, - }, nil) - require.NoError(t, err) - - _, err = os.ReadFile(filepath.Join(destDir, "first")) - require.Error(t, err) - require.True(t, errors.Is(err, os.ErrNotExist)) - - _, err = os.ReadFile(filepath.Join(destDir, "second")) - require.Error(t, err) - require.True(t, errors.Is(err, os.ErrNotExist)) - - dt, err = os.ReadFile(filepath.Join(destDir, "third")) - require.NoError(t, err) - require.Equal(t, "third", string(dt)) - - dt, err = os.ReadFile(filepath.Join(destDir, "forth")) - require.NoError(t, err) - require.Equal(t, "forth", string(dt)) - - // zero limit errors cleanly - st = llb.Image(target, llb.WithLayerLimit(0)) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid layer limit") -} - -func testCallInfo(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureInfo) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - _, err = c.Info(sb.Context()) - require.NoError(t, err) -} - -func testValidateDigestOrigin(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image(imgName)) - - cmdStr := integration.UnixOrWindows( - "touch foo", - "cmd /C echo a > foo", - ) - st := llb.Image(imgName). - Run(llb.Shlex(cmdStr), llb.Dir("/wd")). - AddMount("/wd", 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) - - target := registry + "/buildkit/testdigest:latest" - - resp, err := c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - dgst, ok := resp.ExporterResponse[exptypes.ExporterImageDigestKey] - require.True(t, ok) - - err = c.Prune(sb.Context(), nil, PruneAll) - require.NoError(t, err) - - st = llb.Image(target + "@" + dgst) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // accessing the digest from invalid names should fail - st = llb.Image("example.invalid/nosuchrepo@" + dgst) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - - // also check repo that does exists but not digest - st = llb.Image("docker.io/library/ubuntu@" + dgst) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) -} - -func testExportAnnotations(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) - 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) - - scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) - - amd64 := platforms.MustParse("linux/amd64") - arm64 := platforms.MustParse("linux/arm64") - ps := []ocispecs.Platform{amd64, arm64} - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(ps)), - } - for i, p := range ps { - st := scratch.File( - llb.Mkfile("platform", 0600, []byte(platforms.Format(p))), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - - k := platforms.Format(p) - res.AddRef(k, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: k, - Platform: p, - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - res.AddMeta(exptypes.AnnotationIndexKey("gi"), []byte("generic index")) - res.AddMeta(exptypes.AnnotationIndexDescriptorKey("gid"), []byte("generic index descriptor")) - res.AddMeta(exptypes.AnnotationManifestKey(nil, "gm"), []byte("generic manifest")) - res.AddMeta(exptypes.AnnotationManifestDescriptorKey(nil, "gmd"), []byte("generic manifest descriptor")) - res.AddMeta(exptypes.AnnotationManifestKey(&amd64, "m"), []byte("amd64 manifest")) - res.AddMeta(exptypes.AnnotationManifestKey(&arm64, "m"), []byte("arm64 manifest")) - res.AddMeta(exptypes.AnnotationManifestDescriptorKey(&amd64, "md"), []byte("amd64 manifest descriptor")) - res.AddMeta(exptypes.AnnotationManifestDescriptorKey(&arm64, "md"), []byte("arm64 manifest descriptor")) - res.AddMeta(exptypes.AnnotationKey{Key: "gd"}.String(), []byte("generic default")) - - return res, nil - } - - // testing for image exporter - - target := registry + "/buildkit/testannotations:latest" - - const created = "2022-01-23T12:34:56Z" - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "annotation-index.gio": "generic index opt", - "annotation-index." + ocispecs.AnnotationCreated: created, - "annotation-manifest.gmo": "generic manifest opt", - "annotation-manifest-descriptor.gmdo": "generic manifest descriptor opt", - "annotation-manifest[linux/amd64].mo": "amd64 manifest opt", - "annotation-manifest-descriptor[linux/amd64].mdo": "amd64 manifest descriptor opt", - "annotation-manifest[linux/arm64].mo": "arm64 manifest opt", - "annotation-manifest-descriptor[linux/arm64].mdo": "arm64 manifest descriptor opt", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - require.Equal(t, "generic index", imgs.Index.Annotations["gi"]) - require.Equal(t, "generic index opt", imgs.Index.Annotations["gio"]) - require.Equal(t, created, imgs.Index.Annotations[ocispecs.AnnotationCreated]) - for _, desc := range imgs.Index.Manifests { - require.Equal(t, "generic manifest descriptor", desc.Annotations["gmd"]) - require.Equal(t, "generic manifest descriptor opt", desc.Annotations["gmdo"]) - switch { - case platforms.Only(amd64).Match(*desc.Platform): - require.Equal(t, "amd64 manifest descriptor", desc.Annotations["md"]) - require.Equal(t, "amd64 manifest descriptor opt", desc.Annotations["mdo"]) - case platforms.Only(arm64).Match(*desc.Platform): - require.Equal(t, "arm64 manifest descriptor", desc.Annotations["md"]) - require.Equal(t, "arm64 manifest descriptor opt", desc.Annotations["mdo"]) - default: - require.Fail(t, "unrecognized platform") - } - } - - amdImage := imgs.Find(platforms.Format(amd64)) - require.Equal(t, "generic default", amdImage.Manifest.Annotations["gd"]) - require.Equal(t, "generic manifest", amdImage.Manifest.Annotations["gm"]) - require.Equal(t, "generic manifest opt", amdImage.Manifest.Annotations["gmo"]) - require.Equal(t, "amd64 manifest", amdImage.Manifest.Annotations["m"]) - require.Equal(t, "amd64 manifest opt", amdImage.Manifest.Annotations["mo"]) - - armImage := imgs.Find(platforms.Format(arm64)) - require.Equal(t, "generic default", armImage.Manifest.Annotations["gd"]) - require.Equal(t, "generic manifest", armImage.Manifest.Annotations["gm"]) - require.Equal(t, "generic manifest opt", armImage.Manifest.Annotations["gmo"]) - require.Equal(t, "arm64 manifest", armImage.Manifest.Annotations["m"]) - require.Equal(t, "arm64 manifest opt", armImage.Manifest.Annotations["mo"]) - - // testing for oci exporter - - destDir := t.TempDir() - - out := filepath.Join(destDir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Output: fixedWriteCloser(outW), - Attrs: map[string]string{ - "annotation-index.gio": "generic index opt", - "annotation-index-descriptor.gido": "generic index descriptor opt", - "annotation-index-descriptor." + ocispecs.AnnotationCreated: created, - "annotation-manifest.gmo": "generic manifest opt", - "annotation-manifest-descriptor.gmdo": "generic manifest descriptor opt", - "annotation-manifest[linux/amd64].mo": "amd64 manifest opt", - "annotation-manifest-descriptor[linux/amd64].mdo": "amd64 manifest descriptor opt", - "annotation-manifest[linux/arm64].mo": "arm64 manifest opt", - "annotation-manifest-descriptor[linux/arm64].mdo": "arm64 manifest descriptor opt", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - var layout ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &layout) - require.Equal(t, "generic index descriptor", layout.Manifests[0].Annotations["gid"]) - require.Equal(t, "generic index descriptor opt", layout.Manifests[0].Annotations["gido"]) - require.Equal(t, created, layout.Manifests[0].Annotations[ocispecs.AnnotationCreated]) - require.NoError(t, err) - - var index ocispecs.Index - err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+layout.Manifests[0].Digest.Hex()].Data, &index) - require.Equal(t, "generic index", index.Annotations["gi"]) - require.Equal(t, "generic index opt", index.Annotations["gio"]) - require.NoError(t, err) - - for _, desc := range index.Manifests { - var mfst ocispecs.Manifest - err = json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+desc.Digest.Hex()].Data, &mfst) - require.NoError(t, err) - - require.Equal(t, "generic default", mfst.Annotations["gd"]) - require.Equal(t, "generic manifest", mfst.Annotations["gm"]) - require.Equal(t, "generic manifest descriptor", desc.Annotations["gmd"]) - require.Equal(t, "generic manifest opt", mfst.Annotations["gmo"]) - require.Equal(t, "generic manifest descriptor opt", desc.Annotations["gmdo"]) - - switch { - case platforms.Only(amd64).Match(*desc.Platform): - require.Equal(t, "amd64 manifest", mfst.Annotations["m"]) - require.Equal(t, "amd64 manifest descriptor", desc.Annotations["md"]) - require.Equal(t, "amd64 manifest opt", mfst.Annotations["mo"]) - require.Equal(t, "amd64 manifest descriptor opt", desc.Annotations["mdo"]) - case platforms.Only(arm64).Match(*desc.Platform): - require.Equal(t, "arm64 manifest", mfst.Annotations["m"]) - require.Equal(t, "arm64 manifest descriptor", desc.Annotations["md"]) - require.Equal(t, "arm64 manifest opt", mfst.Annotations["mo"]) - require.Equal(t, "arm64 manifest descriptor opt", desc.Annotations["mdo"]) - default: - require.Fail(t, "unrecognized platform") - } - } -} - -func testExportAnnotationsMediaTypes(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - 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) - - p := platforms.DefaultSpec() - ps := []ocispecs.Platform{p} - - scratch := integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(ps)), - } - for i, p := range ps { - st := scratch.File( - llb.Mkfile("platform", 0600, []byte(platforms.Format(p))), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - - k := platforms.Format(p) - res.AddRef(k, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: k, - Platform: p, - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - target := registry + "/buildkit/testannotationsmedia:1" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - "annotation-manifest.a": "b", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 1, len(imgs.Images)) - - target2 := registry + "/buildkit/testannotationsmedia:2" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target2, - "push": "true", - "annotation-index.c": "d", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target2) - require.NoError(t, err) - imgs2, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 1, len(imgs2.Images)) - - require.Equal(t, "b", imgs.Images[0].Manifest.Annotations["a"]) - require.Equal(t, "d", imgs2.Index.Annotations["c"]) - - require.Equal(t, ocispecs.MediaTypeImageIndex, imgs.Index.MediaType) - require.Equal(t, ocispecs.MediaTypeImageIndex, imgs2.Index.MediaType) -} - -func testExportAttestationsDefaultOCIArtifact(t *testing.T, sb integration.Sandbox) { - testExportAttestations(t, sb, false, false) -} - -func testExportAttestationsOCIArtifact(t *testing.T, sb integration.Sandbox) { - testExportAttestations(t, sb, true, true) -} - -func testExportAttestationsImageManifest(t *testing.T, sb integration.Sandbox) { - testExportAttestations(t, sb, false, true) -} - -func testExportAttestations(t *testing.T, sb integration.Sandbox, ociArtifact bool, setOCIArtifact bool) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - 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) - - ps := []ocispecs.Platform{ - platforms.MustParse("linux/amd64"), - platforms.MustParse("linux/arm64"), - } - - success := []byte(`{"success": true}`) - successDigest := digest.SHA256.FromBytes(success) - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{} - - for _, p := range ps { - pk := platforms.Format(p) - expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) - - // build image - st := llb.Scratch().File( - llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(pk, ref) - - // build attestations - st = llb.Scratch(). - File(llb.Mkfile("/attestation.json", 0600, success)). - File(llb.Mkfile("/attestation2.json", 0600, []byte{})) - def, err = st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err = c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - refAttest, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_InToto, - Ref: refAttest, - Path: "/attestation.json", - InToto: result.InTotoAttestation{ - PredicateType: "https://example.com/attestations/v1.0", - Subjects: []result.InTotoSubject{{ - Kind: gatewaypb.InTotoSubjectKind_Self, - }}, - }, - }) - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_InToto, - Ref: refAttest, - Path: "/attestation2.json", - InToto: result.InTotoAttestation{ - PredicateType: "https://example.com/attestations2/v1.0", - Subjects: []result.InTotoSubject{{ - Kind: gatewaypb.InTotoSubjectKind_Raw, - Name: "/attestation.json", - Digest: []digest.Digest{successDigest}, - }}, - }, - }) - } - - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - t.Run("image", func(t *testing.T) { - expectedOCIArtifact := !setOCIArtifact || ociArtifact - targets := []string{ - registry + "/buildkit/testattestationsfoo:latest", - registry + "/buildkit/testattestationsbar:latest", - } - attrs := map[string]string{ - "name": strings.Join(targets, ","), - "push": "true", - } - if setOCIArtifact { - attrs["oci-artifact"] = strconv.FormatBool(ociArtifact) - } - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: attrs, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(targets[0]) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, len(ps)*2, len(imgs.Images)) - - var bases []*testutil.ImageInfo - for _, p := range ps { - pk := platforms.Format(p) - img := imgs.Find(pk) - require.NotNil(t, img) - require.Equal(t, pk, platforms.Format(*img.Desc.Platform)) - require.Equal(t, 1, len(img.Layers)) - require.Equal(t, fmt.Appendf(nil, "hello %s!", pk), img.Layers[0]["greeting"].Data) - bases = append(bases, img) - } - - atts := imgs.Filter("unknown/unknown") - require.Equal(t, len(ps), len(atts.Images)) - for i, att := range atts.Images { - require.Equal(t, ocispecs.MediaTypeImageManifest, att.Desc.MediaType) - require.Equal(t, "unknown/unknown", platforms.Format(*att.Desc.Platform)) - require.Equal(t, attestation.DockerAnnotationReferenceTypeDefault, att.Desc.Annotations[attestation.DockerAnnotationReferenceType]) - require.Equal(t, bases[i].Desc.Digest.String(), att.Desc.Annotations[attestation.DockerAnnotationReferenceDigest]) - require.Equal(t, 2, len(att.Layers)) - - if expectedOCIArtifact { - subject := att.Manifest.Subject - require.NotNil(t, subject) - require.Equal(t, bases[i].Desc.MediaType, subject.MediaType) - require.Equal(t, bases[i].Desc.Digest, subject.Digest) - require.Equal(t, bases[i].Desc.Size, subject.Size) - require.Empty(t, subject.Annotations) - require.Nil(t, subject.Platform) - require.Equal(t, "application/vnd.docker.attestation.manifest.v1+json", att.Manifest.ArtifactType) - require.Equal(t, ocispecs.DescriptorEmptyJSON, att.Manifest.Config) - } else { - require.Nil(t, att.Manifest.Subject) - require.Empty(t, att.Manifest.ArtifactType) - - // image config is not included in the OCI artifact - require.Equal(t, "unknown/unknown", att.Img.OS+"/"+att.Img.Architecture) - require.Equal(t, len(att.Layers), len(att.Img.RootFS.DiffIDs)) - require.Equal(t, 0, len(att.Img.History)) - } - - var attest intoto.Statement - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - - purls := map[string]string{} - for _, k := range targets { - named, err := reference.ParseNormalizedNamed(k) - require.NoError(t, err) - name := reference.FamiliarName(named) - version := "" - if tagged, ok := named.(reference.Tagged); ok { - version = tagged.Tag() - } - p := fmt.Sprintf("pkg:docker/%s%s@%s?platform=%s", url.QueryEscape(registry), strings.TrimPrefix(name, registry), version, url.PathEscape(platforms.Format(ps[i]))) - purls[k] = p - } - - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) - require.Equal(t, map[string]any{"success": true}, attest.Predicate) - subjects := []intoto.Subject{ - { - Name: purls[targets[0]], - Digest: map[string]string{ - "sha256": bases[i].Desc.Digest.Encoded(), - }, - }, - { - Name: purls[targets[1]], - Digest: map[string]string{ - "sha256": bases[i].Desc.Digest.Encoded(), - }, - }, - } - require.Equal(t, subjects, attest.Subject) - - var attest2 intoto.Statement - require.NoError(t, json.Unmarshal(att.LayersRaw[1], &attest2)) - - require.Equal(t, intoto.StatementInTotoV1, attest2.Type) - require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) - require.Nil(t, attest2.Predicate) - subjects = []intoto.Subject{{ - Name: "/attestation.json", - Digest: map[string]string{ - "sha256": successDigest.Encoded(), - }, - }} - require.Equal(t, subjects, attest2.Subject) - } - - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - return - } - client, err := ctd.New(cdAddress) - require.NoError(t, err) - defer client.Close() - ctx := namespaces.WithNamespace(sb.Context(), "buildkit") - - for _, target := range targets { - err = client.ImageService().Delete(ctx, target, images.SynchronousDelete()) - require.NoError(t, err) - } - checkAllReleasable(t, c, sb, true) - }) - - t.Run("local", func(t *testing.T) { - dir := t.TempDir() - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dir, - Attrs: map[string]string{ - "attestation-prefix": "test.", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - for _, p := range ps { - var attest intoto.Statement - dt, err := os.ReadFile(path.Join(dir, strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation.json")) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(dt, &attest)) - - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) - require.Equal(t, map[string]any{"success": true}, attest.Predicate) - - require.Equal(t, []intoto.Subject{{ - Name: "greeting", - Digest: result.ToDigestMap(digest.Canonical.FromString("hello " + platforms.Format(p) + "!")), - }}, attest.Subject) - - var attest2 intoto.Statement - dt, err = os.ReadFile(path.Join(dir, strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation2.json")) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(dt, &attest2)) - - require.Equal(t, intoto.StatementInTotoV1, attest2.Type) - require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) - require.Nil(t, attest2.Predicate) - subjects := []intoto.Subject{{ - Name: "/attestation.json", - Digest: map[string]string{ - "sha256": successDigest.Encoded(), - }, - }} - require.Equal(t, subjects, attest2.Subject) - } - }) - - t.Run("tar", func(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "out.tar") - outW, err := os.Create(out) - require.NoError(t, err) - - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(outW), - Attrs: map[string]string{ - "attestation-prefix": "test.", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(out) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - for _, p := range ps { - var attest intoto.Statement - item := m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation.json")] - require.NotNil(t, item) - require.NoError(t, json.Unmarshal(item.Data, &attest)) - - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) - require.Equal(t, map[string]any{"success": true}, attest.Predicate) - - require.Equal(t, []intoto.Subject{{ - Name: "greeting", - Digest: result.ToDigestMap(digest.Canonical.FromString("hello " + platforms.Format(p) + "!")), - }}, attest.Subject) - - var attest2 intoto.Statement - item = m[path.Join(strings.ReplaceAll(platforms.Format(p), "/", "_"), "test.attestation2.json")] - require.NotNil(t, item) - require.NoError(t, json.Unmarshal(item.Data, &attest2)) - - require.Equal(t, intoto.StatementInTotoV1, attest2.Type) - require.Equal(t, "https://example.com/attestations2/v1.0", attest2.PredicateType) - require.Nil(t, attest2.Predicate) - subjects := []intoto.Subject{{ - Name: "/attestation.json", - Digest: map[string]string{ - "sha256": successDigest.Encoded(), - }, - }} - require.Equal(t, subjects, attest2.Subject) - } - }) -} - -func testAttestationDefaultSubject(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - 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) - - ps := []ocispecs.Platform{ - platforms.MustParse("linux/amd64"), - } - - success := []byte(`{"success": true}`) - scratch := func() llb.State { - return integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) - } - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{} - - for _, p := range ps { - pk := platforms.Format(p) - expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) - - // build image - st := scratch().File( - llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(pk, ref) - - // build attestations - st = scratch().File(llb.Mkfile("/attestation.json", 0600, success)) - def, err = st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err = c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - refAttest, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_InToto, - Ref: refAttest, - Path: "/attestation.json", - InToto: result.InTotoAttestation{ - PredicateType: "https://example.com/attestations/v1.0", - }, - }) - } - - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - target := registry + "/buildkit/testattestationsemptysubject:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, len(ps)*2, len(imgs.Images)) - - var bases []*testutil.ImageInfo - for _, p := range ps { - pk := platforms.Format(p) - bases = append(bases, imgs.Find(pk)) - } - - atts := imgs.Filter("unknown/unknown") - require.Equal(t, len(ps), len(atts.Images)) - for i, att := range atts.Images { - var attest intoto.Statement - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) - require.Equal(t, map[string]any{"success": true}, attest.Predicate) - - name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsemptysubject@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) - subjects := []intoto.Subject{{ - Name: name, - Digest: map[string]string{ - "sha256": bases[i].Desc.Digest.Encoded(), - }, - }} - require.Equal(t, subjects, attest.Subject) - } -} - -func testAttestationBundle(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - 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) - - ps := []ocispecs.Platform{ - platforms.MustParse("linux/amd64"), - } - - scratch := func() llb.State { - return integration.UnixOrWindows(llb.Scratch(), llb.Image("nanoserver")) - } - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - expPlatforms := &exptypes.Platforms{} - - for _, p := range ps { - pk := platforms.Format(p) - expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) - - // build image - st := scratch().File( - llb.Mkfile("/greeting", 0600, fmt.Appendf(nil, "hello %s!", pk)), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(pk, ref) - - stmt := intoto.Statement{ - StatementHeader: intoto.StatementHeader{ - Type: intoto.StatementInTotoV1, - PredicateType: "https://example.com/attestations/v1.0", - }, - Predicate: map[string]any{ - "foo": "1", - }, - } - buff := bytes.NewBuffer(nil) - enc := json.NewEncoder(buff) - require.NoError(t, enc.Encode(stmt)) - - // build attestations - st = scratch().File( - llb.Mkdir("/bundle", 0700), - ) - st = st.File( - llb.Mkfile("/bundle/attestation.json", 0600, buff.Bytes()), - ) - def, err = st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err = c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - refAttest, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_Bundle, - Ref: refAttest, - Path: "/bundle", - }) - } - - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - target := registry + "/buildkit/testattestationsbundle:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, len(ps)*2, len(imgs.Images)) - - var bases []*testutil.ImageInfo - for _, p := range ps { - pk := platforms.Format(p) - bases = append(bases, imgs.Find(pk)) - } - - atts := imgs.Filter("unknown/unknown") - require.Equal(t, len(ps)*1, len(atts.Images)) - for i, att := range atts.Images { - require.Equal(t, 1, len(att.LayersRaw)) - var attest intoto.Statement - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - - require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) - require.Equal(t, map[string]any{"foo": "1"}, attest.Predicate) - name := fmt.Sprintf("pkg:docker/%s/buildkit/testattestationsbundle@latest?platform=%s", url.QueryEscape(registry), url.QueryEscape(platforms.Format(ps[i]))) - subjects := []intoto.Subject{{ - Name: name, - Digest: map[string]string{ - "sha256": bases[i].Desc.Digest.Encoded(), - }, - }} - require.Equal(t, subjects, attest.Subject) - } -} - -func testSBOMScan(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) - requiresLinux(t) - 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) - - p := platforms.MustParse("linux/amd64") - pk := platforms.Format(p) - - scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - st := llb.Image("busybox", llb.Platform(p)) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.SetRef(ref) - - var img ocispecs.Image - cmd := ` -cat < $BUILDKIT_SCAN_DESTINATION/spdx.json -{ - "_type": "https://in-toto.io/Statement/v1", - "predicateType": "https://spdx.dev/Document", - "predicate": { - "name": "fallback", - "extraParams": { - "ARG1": "$BUILDKIT_SCAN_ARG1", - "ARG2": "$BUILDKIT_SCAN_ARG2" - } - } -} -EOF -` - img.Config.Cmd = []string{"/bin/sh", "-c", cmd} - img.Platform = p - config, err := json.Marshal(img) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal image config") - } - res.AddMeta(exptypes.ExporterImageConfigKey, config) - - return res, nil - } - - scannerTarget := registry + "/buildkit/testsbomscanner:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": scannerTarget, - "push": "true", - "oci-mediatypes": "false", - }, - }, - }, - }, "", scannerFrontend, nil) - require.NoError(t, err) - - makeTargetFrontend := func(attest bool) func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - return func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - // build image - st := llb.Scratch().File( - llb.Mkfile("/greeting", 0600, []byte("hello world!")), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(pk, ref) - - expPlatforms := &exptypes.Platforms{ - Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - // build attestations - if attest { - st = llb.Scratch(). - File(llb.Mkfile("/result.spdx", 0600, []byte(`{"name": "frontend"}`))) - def, err = st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err = c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - refAttest, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_InToto, - Ref: refAttest, - Path: "/result.spdx", - InToto: result.InTotoAttestation{ - PredicateType: intoto.PredicateSPDX, - }, - }) - } - - return res, nil - } - } - - // test the default fallback scanner - target := registry + "/buildkit/testsbom:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(false), nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - // test the frontend builtin scanner - target = registry + "/buildkit/testsbom2:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(true), nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att := imgs.Find("unknown/unknown") - attest := intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{"name": "frontend"}) - - // test the specified fallback scanner - target = registry + "/buildkit/testsbom3:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "generator=" + scannerTarget, - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(false), nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att = imgs.Find("unknown/unknown") - attest = intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{"name": "fallback"}) - - // test the builtin frontend scanner and the specified fallback scanner together - target = registry + "/buildkit/testsbom3:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "generator=" + scannerTarget, - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(true), nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att = imgs.Find("unknown/unknown") - attest = intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{"name": "frontend"}) - - // test configuring the scanner (simple) - target = registry + "/buildkit/testsbom4:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "generator=" + scannerTarget + ",ARG1=foo,ARG2=bar", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(false), nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att = imgs.Find("unknown/unknown") - attest = intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{ - "extraParams": map[string]any{"ARG1": "foo", "ARG2": "bar"}, - }) - - // test configuring the scanner (complex) - target = registry + "/buildkit/testsbom4:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "\"generator=" + scannerTarget + "\",\"ARG1=foo\",\"ARG2=hello,world\"", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", makeTargetFrontend(false), nil) - require.NoError(t, err) - - desc, provider, err = contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err = testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att = imgs.Find("unknown/unknown") - attest = intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{ - "extraParams": map[string]any{"ARG1": "foo", "ARG2": "hello,world"}, - }) -} - -func testSBOMScanSingleRef(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) - requiresLinux(t) - 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) - - p := platforms.DefaultSpec() - pk := platforms.Format(p) - - scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - st := llb.Image("busybox", llb.Platform(p)) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.SetRef(ref) - - var img ocispecs.Image - cmd := ` -cat < $BUILDKIT_SCAN_DESTINATION/spdx.json -{ - "_type": "https://in-toto.io/Statement/v1", - "predicateType": "https://spdx.dev/Document", - "predicate": {"name": "fallback"} -} -EOF -` - img.Config.Cmd = []string{"/bin/sh", "-c", cmd} - img.Platform = p - config, err := json.Marshal(img) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal image config") - } - res.AddMeta(exptypes.ExporterImageConfigKey, config) - - return res, nil - } - - scannerTarget := registry + "/buildkit/testsbomscanner:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": scannerTarget, - "push": "true", - "oci-mediatypes": "false", - }, - }, - }, - }, "", scannerFrontend, nil) - require.NoError(t, err) - - targetFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - // build image - st := llb.Scratch().File( - llb.Mkfile("/greeting", 0600, []byte("hello world!")), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.SetRef(ref) - - var img ocispecs.Image - img.Config.Cmd = []string{"/bin/sh", "-c", "cat /greeting"} - img.Platform = p - config, err := json.Marshal(img) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal image config") - } - res.AddMeta(exptypes.ExporterImageConfigKey, config) - - expPlatforms := &exptypes.Platforms{ - Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - target := registry + "/buildkit/testsbomsingle:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "generator=" + scannerTarget, - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", targetFrontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - var img *testutil.ImageInfo - for _, candidate := range imgs.Images { - if candidate.Desc.Platform != nil && platforms.Format(*candidate.Desc.Platform) == "unknown/unknown" { - continue - } - img = candidate - break - } - require.NotNil(t, img) - require.Equal(t, []string{"/bin/sh", "-c", "cat /greeting"}, img.Img.Config.Cmd) - - att := imgs.Find("unknown/unknown") - require.NotNil(t, att) - attest := intoto.Statement{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - require.Subset(t, attest.Predicate, map[string]any{"name": "fallback"}) -} - -func testSBOMSupplements(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) - requiresLinux(t) - 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) - - p := platforms.MustParse("linux/amd64") - pk := platforms.Format(p) - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - // build image - st := llb.Scratch().File( - llb.Mkfile("/foo", 0600, []byte{}), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(pk, ref) - - expPlatforms := &exptypes.Platforms{ - Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - // build attestations - doc := spdx.Document{ - SPDXVersion: "SPDX-2.2", - SPDXIdentifier: "DOCUMENT", - Files: []*spdx.File{ - { - // foo exists... - FileSPDXIdentifier: "SPDXRef-File-foo", - FileName: "/foo", - }, - { - // ...but bar doesn't - FileSPDXIdentifier: "SPDXRef-File-bar", - FileName: "/bar", - }, - }, - } - docBytes, err := json.Marshal(doc) - if err != nil { - return nil, err - } - st = llb.Scratch(). - File(llb.Mkfile("/result.spdx", 0600, docBytes)) - def, err = st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err = c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - refAttest, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - - res.AddAttestation(pk, gateway.Attestation{ - Kind: gatewaypb.AttestationKind_InToto, - Ref: refAttest, - Path: "/result.spdx", - InToto: result.InTotoAttestation{ - PredicateType: intoto.PredicateSPDX, - }, - Metadata: map[string][]byte{ - result.AttestationSBOMCore: []byte("result"), - }, - }) - - return res, nil - } - - // test the default fallback scanner - target := registry + "/buildkit/testsbom:latest" - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:sbom": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(target) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - require.Equal(t, 2, len(imgs.Images)) - - att := imgs.Find("unknown/unknown") - attest := struct { - intoto.StatementHeader - Predicate spdx.Document - }{} - require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) - require.Equal(t, intoto.StatementInTotoV1, attest.Type) - require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) - - require.Equal(t, "DOCUMENT", string(attest.Predicate.SPDXIdentifier)) - require.Len(t, attest.Predicate.Files, 2) - require.Equal(t, "/foo", attest.Predicate.Files[0].FileName) - require.Regexp(t, "^layerID: sha256:", attest.Predicate.Files[0].FileComment) - require.Equal(t, "/bar", attest.Predicate.Files[1].FileName) - require.Empty(t, attest.Predicate.Files[1].FileComment) - - checkAllReleasable(t, c, sb, true) -} - -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)) -} - -// testMountStubsDirectory verifies that BuildKit cleans up stub directories created for tmpfs -// mounts after a Run step completes. Empty stubs should be removed, stubs with user content -// should be kept, and pre-existing directories used as mount targets should be preserved. -func testMountStubsDirectory(t *testing.T, sb integration.Sandbox) { - // Skipped on Windows because the test relies on tmpfs mounts, multiple simultaneous AddMount - // calls, and llb.Scratch() — none of which are supported on Windows containers. The stub - // cleanup behavior is also Linux-specific (tied to overlayfs mount point handling). - integration.SkipOnPlatform(t, "windows", "requires tmpfs, multiple mounts, and Scratch — all unsupported on Windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("busybox:latest"). - File(llb.Mkdir("/test", 0700)). - File(llb.Mkdir("/test/qux/", 0700)). - Run( - llb.Args([]string{"touch", "/test/baz/keep"}), - // check stubs directory is removed - llb.AddMount("/test/foo", llb.Scratch(), llb.Tmpfs()), - // check that stubs directory are recursively removed - llb.AddMount("/test/bar/x/y", llb.Scratch(), llb.Tmpfs()), - // check that only empty stubs directories are removed - llb.AddMount("/test/baz/x", llb.Scratch(), llb.Tmpfs()), - // check that previously existing directory are not removed - llb.AddMount("/test/qux", llb.Scratch(), llb.Tmpfs()), - ).Root() - st = llb.Scratch().File(llb.Copy(st, "/test", "/", &llb.CopyInfo{CopyDirContentsOnly: true})) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - tmpDir := t.TempDir() - tarFile := filepath.Join(tmpDir, "out.tar") - tarFileW, err := os.Create(tarFile) - require.NoError(t, err) - defer tarFileW.Close() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(tarFileW), - }, - }, - }, nil) - require.NoError(t, err) - tarFileW.Close() - - dt, err := os.ReadFile(tarFile) - require.NoError(t, err) - - m, err := testutil.ReadTarToMap(dt, false) - require.NoError(t, err) - - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - - require.ElementsMatch(t, []string{ - "baz/", - "baz/keep", - "qux/", - }, keys) -} - -// testMountStubsTimestamp verifies that timestamps set on directories used as mount points -// (and their parents) are preserved after the mount is removed. -// https://github.com/moby/buildkit/issues/3148 -func testMountStubsTimestamp(t *testing.T, sb integration.Sandbox) { - // Skipped on Windows because the test relies on tmpfs mounts, multiple AddMount calls, - // Scratch, and Linux-specific commands — all unsupported on Windows containers. - integration.SkipOnPlatform(t, "windows", "requires tmpfs, multiple mounts, and Scratch — all unsupported on Windows") - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - const sourceDateEpoch = int64(1234567890) // Fri Feb 13 11:31:30 PM UTC 2009 - st := llb.Image("busybox:latest").Run( - llb.Args([]string{ - "/bin/touch", fmt.Sprintf("--date=@%d", sourceDateEpoch), - "/bin", - "/etc", - "/var", - "/var/foo", - "/tmp", - "/tmp/foo2", - "/tmp/foo2/bar", - }), - llb.AddMount("/var/foo", llb.Scratch(), llb.Tmpfs()), - llb.AddMount("/tmp/foo2/bar", llb.Scratch(), llb.Tmpfs()), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - tmpDir := t.TempDir() - tarFile := filepath.Join(tmpDir, "out.tar") - tarFileW, err := os.Create(tarFile) - require.NoError(t, err) - defer tarFileW.Close() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterTar, - Output: fixedWriteCloser(tarFileW), - }, - }, - }, nil) - require.NoError(t, err) - tarFileW.Close() - - tarFileR, err := os.Open(tarFile) - require.NoError(t, err) - defer tarFileR.Close() - tarR := tar.NewReader(tarFileR) - touched := map[string]*tar.Header{ - "bin/": nil, // Regular dir - "etc/": nil, // Parent of file mounts (etc/{resolv.conf, hosts}) - "var/": nil, // Parent of dir mount (var/foo/) - "tmp/": nil, // Grandparent of dir mount (tmp/foo2/bar/) - // No support for reproducing the timestamps of mount point directories such as var/foo/ and tmp/foo2/bar/, - // because the touched timestamp value is lost when the mount is unmounted. - } - for { - hd, err := tarR.Next() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - if x, ok := touched[hd.Name]; ok && x == nil { - touched[hd.Name] = hd - } - } - for name, hd := range touched { - t.Logf("Verifying %q (%+v)", name, hd) - require.NotNil(t, hd, name) - require.Equal(t, sourceDateEpoch, hd.ModTime.Unix(), name) - } -} - -func testImageBlobSource(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - requiresLinux(t) - 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) - - st := llb.Image("alpine") - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - name := registry + "/foo/blobtest:img" - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: "image", - Attrs: map[string]string{ - "name": name, - "push": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - desc, provider, err := contentutil.ProviderFromRef(name) - require.NoError(t, err) - - imgs, err := testutil.ReadImages(sb.Context(), provider, desc) - require.NoError(t, err) - - require.Equal(t, 1, len(imgs.Images)) - mfst := imgs.Images[0].Manifest - require.GreaterOrEqual(t, len(mfst.Layers), 1) - - l := mfst.Layers[0] - - blob := llb.ImageBlob(registry+"/foo/blobtest@"+l.Digest.String(), llb.Filename("layer.tar.gz"), llb.Chown(123, 456)) - st = llb.Image("alpine").Run(llb.Shlex(`sh -c 'sha256sum /layers/layer.tar.gz | cut -d" " -f0 > /out/checksum && stat -c "%u-%g-%s" /layers/layer.tar.gz > /out/stat'`), llb.AddMount("/layers", blob, llb.Readonly)).AddMount("/out", llb.Scratch()) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "stat")) - require.NoError(t, err) - - require.Equal(t, "123-456-"+strconv.FormatInt(l.Size, 10), strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "checksum")) - require.NoError(t, err) - - require.Equal(t, l.Digest.Hex(), strings.TrimSpace(string(dt))) - - provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) - require.NoError(t, err) - - var stmt struct { - intoto.StatementHeader - Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` - } - require.NoError(t, json.Unmarshal(provDt, &stmt)) - - expectedName, err := purl.RefToPURL(packageurl.TypeDocker, registry+"/foo/blobtest@"+l.Digest.String(), nil) - require.NoError(t, err) - purlObj, err := packageurl.FromString(expectedName) - require.NoError(t, err) - purlObj.Qualifiers = append(purlObj.Qualifiers, packageurl.Qualifier{Key: "ref_type", Value: "blob"}) - expectedName = purlObj.ToString() - - found := false - for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { - if m.URI == expectedName { - found = true - require.Equal(t, l.Digest.Hex(), m.Digest["sha256"]) - break - } - } - require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.BuildDefinition.ResolvedDependencies) -} - -func testOCILayoutBlobSource(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureOCILayout) - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - st := llb.Image("alpine") - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - ociDir := t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterOCI, - Attrs: map[string]string{ - "tar": "false", - }, - OutputDir: ociDir, - }, - }, - }, nil) - require.NoError(t, err) - - indexDt, err := os.ReadFile(filepath.Join(ociDir, ocispecs.ImageIndexFile)) - require.NoError(t, err) - - var index ocispecs.Index - err = json.Unmarshal(indexDt, &index) - require.NoError(t, err) - require.Equal(t, 1, len(index.Manifests)) - - var mfst ocispecs.Manifest - mfstDt, err := os.ReadFile(filepath.Join(ociDir, "blobs/sha256", index.Manifests[0].Digest.Hex())) - require.NoError(t, err) - err = json.Unmarshal(mfstDt, &mfst) - require.NoError(t, err) - require.GreaterOrEqual(t, len(mfst.Layers), 1) - layer := mfst.Layers[0] - - store, err := local.NewStore(ociDir) - require.NoError(t, err) - csID := "my-blob-content-store" - - blob := llb.OCILayoutBlob("not/real@"+layer.Digest.String(), llb.ImageBlobOCIStore("", csID), llb.Filename("layer.tar.gz"), llb.Chown(123, 456)) - st = llb.Image("alpine").Run(llb.Shlex(`sh -c 'sha256sum /layers/layer.tar.gz | cut -d" " -f0 > /out/checksum && stat -c "%u-%g-%s" /layers/layer.tar.gz > /out/stat'`), llb.AddMount("/layers", blob, llb.Readonly)).AddMount("/out", llb.Scratch()) - - def, err = st.Marshal(sb.Context()) - require.NoError(t, err) - - destDir := t.TempDir() - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "", - }, - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: destDir, - }, - }, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "stat")) - require.NoError(t, err) - require.Equal(t, "123-456-"+strconv.FormatInt(layer.Size, 10), strings.TrimSpace(string(dt))) - - dt, err = os.ReadFile(filepath.Join(destDir, "checksum")) - require.NoError(t, err) - require.Equal(t, layer.Digest.Hex(), strings.TrimSpace(string(dt))) - - provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) - require.NoError(t, err) - - var stmt struct { - intoto.StatementHeader - Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` - } - require.NoError(t, json.Unmarshal(provDt, &stmt)) - - expectedName, err := purl.RefToPURL(packageurl.TypeOCI, "not/real@"+layer.Digest.String(), nil) - require.NoError(t, err) - purlObj, err := packageurl.FromString(expectedName) - require.NoError(t, err) - purlObj.Qualifiers = append(purlObj.Qualifiers, packageurl.Qualifier{Key: "ref_type", Value: "blob"}) - expectedName = purlObj.ToString() - - found := false - for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { - if m.URI == expectedName { - found = true - require.Equal(t, layer.Digest.Hex(), m.Digest["sha256"]) - break - } - } - require.True(t, found, "expected to find %q in %+v", expectedName, stmt.Predicate.BuildDefinition.ResolvedDependencies) -} - -func testFrontendVerifyPlatforms(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File( - llb.Mkfile("foo", 0600, []byte("data")), - ) - - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - } - - wc := newWarningsCapture() - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "platform": "linux/amd64,linux/arm64", - }, - }, "", frontend, wc.status) - require.NoError(t, err) - warnings := wc.wait() - - require.Len(t, warnings, 1) - require.Contains(t, string(warnings[0].Short), "Multiple platforms requested but result is not multi-platform") - - wc = newWarningsCapture() - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{}, - }, "", frontend, wc.status) - require.NoError(t, err) - - warnings = wc.wait() - require.Len(t, warnings, 0, warningsListOutput(warnings)) - - frontend = func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - platformsToTest := []string{"linux/amd64", "linux/arm64"} - expPlatforms := &exptypes.Platforms{ - Platforms: make([]exptypes.Platform, len(platformsToTest)), - } - for i, platform := range platformsToTest { - st := llb.Scratch().File( - llb.Mkfile("platform", 0600, []byte(platform)), - ) - - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platform, ref) - - expPlatforms.Platforms[i] = exptypes.Platform{ - ID: platform, - Platform: platforms.MustParse(platform), - } - } - dt, err := json.Marshal(expPlatforms) - if err != nil { - return nil, err - } - res.AddMeta(exptypes.ExporterPlatformsKey, dt) - - return res, nil - } - - wc = newWarningsCapture() - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "platform": "linux/amd64,linux/arm64", - }, - }, "", frontend, wc.status) - require.NoError(t, err) - warnings = wc.wait() - - require.Len(t, warnings, 0) - - wc = newWarningsCapture() - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{}, - }, "", frontend, wc.status) - require.NoError(t, err) - - warnings = wc.wait() - require.Len(t, warnings, 1) - require.Contains(t, string(warnings[0].Short), "do not match result platforms linux/amd64,linux/arm64") -} - -func testFrontendLintSkipVerifyPlatforms(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - dockerfile := ` -FROM scratch -COPY foo /foo - ` - st := llb.Scratch().File( - llb.Mkfile("Dockerfile", 0600, []byte(dockerfile)). - Mkfile("foo", 0600, []byte("data"))) - - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - - return c.Solve(ctx, gateway.SolveRequest{ - FrontendInputs: map[string]*pb.Definition{ - "context": def.ToPB(), - "dockerfile": def.ToPB(), - }, - FrontendOpt: map[string]string{ - "requestid": "frontend.lint", - "frontend.caps": "moby.buildkit.frontend.subrequests", - }, - }) - } - - wc := newWarningsCapture() - _, err = c.Build(sb.Context(), SolveOpt{ - FrontendAttrs: map[string]string{ - "platform": "linux/amd64,linux/arm64", - }, - }, "", frontend, wc.status) - require.NoError(t, err) - warnings := wc.wait() - - for _, w := range warnings { - t.Logf("warning: %s", string(w.Short)) - } - require.Len(t, warnings, 0) -} - -type warningsCapture struct { - status chan *SolveStatus - statusDone chan struct{} - done chan struct{} - warnings []*VertexWarning - vertexes map[digest.Digest]struct{} -} - -func newWarningsCapture() *warningsCapture { - w := &warningsCapture{ - status: make(chan *SolveStatus), - statusDone: make(chan struct{}), - done: make(chan struct{}), - vertexes: map[digest.Digest]struct{}{}, - } - - go func() { - defer close(w.statusDone) - for { - select { - case st, ok := <-w.status: - if !ok { - return - } - for _, s := range st.Vertexes { - w.vertexes[s.Digest] = struct{}{} - } - w.warnings = append(w.warnings, st.Warnings...) - case <-w.done: - return - } - } - }() - - return w -} - -func (w *warningsCapture) wait() []*VertexWarning { - select { - case <-w.statusDone: - case <-time.After(10 * time.Second): - close(w.done) - } - - <-w.statusDone - return w.warnings -} - -func ensureFile(t *testing.T, path string) { - st, err := os.Stat(path) - require.NoError(t, err, "expected file at %s", path) - require.True(t, st.Mode().IsRegular()) -} - -func ensureFileContents(t *testing.T, path, expectedContents string) { - contents, err := os.ReadFile(path) - require.NoError(t, err) - - actualContents := string(contents) - require.Equal(t, expectedContents, actualContents, - "file contents mismatch for %s\nexpected: %q\nactual: %q", path, expectedContents, actualContents) -} - -func makeSSHAgentSock(t *testing.T, agent agent.Agent) (p string, err error) { - tmpDir := integration.Tmpdir(t) - sockPath := filepath.Join(tmpDir.Name, "ssh_auth_sock") - - listener := net.ListenConfig{} - l, err := listener.Listen(context.TODO(), "unix", sockPath) - if err != nil { - return "", err - } - t.Cleanup(func() { - require.NoError(t, l.Close()) - }) - - s := &server{l: l} - go s.run(agent) - - return sockPath, nil -} - -type imageTimestamps struct { - FromImage []string // from img.Created and img.[]History.Created - FromAnnotation string // from index.Manifests[0].Annotations["org.opencontainers.image.created"] -} - -func readImageTimestamps(dt []byte) (*imageTimestamps, error) { - m, err := testutil.ReadTarToMap(dt, false) - if err != nil { - return nil, err - } - - if _, ok := m["oci-layout"]; !ok { - return nil, errors.Errorf("no oci-layout") - } - - var index ocispecs.Index - if err := json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index); err != nil { - return nil, err - } - if len(index.Manifests) != 1 { - return nil, errors.Errorf("invalid manifest count %d", len(index.Manifests)) - } - - var res imageTimestamps - res.FromAnnotation = index.Manifests[0].Annotations[ocispecs.AnnotationCreated] - - var mfst ocispecs.Manifest - if err := json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst); err != nil { - return nil, err - } - // don't unmarshal to image type so we get the original string value - type history struct { - Created string `json:"created"` - } - - img := struct { - History []history `json:"history"` - Created string `json:"created"` - }{} - - if err := json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Config.Digest.Hex()].Data, &img); err != nil { - return nil, err - } - - res.FromImage = []string{ - img.Created, - } - for _, h := range img.History { - res.FromImage = append(res.FromImage, h.Created) - } - return &res, nil -} - -type server struct { - l net.Listener -} - -func (s *server) run(a agent.Agent) error { - for { - c, err := s.l.Accept() - if err != nil { - return err - } - - go agent.ServeAgent(a, c) - } -} - -type secModeSandbox struct{} - -func (*secModeSandbox) UpdateConfigFile(in string) (string, func() error) { - return in, nil -} - -type secModeInsecure struct{} - -func (*secModeInsecure) UpdateConfigFile(in string) (string, func() error) { - return in + "\n\ninsecure-entitlements = [\"security.insecure\"]\n", nil -} - -var ( - securitySandbox integration.ConfigUpdater = &secModeSandbox{} - securityInsecure integration.ConfigUpdater = &secModeInsecure{} -) - -type netModeHost struct{} - -func (*netModeHost) UpdateConfigFile(in string) (string, func() error) { - return in + "\n\ninsecure-entitlements = [\"network.host\"]\n", nil -} - -type netModeProxyBridge struct{} - -func (*netModeProxyBridge) UpdateConfigFile(in string) (string, func() error) { - return in + ` - -insecure-entitlements = ["network.host"] - -[worker.oci] -networkMode = "bridge" - -[worker.containerd] -networkMode = "bridge" - `, nil -} - -type netModeProxyDefault struct{} - -func (*netModeProxyDefault) UpdateConfigFile(in string) (string, func() error) { - return in + ` - -insecure-entitlements = ["network.host"] -`, nil -} - -type netModeProxyDefaultNoCNI struct{} - -func (*netModeProxyDefaultNoCNI) UpdateConfigFile(in string) (string, func() error) { - return in + ` - -insecure-entitlements = ["network.host"] - -[worker.oci] -cniConfigPath = "/tmp/buildkit-missing-cni.json" - -[worker.containerd] -cniConfigPath = "/tmp/buildkit-missing-cni.json" -`, nil -} - -type netModeProxyHost struct{} - -func (*netModeProxyHost) UpdateConfigFile(in string) (string, func() error) { - return in + ` - -insecure-entitlements = ["network.host"] - -[worker.oci] -networkMode = "host" - -[worker.containerd] -networkMode = "host" -`, nil -} - -type netModeDefault struct{} - -func (*netModeDefault) UpdateConfigFile(in string) (string, func() error) { - return in, nil -} - -type netModeBridgeDNS struct{} - -func (*netModeBridgeDNS) UpdateConfigFile(in string) (string, func() error) { - return in + ` -# configure bridge networking -[worker.oci] -networkMode = "cni" -cniConfigPath = "/etc/buildkit/dns-cni.conflist" - -[worker.containerd] -networkMode = "cni" -cniConfigPath = "/etc/buildkit/dns-cni.conflist" - -[dns] -nameservers = ["10.11.0.1"] -`, nil -} - -var ( - hostNetwork integration.ConfigUpdater = &netModeHost{} - defaultNetwork integration.ConfigUpdater = &netModeDefault{} - proxyDefaultNetwork integration.ConfigUpdater = &netModeProxyDefault{} - proxyDefaultNetworkNoCNI integration.ConfigUpdater = &netModeProxyDefaultNoCNI{} - proxyBridgeNetwork integration.ConfigUpdater = &netModeProxyBridge{} - proxyHostNetwork integration.ConfigUpdater = &netModeProxyHost{} - bridgeDNSNetwork integration.ConfigUpdater = &netModeBridgeDNS{} -) - -func fixedWriteCloser(wc io.WriteCloser) filesync.FileOutputFunc { - return func(map[string]string) (io.WriteCloser, error) { - return wc, nil - } -} - -func testSourcePolicy(t *testing.T, sb integration.Sandbox) { - requiresLinux(t) - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Image("busybox:1.34.1-uclibc").File( - llb.Copy(llb.HTTP("https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md"), - "README.md", "README.md")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - } - - type testCase struct { - srcPol *sourcepolicypb.Policy - expectedErr string - } - testCases := []testCase{ - { - // Valid - srcPol: &sourcepolicypb.Policy{ - Rules: []*sourcepolicypb.Rule{ - { - Action: sourcepolicypb.PolicyAction_CONVERT, - Selector: &sourcepolicypb.Selector{ - Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc", - }, - Updates: &sourcepolicypb.Update{ - Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:3614ca5eacf0a3a1bcc361c939202a974b4902b9334ff36eb29ffe9011aaad83", - }, - }, - { - Action: sourcepolicypb.PolicyAction_CONVERT, - Selector: &sourcepolicypb.Selector{ - Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", - }, - Updates: &sourcepolicypb.Update{ - Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", - Attrs: map[string]string{"http.checksum": "sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53"}, - }, - }, - }, - }, - expectedErr: "", - }, - { - // Invalid docker-image source - srcPol: &sourcepolicypb.Policy{ - Rules: []*sourcepolicypb.Rule{ - { - Action: sourcepolicypb.PolicyAction_CONVERT, - Selector: &sourcepolicypb.Selector{ - Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc", - }, - Updates: &sourcepolicypb.Update{ - Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // invalid - }, - }, - }, - }, - expectedErr: "docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: not found", - }, - { - // Invalid http source - srcPol: &sourcepolicypb.Policy{ - Rules: []*sourcepolicypb.Rule{ - { - Action: sourcepolicypb.PolicyAction_CONVERT, - Selector: &sourcepolicypb.Selector{ - Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", - }, - Updates: &sourcepolicypb.Update{ - Attrs: map[string]string{pb.AttrHTTPChecksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, // invalid - }, - }, - }, - }, - expectedErr: "digest mismatch sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53: sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - }, - } - for i, tc := range testCases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - _, err = c.Build(sb.Context(), SolveOpt{SourcePolicy: tc.srcPol}, "", frontend, nil) - if tc.expectedErr == "" { - require.NoError(t, err) - } else { - require.Error(t, err) - require.Contains(t, err.Error(), tc.expectedErr) - } - }) - } - - t.Run("Frontend policies", func(t *testing.T) { - t.Run("deny http", func(t *testing.T) { - denied := "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md" - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Image("busybox:1.34.1-uclibc").File( - llb.Copy(llb.HTTP(denied), - "README.md", "README.md")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - SourcePolicies: []*sourcepolicypb.Policy{{ - Rules: []*sourcepolicypb.Rule{ - { - Action: sourcepolicypb.PolicyAction_DENY, - Selector: &sourcepolicypb.Selector{ - Identifier: denied, - }, - }, - }, - }}, - }) - } - - _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) - require.ErrorContains(t, err, sourcepolicy.ErrSourceDenied.Error()) - }) - t.Run("resolve image config", func(t *testing.T) { - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - const ( - origRef = "docker.io/library/busybox:1.34.1-uclibc" - updatedRef = "docker.io/library/busybox:latest" - ) - pol := []*sourcepolicypb.Policy{ - { - Rules: []*sourcepolicypb.Rule{ - { - Action: sourcepolicypb.PolicyAction_DENY, - Selector: &sourcepolicypb.Selector{ - Identifier: "*", - }, - }, - { - Action: sourcepolicypb.PolicyAction_ALLOW, - Selector: &sourcepolicypb.Selector{ - Identifier: "docker-image://" + updatedRef + "*", - }, - }, - { - Action: sourcepolicypb.PolicyAction_CONVERT, - Selector: &sourcepolicypb.Selector{ - Identifier: "docker-image://" + origRef, - }, - Updates: &sourcepolicypb.Update{ - Identifier: "docker-image://" + updatedRef, - }, - }, - }, - }, - } - - ref, dgst, _, err := c.ResolveImageConfig(ctx, origRef, sourceresolver.Opt{ - SourcePolicies: pol, - }) - if err != nil { - return nil, err - } - require.Equal(t, updatedRef, ref) - st := llb.Image(ref + "@" + dgst.String()) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - SourcePolicies: pol, - }) - } - _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) - require.NoError(t, err) - }) - }) -} - -func testLLBMountPerformance(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - srcPath := integration.UnixOrWindows("/bin", "/Windows") - - mntInput := llb.Image(imgName) - st := llb.Image(imgName) - var mnts []llb.State - // Reduce iterations on Windows due to significantly slower container operations - numIterations := integration.UnixOrWindows(20, 5) - for range numIterations { - execSt := st.Run( - llb.Args(integration.UnixOrWindows( - []string{"true"}, - []string{"cmd", "/C", "exit 0"}, - )), - ) - mnts = append(mnts, mntInput) - for j := range mnts { - mnts[j] = execSt.AddMount(fmt.Sprintf("/tmp/bin%d", j), mnts[j], llb.SourcePath(srcPath)) - } - st = execSt.Root() - } - - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - // Windows images take longer time, especially on CI systems - // With reduced iterations (5 vs 20), use generous timeout - timeout := integration.UnixOrWindows(time.Minute, 10*time.Minute) - timeoutCtx, cancel := context.WithTimeoutCause(sb.Context(), timeout, nil) - defer cancel() - _, err = c.Solve(timeoutCtx, def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testLayerLimitOnMounts(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - - ctx := sb.Context() - - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - base := llb.Image("busybox:latest") - - const numLayers = 110 - - for range numLayers { - base = base.Run(llb.Shlex("sh -c 'echo hello >> /hello'")).Root() - } - - def, err := base.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(ctx, def, SolveOpt{}, nil) - require.NoError(t, err) - - ls := llb.Image("busybox:latest"). - Run(llb.Shlexf("ls -l /base/hello")) - ls.AddMount("/base", base, llb.Readonly) - - def, err = ls.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(ctx, def, SolveOpt{}, nil) - require.NoError(t, err) -} - -func testClientCustomGRPCOpts(t *testing.T, sb integration.Sandbox) { - var interceptedMethods []string - intercept := func( - ctx context.Context, - method string, - req, - reply any, - cc *grpc.ClientConn, - invoker grpc.UnaryInvoker, - opts ...grpc.CallOption, - ) error { - interceptedMethods = append(interceptedMethods, method) - return invoker(ctx, method, req, reply, cc, opts...) - } - c, err := New(sb.Context(), sb.Address(), WithGRPCDialOption(grpc.WithChainUnaryInterceptor(intercept))) - require.NoError(t, err) - defer c.Close() - - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - st := llb.Image(imgName) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - require.Contains(t, interceptedMethods, "/moby.buildkit.v1.Control/Solve") -} - -func testRunValidExitCodes(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // no exit codes specified, equivalent to [0] - imgName := integration.UnixOrWindows("busybox:latest", "nanoserver:latest") - out := llb.Image(imgName) - shellPrefix := integration.UnixOrWindows("sh -c", "cmd /C") - - out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix)).Root() - out = out.Run(llb.Shlexf(`%s "exit 1"`, shellPrefix)).Root() - def, err := out.Marshal(sb.Context()) - require.NoError(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.ErrorContains(t, err, "exit code: 1") - - // empty exit codes, equivalent to [0] - out = llb.Image(imgName) - out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix), llb.ValidExitCodes()).Root() - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // if we expect non-zero, those non-zero codes should succeed - out = llb.Image(imgName) - out = out.Run(llb.Shlexf(`%s "exit 1"`, shellPrefix), llb.ValidExitCodes(1)).Root() - out = out.Run(llb.Shlexf(`%s "exit 2"`, shellPrefix), llb.ValidExitCodes(2, 3)).Root() - out = out.Run(llb.Shlexf(`%s "exit 3"`, shellPrefix), llb.ValidExitCodes(2, 3)).Root() - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.NoError(t, err) - - // if we expect non-zero, returning zero should fail - out = llb.Image(imgName) - out = out.Run(llb.Shlexf(`%s "exit 0"`, shellPrefix), llb.ValidExitCodes(1)).Root() - def, err = out.Marshal(sb.Context()) - require.NoError(t, err) - _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) - require.Error(t, err) - require.ErrorContains(t, err, "exit code: 0") -} - -func testGitResolveSourceMetadata(t *testing.T, sb integration.Sandbox) { - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - gitDir := t.TempDir() - gitCommands := []string{ - "git init", - "git config --local user.email test@example.com", - "git config --local user.name test", - "touch a", - "git add a", - "git commit -m msg", - "git tag -a v0.1 -m v0.1release", - "echo b > b", - "git add b", - "git commit -m b", - "git checkout -B v2", - "git update-server-info", - } - err = runInDir(gitDir, gitCommands...) - require.NoError(t, err) - - cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") - cmd.Dir = gitDir - out, err := cmd.Output() - require.NoError(t, err) - commitHEAD := strings.TrimSpace(string(out)) - - cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1") - cmd.Dir = gitDir - out, err = cmd.Output() - require.NoError(t, err) - commitTag := strings.TrimSpace(string(out)) - - cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1^{commit}") - cmd.Dir = gitDir - out, err = cmd.Output() - require.NoError(t, err) - commitTagCommit := strings.TrimSpace(string(out)) - - server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) - defer server.Close() - - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - id := "git://" + strings.TrimPrefix(server.URL, "http://") + "/.git" - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - Attrs: map[string]string{ - "git.fullurl": server.URL + "/.git", - }, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.Git) - require.Equal(t, "refs/heads/v2", md.Git.Ref) // default to branch head - require.Equal(t, commitHEAD, md.Git.Checksum) - require.Equal(t, "", md.Git.CommitChecksum) // not annotated tag - require.Equal(t, id, md.Op.Identifier) - require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) - require.Nil(t, md.Git.CommitObject) - require.Nil(t, md.Git.TagObject) - - id += "#v0.1" - md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - Attrs: map[string]string{ - "git.fullurl": server.URL + "/.git", - }, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.Git) - require.Equal(t, "refs/tags/v0.1", md.Git.Ref) - require.Equal(t, commitTag, md.Git.Checksum) // annotated tag - require.Equal(t, commitTagCommit, md.Git.CommitChecksum) - - require.Equal(t, id, md.Op.Identifier) - require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) - require.Nil(t, md.Git.CommitObject) - require.Nil(t, md.Git.TagObject) - - md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - Attrs: map[string]string{ - "git.fullurl": server.URL + "/.git", - }, - }, sourceresolver.Opt{ - GitOpt: &sourceresolver.ResolveGitOpt{ - ReturnObject: true, - }, - }) - if err != nil { - return nil, err - } - require.NotNil(t, md.Git) - require.Equal(t, "refs/tags/v0.1", md.Git.Ref) - require.Equal(t, commitTag, md.Git.Checksum) // annotated tag - require.Equal(t, commitTagCommit, md.Git.CommitChecksum) - - require.Equal(t, id, md.Op.Identifier) - require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) - require.NotNil(t, md.Git.CommitObject) - require.NotNil(t, md.Git.TagObject) - - commitObj, err := gitobject.Parse(md.Git.CommitObject) - require.NoError(t, err) - require.NoError(t, commitObj.VerifyChecksum(md.Git.CommitChecksum)) - - commit, err := commitObj.ToCommit() - require.NoError(t, err) - require.Equal(t, "msg", commit.Message) - require.Equal(t, "test", commit.Author.Name) - require.Equal(t, "test@example.com", commit.Author.Email) - require.Equal(t, "test", commit.Committer.Name) - require.Equal(t, "test@example.com", commit.Committer.Email) - commitTime := commit.Committer.When - require.NotNil(t, commitTime) - require.WithinDuration(t, time.Now(), *commitTime, 2*time.Minute) - - tagObj, err := gitobject.Parse(md.Git.TagObject) - require.NoError(t, err) - require.NoError(t, tagObj.VerifyChecksum(md.Git.Checksum)) - - tag, err := tagObj.ToTag() - require.NoError(t, err) - require.Equal(t, "v0.1release", tag.Message) - require.Equal(t, "v0.1", tag.Tag) - require.Equal(t, "test", tag.Tagger.Name) - require.Equal(t, "test@example.com", tag.Tagger.Email) - tagTime := tag.Tagger.When - require.NotNil(t, tagTime) - require.WithinDuration(t, time.Now(), *tagTime, 2*time.Minute) - return nil, nil - }, nil) - require.NoError(t, err) -} - -func testHTTPResolveSourceMetadata(t *testing.T, sb integration.Sandbox) { - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - modTime := time.Now().Add(-24 * time.Hour) // avoid falso positive with current time - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - LastModified: &modTime, - } - - resp2 := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content2"), - ContentDisposition: "attachment; filename=\"my img.jpg\"", - } - - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - "/bar": resp2, - }) - defer server.Close() - - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - id := server.URL + "/foo" - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) - require.Equal(t, "foo", md.HTTP.Filename) - require.NotNil(t, md.HTTP.LastModified) - require.Equal(t, modTime.Unix(), md.HTTP.LastModified.Unix()) - require.Equal(t, id, md.Op.Identifier) - - id = server.URL + "/bar" - md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - require.Equal(t, digest.FromBytes(resp2.Content), md.HTTP.Digest) - require.Equal(t, "my img.jpg", md.HTTP.Filename) - require.Nil(t, md.HTTP.LastModified) - require.Equal(t, id, md.Op.Identifier) - return nil, nil - }, nil) - require.NoError(t, err) -} - -func testImageResolveConfigDefaultLocalFallback(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureImageExporter) - - cdAddress := sb.ContainerdAddress() - if cdAddress == "" { - t.Skip("test requires containerd worker") - } - - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - cdClient, err := newContainerd(cdAddress) - require.NoError(t, err) - defer cdClient.Close() - - registry, err := sb.NewRegistry() - if errors.Is(err, integration.ErrRequirements) { - t.Skip(err.Error()) - } - require.NoError(t, err) - - target := registry + "/buildkit/default-local-fallback:" + identity.NewID() - st := integration.UnixOrWindows( - llb.Scratch(), - llb.Image("nanoserver:latest"), - ) - def, err := st.File(llb.Mkfile("/fallback", 0600, []byte("local"))).Marshal(ctx) - require.NoError(t, err) - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "store": "true", - }, - }, - }, - }, nil) - require.NoError(t, err) - - imageService := cdClient.ImageService() - imageStoreCtx := namespaces.WithNamespace(ctx, "buildkit") - img, err := imageService.Get(imageStoreCtx, target) - require.NoError(t, err) - - _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - ref, dgst, config, err := c.ResolveImageConfig(ctx, target, sourceresolver.Opt{ - ImageOpt: &sourceresolver.ResolveImageOpt{ - ResolveMode: pb.AttrImageResolveModeDefault, - }, - }) - if err != nil { - return nil, err - } - require.Equal(t, target, ref) - require.Equal(t, img.Target.Digest, dgst) - require.NotEmpty(t, config) - - var ociimg ocispecs.Image - require.NoError(t, json.Unmarshal(config, &ociimg)) - require.NotEmpty(t, ociimg.OS) - require.NotEmpty(t, ociimg.Architecture) - - return gateway.NewResult(), nil - }, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, true) -} - -func testImageResolveAttestationChainRequiresNetwork(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureMultiPlatform, workers.FeatureProvenance) - // this test temporarily requires direct registry access as the integration test - // mirroring system does not support mirroring attestation chains yet. - // Support is coming in future buildx release. - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - amd64, err := platforms.Parse("linux/amd64") - require.NoError(t, err) - - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - const rootDigest = "sha256:4e91099af134f4b1f509fecbd1a55981dfff18d8029d6782e28413210ef468b7" - const imageDigest = "sha256:d2f0b39234a66c3d58f24200d3fda9e4e3d2263c09f9b7286a826ab639713047" - const attestationDigest = "sha256:fb4c46b14f52d1bf790f593921c52dddc698fc6780792fb8469fc60efc0e609b" - const sigDigest = "sha256:bd0a6b088440ba9838e8eec79e736128fe52afce934578eae30ca8675f6d3142" - - id := "registry-1-stage.docker.io/docker/github-builder-test@" + rootDigest - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: "docker-image://" + id, - }, sourceresolver.Opt{ - ImageOpt: &sourceresolver.ResolveImageOpt{ - NoConfig: true, - AttestationChain: true, - Platform: &amd64, - }, - }) - if err != nil { - return nil, err - } - require.Equal(t, rootDigest, md.Image.Digest.String()) - require.Nil(t, md.Image.Config) - require.NotNil(t, md.Image) - require.NotNil(t, md.Image.AttestationChain) - ac := md.Image.AttestationChain - require.Equal(t, rootDigest, ac.Root.String()) - require.Equal(t, imageDigest, ac.ImageManifest.String()) - require.Equal(t, attestationDigest, ac.AttestationManifest.String()) - require.Len(t, ac.SignatureManifests, 1) - require.Equal(t, sigDigest, ac.SignatureManifests[0].String()) - - desc := ac.Blobs[ac.Root] - require.Equal(t, rootDigest, desc.Descriptor.Digest.String()) - require.Len(t, desc.Data, int(desc.Descriptor.Size)) - require.Equal(t, ocispecs.MediaTypeImageIndex, desc.Descriptor.MediaType) - chk := digest.FromBytes(desc.Data) - require.Equal(t, rootDigest, chk.String()) - - desc = ac.Blobs[ac.ImageManifest] - // image manifest is expected to be missing as content is not needed to verify attestation chain - _, ok := ac.Blobs[digest.Digest(imageDigest)] - require.False(t, ok) - - desc = ac.Blobs[ac.AttestationManifest] - require.Equal(t, attestationDigest, desc.Descriptor.Digest.String()) - require.Equal(t, ocispecs.MediaTypeImageManifest, desc.Descriptor.MediaType) - require.Len(t, desc.Data, int(desc.Descriptor.Size)) - chk = digest.FromBytes(desc.Data) - require.Equal(t, attestationDigest, chk.String()) - - desc = ac.Blobs[ac.SignatureManifests[0]] - require.Equal(t, sigDigest, desc.Descriptor.Digest.String()) - require.Equal(t, ocispecs.MediaTypeImageManifest, desc.Descriptor.MediaType) - require.Len(t, desc.Data, int(desc.Descriptor.Size)) - chk = digest.FromBytes(desc.Data) - require.Equal(t, sigDigest, chk.String()) - - var sigMfst ocispecs.Manifest - err = json.Unmarshal(ac.Blobs[ac.SignatureManifests[0]].Data, &sigMfst) - require.NoError(t, err) - require.Equal(t, 1, len(sigMfst.Layers)) - sigLayer := sigMfst.Layers[0] - sigDesc := ac.Blobs[digest.Digest(sigLayer.Digest)] - require.Equal(t, sigLayer.Digest, sigDesc.Descriptor.Digest) - require.Len(t, sigDesc.Data, int(sigDesc.Descriptor.Size)) - require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", sigDesc.Descriptor.MediaType) - chk = digest.FromBytes(sigDesc.Data) - require.Equal(t, sigLayer.Digest, chk) - - require.Len(t, md.Image.AttestationChain.Blobs, 4) - return nil, nil - }, nil) - require.NoError(t, err) -} - -func testImageResolveProvenanceAttestation(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) - requiresLinux(t) - - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - target, platform := buildProvenanceImage(ctx, t, c, sb) - - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: "docker-image://" + target, - }, sourceresolver.Opt{ - ImageOpt: &sourceresolver.ResolveImageOpt{ - NoConfig: true, - ResolveAttestations: []string{ - policyimage.SLSAProvenancePredicateType02, - policyimage.SLSAProvenancePredicateType1, - }, - Platform: &platform, - ResolveMode: pb.AttrImageResolveModeForcePull, - }, - }) - if err != nil { - return nil, err - } - require.NotNil(t, md.Image) - require.NotNil(t, md.Image.AttestationChain) - ac := md.Image.AttestationChain - require.NotEmpty(t, ac.AttestationManifest) - att := ac.Blobs[ac.AttestationManifest] - require.NotEmpty(t, att.Data) - - var manifest ocispecs.Manifest - require.NoError(t, json.Unmarshal(att.Data, &manifest)) - require.NotEmpty(t, manifest.Layers) - var ( - stmtBytes []byte - foundLayer ocispecs.Descriptor - ) - for _, layer := range manifest.Layers { - if !isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { - continue - } - blob, ok := ac.Blobs[layer.Digest] - if !ok { - continue - } - stmtBytes = blob.Data - foundLayer = layer - break - } - require.NotEmpty(t, stmtBytes) - require.Contains(t, []string{ - policyimage.SLSAProvenancePredicateType02, - policyimage.SLSAProvenancePredicateType1, - }, foundLayer.Annotations["in-toto.io/predicate-type"]) - - var stmt intoto.Statement - require.NoError(t, json.Unmarshal(stmtBytes, &stmt)) - require.Equal(t, intoto.StatementInTotoV1, stmt.Type) - require.Contains(t, []string{ - policyimage.SLSAProvenancePredicateType02, - policyimage.SLSAProvenancePredicateType1, - }, stmt.PredicateType) - require.Equal(t, stmt.Subject[0].Digest["sha256"], ac.ImageManifest.Hex()) - return nil, nil - }, nil) - require.NoError(t, err) -} - -func testImageResolveAttestationChainLocal(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) - requiresLinux(t) - - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - target, platform := buildProvenanceImage(ctx, t, c, sb) - - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: "docker-image://" + target, - }, sourceresolver.Opt{ - ImageOpt: &sourceresolver.ResolveImageOpt{ - NoConfig: true, - AttestationChain: true, - Platform: &platform, - ResolveMode: pb.AttrImageResolveModeForcePull, - }, - }) - if err != nil { - return nil, err - } - require.NotNil(t, md.Image) - require.NotNil(t, md.Image.AttestationChain) - ac := md.Image.AttestationChain - require.NotEmpty(t, ac.AttestationManifest) - att := ac.Blobs[ac.AttestationManifest] - require.NotEmpty(t, att.Data) - - var manifest ocispecs.Manifest - require.NoError(t, json.Unmarshal(att.Data, &manifest)) - require.NotEmpty(t, manifest.Layers) - found := false - for _, layer := range manifest.Layers { - if isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { - found = true - break - } - } - require.True(t, found) - return nil, nil - }, nil) - require.NoError(t, err) -} - -func buildProvenanceImage(ctx context.Context, t *testing.T, c *Client, sb integration.Sandbox) (string, ocispecs.Platform) { - t.Helper() - - registry, err := sb.NewRegistry() - if errors.Is(err, integration.ErrRequirements) { - t.Skip(err.Error()) - } - require.NoError(t, err) - - platform := platforms.Normalize(platforms.DefaultSpec()) - platformKey := platforms.Format(platform) - target := registry + "/buildkit/testprovenance:latest" - - frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - res := gateway.NewResult() - - st := llb.Scratch().File( - llb.Mkfile("/greeting", 0600, []byte("hello provenance")), - ) - def, err := st.Marshal(ctx) - if err != nil { - return nil, err - } - r, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - ref, err := r.SingleRef() - if err != nil { - return nil, err - } - _, err = ref.ToState() - if err != nil { - return nil, err - } - res.AddRef(platformKey, ref) - - img := ocispecs.Image{ - Platform: platform, - } - config, err := json.Marshal(img) - if err != nil { - return nil, errors.Wrapf(err, "failed to marshal image config") - } - res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, platformKey), config) - - 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 - } - - _, err = c.Build(ctx, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "mode=max,version=v1", - }, - Exports: []ExportEntry{ - { - Type: ExporterImage, - Attrs: map[string]string{ - "name": target, - "push": "true", - }, - }, - }, - }, "", frontend, nil) - require.NoError(t, err) - - return target, platform -} - -// isSLSAPredicateType reports whether the predicate type represents SLSA provenance. -func isSLSAPredicateType(v string) bool { - switch v { - case policyimage.SLSAProvenancePredicateType02, policyimage.SLSAProvenancePredicateType1: - return true - default: - return false - } -} - -func testHTTPPruneAfterCacheKey(t *testing.T, sb integration.Sandbox) { - // this test depends on hitting race condition in internal functions. - // If debugging and expecting failure you can add small sleep in beginning of source/http.Exec() to hit reliably - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - } - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - done := make(chan struct{}) - - startScan := make(chan struct{}) - stopScan := make(chan struct{}) - pauseScan := make(chan struct{}) - - go func() { - // attempt to prune the HTTP record in between cachekey and snapshot - defer close(done) - for { - select { - case <-startScan: - scan: - for { - select { - case <-pauseScan: - break scan - default: - du, err := c.DiskUsage(ctx) - assert.NoError(t, err) - for _, entry := range du { - if entry.Description == "http url "+server.URL+"/foo" { - if !entry.InUse { - t.Logf("entry no longer in use, pruning") - err = c.Prune(ctx, nil) - assert.NoError(t, err) - - resp.Etag = identity.NewID() - resp.Content = []byte("content2") - } - } - } - } - } - case <-stopScan: - return - } - } - }() - - const iterations = 10 - for range iterations { - startScan <- struct{}{} - resp.Etag = identity.NewID() - resp.Content = []byte("content1") - _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - st := llb.Scratch().File(llb.Copy(llb.HTTP(server.URL+"/foo"), "foo", "bar")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - resp, err := c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - if err != nil { - return nil, err - } - - return resp, nil - }, nil) - require.NoError(t, err) - - pauseScan <- struct{}{} - - err = c.Prune(ctx, nil) - require.NoError(t, err) - - checkAllReleasable(t, c, sb, false) - } - close(stopScan) - <-done -} - -// testHTTPPruneAfterResolveMeta ensures that pruning after ResolveSourceMetadata -// doesn't pull in new data for same build. Once URL has been resolved once for a specific -// build, the data should be considered immutable and remote changes don't affect ongoing build. -func testHTTPPruneAfterResolveMeta(t *testing.T, sb integration.Sandbox) { - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - } - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - dest := t.TempDir() - - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dest, - }, - }, - }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { - id := server.URL + "/foo" - md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - - // prune all - err = c.Prune(ctx, nil) - require.NoError(t, err) - - resp.Content = []byte("content2") // etag is same so should hit cache if record not pruned - - st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return gc.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(dest, "bar")) - require.NoError(t, err) - require.Equal(t, "content1", string(dt)) - - checkAllReleasable(t, c, sb, false) -} - -func testHTTPResolveMetaReuse(t *testing.T, sb integration.Sandbox) { - // the difference with testHTTPPruneAfterResolveMeta is that here we change content with the etag on the server - // but because the URL was already resolved once, the new content should not be seen - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - } - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - dest := t.TempDir() - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dest, - }, - }, - }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { - id := server.URL + "/foo" - md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - - resp.Etag = identity.NewID() - resp.Content = []byte("content2") // etag changed so new content would be returned if re-resolving - - st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return gc.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(dest, "bar")) - require.NoError(t, err) - require.Equal(t, "content1", string(dt)) -} - -// testHTTPResolveMultiBuild is a negative test for testHTTPResolveMetaReuse to ensure that -// URLs are resolved in between separate builds -func testHTTPResolveMultiBuild(t *testing.T, sb integration.Sandbox) { - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - resp := &httpserver.Response{ - Etag: identity.NewID(), - Content: []byte("content1"), - } - server := httpserver.NewTestServer(map[string]*httpserver.Response{ - "/foo": resp, - }) - defer server.Close() - - dest := t.TempDir() - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dest, - }, - }, - }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { - id := server.URL + "/foo" - md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) - - st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return gc.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(dest, "bar")) - require.NoError(t, err) - require.Equal(t, "content1", string(dt)) - - resp.Etag = identity.NewID() - resp.Content = []byte("content2") - - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dest, - }, - }, - }, "test", func(ctx context.Context, gc gateway.Client) (*gateway.Result, error) { - id := server.URL + "/foo" - md, err := gc.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.HTTP) - require.Equal(t, digest.FromBytes(resp.Content), md.HTTP.Digest) - st := llb.Scratch().File(llb.Copy(llb.HTTP(id), "foo", "bar")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return gc.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - dt, err = os.ReadFile(filepath.Join(dest, "bar")) - require.NoError(t, err) - require.Equal(t, "content2", string(dt)) -} - -func testGitResolveMutatedSource(t *testing.T, sb integration.Sandbox) { - integration.SkipOnPlatform(t, "windows") - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - gitDir := t.TempDir() - gitCommands := []string{ - "git init", - "git config --local user.email test", - "git config --local user.name test", - "echo a > a", - "git add a", - "git commit -m a", - "git tag -a v0.1 -m v0.1", - "echo b > b", - "git add b", - "git commit -m b", - "git checkout -B v2", - "git update-server-info", - } - err = runInDir(gitDir, gitCommands...) - require.NoError(t, err) - - cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1") - cmd.Dir = gitDir - out, err := cmd.Output() - require.NoError(t, err) - commitTag := strings.TrimSpace(string(out)) - - cmd = exec.CommandContext(context.TODO(), "git", "rev-parse", "v0.1^{commit}") - cmd.Dir = gitDir - out, err = cmd.Output() - require.NoError(t, err) - commitTagCommit := strings.TrimSpace(string(out)) - - server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) - defer server.Close() - - dest := t.TempDir() - - _, err = c.Build(ctx, SolveOpt{ - Exports: []ExportEntry{ - { - Type: ExporterLocal, - OutputDir: dest, - }, - }, - }, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { - id := "git://" + strings.TrimPrefix(server.URL, "http://") + "/.git#v0.1" - md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - Attrs: map[string]string{ - "git.fullurl": server.URL + "/.git", - }, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.Git) - require.Equal(t, "refs/tags/v0.1", md.Git.Ref) - require.Equal(t, commitTag, md.Git.Checksum) - require.Equal(t, commitTagCommit, md.Git.CommitChecksum) - require.Equal(t, id, md.Op.Identifier) - require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) - - // update the tag to point to a different commit - err = runInDir(gitDir, []string{ - "git tag -f v0.1", - "git update-server-info", - }...) - require.NoError(t, err) - - md, err = c.ResolveSourceMetadata(ctx, &pb.SourceOp{ - Identifier: id, - Attrs: map[string]string{ - "git.fullurl": server.URL + "/.git", - }, - }, sourceresolver.Opt{}) - if err != nil { - return nil, err - } - require.NotNil(t, md.Git) - require.Equal(t, "refs/tags/v0.1", md.Git.Ref) - require.Equal(t, commitTag, md.Git.Checksum) - require.Equal(t, commitTagCommit, md.Git.CommitChecksum) - require.Equal(t, id, md.Op.Identifier) - require.Equal(t, server.URL+"/.git", md.Op.Attrs["git.fullurl"]) - - st := llb.Git(server.URL+"/.git", "", llb.GitRef("v0.1")) - def, err := st.Marshal(sb.Context()) - if err != nil { - return nil, err - } - return c.Solve(ctx, gateway.SolveRequest{ - Definition: def.ToPB(), - }) - }, nil) - require.NoError(t, err) - - _, err = os.ReadFile(filepath.Join(dest, "b")) - require.Error(t, err) - require.True(t, os.IsNotExist(err), "expected file b to not exist") - - dt, err := os.ReadFile(filepath.Join(dest, "a")) - require.NoError(t, err) - require.Equal(t, "a\n", string(dt)) - - checkAllReleasable(t, c, sb, false) -} - -// testGitBundleRoundTrip exercises the bundle round-trip end to end: -// -// 1. Seed a git repo served over HTTP. -// 2. Build 1 uses GitCheckoutBundle() to export a single-file git bundle. -// 3. Build 2 imports that bundle via GitBundleURL("oci-layout+blob://...") -// against a client-side OCI layout store, once with the sha pinned as ref -// and once with a symbolic ref ("master"). Both checkouts must succeed -// and produce a worktree matching Build 0 (plain git clone). -// 4. A KeepGitDir subtest exercises llb.KeepGitDir() + bundle and asserts -// no fake refs/buildkit/* namespace leaks into the checkout's .git dir. -// 5. A provenance subtest enables attest:provenance and asserts both the -// git-commit material and the bundle-blob material are recorded. -func testGitBundleRoundTrip(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureOCILayout) - integration.SkipOnPlatform(t, "windows") - ctx := sb.Context() - c, err := New(ctx, sb.Address()) - require.NoError(t, err) - defer c.Close() - - gitDir := t.TempDir() - err = runInDir(gitDir, - "git init -b master", - "git config --local user.email test@example.com", - "git config --local user.name test", - "echo hello > file.txt", - "git add file.txt", - "git commit -m initial", - "git update-server-info", - ) - require.NoError(t, err) - - cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") - cmd.Dir = gitDir - out, err := cmd.Output() - require.NoError(t, err) - headSha := strings.TrimSpace(string(out)) - - server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) - defer server.Close() - repoURL := server.URL + "/.git" - - // Build 1: export a bundle via GitCheckoutBundle. - bundleDir := t.TempDir() - st := llb.Git(repoURL, "master", llb.GitChecksum(headSha), llb.GitCheckoutBundle()) - def, err := st.Marshal(ctx) - require.NoError(t, err) - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: bundleDir}}, - }, nil) - require.NoError(t, err) - - bundleBytes, err := os.ReadFile(filepath.Join(bundleDir, "bundle")) - require.NoError(t, err) - require.NotEmpty(t, bundleBytes) - bundleDgst := digest.FromBytes(bundleBytes) - - // Sanity-check the bundle locally: it must contain the commit we pinned, - // landing in the natural ref namespace (refs/heads/master). - localBare := t.TempDir() - err = runInDir(localBare, "git init --bare") - require.NoError(t, err) - bundleRoot := t.TempDir() - root, err := os.OpenRoot(bundleRoot) - require.NoError(t, err) - require.NoError(t, root.WriteFile("bundle.pack", bundleBytes, 0644)) - require.NoError(t, root.Close()) - bundlePath := filepath.Join(bundleRoot, "bundle.pack") - err = runInDir(localBare, fmt.Sprintf("git fetch %s +refs/*:refs/*", bundlePath)) - require.NoError(t, err) - //nolint:gosec // Test-controlled temp dir and commit SHA are not attacker input. - cmd = exec.CommandContext(context.TODO(), "git", "--git-dir="+localBare, "cat-file", "-e", headSha+"^{commit}") - require.NoError(t, cmd.Run(), "bundle does not contain expected commit") - //nolint:gosec // Test-controlled temp dir is not attacker input. - cmd = exec.CommandContext(context.TODO(), "git", "--git-dir="+localBare, "rev-parse", "--verify", "refs/heads/master") - masterOut, err := cmd.CombinedOutput() - require.NoError(t, err, "bundle should carry user's ref refs/heads/master; output=%q", string(masterOut)) - require.Equal(t, headSha, strings.TrimSpace(string(masterOut))) - - // Stage the bundle in a client-side OCI layout store so Build 2 can - // resolve it through an oci-layout+blob:// locator. - storeDir := t.TempDir() - store, err := local.NewStore(storeDir) - require.NoError(t, err) - err = content.WriteBlob(ctx, store, "bundle-"+bundleDgst.String(), bytes.NewReader(bundleBytes), - ocispecs.Descriptor{Digest: bundleDgst, Size: int64(len(bundleBytes))}) - require.NoError(t, err) - - csID := "git-bundle-oci-store" - bundleLocator := fmt.Sprintf("oci-layout+blob://not/real@%s", bundleDgst.String()) - - cases := []struct { - name string - ref string // value passed as the fragment/ref to llb.Git - }{ - {"sha-ref", headSha}, - {"symbolic-ref", "master"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - destDir := t.TempDir() - st := llb.Git( - repoURL, - tc.ref, - llb.GitChecksum(headSha), - llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) - require.NoError(t, err) - require.Equal(t, "hello\n", string(dt)) - }) - } - - // KeepGitDir + bundle: the checkout's .git must not contain any leaked - // refs/buildkit/* namespace. `.git/refs/heads/*` and `.git/refs/tags/*` - // are populated by the plain-git checkout path (it runs its own fetch - // into a freshly inited repo), so we only assert no refs/buildkit/ - // leak — the whole point of Item A is that the bundle no longer uses - // that namespace. - t.Run("keepgitdir-no-namespace-leak", func(t *testing.T) { - destDir := t.TempDir() - st := llb.Git( - repoURL, - "master", - llb.GitChecksum(headSha), - llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), - llb.KeepGitDir(), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, nil) - require.NoError(t, err) - - // .git dir should be present. - st2, err := os.Stat(filepath.Join(destDir, ".git")) - require.NoError(t, err) - require.True(t, st2.IsDir(), ".git should be a directory") - - // No fake internal namespace anywhere under the checkout's .git. - leakRoot := filepath.Join(destDir, ".git", "refs", "buildkit") - _, err = os.Stat(leakRoot) - require.True(t, os.IsNotExist(err), ".git/refs/buildkit should not exist: %v", err) - - // Also scan packed-refs, which is where cloned/fetched refs may - // live on disk after git gc / pack-refs. - if pr, err := os.ReadFile(filepath.Join(destDir, ".git", "packed-refs")); err == nil { - require.NotContains(t, string(pr), "refs/buildkit/", - "packed-refs should not carry refs/buildkit/* entries") - } - - // The user's ref name must survive into .git: passing "master" to - // llb.Git should produce a checkout whose .git carries - // refs/heads/master, either loose or packed. - //nolint:gosec // Test-controlled export dir is not attacker input. - cmd := exec.CommandContext(context.TODO(), "git", - "--git-dir="+filepath.Join(destDir, ".git"), - "rev-parse", "--verify", "refs/heads/master") - refOut, err := cmd.CombinedOutput() - require.NoError(t, err, ".git should carry refs/heads/master; output=%q", string(refOut)) - require.Equal(t, headSha, strings.TrimSpace(string(refOut))) - - // HEAD must point at the pinned commit. - //nolint:gosec // Test-controlled export dir is not attacker input. - cmd = exec.CommandContext(context.TODO(), "git", - "--git-dir="+filepath.Join(destDir, ".git"), - "rev-parse", "HEAD") - gotOut, err := cmd.CombinedOutput() - require.NoError(t, err, "output=%q", string(gotOut)) - require.Equal(t, headSha, strings.TrimSpace(string(gotOut))) - - // file.txt content matches. - dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) - require.NoError(t, err) - require.Equal(t, "hello\n", string(dt)) - }) - - // Provenance subtest: assert that the git-commit material and the - // bundle-blob material are both recorded. - t.Run("provenance", func(t *testing.T) { - workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance) - destDir := t.TempDir() - st := llb.Scratch().File( - llb.Copy( - llb.Git( - repoURL, - "master", - llb.GitChecksum(headSha), - llb.GitBundleURL(bundleLocator, llb.GitBundleOCIStore("", csID)), - ), - "file.txt", - "file.txt", - ), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "", - }, - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, - OCIStores: map[string]content.Store{ - csID: store, - }, - }, nil) - require.NoError(t, err) - - provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) - require.NoError(t, err) - - var stmt struct { - intoto.StatementHeader - Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` - } - require.NoError(t, json.Unmarshal(provDt, &stmt)) - - // Bundle-backed git sources emit two materials: the git source - // at its raw repository URL (same shape as a non-bundle git - // source), and the bundle blob as a purl with a vcs_url - // qualifier linking back to the git URL. - expectedGitURI := repoURL + "#master" - var foundCommit, foundBundle bool - for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { - if m.URI == expectedGitURI && m.Digest["sha1"] == headSha { - foundCommit = true - } - if strings.HasPrefix(m.URI, "pkg:oci/") && strings.Contains(m.URI, "ref_type=bundle") && strings.Contains(m.URI, "vcs_url=") && m.Digest["sha256"] == bundleDgst.Hex() { - foundBundle = true - } - } - require.True(t, foundCommit, "expected git commit material %q with sha1=%s in %+v", expectedGitURI, headSha, stmt.Predicate.BuildDefinition.ResolvedDependencies) - require.True(t, foundBundle, "expected bundle blob material (pkg:oci/...?ref_type=bundle) with sha256=%s in %+v", bundleDgst.Hex(), stmt.Predicate.BuildDefinition.ResolvedDependencies) - }) - - checkAllReleasable(t, c, sb, false) -} - -// testGitBundleRoundTripRegistry mirrors testGitBundleRoundTrip but resolves -// the bundle blob through a registry using the docker-image+blob:// scheme. -// The bundle bytes are uploaded directly to the sandbox registry by digest -// and then re-imported in Build 2. -func testGitBundleRoundTripRegistry(t *testing.T, sb integration.Sandbox) { - workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) - integration.SkipOnPlatform(t, "windows") - requiresLinux(t) - ctx := sb.Context() - c, err := New(ctx, 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) - - gitDir := t.TempDir() - err = runInDir(gitDir, - "git init -b master", - "git config --local user.email test@example.com", - "git config --local user.name test", - "echo hello > file.txt", - "git add file.txt", - "git commit -m initial", - "git update-server-info", - ) - require.NoError(t, err) - - cmd := exec.CommandContext(context.TODO(), "git", "rev-parse", "HEAD") - cmd.Dir = gitDir - out, err := cmd.Output() - require.NoError(t, err) - headSha := strings.TrimSpace(string(out)) - - server := httptest.NewServer(http.FileServer(http.Dir(filepath.Clean(gitDir)))) - defer server.Close() - repoURL := server.URL + "/.git" - - // Build 1: export a bundle via GitCheckoutBundle. - bundleDir := t.TempDir() - st := llb.Git(repoURL, "master", llb.GitChecksum(headSha), llb.GitCheckoutBundle()) - def, err := st.Marshal(ctx) - require.NoError(t, err) - - _, err = c.Solve(ctx, def, SolveOpt{ - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: bundleDir}}, - }, nil) - require.NoError(t, err) - - bundleBytes, err := os.ReadFile(filepath.Join(bundleDir, "bundle")) - require.NoError(t, err) - require.NotEmpty(t, bundleBytes) - bundleDgst := digest.FromBytes(bundleBytes) - - // Push the bundle as a raw blob into the sandbox registry under a - // repository name. The docker registry accepts arbitrary blobs by - // digest; the repository exists implicitly once any blob is uploaded - // under its name. - bundleRepoRef := registry + "/foo/bundle@" + bundleDgst.String() - ingester, err := contentutil.IngesterFromRef(bundleRepoRef) - require.NoError(t, err) - err = content.WriteBlob(ctx, ingester, "bundle-"+bundleDgst.String(), bytes.NewReader(bundleBytes), - ocispecs.Descriptor{Digest: bundleDgst, Size: int64(len(bundleBytes))}) - require.NoError(t, err) - - bundleLocator := "docker-image+blob://" + registry + "/foo/bundle@" + bundleDgst.String() - - t.Run("checkout", func(t *testing.T) { - destDir := t.TempDir() - st := llb.Git( - repoURL, - "master", - llb.GitChecksum(headSha), - llb.GitBundleURL(bundleLocator), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, - }, nil) - require.NoError(t, err) - - dt, err := os.ReadFile(filepath.Join(destDir, "file.txt")) - require.NoError(t, err) - require.Equal(t, "hello\n", string(dt)) - }) - - t.Run("provenance", func(t *testing.T) { - workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance) - destDir := t.TempDir() - st := llb.Scratch().File( - llb.Copy( - llb.Git( - repoURL, - "master", - llb.GitChecksum(headSha), - llb.GitBundleURL(bundleLocator), - ), - "file.txt", - "file.txt", - ), - ) - def, err := st.Marshal(sb.Context()) - require.NoError(t, err) - - _, err = c.Solve(sb.Context(), def, SolveOpt{ - FrontendAttrs: map[string]string{ - "attest:provenance": "", - }, - Exports: []ExportEntry{{Type: ExporterLocal, OutputDir: destDir}}, - }, nil) - require.NoError(t, err) - - provDt, err := os.ReadFile(filepath.Join(destDir, "provenance.json")) - require.NoError(t, err) - - var stmt struct { - intoto.StatementHeader - Predicate provenancetypes.ProvenancePredicateSLSA1 `json:"predicate"` - } - require.NoError(t, json.Unmarshal(provDt, &stmt)) - - expectedGitURI := repoURL + "#master" - var foundCommit, foundBundle bool - for _, m := range stmt.Predicate.BuildDefinition.ResolvedDependencies { - if m.URI == expectedGitURI && m.Digest["sha1"] == headSha { - foundCommit = true - } - if strings.HasPrefix(m.URI, "pkg:docker/") && strings.Contains(m.URI, "ref_type=bundle") && strings.Contains(m.URI, "vcs_url=") && m.Digest["sha256"] == bundleDgst.Hex() { - foundBundle = true - } - } - require.True(t, foundCommit, "expected git commit material %q with sha1=%s in %+v", expectedGitURI, headSha, stmt.Predicate.BuildDefinition.ResolvedDependencies) - require.True(t, foundBundle, "expected bundle blob material (pkg:docker/...?ref_type=bundle) with sha256=%s in %+v", bundleDgst.Hex(), stmt.Predicate.BuildDefinition.ResolvedDependencies) - }) - - checkAllReleasable(t, c, sb, false) -} - -func runInDir(dir string, cmds ...string) error { - return runInDirEnv(dir, nil, cmds...) -} - -func runInDirEnv(dir string, env []string, cmds ...string) error { - for _, args := range cmds { - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.CommandContext(context.TODO(), "powershell", "-command", args) - } else { - cmd = exec.CommandContext(context.TODO(), "sh", "-c", args) - } - cmd.Env = append(os.Environ(), env...) - cmd.Dir = dir - if err := cmd.Run(); err != nil { - return errors.Wrapf(err, "error running %v", args) - } - } - return nil -} - -type warningsListOutput []*VertexWarning - -func (w warningsListOutput) String() string { - if len(w) == 0 { - return "" - } - var b strings.Builder - - for _, warn := range w { - _, _ = b.Write(warn.Short) - } - return b.String() -} - -func testListenBuildHistoryExcludesSoftDeletedRecords(t *testing.T, sb integration.Sandbox) { - c, err := New(sb.Context(), sb.Address()) - require.NoError(t, err) - defer c.Close() - - // Create 3 completed builds so we have multiple history records. - var buildRefs [3]string - for i := range buildRefs { - def, err := llb.Scratch().File(llb.Mkfile(fmt.Sprintf("file%d", i), 0o644, nil)).Marshal(sb.Context()) - require.NoError(t, err) - - buildRefs[i] = identity.NewID() - _, err = c.Solve(sb.Context(), def, SolveOpt{Ref: buildRefs[i]}, nil) - require.NoError(t, err) - } - - refToDelete := buildRefs[1] - - // Start a streaming listener on one specific ref. This increments the - // internal reference count, which causes Delete to soft-delete instead - // of removing the record from the database. - listenerCtx, listenerCancel := context.WithCancelCause(sb.Context()) - defer listenerCancel(nil) - - cl, err := c.ControlClient().ListenBuildHistory(listenerCtx, &controlapi.BuildHistoryRequest{ - Ref: refToDelete, - }) - require.NoError(t, err) - - // Read the initial record so the listener is fully registered. - _, err = cl.Recv() - require.NoError(t, err) - - // Soft-delete: the record is marked deleted but stays in the DB because - // the listener above still holds a reference. - _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ - Ref: refToDelete, - Delete: true, - }) - require.NoError(t, err) - - // List all remaining history records with a limit to trigger sorting. - // Before the fix this panicked with a nil pointer dereference because - // the soft-deleted record left a nil entry in the event slice. - cl2, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ - EarlyExit: true, - Limit: 10, - }) - require.NoError(t, err) - - gotRefs := map[string]bool{} - for { - resp, err := cl2.Recv() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - gotRefs[resp.Record.Ref] = true - } - - // The soft-deleted record must not appear in the results. - assert.False(t, gotRefs[refToDelete], "soft-deleted ref %s should not appear in history list", refToDelete) - assert.True(t, gotRefs[buildRefs[0]], "ref %s should appear in history list", buildRefs[0]) - assert.True(t, gotRefs[buildRefs[2]], "ref %s should appear in history list", buildRefs[2]) - - // Clean up the streaming listener. - listenerCancel(nil) - // Drain the stream so gRPC can clean up. - for { - if _, err := cl.Recv(); err != nil { - break - } - } -} - -func parseFSMetadata(t *testing.T, dt []byte) []fsutiltypes.Stat { - var m []fsutiltypes.Stat - for len(dt) > 0 { - var s fsutiltypes.Stat - n := binary.LittleEndian.Uint32(dt[:4]) - dt = dt[4:] - err := s.Unmarshal(dt[:n]) - require.NoError(t, err) - m = append(m, *s.CloneVT()) - dt = dt[n:] - } - return m -} diff --git a/client/client_utils_test.go b/client/client_utils_test.go new file mode 100644 index 000000000..86fd562ba --- /dev/null +++ b/client/client_utils_test.go @@ -0,0 +1,328 @@ +package client + +import ( + "context" + "encoding/binary" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + "time" + + ctd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/content/proxy" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/containerd/v2/pkg/namespaces" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/session/filesync" + "github.com/moby/buildkit/util/testutil" + "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" + fsutiltypes "github.com/tonistiigi/fsutil/types" +) + +func checkAllReleasable(t *testing.T, c *Client, sb integration.Sandbox, checkContent bool) { + cl, err := c.ControlClient().ListenBuildHistory(sb.Context(), &controlapi.BuildHistoryRequest{ + EarlyExit: true, + }) + require.NoError(t, err) + + for { + resp, err := cl.Recv() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + _, err = c.ControlClient().UpdateBuildHistory(sb.Context(), &controlapi.UpdateBuildHistoryRequest{ + Ref: resp.Record.Ref, + Delete: true, + }) + require.NoError(t, err) + } + + retries := 0 +loop0: + for { + require.Greater(t, 20, retries) + retries++ + du, err := c.DiskUsage(sb.Context()) + require.NoError(t, err) + for _, d := range du { + if d.InUse { + time.Sleep(500 * time.Millisecond) + continue loop0 + } + } + break + } + + err = c.Prune(sb.Context(), nil, PruneAll) + require.NoError(t, err) + + du, err := c.DiskUsage(sb.Context()) + require.NoError(t, err) + require.Equal(t, 0, len(du)) + + // examine contents of exported tars (requires containerd) + cdAddress := sb.ContainerdAddress() + if cdAddress == "" { + if checkContent { + if err := workers.HasFeatureCompat(t, sb, workers.FeatureContentCheck); err == nil { + store := proxy.NewContentStore(c.ContentClient()) + count := 0 + err := store.Walk(sb.Context(), func(info content.Info) error { + count++ + return nil + }) + require.NoError(t, err) + require.Equal(t, 0, count) + } + } + t.Logf("checkAllReleasable: skipping check for exported tars in non-containerd test") + return + } + + // TODO: make public pull helper function so this can be checked for standalone as well + + client, err := newContainerd(cdAddress) + require.NoError(t, err) + defer client.Close() + + for _, ns := range []string{"buildkit", "buildkit_history"} { + ctx := namespaces.WithNamespace(sb.Context(), ns) + snapshotterName := sb.Snapshotter() + snapshotService := client.SnapshotService(snapshotterName) + + checkLeases := func() { + leases, err := client.LeasesService().List(ctx) + require.NoError(t, err) + count := 0 + for _, l := range leases { + _, isTemp := l.Labels["buildkit/lease.temporary"] + _, isExpire := l.Labels["containerd.io/gc.expire"] + if isTemp && isExpire { + continue + } + resources, err := client.LeasesService().ListResources(ctx, l) + require.NoError(t, err) + if len(resources) == 0 { + continue + } + count++ + t.Logf("lease: %v", l) + for _, r := range resources { + t.Logf("lease resource: %v", r) + } + } + require.Equal(t, 0, count) + } + + if checkContent { + images, err := client.ImageService().List(ctx) + require.NoError(t, err) + for _, img := range images { + err := client.ImageService().Delete(ctx, img.Name) + require.NoError(t, err) + } + } + + retries = 0 + for { + count := 0 + err = snapshotService.Walk(ctx, func(context.Context, snapshots.Info) error { + count++ + return nil + }) + require.NoError(t, err) + if count == 0 { + break + } + require.Less(t, retries, 20) + retries++ + time.Sleep(500 * time.Millisecond) + } + + if !checkContent { + checkLeases() + return + } + + retries = 0 + for { + count := 0 + var infos []content.Info + err = client.ContentStore().Walk(ctx, func(info content.Info) error { + count++ + infos = append(infos, info) + return nil + }) + require.NoError(t, err) + if count == 0 { + break + } + if retries >= 50 { + for _, info := range infos { + t.Logf("content: %v %v %+v", info.Digest, info.Size, info.Labels) + ra, err := client.ContentStore().ReaderAt(ctx, ocispecs.Descriptor{ + Digest: info.Digest, + Size: info.Size, + }) + if err == nil { + dt := make([]byte, 1024) + n, err := ra.ReadAt(dt, 0) + t.Logf("data: %+v %q", err, string(dt[:n])) + } + } + require.FailNowf(t, "content still exists", "%+v", infos) + } + retries++ + time.Sleep(500 * time.Millisecond) + } + + checkLeases() + } +} + +func ensureFile(t *testing.T, path string) { + st, err := os.Stat(path) + require.NoError(t, err, "expected file at %s", path) + require.True(t, st.Mode().IsRegular()) +} + +func ensureFileContents(t *testing.T, path, expectedContents string) { + contents, err := os.ReadFile(path) + require.NoError(t, err) + + actualContents := string(contents) + require.Equal(t, expectedContents, actualContents, + "file contents mismatch for %s\nexpected: %q\nactual: %q", path, expectedContents, actualContents) +} + +// ensurePruneAll tries to ensure Prune completes with retries. +// Current cache implementation defers release-related logic using goroutine so +// there can be situation where a build has finished but the following prune doesn't +// cleanup cache because some records still haven't been released. +// This function tries to ensure prune by retrying it. +func ensurePruneAll(t *testing.T, c *Client, sb integration.Sandbox) { + for i := range 2 { + require.NoError(t, c.Prune(sb.Context(), nil, PruneAll)) + for range 20 { + du, err := c.DiskUsage(sb.Context()) + require.NoError(t, err) + if len(du) == 0 { + return + } + time.Sleep(500 * time.Millisecond) + } + t.Logf("retrying prune(%d)", i) + } + t.Fatalf("failed to ensure prune") +} + +func fixedWriteCloser(wc io.WriteCloser) filesync.FileOutputFunc { + return func(map[string]string) (io.WriteCloser, error) { + return wc, nil + } +} + +func newContainerd(cdAddress string) (*ctd.Client, error) { + return ctd.New(cdAddress, ctd.WithTimeout(60*time.Second)) +} + +func parseFSMetadata(t *testing.T, dt []byte) []fsutiltypes.Stat { + var m []fsutiltypes.Stat + for len(dt) > 0 { + var s fsutiltypes.Stat + n := binary.LittleEndian.Uint32(dt[:4]) + dt = dt[4:] + err := s.Unmarshal(dt[:n]) + require.NoError(t, err) + m = append(m, *s.CloneVT()) + dt = dt[n:] + } + return m +} + +func readFileInImage(ctx context.Context, t *testing.T, c *Client, ref, path string) ([]byte, error) { + def, err := llb.Image(ref).Marshal(ctx) + if err != nil { + return nil, err + } + destDir := t.TempDir() + + _, err = c.Solve(ctx, def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + if err != nil { + return nil, err + } + return os.ReadFile(filepath.Join(destDir, filepath.Clean(path))) +} + +type imageTimestamps struct { + FromImage []string // from img.Created and img.[]History.Created + FromAnnotation string // from index.Manifests[0].Annotations["org.opencontainers.image.created"] +} + +func readImageTimestamps(dt []byte) (*imageTimestamps, error) { + m, err := testutil.ReadTarToMap(dt, false) + if err != nil { + return nil, err + } + + if _, ok := m["oci-layout"]; !ok { + return nil, errors.Errorf("no oci-layout") + } + + var index ocispecs.Index + if err := json.Unmarshal(m[ocispecs.ImageIndexFile].Data, &index); err != nil { + return nil, err + } + if len(index.Manifests) != 1 { + return nil, errors.Errorf("invalid manifest count %d", len(index.Manifests)) + } + + var res imageTimestamps + res.FromAnnotation = index.Manifests[0].Annotations[ocispecs.AnnotationCreated] + + var mfst ocispecs.Manifest + if err := json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+index.Manifests[0].Digest.Hex()].Data, &mfst); err != nil { + return nil, err + } + // don't unmarshal to image type so we get the original string value + type history struct { + Created string `json:"created"` + } + + img := struct { + History []history `json:"history"` + Created string `json:"created"` + }{} + + if err := json.Unmarshal(m[ocispecs.ImageBlobsDir+"/sha256/"+mfst.Config.Digest.Hex()].Data, &img); err != nil { + return nil, err + } + + res.FromImage = []string{ + img.Created, + } + for _, h := range img.History { + res.FromImage = append(res.FromImage, h.Created) + } + return &res, nil +} + +func requiresLinux(t *testing.T) { + integration.SkipOnPlatform(t, "!linux") +} diff --git a/client/client_warnings_test.go b/client/client_warnings_test.go new file mode 100644 index 000000000..1998e3ba1 --- /dev/null +++ b/client/client_warnings_test.go @@ -0,0 +1,155 @@ +package client + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/testutil/integration" + digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testWarnings(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Scratch().File(llb.Mkfile("/dummy", 0o600, []byte("foo"))) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + dgst, _, _, _, err := st.Output().Vertex(ctx, def.Constraints).Marshal(ctx, def.Constraints) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + require.NoError(t, c.Warn(ctx, dgst, "this is warning", client.WarnOpts{ + Level: 3, + SourceInfo: &pb.SourceInfo{ + Filename: "mydockerfile", + Data: []byte("filedata"), + }, + Range: []*pb.Range{ + {Start: &pb.Position{Line: 2}, End: &pb.Position{Line: 4}}, + }, + Detail: [][]byte{[]byte("this is detail"), []byte("and more detail")}, + URL: "https://example.com", + })) + + return r, nil + } + + wc := newWarningsCapture() + + _, err = c.Build(ctx, SolveOpt{}, product, b, wc.status) + require.NoError(t, err) + + warnings := wc.wait() + + require.Equal(t, 1, len(wc.vertexes)) + require.Equal(t, 1, len(warnings)) + + w := warnings[0] + + require.Equal(t, "this is warning", string(w.Short)) + require.Equal(t, 2, len(w.Detail)) + require.Equal(t, "this is detail", string(w.Detail[0])) + require.Equal(t, "and more detail", string(w.Detail[1])) + require.Equal(t, "https://example.com", w.URL) + require.Equal(t, 3, w.Level) + _, ok := wc.vertexes[w.Vertex] + require.True(t, ok) + + require.Equal(t, "mydockerfile", w.SourceInfo.Filename) + require.Equal(t, "filedata", string(w.SourceInfo.Data)) + require.Equal(t, 1, len(w.Range)) + require.Equal(t, int32(2), w.Range[0].Start.Line) + require.Equal(t, int32(4), w.Range[0].End.Line) + + checkAllReleasable(t, c, sb, true) +} + +type warningsCapture struct { + status chan *SolveStatus + statusDone chan struct{} + done chan struct{} + warnings []*VertexWarning + vertexes map[digest.Digest]struct{} +} + +func newWarningsCapture() *warningsCapture { + w := &warningsCapture{ + status: make(chan *SolveStatus), + statusDone: make(chan struct{}), + done: make(chan struct{}), + vertexes: map[digest.Digest]struct{}{}, + } + + go func() { + defer close(w.statusDone) + for { + select { + case st, ok := <-w.status: + if !ok { + return + } + for _, s := range st.Vertexes { + w.vertexes[s.Digest] = struct{}{} + } + w.warnings = append(w.warnings, st.Warnings...) + case <-w.done: + return + } + } + }() + + return w +} + +func (w *warningsCapture) wait() []*VertexWarning { + select { + case <-w.statusDone: + case <-time.After(10 * time.Second): + close(w.done) + } + + <-w.statusDone + return w.warnings +} + +type warningsListOutput []*VertexWarning + +func (w warningsListOutput) String() string { + if len(w) == 0 { + return "" + } + var b strings.Builder + + for _, warn := range w { + _, _ = b.Write(warn.Short) + } + return b.String() +} diff --git a/client/gateway_container_exec_test.go b/client/gateway_container_exec_test.go new file mode 100644 index 000000000..eb8348551 --- /dev/null +++ b/client/gateway_container_exec_test.go @@ -0,0 +1,1272 @@ +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "syscall" + "testing" + "time" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + gatewayapi "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/solver/errdefs" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +// testClientGatewayContainerCancelExecTty is testing the tty shuts down cleanly +// on context.Cancel +func testClientGatewayContainerCancelExecTty(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + inputR, inputW := io.Pipe() + output := bytes.NewBuffer(nil) + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) + defer timeout() + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + defer pid1.Wait() + defer ctr.Release(ctx) + + execCtx, cancel := context.WithCancelCause(ctx) + defer func() { cancel(errors.WithStack(context.Canceled)) }() + + prompt := newTestPrompt(execCtx, t, inputW, output) + pid2, err := ctr.Start(execCtx, client.StartRequest{ + Args: []string{"sh"}, + Tty: true, + Stdin: inputR, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + Stderr: &iohelper.NopWriteCloser{Writer: output}, + Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, + }) + require.NoError(t, err) + + prompt.SendExpect("echo hi", "hi") + cancel(errors.WithStack(context.Canceled)) + + err = pid2.Wait() + require.ErrorIs(t, err, context.Canceled) + + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + require.Contains(t, err.Error(), context.Canceled.Error()) + + inputW.Close() + inputR.Close() + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerCancelOnRelease is testing that all running +// processes are terminated when the container is released. +func testClientGatewayContainerCancelOnRelease(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + + start := time.Now() + defer func() { + // ensure pid1 and pid2 exit from cancel before the 10s sleep + // exits naturally + require.WithinDuration(t, start, time.Now(), 10*time.Second) + }() + + // background pid1 process that starts container + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + pid2, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + ctr.Release(ctx) + err = pid1.Wait() + require.Contains(t, err.Error(), context.Canceled.Error()) + + err = pid2.Wait() + require.Contains(t, err.Error(), context.Canceled.Error()) + + return &client.Result{}, nil + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerCancelPID1Tty is testing that the tty will cleanly +// shutdown on context cancel +func testClientGatewayContainerCancelPID1Tty(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + inputR, inputW := io.Pipe() + output := bytes.NewBuffer(nil) + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + ctx, cancel := context.WithTimeoutCause(ctx, 10*time.Second, nil) + defer cancel() + + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + defer ctr.Release(ctx) + + prompt := newTestPrompt(ctx, t, inputW, output) + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh"}, + Tty: true, + Stdin: inputR, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + Stderr: &iohelper.NopWriteCloser{Writer: output}, + Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, + }) + require.NoError(t, err) + prompt.SendExpect("echo hi", "hi") + cancel() + + err = pid1.Wait() + require.ErrorIs(t, err, context.Canceled) + + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + + inputW.Close() + inputR.Close() + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerExecPipe is testing the ability to pipe multiple +// process together all started via `Exec` into the same container. +// We are mimicing: `echo testing | cat | cat > /tmp/foo && cat /tmp/foo` +func testClientGatewayContainerExecPipe(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, stdin *io.PipeWriter) { + stdin.Close() + pid1.Wait() + ctr.Release(context.WithoutCancel(ctx)) + }) +} + +// testClientGatewayContainerExecPipeRelease verifies that releasing the +// container while pid1 still has an open stdin reader does not leave runc +// blocked on the stdin copy. +func testClientGatewayContainerExecPipeRelease(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, _ *io.PipeWriter) { + ctr.Release(context.WithoutCancel(ctx)) + pid1.Wait() + }) +} + +// testClientGatewayContainerExecPipeSignalKill verifies that SIGKILL delivered +// via the process Signal channel unblocks runc when pid1 still has an open +// stdin reader. +func testClientGatewayContainerExecPipeSignalKill(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerExecPipeWithCleanup(t, sb, func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, _ *io.PipeWriter) { + pid1.Signal(ctx, syscall.SIGKILL) + pid1.Wait() + ctr.Release(context.WithoutCancel(ctx)) + }) +} + +func testClientGatewayContainerExecPipeWithCleanup(t *testing.T, sb integration.Sandbox, cleanup func(ctx context.Context, ctr client.Container, pid1 client.ContainerProcess, stdin *io.PipeWriter)) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + output := bytes.NewBuffer(nil) + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + + // background pid1 process that starts container + pid1StdinR, pid1StdinW := io.Pipe() + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat"}, + Stdin: pid1StdinR, + }) + if err != nil { + ctr.Release(context.WithoutCancel(ctx)) + return nil, err + } + + defer cleanup(ctx, ctr, pid1, pid1StdinW) + + // first part is `echo testing | cat` + stdin2 := bytes.NewBuffer([]byte("testing")) + stdin3, stdout2 := io.Pipe() + + pid2, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat"}, + Cwd: "/", + Tty: false, + Stdin: io.NopCloser(stdin2), + Stdout: stdout2, + }) + if err != nil { + return nil, err + } + + // next part is: `| cat > /tmp/test` + pid3, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-c", "cat > /tmp/test"}, + Stdin: stdin3, + }) + if err != nil { + return nil, err + } + + err = pid2.Wait() + if err != nil { + stdout2.Close() + return nil, err + } + + err = stdout2.Close() + if err != nil { + return nil, err + } + + err = pid3.Wait() + if err != nil { + return nil, err + } + + err = stdin3.Close() + if err != nil { + return nil, err + } + + pid4, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat", "/tmp/test"}, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + }) + if err != nil { + return nil, err + } + + err = pid4.Wait() + if err != nil { + return nil, err + } + + return &client.Result{}, nil + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.NoError(t, err) + require.Equal(t, "testing", output.String()) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerExecTty is testing that we can get a tty via +// executor.Exec (secondary process) +func testClientGatewayContainerExecTty(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + inputR, inputW := io.Pipe() + output := bytes.NewBuffer(nil) + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) + defer timeout() + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + defer pid1.Wait() + defer ctr.Release(ctx) + + prompt := newTestPrompt(ctx, t, inputW, output) + pid2, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh"}, + Tty: true, + Stdin: inputR, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + Stderr: &iohelper.NopWriteCloser{Writer: output}, + Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, + }) + require.NoError(t, err) + + err = pid2.Resize(ctx, client.WinSize{Rows: 40, Cols: 80}) + require.NoError(t, err) + prompt.SendExpect("ttysize", "80 40") + prompt.Send("cd /tmp") + prompt.SendExpect("pwd", "/tmp") + prompt.Send("echo foobar > newfile") + prompt.SendExpect("cat /tmp/newfile", "foobar") + err = pid2.Resize(ctx, client.WinSize{Rows: 60, Cols: 100}) + require.NoError(t, err) + prompt.SendExpect("ttysize", "100 60") + prompt.SendExit(99) + + err = pid2.Wait() + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(99), exitError.ExitCode) + + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(99), exitError.ExitCode) + require.Regexp(t, "exit code: 99", err.Error()) + + inputW.Close() + inputR.Close() + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerPID1Exit is testing that all process started +// via `Exec` are shutdown when the primary pid1 process exits +func testClientGatewayContainerPID1Exit(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + defer ctr.Release(ctx) + + start := time.Now() + defer func() { + // ensure pid1 and pid2 exits from cancel before the 10s sleep + // exits naturally + require.WithinDuration(t, start, time.Now(), 10*time.Second) + // assert this test ran for at least one second for pid1 + lapse := time.Since(start) + require.Greater(t, lapse.Seconds(), float64(1)) + }() + + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "1"}, + }) + require.NoError(t, err) + defer pid1.Wait() + + pid2, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + return &client.Result{}, pid2.Wait() + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(137), exitError.ExitCode) + // `exit code: 137` (ie sigkill) + require.Regexp(t, "exit code: 137", err.Error()) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerPID1Fail is testing clean shutdown and release +// of resources when the primary pid1 exits with non-zero exit status +func testClientGatewayContainerPID1Fail(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-c", "exit 99"}, + }) + if err != nil { + ctr.Release(ctx) + return nil, err + } + + defer ctr.Release(ctx) + err = pid1.Wait() + + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(99), exitError.ExitCode) + + return nil, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerPID1Tty is testing that we can get a tty via +// a container pid1, executor.Run +func testClientGatewayContainerPID1Tty(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + inputR, inputW := io.Pipe() + output := bytes.NewBuffer(nil) + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) + defer timeout() + + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + defer ctr.Release(ctx) + + prompt := newTestPrompt(ctx, t, inputW, output) + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh"}, + Tty: true, + Stdin: inputR, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + Stderr: &iohelper.NopWriteCloser{Writer: output}, + Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, + }) + require.NoError(t, err) + err = pid1.Resize(ctx, client.WinSize{Rows: 40, Cols: 80}) + require.NoError(t, err) + prompt.SendExpect("ttysize", "80 40") + prompt.Send("cd /tmp") + prompt.SendExpect("pwd", "/tmp") + prompt.Send("echo foobar > newfile") + prompt.SendExpect("cat /tmp/newfile", "foobar") + err = pid1.Resize(ctx, client.WinSize{Rows: 60, Cols: 100}) + require.NoError(t, err) + prompt.SendExpect("ttysize", "100 60") + prompt.SendExit(99) + + err = pid1.Wait() + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(99), exitError.ExitCode) + + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + + inputW.Close() + inputR.Close() + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerSignal is testing that we can send a signal +func testClientGatewayContainerSignal(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + ctx, timeout := context.WithTimeoutCause(ctx, 10*time.Second, nil) + defer timeout() + + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr1, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + defer ctr1.Release(ctx) + + pid1, err := ctr1.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-c", `trap 'kill $(jobs -p); exit 99' HUP; sleep 10 & wait`}, + }) + require.NoError(t, err) + + // allow for the shell script to setup the trap before we signal it + time.Sleep(time.Second) + + err = pid1.Signal(ctx, syscall.SIGHUP) + require.NoError(t, err) + + err = pid1.Wait() + var exitError *gatewayapi.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(99), exitError.ExitCode) + + // Now try again to signal an exec process + + ctr2, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + require.NoError(t, err) + defer ctr2.Release(ctx) + + pid1, err = ctr2.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + }) + require.NoError(t, err) + + pid2, err := ctr2.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-c", `trap 'kill $(jobs -p); exit 111' INT; sleep 10 & wait`}, + }) + require.NoError(t, err) + + // allow for the shell script to setup the trap before we signal it + time.Sleep(time.Second) + + err = pid2.Signal(ctx, syscall.SIGINT) + require.NoError(t, err) + + err = pid2.Wait() + require.ErrorAs(t, err, &exitError) + require.Equal(t, uint32(111), exitError.ExitCode) + + pid1.Signal(ctx, syscall.SIGKILL) + pid1.Wait() + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.Error(t, err) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayExecError is testing gateway exec to recreate the container +// process for a failed execop. +func testClientGatewayExecError(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + id := identity.NewID() + tests := []struct { + Name string + State llb.State + NumMounts int + Paths []string + }{{ + "only rootfs", + llb.Image("busybox:latest").Run( + llb.Shlexf(`sh -c "echo %s > /data && fail"`, id), + ).Root(), + 1, + []string{"/data"}, + }, { + "rootfs and readwrite scratch mount", + llb.Image("busybox:latest").Run( + llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), + llb.AddMount("/rw", llb.Scratch()), + ).Root(), + 2, + []string{"/data", "/rw/data"}, + }, { + "rootfs and readwrite mount", + llb.Image("busybox:latest").Run( + llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), + llb.AddMount("/rw", llb.Scratch().File(llb.Mkfile("foo", 0o700, []byte(id)))), + ).Root(), + 2, + []string{"/data", "/rw/data", "/rw/foo"}, + }, { + "rootfs and readonly scratch mount", + llb.Image("busybox:latest").Run( + llb.Shlexf(`sh -c "echo %s > /data && echo %s > /readonly/foo"`, id, id), + llb.AddMount("/readonly", llb.Scratch(), llb.Readonly), + ).Root(), + 2, + []string{"/data"}, + }, { + "rootfs and readwrite force no output mount", + llb.Image("busybox:latest").Run( + llb.Shlexf(`sh -c "echo %s > /data && echo %s > /rw/data && fail"`, id, id), + llb.AddMount( + "/rw", + llb.Scratch().File(llb.Mkfile("foo", 0o700, []byte(id))), + llb.ForceNoOutput, + ), + ).Root(), + 2, + []string{"/data", "/rw/data", "/rw/foo"}, + }} + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + def, err := tt.State.Marshal(ctx) + require.NoError(t, err) + + _, solveErr := c.Solve(ctx, client.SolveRequest{ + Evaluate: true, + Definition: def.ToPB(), + }) + require.Error(t, solveErr) + + var se *errdefs.SolveError + require.ErrorAs(t, solveErr, &se) + require.Len(t, se.InputIDs, tt.NumMounts) + require.Len(t, se.MountIDs, tt.NumMounts) + + op := se.Op + require.NotNil(t, op) + require.NotNil(t, op.Op) + + opExec, ok := se.Op.Op.(*pb.Op_Exec) + require.True(t, ok) + + exec := opExec.Exec + + var mounts []client.Mount + for i, mnt := range exec.Mounts { + mounts = append(mounts, client.Mount{ + Selector: mnt.Selector, + Dest: mnt.Dest, + ResultID: se.MountIDs[i], + Readonly: mnt.Readonly, + MountType: mnt.MountType, + CacheOpt: mnt.CacheOpt, + SecretOpt: mnt.SecretOpt, + SSHOpt: mnt.SSHOpt, + }) + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: mounts, + NetMode: exec.Network, + Platform: op.Platform, + Constraints: op.Constraints, + }) + require.NoError(t, err) + defer ctr.Release(ctx) + + inputR, inputW := io.Pipe() + defer inputW.Close() + defer inputR.Close() + + pid1Output := bytes.NewBuffer(nil) + + prompt := newTestPrompt(ctx, t, inputW, pid1Output) + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh"}, + Tty: true, + Stdin: inputR, + Stdout: &iohelper.NopWriteCloser{Writer: pid1Output}, + Stderr: &iohelper.NopWriteCloser{Writer: pid1Output}, + Env: []string{fmt.Sprintf("PS1=%s", prompt.String())}, + }) + require.NoError(t, err) + + meta := exec.Meta + for _, p := range tt.Paths { + output := bytes.NewBuffer(nil) + proc, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat", p}, + Env: meta.Env, + User: meta.User, + Cwd: meta.Cwd, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + SecurityMode: exec.Security, + }) + require.NoError(t, err) + + err = proc.Wait() + require.NoError(t, err) + require.Equal(t, id, strings.TrimSpace(output.String())) + } + + prompt.SendExit(0) + err = pid1.Wait() + require.NoError(t, err) + }) + } + + return client.NewResult(), nil + } + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayExecFileActionError is testing gateway exec into the modified +// mount of a failed fileop during a solve. +func testClientGatewayExecFileActionError(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + def, err := st.Marshal(ctx) + require.NoError(t, err) + + res, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + require.NoError(t, err) + + debugfs, err := res.SingleRef() + require.NoError(t, err) + + id := identity.NewID() + tests := []struct { + Name string + State llb.State + NumInputs int + NumOutputs int + Path string + }{{ + "mkfile", + llb.Scratch().File( + llb.Mkdir("/found", 0o700). + Mkfile("/found/foo", 0o600, []byte(id)). + Mkfile("/notfound/foo", 0o600, []byte(id)), + ), + 0, 3, "/input/found/foo", + }, { + "copy from input", + llb.Image("busybox").File( + llb.Copy( + llb.Scratch().File( + llb.Mkdir("/foo", 0o600).Mkfile("/foo/bar", 0o700, []byte(id)), + ), + "/foo/bar", + "/notfound/baz", + ), + ), + 2, 1, "/secondary/foo/bar", + }, { + "copy from action", + llb.Image("busybox").File( + llb.Copy( + llb.Mkdir("/foo", 0o600).Mkfile("/foo/bar", 0o700, []byte(id)).WithState(llb.Scratch()), + "/foo/bar", + "/notfound/baz", + ), + ), + 1, 3, "/secondary/foo/bar", + }} + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + def, err := tt.State.Marshal(ctx) + require.NoError(t, err) + + _, err = c.Solve(ctx, client.SolveRequest{ + Evaluate: true, + Definition: def.ToPB(), + }) + require.Error(t, err) + + var se *errdefs.SolveError + require.ErrorAs(t, err, &se) + require.Len(t, se.InputIDs, tt.NumInputs) + + // There is one output for every action in the fileop that failed. + require.Len(t, se.MountIDs, tt.NumOutputs) + + op, ok := se.Op.Op.(*pb.Op_File) + require.True(t, ok) + + subject, ok := se.Subject.(*errdefs.Solve_File) + require.True(t, ok) + + // Retrieve the action that failed from the sbuject. + idx := subject.File.Index + require.Less(t, int(idx), len(op.File.Actions)) + action := op.File.Actions[idx] + + // The output for a file action is mapped by its index. + inputID := se.MountIDs[idx] + + var secondaryID string + if action.SecondaryInput != -1 { + // If the secondary input is a result from another exec, it will be one + // of the input IDs, otherwise it's a intermediary mutable from another + // action in the same fileop. + if int(action.SecondaryInput) < len(se.InputIDs) { + secondaryID = se.InputIDs[action.SecondaryInput] + } else { + secondaryID = se.MountIDs[int(action.SecondaryInput)-len(se.InputIDs)] + } + } + + mounts := []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: debugfs, + }, { + Dest: "/input", + MountType: pb.MountType_BIND, + ResultID: inputID, + }} + + if secondaryID != "" { + mounts = append(mounts, client.Mount{ + Dest: "/secondary", + MountType: pb.MountType_BIND, + ResultID: secondaryID, + }) + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: mounts}) + require.NoError(t, err) + + // Verify that the randomly generated data can be found in a mutable ref + // created by the actions that have succeeded. + output := bytes.NewBuffer(nil) + proc, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat", tt.Path}, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + }) + require.NoError(t, err) + + err = proc.Wait() + require.NoError(t, err) + require.Equal(t, id, strings.TrimSpace(output.String())) + + err = ctr.Release(ctx) + require.NoError(t, err) + }) + } + + return client.NewResult(), nil + } + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewaySlowCacheExecError is testing gateway exec into the ref +// that failed to mount during an execop. +func testClientGatewaySlowCacheExecError(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + id := identity.NewID() + input := llb.Scratch().File( + llb.Mkdir("/found", 0o700). + Mkfile("/found/data", 0o600, []byte(id)), + ) + + st := llb.Image("busybox:latest").Run( + llb.Shlexf("echo hello"), + // Only readonly mounts trigger slow cache errors. + llb.AddMount("/src", input, llb.SourcePath("/notfound"), llb.Readonly), + ).Root() + + def, err := st.Marshal(ctx) + require.NoError(t, err) + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + _, solveErr := c.Solve(ctx, client.SolveRequest{ + Evaluate: true, + Definition: def.ToPB(), + }) + require.Error(t, solveErr) + + var se *errdefs.SolveError + require.ErrorAs(t, solveErr, &se) + + _, ok := se.Op.Op.(*pb.Op_Exec) + require.True(t, ok) + + _, ok = se.Subject.(*errdefs.Solve_Cache) + require.True(t, ok) + // Slow cache errors should only have exactly one input and no outputs. + require.Len(t, se.InputIDs, 1) + require.Len(t, se.MountIDs, 0) + + st := llb.Image("busybox:latest") + def, err := st.Marshal(ctx) + require.NoError(t, err) + + res, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + require.NoError(t, err) + + ref, err := res.SingleRef() + require.NoError(t, err) + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: ref, + }, { + Dest: "/problem", + MountType: pb.MountType_BIND, + ResultID: se.InputIDs[0], + }}, + }) + require.NoError(t, err) + defer ctr.Release(ctx) + + output := bytes.NewBuffer(nil) + proc, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat", "/problem/found/data"}, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + }) + require.NoError(t, err) + + err = proc.Wait() + require.NoError(t, err) + require.Equal(t, id, strings.TrimSpace(output.String())) + + return client.NewResult(), nil + } + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +type testPrompt struct { + ctx context.Context + t *testing.T + output *bytes.Buffer + input io.Writer + prompt string + pos int +} + +func (p *testPrompt) String() string { return p.prompt } + +func (p *testPrompt) SendExit(status int) { + p.input.Write(fmt.Appendf(nil, "exit %d\n", status)) +} + +func (p *testPrompt) Send(cmd string) { + p.input.Write([]byte(cmd + "\n")) + p.wait(p.prompt) +} + +func (p *testPrompt) SendExpect(cmd, expected string) { + for { + p.input.Write([]byte(cmd + "\n")) + response := p.wait(p.prompt) + if strings.Contains(response, expected) { + return + } + } +} + +func (p *testPrompt) wait(msg string) string { + for { + newOutput := p.output.String()[p.pos:] + if strings.Contains(newOutput, msg) { + p.pos += len(newOutput) + return newOutput + } + select { + case <-p.ctx.Done(): + p.t.Logf("Output at timeout: %s", p.output.String()) + p.t.Fatalf("Timeout waiting for %q", msg) + case <-time.After(100 * time.Millisecond): + } + } +} + +func newTestPrompt(ctx context.Context, t *testing.T, input io.Writer, output *bytes.Buffer) *testPrompt { + return &testPrompt{ + ctx: ctx, + t: t, + input: input, + output: output, + prompt: "% ", + } +} diff --git a/client/gateway_container_mount_test.go b/client/gateway_container_mount_test.go new file mode 100644 index 000000000..080b8a507 --- /dev/null +++ b/client/gateway_container_mount_test.go @@ -0,0 +1,386 @@ +package client + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/secrets/secretsprovider" + "github.com/moby/buildkit/session/sshforward/sshprovider" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/iohelper" + utilsystem "github.com/moby/buildkit/util/system" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/tonistiigi/fsutil" + "golang.org/x/crypto/ssh/agent" +) + +// testClientGatewayContainerMounts is testing mounts derived from various +// llb.States +func testClientGatewayContainerMounts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + tmpdir := integration.Tmpdir(t) + + err = os.WriteFile(filepath.Join(tmpdir.Name, "local-file"), []byte("local"), 0o644) + require.NoError(t, err) + + a := agent.NewKeyring() + sockPath, err := makeSSHAgentSock(t, a) + require.NoError(t, err) + + ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{ + ID: t.Name(), + Paths: []string{sockPath}, + }}) + require.NoError(t, err) + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + mounts := map[string]llb.State{ + "/": llb.Image("busybox:latest").Run( + llb.Shlex("touch /root-file /cached/cache-file"), + llb.AddMount("/cached", llb.Scratch(), llb.AsPersistentCacheDir(t.Name(), llb.CacheMountShared)), + ).Root(), + "/foo": llb.Image("busybox:latest").Run( + llb.Shlex("touch foo-file"), + llb.Dir("/tmp"), + llb.AddMount("/tmp", llb.Scratch()), + ).GetMount("/tmp"), + "/local": llb.Local("mylocal"), + // TODO How do we get a results.Ref for a cache mount, tmpfs mount + } + + containerMounts := []client.Mount{ + { + Dest: "/", + MountType: pb.MountType_BIND, + }, + { + Dest: "/foo", + MountType: pb.MountType_BIND, + }, + { + Dest: "/local", + MountType: pb.MountType_BIND, + }, + { + Dest: "/cached", + MountType: pb.MountType_CACHE, + CacheOpt: &pb.CacheOpt{ + ID: t.Name(), + Sharing: pb.CacheSharingOpt_SHARED, + }, + }, + { + Dest: "/tmpfs", + MountType: pb.MountType_TMPFS, + }, + { + Dest: "/run/secrets/mysecret", + MountType: pb.MountType_SECRET, + SecretOpt: &pb.SecretOpt{ + ID: "/run/secrets/mysecret", + }, + }, + { + Dest: sockPath, + MountType: pb.MountType_SSH, + SSHOpt: &pb.SSHOpt{ + ID: t.Name(), + }, + }, + } + + // Fill in mount references. + for i, m := range containerMounts { + st := mounts[m.Dest] + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + containerMounts[i].Ref = r.Ref + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts}) + if err != nil { + return nil, err + } + + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sleep", "10"}, + Stderr: os.Stderr, + }) + require.NoError(t, err) + defer pid1.Wait() + + files := []struct { + index int + path string + data []byte + }{ + {0, "/root-file", []byte(nil)}, + {1, "/foo-file", []byte(nil)}, + {2, "/local-file", []byte(`local`)}, + {3, "/cache-file", []byte(nil)}, + } + for _, file := range files { + cpath := containerMounts[file.index].Dest + file.path + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-f", cpath}, + }) + require.NoError(t, err, "cannot start container to check for file: %s", cpath) + err = pid.Wait() + require.NoError(t, err, "process for checking file failed: %s", cpath) + + _, err = ctr.StatFile(ctx, client.StatContainerRequest{ + StatRequest: client.StatRequest{ + Path: file.path, + }, + MountIndex: file.index, + }) + require.NoError(t, err, "stat file for %q on mount %d failed", file.path, file.index) + + b, err := ctr.ReadFile(ctx, client.ReadContainerRequest{ + ReadRequest: client.ReadRequest{ + Filename: file.path, + }, + MountIndex: file.index, + }) + require.NoError(t, err, "read file for %q on mount %d failed", file.path, file.index) + require.Equal(t, file.data, b) + } + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-f", "/root-file"}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-f", "/foo/foo-file"}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-f", "/local/local-file"}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-f", "/cached/cache-file"}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-w", "/tmpfs"}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + secretOutput := bytes.NewBuffer(nil) + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"cat", "/run/secrets/mysecret"}, + Stdout: &iohelper.NopWriteCloser{Writer: secretOutput}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + require.Equal(t, "foo-secret", secretOutput.String()) + + pid, err = ctr.Start(ctx, client.StartRequest{ + Args: []string{"test", "-S", sockPath}, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + return &client.Result{}, ctr.Release(ctx) + } + + _, err = c.Build(ctx, SolveOpt{ + LocalMounts: map[string]fsutil.FS{ + "mylocal": tmpdir, + }, + Session: []session.Attachable{ + ssh, + secretsprovider.FromMap(map[string][]byte{ + "/run/secrets/mysecret": []byte("foo-secret"), + }), + }, + }, product, b, nil) + require.Error(t, err) + require.Contains(t, err.Error(), context.Canceled.Error()) + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerPlatformPATH is testing the correct default PATH +// gets set for the requested platform +func testClientGatewayContainerPlatformPATH(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + def, err := st.Marshal(ctx) + require.NoError(t, err) + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + require.NoError(t, err) + + tests := []struct { + Name string + Platform *pb.Platform + Expected string + }{{ + "default path", + nil, + utilsystem.DefaultPathEnvUnix, + }, { + "linux path", + &pb.Platform{OS: "linux"}, + utilsystem.DefaultPathEnvUnix, + }, { + "windows path", + &pb.Platform{OS: "windows"}, + utilsystem.DefaultPathEnvWindows, + }} + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + Platform: tt.Platform, + }) + require.NoError(t, err) + output := bytes.NewBuffer(nil) + pid1, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"/bin/sh", "-c", "echo -n $PATH"}, + Stdout: &iohelper.NopWriteCloser{Writer: output}, + }) + require.NoError(t, err) + + err = pid1.Wait() + require.NoError(t, err) + require.Equal(t, tt.Expected, output.String()) + err = ctr.Release(ctx) + require.NoError(t, err) + }) + } + return &client.Result{}, err + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) +} + +func testClientGatewayContainerSecretEnv(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + mounts := map[string]llb.State{ + "/": llb.Image("busybox:latest"), + } + + var containerMounts []client.Mount + for mountpoint, st := range mounts { + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + containerMounts = append(containerMounts, client.Mount{ + Dest: mountpoint, + MountType: pb.MountType_BIND, + Ref: r.Ref, + }) + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts}) + if err != nil { + return nil, err + } + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-c", "test $SOME_SECRET = foo-secret"}, + SecretEnv: []*pb.SecretEnv{ + { + ID: "sekrit", + Name: "SOME_SECRET", + }, + }, + }) + require.NoError(t, err) + err = pid.Wait() + require.NoError(t, err) + + return &client.Result{}, ctr.Release(ctx) + } + + _, err = c.Build(ctx, SolveOpt{ + Session: []session.Attachable{ + secretsprovider.FromMap(map[string][]byte{ + "sekrit": []byte("foo-secret"), + }), + }, + }, product, b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} diff --git a/client/gateway_container_network_test.go b/client/gateway_container_network_test.go new file mode 100644 index 000000000..6e302f5d4 --- /dev/null +++ b/client/gateway_container_network_test.go @@ -0,0 +1,201 @@ +package client + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/entitlements" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil/echoserver" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testClientGatewayContainerExtraHosts(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + product := "buildkit_test" + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + ExtraHosts: []*pb.HostIP{{ + Host: "some.host", + IP: "169.254.11.22", + }}, + }) + if err != nil { + return nil, err + } + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"grep", "169.254.11.22\tsome.host", "/etc/hosts"}, + Stdout: &iohelper.NopWriteCloser{Writer: stdout}, + Stderr: &iohelper.NopWriteCloser{Writer: stderr}, + }) + if err != nil { + ctr.Release(ctx) + return nil, err + } + defer ctr.Release(ctx) + + err = pid.Wait() + + t.Logf("Stdout: %q", stdout.String()) + t.Logf("Stderr: %q", stderr.String()) + + require.NoError(t, err) + + return &client.Result{}, nil + } + + _, err = c.Build(ctx, SolveOpt{}, product, b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testClientGatewayContainerHostNetworking(t *testing.T, sb integration.Sandbox, expectFail bool) { + if os.Getenv("BUILDKIT_RUN_NETWORK_INTEGRATION_TESTS") == "" { + t.SkipNow() + } + + if sb.Rootless() && sb.Value("netmode") == defaultNetwork { + // skip "default" network test for rootless, it always runs with "host" network + // https://github.com/moby/buildkit/blob/v0.9.0/docs/rootless.md#known-limitations + t.SkipNow() + } + + requiresLinux(t) + + ctx := sb.Context() + product := "buildkit_test" + + var allowedEntitlements []string + netMode := pb.NetMode_UNSET + if sb.Value("netmode") == hostNetwork { + netMode = pb.NetMode_HOST + allowedEntitlements = []string{entitlements.EntitlementNetworkHost.String()} + if expectFail { + allowedEntitlements = []string{} + } + } + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + s, err := echoserver.NewTestServer("foo") + require.NoError(t, err) + addrParts := strings.Split(s.Addr().String(), ":") + port := addrParts[len(addrParts)-1] + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + NetMode: netMode, + }) + if err != nil { + return nil, err + } + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"/bin/sh", "-c", fmt.Sprintf("nc 127.0.0.1 %s | grep foo", port)}, + Stdout: &iohelper.NopWriteCloser{Writer: stdout}, + Stderr: &iohelper.NopWriteCloser{Writer: stderr}, + }) + if err != nil { + ctr.Release(ctx) + return nil, err + } + defer ctr.Release(ctx) + + err = pid.Wait() + + t.Logf("Stdout: %q", stdout.String()) + t.Logf("Stderr: %q", stderr.String()) + + if netMode == pb.NetMode_HOST { + if expectFail { + require.Error(t, err) + require.Contains(t, err.Error(), "network.host is not allowed") + } else { + require.NoError(t, err) + } + } else { + require.Error(t, err) + } + + return &client.Result{}, nil + } + + solveOpts := SolveOpt{ + AllowedEntitlements: allowedEntitlements, + } + _, err = c.Build(ctx, solveOpts, product, b, nil) + require.NoError(t, err) + + checkAllReleasable(t, c, sb, true) +} + +func testClientGatewayContainerHostNetworkingAccess(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerHostNetworking(t, sb, false) +} + +func testClientGatewayContainerHostNetworkingValidation(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerHostNetworking(t, sb, true) +} diff --git a/client/gateway_container_security_test.go b/client/gateway_container_security_test.go new file mode 100644 index 000000000..068c100f2 --- /dev/null +++ b/client/gateway_container_security_test.go @@ -0,0 +1,221 @@ +package client + +import ( + "bytes" + "context" + "strconv" + "strings" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/util/entitlements" + "github.com/moby/buildkit/util/iohelper" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func testClientGatewayContainerInvalidSecurityMode(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + defer checkAllReleasable(t, c, sb, true) + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + defer ctr.Release(ctx) + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: []string{"sh", "-euc", `sec=$(awk '/^Seccomp:/{print $2}' /proc/self/status); echo "Seccomp=$sec"; test "$sec" = 0`}, + Stdout: &iohelper.NopWriteCloser{Writer: stdout}, + Stderr: &iohelper.NopWriteCloser{Writer: stderr}, + SecurityMode: pb.SecurityMode(2), + }) + if err != nil { + return nil, err + } + + err = pid.Wait() + + t.Logf("Stdout: %q", stdout.String()) + t.Logf("Stderr: %q", stderr.String()) + + return nil, err + } + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid security mode") +} + +func testClientGatewayContainerSecurityMode(t *testing.T, sb integration.Sandbox, expectFail bool) { + workers.CheckFeatureCompat(t, sb, workers.FeatureSecurityMode) + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + + command := []string{"sh", "-c", `cat /proc/self/status | grep CapEff | cut -f 2`} + mode := llb.SecurityModeSandbox + var allowedEntitlements []string + var assertCaps func(caps uint64) + secMode := sb.Value("secmode") + if secMode == securitySandbox { + assertCaps = func(caps uint64) { + /* + $ capsh --decode=00000000a80425fb + 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap, + cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap + */ + require.Equal(t, uint64(0xa80425fb), caps) + } + allowedEntitlements = []string{} + if expectFail { + return + } + } else { + assertCaps = func(caps uint64) { + /* + $ capsh --decode=0000003fffffffff + 0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid, + cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw, + cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin, + cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write, + cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read + */ + + // require that _at least_ minimum capabilities are granted + require.Equal(t, uint64(0x3fffffffff), caps&0x3fffffffff) + } + mode = llb.SecurityModeInsecure + allowedEntitlements = []string{entitlements.EntitlementSecurityInsecure.String()} + if expectFail { + allowedEntitlements = []string{} + } + } + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + ctr, err := c.NewContainer(ctx, client.NewContainerRequest{ + Mounts: []client.Mount{{ + Dest: "/", + MountType: pb.MountType_BIND, + Ref: r.Ref, + }}, + }) + if err != nil { + return nil, err + } + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + pid, err := ctr.Start(ctx, client.StartRequest{ + Args: command, + Stdout: &iohelper.NopWriteCloser{Writer: stdout}, + Stderr: &iohelper.NopWriteCloser{Writer: stderr}, + SecurityMode: mode, + }) + if err != nil { + ctr.Release(ctx) + return nil, err + } + defer ctr.Release(ctx) + + err = pid.Wait() + + t.Logf("Stdout: %q", stdout.String()) + t.Logf("Stderr: %q", stderr.String()) + + if expectFail { + require.Error(t, err) + require.Contains(t, err.Error(), "security.insecure is not allowed") + return nil, err + } + + require.NoError(t, err) + + capsValue, err := strconv.ParseUint(strings.TrimSpace(stdout.String()), 16, 64) + require.NoError(t, err) + + assertCaps(capsValue) + + return &client.Result{}, nil + } + + solveOpts := SolveOpt{ + AllowedEntitlements: allowedEntitlements, + } + _, err = c.Build(ctx, solveOpts, product, b, nil) + + if expectFail { + require.Error(t, err) + require.Contains(t, err.Error(), "security.insecure is not allowed") + } else { + require.NoError(t, err) + } + + checkAllReleasable(t, c, sb, true) +} + +// testClientGatewayContainerSecurityModeCaps ensures that the correct security mode +// is propagated to the gateway container +func testClientGatewayContainerSecurityModeCaps(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerSecurityMode(t, sb, false) +} + +func testClientGatewayContainerSecurityModeValidation(t *testing.T, sb integration.Sandbox) { + testClientGatewayContainerSecurityMode(t, sb, true) +} diff --git a/client/gateway_solve_test.go b/client/gateway_solve_test.go new file mode 100644 index 000000000..c2e592a32 --- /dev/null +++ b/client/gateway_solve_test.go @@ -0,0 +1,317 @@ +package client + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/frontend/gateway/client" + gatewayapi "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/grpcerrors" + "github.com/moby/buildkit/util/testutil/integration" + "github.com/moby/buildkit/util/testutil/workers" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func testClientGatewayEmptyImageExec(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) + 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) + target := registry + "/buildkit/testemptyimage:latest" + + // push an empty image + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", func(ctx context.Context, c client.Client) (*client.Result, error) { + return client.NewResult(), nil + }, nil) + require.NoError(t, err) + + _, err = c.Build(sb.Context(), SolveOpt{}, "", func(ctx context.Context, gw client.Client) (*client.Result, error) { + // create an exec on that empty image (expected to fail, but not to panic) + st := llb.Image(target).Run( + llb.Args([]string{"echo", "hello"}), + ).Root() + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + _, err = gw.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + Evaluate: true, + }) + require.ErrorContains(t, err, `process "echo hello" did not complete successfully`) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func testClientGatewayEmptySolve(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + r, err := c.Solve(ctx, client.SolveRequest{}) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + if r.Ref != nil || r.Refs != nil || r.Metadata != nil { + return nil, errors.Errorf("got unexpected non-empty result %+v", r) + } + return r, nil + } + + _, err = c.Build(ctx, SolveOpt{}, "", b, nil) + require.NoError(t, err) +} + +func testClientGatewayFailedSolve(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + return nil, errors.New("expected to fail") + } + + _, err = c.Build(ctx, SolveOpt{}, "", b, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "expected to fail") +} + +func testClientGatewayNilResult(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureMergeDiff) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + st := llb.Image("busybox:latest") + diff := llb.Diff(st, st) + def, err := diff.Marshal(sb.Context()) + if err != nil { + return nil, err + } + res, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + Evaluate: true, + }) + require.NoError(t, err) + + ref, err := res.SingleRef() + require.NoError(t, err) + + dirEnts, err := ref.ReadDir(ctx, client.ReadDirRequest{ + Path: "/", + }) + require.NoError(t, err) + require.Len(t, dirEnts, 0) + return nil, nil + } + + _, err = c.Build(sb.Context(), SolveOpt{}, "", b, nil) + require.NoError(t, err) +} + +func testClientGatewaySolve(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + product := "buildkit_test" + optKey := "test-string" + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + if c.BuildOpts().Product != product { + return nil, errors.Errorf("expected product %q, got %q", product, c.BuildOpts().Product) + } + opts := c.BuildOpts().Opts + testStr, ok := opts[optKey] + if !ok { + return nil, errors.Errorf(`build option %q missing`, optKey) + } + + run := llb.Image("busybox:latest").Run( + llb.ReadonlyRootFS(), + llb.Args([]string{"/bin/sh", "-ec", `echo -n '` + testStr + `' > /out/foo`}), + ) + st := run.AddMount("/out", llb.Scratch()) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal state") + } + + r, err := c.Solve(ctx, client.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, errors.Wrap(err, "failed to solve") + } + + read, err := r.Ref.ReadFile(ctx, client.ReadRequest{ + Filename: "/foo", + }) + if err != nil { + return nil, errors.Wrap(err, "failed to read result") + } + if testStr != string(read) { + return nil, errors.Errorf("read back %q, expected %q", string(read), testStr) + } + return r, nil + } + + tmpdir := t.TempDir() + + testStr := "This is a test" + + _, err = c.Build(ctx, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: tmpdir, + }, + }, + FrontendAttrs: map[string]string{ + optKey: testStr, + }, + }, product, b, nil) + require.NoError(t, err) + + read, err := os.ReadFile(filepath.Join(tmpdir, "foo")) + require.NoError(t, err) + require.Equal(t, testStr, string(read)) + + checkAllReleasable(t, c, sb, true) +} + +func testClientSlowCacheRootfsRef(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + b := func(ctx context.Context, c client.Client) (*client.Result, error) { + id := identity.NewID() + input := llb.Scratch().File( + llb.Mkdir("/found", 0o700). + Mkfile("/found/data", 0o600, []byte(id)), + ) + + st := llb.Image("busybox:latest").Run( + llb.Shlexf("echo hello"), + // Only readonly mounts trigger slow cache errors. + llb.AddMount("/src", input, llb.SourcePath("/notfound"), llb.Readonly), + ).Root() + + def1, err := st.Marshal(ctx) + require.NoError(t, err) + + res1, err := c.Solve(ctx, client.SolveRequest{ + Definition: def1.ToPB(), + }) + require.NoError(t, err) + + ref1, err := res1.SingleRef() + require.NoError(t, err) + + // First stat should error because unlazy-ing the reference causes an error + // in CalcSlowCache. + _, err = ref1.StatFile(ctx, client.StatRequest{ + Path: ".", + }) + require.Error(t, err) + + def2, err := llb.Image("busybox:latest").Marshal(ctx) + require.NoError(t, err) + + res2, err := c.Solve(ctx, client.SolveRequest{ + Definition: def2.ToPB(), + }) + require.NoError(t, err) + + ref2, err := res2.SingleRef() + require.NoError(t, err) + + // Second stat should not error because the rootfs for `busybox` should not + // have been released. + _, err = ref2.StatFile(ctx, client.StatRequest{ + Path: ".", + }) + require.NoError(t, err) + + return res2, nil + } + + _, err = c.Build(ctx, SolveOpt{}, "buildkit_test", b, nil) + require.NoError(t, err) + checkAllReleasable(t, c, sb, true) +} + +func testNoBuildID(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + g := gatewayapi.NewLLBBridgeClient(c.conn) + _, err = g.Ping(ctx, &gatewayapi.PingRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "no buildid found in context") +} + +func testUnknownBuildID(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + + ctx := sb.Context() + + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + g := c.gatewayClientForBuild(t.Name() + identity.NewID()) + _, err = g.Ping(ctx, &gatewayapi.PingRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "no such job") + require.Equal(t, codes.NotFound, grpcerrors.Code(err)) +} diff --git a/client/policy_test.go b/client/policy_test.go index 3e870b0f2..da15a70b9 100644 --- a/client/policy_test.go +++ b/client/policy_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" "sync/atomic" "testing" @@ -29,6 +30,7 @@ import ( solvererrdefs "github.com/moby/buildkit/solver/errdefs" provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" opspb "github.com/moby/buildkit/solver/pb" + "github.com/moby/buildkit/sourcepolicy" sourcepolicypb "github.com/moby/buildkit/sourcepolicy/pb" "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/moby/buildkit/util/entitlements" @@ -1580,3 +1582,187 @@ func tamperDigestHex(dgst digest.Digest) digest.Digest { } return digest.NewDigestFromEncoded(dgst.Algorithm(), string(hexPart)) } + +func testSourcePolicy(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Image("busybox:1.34.1-uclibc").File( + llb.Copy(llb.HTTP("https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md"), + "README.md", "README.md")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + } + + type testCase struct { + srcPol *sourcepolicypb.Policy + expectedErr string + } + testCases := []testCase{ + { + // Valid + srcPol: &sourcepolicypb.Policy{ + Rules: []*sourcepolicypb.Rule{ + { + Action: sourcepolicypb.PolicyAction_CONVERT, + Selector: &sourcepolicypb.Selector{ + Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc", + }, + Updates: &sourcepolicypb.Update{ + Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:3614ca5eacf0a3a1bcc361c939202a974b4902b9334ff36eb29ffe9011aaad83", + }, + }, + { + Action: sourcepolicypb.PolicyAction_CONVERT, + Selector: &sourcepolicypb.Selector{ + Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", + }, + Updates: &sourcepolicypb.Update{ + Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", + Attrs: map[string]string{"http.checksum": "sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53"}, + }, + }, + }, + }, + expectedErr: "", + }, + { + // Invalid docker-image source + srcPol: &sourcepolicypb.Policy{ + Rules: []*sourcepolicypb.Rule{ + { + Action: sourcepolicypb.PolicyAction_CONVERT, + Selector: &sourcepolicypb.Selector{ + Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc", + }, + Updates: &sourcepolicypb.Update{ + Identifier: "docker-image://docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // invalid + }, + }, + }, + }, + expectedErr: "docker.io/library/busybox:1.34.1-uclibc@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: not found", + }, + { + // Invalid http source + srcPol: &sourcepolicypb.Policy{ + Rules: []*sourcepolicypb.Rule{ + { + Action: sourcepolicypb.PolicyAction_CONVERT, + Selector: &sourcepolicypb.Selector{ + Identifier: "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md", + }, + Updates: &sourcepolicypb.Update{ + Attrs: map[string]string{opspb.AttrHTTPChecksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, // invalid + }, + }, + }, + }, + expectedErr: "digest mismatch sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53: sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + } + for i, tc := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + _, err = c.Build(sb.Context(), SolveOpt{SourcePolicy: tc.srcPol}, "", frontend, nil) + if tc.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + } + }) + } + + t.Run("Frontend policies", func(t *testing.T) { + t.Run("deny http", func(t *testing.T) { + denied := "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md" + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Image("busybox:1.34.1-uclibc").File( + llb.Copy(llb.HTTP(denied), + "README.md", "README.md")) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + SourcePolicies: []*sourcepolicypb.Policy{{ + Rules: []*sourcepolicypb.Rule{ + { + Action: sourcepolicypb.PolicyAction_DENY, + Selector: &sourcepolicypb.Selector{ + Identifier: denied, + }, + }, + }, + }}, + }) + } + + _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) + require.ErrorContains(t, err, sourcepolicy.ErrSourceDenied.Error()) + }) + t.Run("resolve image config", func(t *testing.T) { + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + const ( + origRef = "docker.io/library/busybox:1.34.1-uclibc" + updatedRef = "docker.io/library/busybox:latest" + ) + pol := []*sourcepolicypb.Policy{ + { + Rules: []*sourcepolicypb.Rule{ + { + Action: sourcepolicypb.PolicyAction_DENY, + Selector: &sourcepolicypb.Selector{ + Identifier: "*", + }, + }, + { + Action: sourcepolicypb.PolicyAction_ALLOW, + Selector: &sourcepolicypb.Selector{ + Identifier: "docker-image://" + updatedRef + "*", + }, + }, + { + Action: sourcepolicypb.PolicyAction_CONVERT, + Selector: &sourcepolicypb.Selector{ + Identifier: "docker-image://" + origRef, + }, + Updates: &sourcepolicypb.Update{ + Identifier: "docker-image://" + updatedRef, + }, + }, + }, + }, + } + + ref, dgst, _, err := c.ResolveImageConfig(ctx, origRef, sourceresolver.Opt{ + SourcePolicies: pol, + }) + if err != nil { + return nil, err + } + require.Equal(t, updatedRef, ref) + st := llb.Image(ref + "@" + dgst.String()) + def, err := st.Marshal(sb.Context()) + if err != nil { + return nil, err + } + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + SourcePolicies: pol, + }) + } + _, err = c.Build(sb.Context(), SolveOpt{}, "", frontend, nil) + require.NoError(t, err) + }) + }) +}