dap: support evaluate request to invoke a container
Supports using the `evaluate` request in REPL mode to start a container with the `exec` command. Presently doesn't support any arguments. This improves the dap server so it is capable of sending reverse requests and receiving the response. It also adds a hidden command `dap attach` that attaches to the socket created by `evaluate`. This requires the client to support `runInTerminal`. Likely needs some additional work to make sure resources are cleaned up cleanly especially when the build is unpaused or terminated, but it should work as a decent base. Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
package dap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/docker/buildx/build"
|
||||
"github.com/docker/cli/cli-plugins/metadata"
|
||||
"github.com/google/go-dap"
|
||||
"github.com/google/shlex"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (d *Adapter[C]) Evaluate(ctx Context, req *dap.EvaluateRequest, resp *dap.EvaluateResponse) error {
|
||||
if req.Arguments.Context != "repl" {
|
||||
return errors.Errorf("unsupported evaluate context: %s", req.Arguments.Context)
|
||||
}
|
||||
|
||||
args, err := shlex.Split(req.Arguments.Expression)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "cannot parse expression")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var t *thread
|
||||
if req.Arguments.FrameId > 0 {
|
||||
if t = d.getThreadByFrameID(req.Arguments.FrameId); t == nil {
|
||||
return errors.Errorf("no thread with frame id %d", req.Arguments.FrameId)
|
||||
}
|
||||
} else {
|
||||
if t = d.getFirstThread(); t == nil {
|
||||
return errors.New("no paused thread")
|
||||
}
|
||||
}
|
||||
|
||||
cmd := d.replCommands(ctx, t, resp)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetErr(d.Out())
|
||||
if err := cmd.Execute(); err != nil {
|
||||
fmt.Fprintf(d.Out(), "ERROR: %+v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Adapter[C]) replCommands(ctx Context, t *thread, resp *dap.EvaluateResponse) *cobra.Command {
|
||||
rootCmd := &cobra.Command{}
|
||||
|
||||
execCmd := &cobra.Command{
|
||||
Use: "exec",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !d.supportsExec {
|
||||
return errors.New("cannot exec without runInTerminal client capability")
|
||||
}
|
||||
return t.Exec(ctx, args, resp)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(execCmd)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func (t *thread) Exec(ctx Context, args []string, eresp *dap.EvaluateResponse) (retErr error) {
|
||||
cfg := &build.InvokeConfig{Tty: true}
|
||||
if len(cfg.Entrypoint) == 0 && len(cfg.Cmd) == 0 {
|
||||
cfg.Entrypoint = []string{"/bin/sh"} // launch shell by default
|
||||
cfg.Cmd = []string{}
|
||||
cfg.NoCmd = false
|
||||
}
|
||||
|
||||
ctr, err := build.NewContainer(ctx, t.rCtx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
ctr.Cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
dir, err := os.MkdirTemp("", "buildx-dap-exec")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
}()
|
||||
|
||||
socketPath := filepath.Join(dir, "s.sock")
|
||||
l, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer os.RemoveAll(dir)
|
||||
t.runExec(l, ctr, cfg)
|
||||
}()
|
||||
|
||||
// TODO: this should work in standalone mode too.
|
||||
docker := os.Getenv(metadata.ReexecEnvvar)
|
||||
req := &dap.RunInTerminalRequest{
|
||||
Request: dap.Request{
|
||||
Command: "runInTerminal",
|
||||
},
|
||||
Arguments: dap.RunInTerminalRequestArguments{
|
||||
Kind: "integrated",
|
||||
Args: []string{docker, "buildx", "dap", "attach", socketPath},
|
||||
Env: map[string]any{
|
||||
"BUILDX_EXPERIMENTAL": "1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := ctx.Request(req)
|
||||
if !resp.GetResponse().Success {
|
||||
return errors.New(resp.GetResponse().Message)
|
||||
}
|
||||
|
||||
eresp.Body.Result = fmt.Sprintf("Started process attached to %s.", socketPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *thread) runExec(l net.Listener, ctr *build.Container, cfg *build.InvokeConfig) {
|
||||
defer l.Close()
|
||||
defer ctr.Cancel()
|
||||
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// start a background goroutine to politely refuse any subsequent connections.
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(conn, "Error: Already connected to exec instance.")
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
ctr.Exec(context.Background(), cfg, conn, conn, conn)
|
||||
}
|
||||
Reference in New Issue
Block a user