dap: add debug adapter implementation

Adds a simple implementation of the debug adapter that supports the very
basics of a debug adapter.

It supports the launch request, the configuration done request, the
creation of threads, stopping, resuming, and disconnecting from server.

It does not support custom breakpoints, stack traces, or variable
inspection yet. These are planned to be added in the future.

Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
This commit is contained in:
Jonathan A. Sternberg
2025-06-30 10:51:20 -05:00
parent 43e2f27cac
commit 42599a7d49
27 changed files with 4034 additions and 213 deletions
+57 -129
View File
@@ -20,7 +20,6 @@ import (
"github.com/containerd/console"
"github.com/docker/buildx/build"
"github.com/docker/buildx/builder"
"github.com/docker/buildx/monitor"
"github.com/docker/buildx/store"
"github.com/docker/buildx/store/storeutil"
"github.com/docker/buildx/util/buildflags"
@@ -28,6 +27,7 @@ import (
"github.com/docker/buildx/util/confutil"
"github.com/docker/buildx/util/desktop"
"github.com/docker/buildx/util/dockerutil"
"github.com/docker/buildx/util/ioset"
"github.com/docker/buildx/util/metricutil"
"github.com/docker/buildx/util/osutil"
"github.com/docker/buildx/util/platformutil"
@@ -55,7 +55,6 @@ import (
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/tonistiigi/go-csvvalue"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"google.golang.org/grpc/codes"
@@ -100,8 +99,6 @@ type buildOptions struct {
pull bool
exportPush bool
exportLoad bool
invokeConfig *invokeConfig
}
func (o *buildOptions) toOptions() (*BuildOptions, error) {
@@ -276,7 +273,7 @@ func (o *buildOptionsHash) String() string {
return o.result
}
func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) (err error) {
func runBuild(ctx context.Context, dockerCli command.Cli, debugOpts debuggerOptions, options buildOptions) (err error) {
mp := dockerCli.MeterProvider()
ctx, end, err := tracing.TraceCurrentCommand(ctx, []string{"build", options.contextPath},
@@ -320,10 +317,6 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions)
}
driverType := b.Driver
var term bool
if _, err := console.ConsoleFromFile(os.Stderr); err == nil {
term = true
}
attributes := buildMetricAttributes(dockerCli, driverType, &options)
ctx2, cancel := context.WithCancelCause(context.TODO())
@@ -332,8 +325,38 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions)
if err != nil {
return err
}
var (
out io.Writer = os.Stderr
dbg debuggerInstance
)
if debugOpts != nil {
if options.dockerfileName == "-" || options.contextPath == "-" {
// stdin must be usable for debugger
return errors.Errorf("Dockerfile or context from stdin is not supported with debugger")
}
dbg, err = debugOpts.New(ioset.In{
Stdin: io.NopCloser(dockerCli.In()),
Stdout: nopCloser{dockerCli.Out()},
Stderr: nopCloser{dockerCli.Err()},
})
if err != nil {
return err
}
out = dbg.Out()
}
var term bool
if c, ok := out.(console.File); ok {
if _, err := console.ConsoleFromFile(c); err == nil {
term = true
}
}
var printer *progress.Printer
printer, err = progress.NewPrinter(ctx2, os.Stderr, progressMode,
printer, err = progress.NewPrinter(ctx2, out, progressMode,
progress.WithDesc(
fmt.Sprintf("building with %q instance using %s driver", b.Name, b.Driver),
fmt.Sprintf("%s:%s", b.Driver, b.Name),
@@ -348,7 +371,7 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions)
}
done := timeBuildCommand(mp, attributes)
resp, inputs, retErr := runBuildWithOptions(ctx, dockerCli, opts, options, printer)
resp, inputs, retErr := runBuildWithOptions(ctx, dockerCli, opts, dbg, printer)
if err := printer.Wait(); retErr == nil {
retErr = err
@@ -406,26 +429,19 @@ func getImageID(resp map[string]string) string {
return dgst
}
func runBuildWithOptions(ctx context.Context, dockerCli command.Cli, opts *BuildOptions, options buildOptions, printer *progress.Printer) (_ *client.SolveResponse, _ *build.Inputs, retErr error) {
if options.invokeConfig != nil && (options.dockerfileName == "-" || options.contextPath == "-") {
// stdin must be usable for monitor
return nil, nil, errors.Errorf("Dockerfile or context from stdin is not supported with invoke")
}
func runBuildWithOptions(ctx context.Context, dockerCli command.Cli, opts *BuildOptions, dbg debuggerInstance, printer *progress.Printer) (_ *client.SolveResponse, _ *build.Inputs, retErr error) {
var bh build.Handler
if dbg != nil {
if err := dbg.Start(printer, opts); err != nil {
return nil, nil, err
}
defer dbg.Stop()
var (
in io.ReadCloser
m *monitor.Monitor
bh build.Handler
)
if options.invokeConfig == nil {
in = dockerCli.In()
} else {
m = monitor.New(&options.invokeConfig.InvokeConfig, dockerCli.In(), os.Stdout, os.Stderr, printer)
defer m.Close()
bh = m.Handler()
bh = dbg.Handler()
dockerCli.SetIn(nil)
}
in := dockerCli.In()
for {
resp, inputs, err := RunBuild(ctx, dockerCli, opts, in, printer, &bh)
if err != nil {
@@ -440,7 +456,7 @@ func runBuildWithOptions(ctx context.Context, dockerCli command.Cli, opts *Build
}
}
func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugConfig *debugOptions) *cobra.Command {
func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugger debuggerOptions) *cobra.Command {
cFlags := &commonFlags{}
options := &buildOptions{}
@@ -453,7 +469,9 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugConfig *debugOp
"aliases": "docker build, docker builder build, docker image build, docker buildx b",
},
RunE: func(cmd *cobra.Command, args []string) error {
options.contextPath = args[0]
if len(args) > 0 {
options.contextPath = args[0]
}
options.builder = rootOpts.builder
options.metadataFile = cFlags.metadataFile
options.noCache = false
@@ -467,15 +485,7 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions, debugConfig *debugOp
options.progress = cFlags.progress
cmd.Flags().VisitAll(checkWarnedFlags)
if debugConfig != nil && (debugConfig.InvokeFlag != "" || debugConfig.OnFlag != "") {
iConfig := new(invokeConfig)
if err := iConfig.parseInvokeConfig(debugConfig.InvokeFlag, debugConfig.OnFlag); err != nil {
return err
}
options.invokeConfig = iConfig
}
return runBuild(cmd.Context(), dockerCli, *options)
return runBuild(cmd.Context(), dockerCli, debugger, *options)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveFilterDirs
@@ -866,96 +876,6 @@ func printValue(w io.Writer, printer callFunc, version string, format string, re
return printer([]byte(res["result.json"]), w)
}
type invokeConfig struct {
build.InvokeConfig
invokeFlag string
}
func (cfg *invokeConfig) parseInvokeConfig(invoke, on string) error {
switch on {
case "always":
cfg.SuspendOn = build.SuspendAlways
case "error":
cfg.SuspendOn = build.SuspendError
default:
if invoke != "" {
cfg.SuspendOn = build.SuspendAlways
}
}
cfg.invokeFlag = invoke
cfg.Tty = true
cfg.NoCmd = true
switch invoke {
case "default", "":
return nil
case "on-error":
// NOTE: we overwrite the command to run because the original one should fail on the failed step.
// TODO: make this configurable via flags or restorable from LLB.
// Discussion: https://github.com/docker/buildx/pull/1640#discussion_r1113295900
cfg.Cmd = []string{"/bin/sh"}
cfg.NoCmd = false
return nil
}
csvParser := csvvalue.NewParser()
csvParser.LazyQuotes = true
fields, err := csvParser.Fields(invoke, nil)
if err != nil {
return err
}
if len(fields) == 1 && !strings.Contains(fields[0], "=") {
cfg.Cmd = []string{fields[0]}
cfg.NoCmd = false
return nil
}
cfg.NoUser = true
cfg.NoCwd = true
for _, field := range fields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
return errors.Errorf("invalid value %s", field)
}
key := strings.ToLower(parts[0])
value := parts[1]
switch key {
case "args":
cfg.Cmd = append(cfg.Cmd, maybeJSONArray(value)...)
cfg.NoCmd = false
case "entrypoint":
cfg.Entrypoint = append(cfg.Entrypoint, maybeJSONArray(value)...)
if cfg.Cmd == nil {
cfg.Cmd = []string{}
cfg.NoCmd = false
}
case "env":
cfg.Env = append(cfg.Env, maybeJSONArray(value)...)
case "user":
cfg.User = value
cfg.NoUser = false
case "cwd":
cfg.Cwd = value
cfg.NoCwd = false
case "tty":
cfg.Tty, err = strconv.ParseBool(value)
if err != nil {
return errors.Errorf("failed to parse tty: %v", err)
}
default:
return errors.Errorf("unknown key %q", key)
}
}
return nil
}
func maybeJSONArray(v string) []string {
var list []string
if err := json.Unmarshal([]byte(v), &list); err == nil {
return list
}
return []string{v}
}
func callAlias(target *string, value string) cobrautil.BoolFuncValue {
return func(s string) error {
v, err := strconv.ParseBool(s)
@@ -1210,3 +1130,11 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *BuildOptions, inSt
}
return resp[defaultTargetName], inputs, nil
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package commands
import (
"context"
"github.com/docker/buildx/dap"
"github.com/docker/buildx/util/cobrautil"
"github.com/docker/buildx/util/ioset"
"github.com/docker/buildx/util/progress"
"github.com/docker/cli/cli/command"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
func dapCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
var options dapOptions
cmd := &cobra.Command{
Use: "dap",
Short: "Start debug adapter protocol compatible debugger",
}
cobrautil.MarkCommandExperimental(cmd)
flags := cmd.Flags()
flags.StringVar(&options.OnFlag, "on", "error", "When to pause the adapter ([always, error])")
cobrautil.MarkFlagsExperimental(flags, "on")
dapBuildCmd := buildCmd(dockerCli, rootOpts, &options)
dapBuildCmd.Args = cobra.RangeArgs(0, 1)
cmd.AddCommand(dapBuildCmd)
return cmd
}
type dapOptions struct {
// OnFlag is a flag to configure the timing of launching the debugger.
OnFlag string
}
func (d *dapOptions) New(in ioset.In) (debuggerInstance, error) {
invokeConfig, err := parseInvokeConfig("", d.OnFlag)
if err != nil {
return nil, err
}
conn := dap.NewConn(in.Stdin, in.Stdout)
return &adapterProtocolDebugger{
Adapter: dap.New[LaunchConfig](invokeConfig),
conn: conn,
}, nil
}
type LaunchConfig struct {
Dockerfile string `json:"dockerfile,omitempty"`
ContextPath string `json:"contextPath,omitempty"`
Target string `json:"target,omitempty"`
}
type adapterProtocolDebugger struct {
*dap.Adapter[LaunchConfig]
conn dap.Conn
}
func (d *adapterProtocolDebugger) Start(printer *progress.Printer, opts *BuildOptions) error {
cfg, err := d.Adapter.Start(context.Background(), d.conn)
if err != nil {
return errors.Wrap(err, "debug adapter did not start")
}
if cfg.Dockerfile != "" {
opts.DockerfileName = cfg.Dockerfile
}
if cfg.ContextPath != "" {
opts.ContextPath = cfg.ContextPath
}
return nil
}
func (d *adapterProtocolDebugger) Stop() error {
defer d.conn.Close()
return d.Adapter.Stop()
}
+145 -1
View File
@@ -1,9 +1,21 @@
package commands
import (
"encoding/json"
"io"
"os"
"strconv"
"strings"
"github.com/docker/buildx/build"
"github.com/docker/buildx/monitor"
"github.com/docker/buildx/util/cobrautil"
"github.com/docker/buildx/util/ioset"
"github.com/docker/buildx/util/progress"
"github.com/docker/cli/cli/command"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/tonistiigi/go-csvvalue"
)
type debugOptions struct {
@@ -14,9 +26,21 @@ type debugOptions struct {
OnFlag string
}
// debuggerOptions will start a debuggerOptions instance.
type debuggerOptions interface {
New(in ioset.In) (debuggerInstance, error)
}
// debuggerInstance is an instance of a Debugger that has been started.
type debuggerInstance interface {
Start(printer *progress.Printer, opts *BuildOptions) error
Handler() build.Handler
Stop() error
Out() io.Writer
}
func debugCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
var options debugOptions
cmd := &cobra.Command{
Use: "debug",
Short: "Start debugger",
@@ -32,3 +56,123 @@ func debugCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
cmd.AddCommand(buildCmd(dockerCli, rootOpts, &options))
return cmd
}
func (d *debugOptions) New(in ioset.In) (debuggerInstance, error) {
cfg, err := parseInvokeConfig(d.InvokeFlag, d.OnFlag)
if err != nil {
return nil, err
}
return &monitorDebuggerInstance{
cfg: cfg,
in: in.Stdin,
}, nil
}
type monitorDebuggerInstance struct {
cfg *build.InvokeConfig
in io.ReadCloser
m *monitor.Monitor
}
func (d *monitorDebuggerInstance) Start(printer *progress.Printer, opts *BuildOptions) error {
d.m = monitor.New(d.cfg, d.in, os.Stdout, os.Stderr, printer)
return nil
}
func (d *monitorDebuggerInstance) Handler() build.Handler {
return d.m.Handler()
}
func (d *monitorDebuggerInstance) Stop() error {
return d.m.Close()
}
func (d *monitorDebuggerInstance) Out() io.Writer {
return os.Stderr
}
func parseInvokeConfig(invoke, on string) (*build.InvokeConfig, error) {
cfg := &build.InvokeConfig{}
switch on {
case "always":
cfg.SuspendOn = build.SuspendAlways
case "error":
cfg.SuspendOn = build.SuspendError
default:
if invoke != "" {
cfg.SuspendOn = build.SuspendAlways
}
}
cfg.Tty = true
cfg.NoCmd = true
switch invoke {
case "default", "":
return cfg, nil
case "on-error":
// NOTE: we overwrite the command to run because the original one should fail on the failed step.
// TODO: make this configurable via flags or restorable from LLB.
// Discussion: https://github.com/docker/buildx/pull/1640#discussion_r1113295900
cfg.Cmd = []string{"/bin/sh"}
cfg.NoCmd = false
return cfg, nil
}
csvParser := csvvalue.NewParser()
csvParser.LazyQuotes = true
fields, err := csvParser.Fields(invoke, nil)
if err != nil {
return nil, err
}
if len(fields) == 1 && !strings.Contains(fields[0], "=") {
cfg.Cmd = []string{fields[0]}
cfg.NoCmd = false
return cfg, nil
}
cfg.NoUser = true
cfg.NoCwd = true
for _, field := range fields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
return nil, errors.Errorf("invalid value %s", field)
}
key := strings.ToLower(parts[0])
value := parts[1]
switch key {
case "args":
cfg.Cmd = append(cfg.Cmd, maybeJSONArray(value)...)
cfg.NoCmd = false
case "entrypoint":
cfg.Entrypoint = append(cfg.Entrypoint, maybeJSONArray(value)...)
if cfg.Cmd == nil {
cfg.Cmd = []string{}
cfg.NoCmd = false
}
case "env":
cfg.Env = append(cfg.Env, maybeJSONArray(value)...)
case "user":
cfg.User = value
cfg.NoUser = false
case "cwd":
cfg.Cwd = value
cfg.NoCwd = false
case "tty":
cfg.Tty, err = strconv.ParseBool(value)
if err != nil {
return nil, errors.Errorf("failed to parse tty: %v", err)
}
default:
return nil, errors.Errorf("unknown key %q", key)
}
}
return cfg, nil
}
func maybeJSONArray(v string) []string {
var list []string
if err := json.Unmarshal([]byte(v), &list); err == nil {
return list
}
return []string{v}
}
+1
View File
@@ -120,6 +120,7 @@ func addCommands(cmd *cobra.Command, opts *rootOptions, dockerCli command.Cli) {
)
if confutil.IsExperimental() {
cmd.AddCommand(debugCmd(dockerCli, opts))
cmd.AddCommand(dapCmd(dockerCli, opts))
}
cmd.RegisterFlagCompletionFunc( //nolint:errcheck