frontend/mux.go (462 lines of code) (raw):
package frontend
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
"fmt"
"path"
"slices"
"strings"
"github.com/Azure/dalec"
"github.com/containerd/platforms"
"github.com/goccy/go-yaml"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/frontend/dockerui"
gwclient "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/frontend/subrequests"
bktargets "github.com/moby/buildkit/frontend/subrequests/targets"
"github.com/moby/buildkit/util/bklog"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
)
const (
keyResolveSpec = "frontend.dalec.resolve"
// KeyDefaultPlatform is the subreuqest id for returning the default platform
// for the builder.
KeyDefaultPlatform = "frontend.dalec.defaultPlatform"
)
// BuildMux implements a buildkit BuildFunc via its Handle method. With a
// BuildMux you register routes with mux.Add("someKey", SomeHandler,
// optionalTargetInfo).
//
// When a build request is made, BuildMux tries to match the requested build
// target to a registered handler via the registered handler. The correct
// handler to use is determined with the following logic:
//
// 1. Build target is an exact match to a registered route
// 2. Build target is empty, check if a default handler is registered
// 3. Check if any of the registered
// handlers have a route that is a prefix match to the build target
// BuildMux route handlers also must be buildkit BuildFuncs. As such a handler
// can itself also be a distinc BuildMux with its own set of routes. This
// allows handlers to also do their own routing. All logic for what to route is
// handled outside of the BuildMux.
//
// BuildMux and buildkit BuildFunc have a similar relationship as http.ServeMux
// and http.Handler (where http.ServeMux is an http.Handler). In the same way
// BuildMux is a BuildFunc (or rather BuildMux.Handle is). So BuildMux can be
// nested.
//
// When BuildMux calls a handler, it modifies the client to chomp off the
// matched route prefix. So a BuildMux with receiving a build target of
// mariner2/container will match on the registered handler for mariner2 then
// call the handler with the build target changed to just container.
//
// Finally, BuildMux sets an extra build option on the client
// dalec.target=<matched prefix>. This is only done if the dalec.target option
// is not already set, so dalec.target is only modified once and then used in
// handlers to determine which target in spec.Targets applies to them.
type BuildMux struct {
handlers map[string]handler
defaultH *handler
// cached spec so we don't have to load it every time its needed
spec *dalec.Spec
}
type handler struct {
f gwclient.BuildFunc
t *bktargets.Target
}
// Add adds a handler for the given target
// [targetPath] is the resource path to be handled
func (m *BuildMux) Add(targetPath string, bf gwclient.BuildFunc, info *bktargets.Target) {
if m.handlers == nil {
m.handlers = make(map[string]handler)
}
h := handler{bf, info}
m.handlers[targetPath] = h
if info != nil && info.Default {
m.defaultH = &h
}
bklog.G(context.TODO()).WithField("target", targetPath).Info("Added handler to router")
}
const keyTarget = "target"
// describe returns the subrequests that are supported
func (m *BuildMux) describe() (*gwclient.Result, error) {
subs := []subrequests.Request{bktargets.SubrequestsTargetsDefinition, subrequests.SubrequestsDescribeDefinition}
dt, err := json.Marshal(subs)
if err != nil {
return nil, errors.Wrap(err, "error marshalling describe result to json")
}
buf := bytes.NewBuffer(nil)
if err := subrequests.PrintDescribe(dt, buf); err != nil {
return nil, err
}
res := gwclient.NewResult()
res.Metadata = map[string][]byte{
"result.txt": buf.Bytes(),
"version": []byte(subrequests.SubrequestsDescribeDefinition.Version),
}
return res, nil
}
func (m *BuildMux) handleSubrequest(ctx context.Context, client gwclient.Client, opts map[string]string) (*gwclient.Result, bool, error) {
switch opts[requestIDKey] {
case "":
return nil, false, nil
case subrequests.RequestSubrequestsDescribe:
res, err := m.describe()
return res, true, err
case bktargets.SubrequestsTargetsDefinition.Name:
res, err := m.list(ctx, client, opts[keyTarget])
return res, true, err
case keyTopLevelTarget:
return nil, false, nil
case keyResolveSpec:
res, err := handleResolveSpec(ctx, client)
return res, true, err
case KeyDefaultPlatform:
res, err := handleDefaultPlatform()
return res, true, err
default:
return nil, false, errors.Errorf("unsupported subrequest %q", opts[requestIDKey])
}
}
func handleResolveSpec(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) {
dc, err := dockerui.NewClient(client)
if err != nil {
return nil, err
}
targets := dc.TargetPlatforms
if len(targets) == 0 {
targets = append(targets, platforms.DefaultSpec())
}
out := make([]*dalec.Spec, 0, len(targets))
for _, p := range targets {
spec, err := LoadSpec(ctx, dc, &p)
if err != nil {
return nil, err
}
out = append(out, spec)
}
dtYaml, err := yaml.Marshal(out)
if err != nil {
return nil, err
}
dtJSON, err := json.Marshal(out)
if err != nil {
return nil, err
}
res := gwclient.NewResult()
res.AddMeta("result.json", dtJSON)
// result.txt here so that `docker buildx build --print` will output it directly.
// Otherwise it prints a go object.
res.AddMeta("result.txt", dtYaml)
return res, nil
}
// handleDefaultPlatform returns the default platform
func handleDefaultPlatform() (*gwclient.Result, error) {
res := gwclient.NewResult()
p := platforms.DefaultSpec()
dt, err := json.Marshal(p)
if err != nil {
return nil, err
}
res.AddMeta("result.json", dt)
res.AddMeta("result.txt", []byte(platforms.Format(p)))
return res, nil
}
func (m *BuildMux) loadSpec(ctx context.Context, client gwclient.Client) (*dalec.Spec, error) {
if m.spec != nil {
return m.spec, nil
}
dc, err := dockerui.NewClient(client)
if err != nil {
return nil, err
}
// Note: this is not suitable for passing to builds since it does not have platform information
spec, err := LoadSpec(ctx, dc, nil, func(cfg *LoadConfig) {
// We want to allow any arg to be passed to the spec since we don't know what
// args are valid at this point, nor do we care here.
cfg.SubstituteOpts = append(cfg.SubstituteOpts, dalec.WithAllowAnyArg)
})
if err != nil {
return nil, err
}
m.spec = spec
return spec, nil
}
func maybeSetDalecTargetKey(client gwclient.Client, key string) gwclient.Client {
opts := client.BuildOpts()
if opts.Opts[keyTopLevelTarget] != "" {
// do nothing since this is already set
return client
}
// optimization to help prevent unnecessary grpc requests
// The gateway client will make a grpc request to get the build opts from the gateway.
// This just caches those opts locally.
// If the client is already a clientWithCustomOpts, then the opts are already cached.
if _, ok := client.(*clientWithCustomOpts); !ok {
// this forces the client to use our cached opts from above
client = &clientWithCustomOpts{opts: opts, Client: client}
}
return setClientOptOption(client, map[string]string{keyTopLevelTarget: key, "build-arg:" + dalec.KeyDalecTarget: key})
}
// list outputs the list of targets that are supported by the mux
func (m *BuildMux) list(ctx context.Context, client gwclient.Client, target string) (*gwclient.Result, error) {
var ls bktargets.List
var check []string
if target == "" {
check = maps.Keys(m.handlers)
} else {
// Use the target as a filter so the response only includes routes that are underneath the target
check = append(check, target)
}
slices.Sort(check)
bklog.G(ctx).WithField("checks", check).Debug("Checking targets")
for _, t := range check {
ctx := bklog.WithLogger(ctx, bklog.G(ctx).WithField("check", t))
bklog.G(ctx).Debug("Lookup target")
matched, h, err := m.lookupTarget(ctx, t)
if err != nil {
bklog.G(ctx).WithError(err).Warn("Error looking up target, skipping")
continue
}
ctx = bklog.WithLogger(ctx, bklog.G(ctx).WithField("matched", matched))
bklog.G(ctx).Debug("Matched target")
if h.t != nil {
t := *h.t
// We have the target info, we can use this directly
ls.Targets = append(ls.Targets, t)
continue
}
bklog.G(ctx).Info("No target info, calling handler")
// No target info, so call the handler to get the info
// This calls the route handler.
// The route handler must be setup to handle the subrequest
// Today we assume all route handers are setup to handle the subrequest.
res, err := h.f(ctx, maybeSetDalecTargetKey(trimTargetOpt(client, matched), matched))
if err != nil {
bklog.G(ctx).Errorf("%+v", err)
return nil, err
}
var _ls bktargets.List
if err := unmarshalResult(res, &_ls); err != nil {
return nil, err
}
for _, t := range _ls.Targets {
t.Name = path.Join(matched, t.Name)
ls.Targets = append(ls.Targets, t)
}
}
return ls.ToResult()
}
type noSuchHandlerError struct {
Target string
Available []string
}
func handlerNotFound(target string, available []string) error {
return &noSuchHandlerError{Target: target, Available: available}
}
func (err *noSuchHandlerError) Error() string {
return fmt.Sprintf("no such handler for target %q: available targets: %s", err.Target, strings.Join(err.Available, ", "))
}
func (m *BuildMux) lookupTarget(ctx context.Context, target string) (matchedPattern string, _ *handler, _ error) {
// `target` is from `docker build --target=<target>`
// cases for `t` are as follows:
// 1. may have an exact match in the handlers (ideal)
// 2. No matching handler and `target == ""` and there is a default handler set (assume default handler)
// 3. may have a prefix match in the handlers, e.g. handler for `foo`, `target == "foo/bar"` (assume nested route)
// 4. No match in the handlers (error)
h, ok := m.handlers[target]
if ok {
return target, &h, nil
}
if target == "" && m.defaultH != nil {
bklog.G(ctx).Info("Using default target")
return target, m.defaultH, nil
}
var candidates []string
for k := range m.handlers {
// for prefix matching, we should skip anything that doesn't have a target defined
if strings.HasPrefix(target, k+"/") {
candidates = append(candidates, k)
}
}
if len(candidates) > 0 {
// Sort uses a lexicographic sort, so the longest prefix will be last
// We want to match the longest prefix, so we need the last element
slices.Sort(candidates)
k := candidates[len(candidates)-1]
h := m.handlers[k]
bklog.G(ctx).WithField("prefix", k).WithField("matching request", target).Info("Using prefix match for target")
return k, &h, nil
}
return "", nil, handlerNotFound(target, maps.Keys(m.handlers))
}
// Handle is a [gwclient.BuildFunc] that routes requests to registered handlers
func (m *BuildMux) Handle(ctx context.Context, client gwclient.Client) (_ *gwclient.Result, retErr error) {
// Cache the opts in case this is the raw client
// This prevents a grpc request for multiple calls to BuildOpts
opts := client.BuildOpts().Opts
origOpts := dalec.DuplicateMap(opts)
t := opts[keyTarget]
defer func() {
if retErr != nil {
if _, ok := origOpts[keyTopLevelTarget]; !ok {
retErr = errors.Wrapf(retErr, "error handling requested build target %q", t)
// If we have a spec name, load it to make the error message more helpful
spec, _ := m.loadSpec(ctx, client)
if spec != nil && spec.Name != "" {
retErr = errors.Wrapf(retErr, "spec: %s", spec.Name)
}
}
}
}()
ctx = bklog.WithLogger(ctx, bklog.G(ctx).
WithFields(logrus.Fields{
"handlers": maps.Keys(m.handlers),
"target": opts[keyTarget],
"requestid": opts[requestIDKey],
"targetKey": GetTargetKey(client),
}))
bklog.G(ctx).Info("Handling request")
res, handled, err := m.handleSubrequest(ctx, client, opts)
if err != nil {
return nil, err
}
if handled {
return res, nil
}
matched, h, err := m.lookupTarget(ctx, t)
if err != nil {
return nil, err
}
ctx = bklog.WithLogger(ctx, bklog.G(ctx).WithField("matched", matched))
// each call to `Handle` handles the next part of the target
// When we call the handler, we want to remove the part of the target that is being handled so the next handler can handle the next part
client = trimTargetOpt(client, matched)
client = maybeSetDalecTargetKey(client, matched)
res, err = h.f(ctx, client)
if err != nil {
err = injectPathsToNotFoundError(matched, err)
return res, err
}
// If this request was a request to list targets, we need to modify the response a bit
// Otherwise we can just return the result as is.
if opts[requestIDKey] == bktargets.SubrequestsTargetsDefinition.Name {
return m.fixupListResult(matched, res)
}
return res, nil
}
// fixupListResult updates the targets to include the matched key in their path
// This is used when a list request is made.
//
// The target handler does not know know the full path of the target.
// Specifically it does not include the `matched` part of the target path
// because the matched part is removed from the target path before the handler
// is called.
// This function adds the matched part back to the target path so the response includes the full path.
func (m *BuildMux) fixupListResult(matched string, res *gwclient.Result) (*gwclient.Result, error) {
var v bktargets.List
if err := unmarshalResult(res, &v); err != nil {
return nil, err
}
updated := make([]bktargets.Target, 0, len(v.Targets))
for _, t := range v.Targets {
t.Name = path.Join(matched, t.Name)
updated = append(updated, t)
}
v.Targets = updated
if err := marshalResult(res, &v); err != nil {
return nil, err
}
asResult, err := v.ToResult()
if err != nil {
return nil, err
}
// update the original result with the new data
// See `v.ToResult()` for the metadata keys
res.AddMeta("result.json", asResult.Metadata["result.json"])
res.AddMeta("result.txt", asResult.Metadata["result.txt"])
res.AddMeta("version", asResult.Metadata["version"])
return res, nil
}
// If the error is from noSuchHandlerError, we want to update the error to include the matched target
// This makes sure the returned error message has the full target path.
func injectPathsToNotFoundError(matched string, err error) error {
if err == nil {
return nil
}
var e *noSuchHandlerError
if !errors.As(err, &e) {
return err
}
e.Target = path.Join(matched, e.Target)
for i, v := range e.Available {
e.Available[i] = path.Join(matched, v)
}
return e
}
func unmarshalResult[T any](res *gwclient.Result, v *T) error {
dt, ok := res.Metadata["result.json"]
if !ok {
return errors.Errorf("no result.json metadata in response")
}
return json.Unmarshal(dt, v)
}
func marshalResult[T any](res *gwclient.Result, v *T) error {
dt, err := json.Marshal(v)
if err != nil {
return errors.Wrap(err, "error marshalling result to json")
}
res.Metadata["result.json"] = dt
res.Metadata["result.txt"] = dt
return nil
}
// CurrentFrontend is an interface typically implemented by a [gwclient.Client]
// This is used to get the rootfs of the current frontend.
type CurrentFrontend interface {
CurrentFrontend() (*llb.State, error)
}
var (
_ gwclient.Client = (*clientWithCustomOpts)(nil)
_ CurrentFrontend = (*clientWithCustomOpts)(nil)
)
type clientWithCustomOpts struct {
opts gwclient.BuildOpts
gwclient.Client
}
func trimTargetOpt(client gwclient.Client, prefix string) *clientWithCustomOpts {
opts := client.BuildOpts()
updated := strings.TrimPrefix(opts.Opts[keyTarget], prefix)
if len(updated) > 0 && updated[0] == '/' {
updated = updated[1:]
}
opts.Opts[keyTarget] = updated
return &clientWithCustomOpts{
Client: client,
opts: opts,
}
}
func setClientOptOption(client gwclient.Client, extraOpts map[string]string) *clientWithCustomOpts {
opts := client.BuildOpts()
for key, value := range extraOpts {
opts.Opts[key] = value
}
return &clientWithCustomOpts{
Client: client,
opts: opts,
}
}
func (d *clientWithCustomOpts) BuildOpts() gwclient.BuildOpts {
return d.opts
}
func (d *clientWithCustomOpts) CurrentFrontend() (*llb.State, error) {
return d.Client.(CurrentFrontend).CurrentFrontend()
}
// Handler returns a [gwclient.BuildFunc] that uses the mux to route requests to appropriate handlers
func (m *BuildMux) Handler(opts ...func(context.Context, gwclient.Client, *BuildMux) error) gwclient.BuildFunc {
return func(ctx context.Context, client gwclient.Client) (_ *gwclient.Result, retErr error) {
defer func() {
defer func() {
if r := recover(); r != nil {
trace := getPanicStack()
recErr := fmt.Errorf("recovered from panic in handler: %+v", r)
retErr = stderrors.Join(recErr, trace)
}
}()
}()
if !SupportsDiffMerge(client) {
dalec.DisableDiffMerge(true)
}
for _, opt := range opts {
if err := opt(ctx, client, m); err != nil {
return nil, err
}
}
return m.Handle(ctx, client)
}
}
// WithTargetForwardingHandler registers a handler for each spec target that has a custom frontend
func WithTargetForwardingHandler(ctx context.Context, client gwclient.Client, m *BuildMux) error {
if k := GetTargetKey(client); k != "" {
// This is already a forwarded request, so we don't want to forward again
return fmt.Errorf("target forwarding requested but target is already forwarded: this is a bug in the frontend for %q", k)
}
spec, err := m.loadSpec(ctx, client)
if err != nil {
return err
}
for key, t := range spec.Targets {
if t.Frontend == nil {
continue
}
m.Add(key, func(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) {
ctx = bklog.WithLogger(ctx, bklog.G(ctx).WithField("frontend", key).WithField("frontend-ref", t.Frontend.Image).WithField("forwarded", true))
bklog.G(ctx).Info("Forwarding to custom frontend")
req, err := newSolveRequest(
copyForForward(ctx, client),
withSpec(ctx, spec, dalec.ProgressGroup("prepare spec to forward to frontend")),
toFrontend(t.Frontend),
withTarget(client.BuildOpts().Opts[keyTarget]),
)
if err != nil {
return nil, err
}
return client.Solve(ctx, req)
}, nil)
bklog.G(ctx).WithField("target", key).WithField("targets", maps.Keys(m.handlers)).WithField("targetKey", GetTargetKey(client)).Info("Added custom frontend to router")
}
return nil
}
// WithBuiltinHandler registers a late-binding handler for the given target key.
// These are only added if the target is in the spec OR the spec has no explicit targets.
func WithBuiltinHandler(key string, bf gwclient.BuildFunc) func(context.Context, gwclient.Client, *BuildMux) error {
return func(ctx context.Context, client gwclient.Client, m *BuildMux) error {
if !shouldLoadTarget(ctx, client, m, key) {
return nil
}
m.Add(key, bf, nil)
return nil
}
}
// shouldLoadTarget is used to determine if the spec is overriding the built-in
// target with the same targetKey.
//
// When there is nothing specified in `spec.Targets` this always returns true.
//
// When `spec.Targets` is populated but the provided targetKey does not appear
// in `spec.Targets` this returns false.
//
// When the provided targetKey is in `spec.Targets` but the target spec defines
// a frontend to forward to, this returns false.
//
// Otherwise true.
func shouldLoadTarget(ctx context.Context, client gwclient.Client, mux *BuildMux, targetKey string) bool {
spec, err := mux.loadSpec(ctx, client)
if err != nil {
Warnf(ctx, client, llb.Scratch(), "Cannot load target %s due to error loading spec: %v", targetKey, err)
return false
}
if len(spec.Targets) == 0 {
return true
}
t, ok := spec.Targets[targetKey]
if !ok {
bklog.G(ctx).WithField("spec targets", maps.Keys(spec.Targets)).WithField("targetKey", targetKey).Info("Target not in the spec, skipping")
return false
}
if t.Frontend != nil {
bklog.G(ctx).WithField("targetKey", targetKey).Info("Target has custom frontend, skipping builtin-handler")
Warnf(ctx, client, llb.Scratch(), "Built-in target %q overwritten by target in spec", targetKey)
return false
}
return true
}
// LoadBuiltinTargets is like [WithBuiltinHandler] but accepts a mapping of handlers
// instead of one at a time.
func LoadBuiltinTargets(targets map[string]gwclient.BuildFunc) func(context.Context, gwclient.Client, *BuildMux) error {
return func(ctx context.Context, client gwclient.Client, mux *BuildMux) error {
for target, handler := range targets {
if shouldLoadTarget(ctx, client, mux, target) {
mux.Add(target, handler, nil)
}
}
return nil
}
}