Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,23 @@ COPY --from=builder /usr/src/app/testresult.xml .
buildctl build ... --opt target=testresult --output type=local,dest=path/to/output-dir
```

Alternatively, `src=<path>` exports only part of the build result, without
needing a dedicated stage:

```bash
buildctl build ... --output type=local,dest=path/to/output-dir,src=/usr/src/app/reports
```

The contents of `src` are written to the root of the destination, so
`src=/usr/src/app/reports` exports `reports/index.html` as `index.html`. Omit
`src` to export the entire filesystem.

`src` must point to a directory, and is always resolved from the root of the
build result, so `src=app/build` and `src=/app/build` are equivalent. Symlinks
are resolved within the build result: a link pointing at `/etc` refers to `/etc`
inside the result, never on the host running BuildKit. With a multi-platform
build, `src` is applied to each platform.

With a [multi-platform build](docs/multi-platform.md), a subfolder matching
each target platform will be created in the destination directory:

Expand Down Expand Up @@ -379,10 +396,12 @@ buildctl build ... --output type=local,dest=./bin/release,mode=delete
```

Tar exporter is similar to local exporter but transfers the files through a tarball.
It supports `src=<path>` with the same meaning as above.

```bash
buildctl build ... --output type=tar,dest=out.tar
buildctl build ... --output type=tar > out.tar
buildctl build ... --output type=tar,dest=reports.tar,src=/usr/src/app/reports
```

#### Docker tarball
Expand Down
174 changes: 174 additions & 0 deletions client/client_export_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"testing"

"github.com/containerd/containerd/v2/core/images"
"github.com/containerd/continuity/fs/fstest"
"github.com/containerd/platforms"
controlapi "github.com/moby/buildkit/api/services/control"
"github.com/moby/buildkit/client/llb"
Expand Down Expand Up @@ -961,3 +962,176 @@ func testTarExporterWithSocketCopy(t *testing.T, sb integration.Sandbox) {
_, err = c.Solve(sb.Context(), def, SolveOpt{}, nil)
require.NoError(t, err)
}

// sourceTestState returns a state with a nested layout, so a src pointing at
// "sub" can be told apart from the whole result.
//
// top.txt
// sub/nested.txt
// sub/deeper/deep.txt
func sourceTestState() llb.State {
return llb.Scratch().
File(llb.Mkfile("top.txt", 0600, []byte("top"))).
File(llb.Mkdir("sub", 0755)).
File(llb.Mkfile("sub/nested.txt", 0600, []byte("nested"))).
File(llb.Mkdir("sub/deeper", 0755)).
File(llb.Mkfile("sub/deeper/deep.txt", 0600, []byte("deep")))
}

func testExportLocalSource(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

def, err := sourceTestState().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{"src": "/sub"},
},
},
}, nil)
require.NoError(t, err)

require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.Apply(
fstest.CreateFile("nested.txt", []byte("nested"), 0600),
fstest.CreateDir("deeper", 0755),
fstest.CreateFile("deeper/deep.txt", []byte("deep"), 0600),
)))
}

func testExportLocalSourceNotFound(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

def, err := sourceTestState().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{"src": "/nope"},
},
},
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "src=/nope no such file or directory")
// the mountpoint of the ref inside the daemon must never reach the client
require.NotContains(t, err.Error(), "buildkit-mount")
}

func testExportLocalSourceMultiPlatform(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"}

// every platform builds the same layout and differs only in file contents,
// so the exported platform directories can be compared against each other
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("top.txt", 0600, []byte("top"))).
File(llb.Mkdir("sub", 0755)).
File(llb.Mkfile("sub/nested.txt", 0600, []byte(platform))).
File(llb.Mkdir("sub/deeper", 0755)).
File(llb.Mkfile("sub/deeper/deep.txt", 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
}
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{"src": "/sub"},
},
},
}, "", frontend, nil)
require.NoError(t, err)

