Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable multiple exporters #2760

Closed
Closed
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
487 changes: 303 additions & 184 deletions api/services/control/control.pb.go

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions api/services/control/control.proto
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ message PruneRequest {
}

message DiskUsageRequest {
repeated string filter = 1;
repeated string filter = 1;
}

message DiskUsageResponse {
Expand All @@ -60,8 +60,8 @@ message UsageRecord {
message SolveRequest {
string Ref = 1;
pb.Definition Definition = 2;
string Exporter = 3;
map<string, string> ExporterAttrs = 4;
string ExporterDeprecated = 3;
map<string, string> ExporterAttrsDeprecated = 4;
string Session = 5;
string Frontend = 6;
map<string, string> FrontendAttrs = 7;
Expand All @@ -70,6 +70,7 @@ message SolveRequest {
map<string, pb.Definition> FrontendInputs = 10;
bool Internal = 11; // Internal builds are not recorded in build history
moby.buildkit.v1.sourcepolicy.Policy SourcePolicy = 12;
repeated Exporter Exporters = 13;
}

message CacheOptions {
Expand Down Expand Up @@ -231,7 +232,12 @@ message BuildResultInfo {
repeated Descriptor Attestations = 2;
}

// Exporter describes the output exporter
message Exporter {
// Type identifies the exporter
string Type = 1;
// Attrs specifies exporter configuration
map<string, string> Attrs = 2;
// ID identifies the exporter in the wire protocol
string id = 3 [(gogoproto.customname) = "ID"];
}
75 changes: 74 additions & 1 deletion client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func TestIntegration(t *testing.T) {
testTarExporterWithSocketCopy,
testTarExporterSymlink,
testMultipleRegistryCacheImportExport,
testMultipleExporters,
testSourceMap,
testSourceMapFromRef,
testLazyImagePush,
Expand Down Expand Up @@ -2551,6 +2552,78 @@ func testUser(t *testing.T, sb integration.Sandbox) {
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"

imageExporter := ExporterImage
if integration.IsTestDockerd() {
imageExporter = "moby"
}

resp, err := c.Solve(sb.Context(), def, SolveOpt{
Exports: []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),
},

{
Type: imageExporter,
Attrs: map[string]string{
"name": strings.Join([]string{target1, target2}, ","),
},
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tar exporters here as well for good measure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, just realized that this could be a bigger issue with the multiple file sender implementations like local and tar exporters. There needs to be some sort of fan-out for the multiple file exporters on the gRPC level.

},
}, nil)
require.NoError(t, err)
require.Equal(t, resp.ExporterResponse["image.name"], target1+","+target2)
a-palchikov marked this conversation as resolved.
Show resolved Hide resolved
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"))
}

func testOCIExporter(t *testing.T, sb integration.Sandbox) {
integration.CheckFeatureCompat(t, sb, integration.FeatureOCIExporter)
requiresLinux(t)
Expand Down Expand Up @@ -6739,7 +6812,7 @@ func testMergeOpCache(t *testing.T, sb integration.Sandbox, mode string) {

for i, layer := range manifest.Layers {
_, err = contentStore.Info(ctx, layer.Digest)
require.ErrorIs(t, err, ctderrdefs.ErrNotFound, "unexpected error %v for index %d", err, i)
require.ErrorIs(t, err, ctderrdefs.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
Expand Down
142 changes: 94 additions & 48 deletions client/solve.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -55,8 +56,8 @@ type SolveOpt struct {
type ExportEntry struct {
Type string
Attrs map[string]string
Output func(map[string]string) (io.WriteCloser, error) // for ExporterOCI and ExporterDocker
OutputDir string // for ExporterLocal
Output filesync.FileOutputFunc // for ExporterOCI and ExporterDocker
OutputDir string // for ExporterLocal
}

type CacheOptionsEntry struct {
Expand Down Expand Up @@ -125,12 +126,23 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG
return nil, err
}

var ex ExportEntry
if len(opt.Exports) > 1 {
return nil, errors.New("currently only single Exports can be specified")
type exporter struct {
ExportEntry
id string
}
if len(opt.Exports) == 1 {
ex = opt.Exports[0]

var exporters []exporter
ids := make(map[string]int)
for _, exp := range opt.Exports {
if id, ok := ids[exp.Type]; !ok {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A naive way to auto-id the exporters. Potentially could also be provided id from configuration.

ids[exp.Type] = 1
} else {
ids[exp.Type] = id + 1
}
exporters = append(exporters, exporter{
ExportEntry: exp,
id: fmt.Sprint(exp.Type, ids[exp.Type]),
})
}

storesToUpdate := []string{}
Expand All @@ -156,58 +168,70 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG
contentStores[key2] = store
}

var supportFile bool
var supportDir bool
switch ex.Type {
case ExporterLocal:
supportDir = true
case ExporterTar:
supportFile = true
case ExporterOCI, ExporterDocker:
supportDir = ex.OutputDir != ""
supportFile = ex.Output != nil
var exporterConfig struct {
outputDirs []string
outputs map[string]filesync.FileOutputFunc
}

if supportFile && supportDir {
return nil, errors.Errorf("both file and directory output is not supported by %s exporter", ex.Type)
}
if !supportFile && ex.Output != nil {
return nil, errors.Errorf("output file writer is not supported by %s exporter", ex.Type)
}
if !supportDir && ex.OutputDir != "" {
return nil, errors.Errorf("output directory is not supported by %s exporter", ex.Type)
}

if supportFile {
if ex.Output == nil {
return nil, errors.Errorf("output file writer is required for %s exporter", ex.Type)
}
s.Allow(filesync.NewFSSyncTarget(ex.Output))
}
if supportDir {
if ex.OutputDir == "" {
return nil, errors.Errorf("output directory is required for %s exporter", ex.Type)
}
for _, ex := range exporters {
var supportFile bool
var supportDir bool
switch ex.Type {
case ExporterLocal:
supportDir = true
case ExporterTar:
supportFile = true
case ExporterOCI, ExporterDocker:
if err := os.MkdirAll(ex.OutputDir, 0755); err != nil {
return nil, err
supportDir = ex.OutputDir != ""
supportFile = ex.Output != nil
}
if supportFile && supportDir {
return nil, errors.Errorf("both file and directory output is not supported by %s exporter", ex.Type)
}
if !supportFile && ex.Output != nil {
return nil, errors.Errorf("output file writer is not supported by %s exporter", ex.Type)
}
if !supportDir && ex.OutputDir != "" {
return nil, errors.Errorf("output directory is not supported by %s exporter", ex.Type)
}
if supportFile {
if ex.Output == nil {
return nil, errors.Errorf("output file writer is required for %s exporter", ex.Type)
}
cs, err := contentlocal.NewStore(ex.OutputDir)
if err != nil {
return nil, err
if exporterConfig.outputs == nil {
exporterConfig.outputs = make(map[string]filesync.FileOutputFunc)
}
exporterConfig.outputs[ex.id] = ex.Output
}
if supportDir {
if ex.OutputDir == "" {
return nil, errors.Errorf("output directory is required for %s exporter", ex.Type)
}
switch ex.Type {
case ExporterOCI, ExporterDocker:
if err := os.MkdirAll(ex.OutputDir, 0755); err != nil {
return nil, err
}
cs, err := contentlocal.NewStore(ex.OutputDir)
if err != nil {
return nil, err
}
contentStores["export"] = cs
storesToUpdate = append(storesToUpdate, ex.OutputDir)
default:
exporterConfig.outputDirs = append(exporterConfig.outputDirs, ex.OutputDir)
}
contentStores["export"] = cs
storesToUpdate = append(storesToUpdate, ex.OutputDir)
default:
s.Allow(filesync.NewFSSyncTargetDir(ex.OutputDir))
}
}

if len(contentStores) > 0 {
s.Allow(sessioncontent.NewAttachable(contentStores))
}

if len(exporterConfig.outputDirs) > 0 || len(exporterConfig.outputs) > 0 {
s.Allow(filesync.NewFSSyncTarget(exporterConfig.outputs, exporterConfig.outputDirs))
}

eg.Go(func() error {
sd := c.sessionDialer
if sd == nil {
Expand Down Expand Up @@ -255,11 +279,33 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG
frontendInputs[key] = def.ToPB()
}

exports := make([]*controlapi.Exporter, 0, len(opt.Exports))
var localExporter *controlapi.Exporter
for _, exp := range exporters {
if exp.Type != ExporterLocal {
exports = append(exports, &controlapi.Exporter{
ID: exp.id,
Type: exp.Type,
Attrs: exp.Attrs,
})
} else if localExporter == nil {
// TODO(dima): different options per local exporter?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still unclear how to deal with potentially different options for multiple local exporters as currently (as implemented in this PR), the local export outputs are duplicated solely on the client, which means that any configuration taken into account on the server (for example, the attestation file prefix) will have to come from a single exporter configuration - e.g. the first (as is also currently implemented).

Would appreciate feedback on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we do my suggestion below for multi-plexing between multiple output directories, this issue should go away? We then shouldn't need to do the de-duplication.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would need more context on this. It was a good pointer to avoid a new gRPC service - I did this with metadata instead, but it does not remove the option for users to specify multiple outputs of type local (including options which may vary) - this was my confusion at this specific point.
Currently, only the first local export configuration block is used for all (if >1 specified on command line) and then the destination directories from each corresponding local export are used to duplicate the results of the first export. Sorry if I'm misunderstanding your concern and suggestion - would appreciate more feedback.

localExporter = &controlapi.Exporter{
Type: ExporterLocal,
Attrs: exp.Attrs,
}
}
}
if localExporter != nil {
// Add a single instance of the local exporter
// since it's replicated entirely on the client
exports = append(exports, localExporter)
}

resp, err := c.ControlClient().Solve(ctx, &controlapi.SolveRequest{
Ref: ref,
Definition: pbd,
Exporter: ex.Type,
ExporterAttrs: ex.Attrs,
Exporters: exports,
Session: s.ID(),
Frontend: opt.Frontend,
FrontendAttrs: frontendAttrs,
Expand Down
Loading
Loading