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 } }