// src is applied per platform: every platform directory holds the same
// re-rooted layout, with only the file contents telling them apart
for _, platform := range platformsToTest {
platDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_"))
require.NoError(t, fstest.CheckDirectoryEqualWithApplier(platDir, fstest.Apply(
fstest.CreateFile("nested.txt", []byte(platform), 0600),
fstest.CreateDir("deeper", 0755),
fstest.CreateFile("deeper/deep.txt", []byte(platform), 0600),
)), "unexpected content for %s", platform)
}
}

func testExportTarSource(t *testing.T, sb integration.Sandbox) {
c, err := New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

def, err := sourceTestState().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}),
Attrs: map[string]string{"src": "/sub"},
},
},
}, nil)
require.NoError(t, err)

m, err := testutil.ReadTarToMap(buf.Bytes(), false)
require.NoError(t, err)

item, ok := m["nested.txt"]
require.True(t, ok, "src contents must be at the root of the tarball")
require.Equal(t, []byte("nested"), item.Data)

_, ok = m["deeper/deep.txt"]
require.True(t, ok)

_, ok = m["top.txt"]
require.False(t, ok, "paths outside src must not be in the tarball")
}
4 changes: 4 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms,
testExportLocalNoPlatformSplit,
testExportLocalNoPlatformSplitOverwrite,
testExportLocalSource,
testExportLocalSourceNotFound,
testExportLocalSourceMultiPlatform,
testExportTarSource,
testExportTarPlatformIDSanitized,
testExporterTargetExists,
testMultipleExporters,
Expand Down
46 changes: 43 additions & 3 deletions exporter/local/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import (
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path"
"strconv"
"strings"
"time"

"github.com/containerd/continuity/fs"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/cache"
"github.com/moby/buildkit/client"
Expand All @@ -35,12 +35,14 @@ const (
// in subfolders when multiple platform references are exported.
keyPlatformSplit = "platform-split"
keyMode = "mode"
keySource = "src"
)

type CreateFSOpts struct {
Epoch *epoch.Epoch
AttestationPrefix string
PlatformSplit *bool
Source string
}

func (c *CreateFSOpts) UsePlatformSplit(isMap bool) bool {
Expand Down Expand Up @@ -73,6 +75,12 @@ func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) {
if _, err := client.ParseLocalExporterMode(v); err != nil {
return nil, err
}
case keySource:
var src = strings.TrimSpace(v)
if src == "" {
return nil, errors.Errorf("empty value for %s omit it to export the entire filesystem", keySource)
}
c.Source = path.Join("/", src)
default:
rest[k] = v
}
Expand All @@ -81,6 +89,38 @@ func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) {
return rest, nil
}

// Resolves source inside mountRoot and prevents path traversal
// An empty source returns mountRoot itself.
func resolveSafeSource(mountRoot, source string) (fsutil.FS, error) {
root, err := fs.RootPath(mountRoot, source)
if err != nil {
return nil, sourceError(err, source)
}

outputFS, err := fsutil.NewFS(root)
if err != nil {
return nil, sourceError(err, source)
}

return outputFS, nil
}

// Reports err against the source the client asked for, hiding the
// daemon-side mountpoint that RootPath and NewFS name.
func sourceError(err error, source string) error {
if source == "" {
return err
}
// the innermost *os.PathError carries the bare syscall error, with no path
cause := err
for e := err; e != nil; e = errors.Unwrap(e) {
if pe, ok := e.(*os.PathError); ok {
cause = pe.Err
}
}
return errors.Errorf("%s=%s %v", keySource, source, cause)
}

func CreateFS(ctx context.Context, sessionID string, k string, ref cache.ImmutableRef, attestations []exporter.Attestation, defaultTime time.Time, isMap bool, opt CreateFSOpts) (fsutil.FS, func() error, error) {
var cleanup func() error
var src string
Expand Down Expand Up @@ -110,7 +150,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
cleanup = lm.Unmount
}

outputFS, err := fsutil.NewFS(src)
outputFS, err := resolveSafeSource(src, opt.Source)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -155,7 +195,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
}
if len(attestations) > 0 {
subjects := []intoto.Subject{}
err = outputFS.Walk(ctx, "", func(path string, entry fs.DirEntry, err error) error {
err = outputFS.Walk(ctx, "", func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
Expand Down
Loading