mirror of https://github.com/docker/buildx.git
309 lines
6.3 KiB
Go
309 lines
6.3 KiB
Go
package dap
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/docker/buildx/build"
|
|
"github.com/docker/buildx/util/ioset"
|
|
"github.com/docker/cli/cli-plugins/metadata"
|
|
"github.com/google/go-dap"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/sync/semaphore"
|
|
)
|
|
|
|
type shell struct {
|
|
// SocketPath is set on the first time Init is invoked
|
|
// and stays that way.
|
|
SocketPath string
|
|
|
|
// Locks access to the session from the debug adapter.
|
|
// Only one debug thread can access the shell at a time.
|
|
sem *semaphore.Weighted
|
|
|
|
// Initialized once per shell and reused.
|
|
once sync.Once
|
|
err error
|
|
l net.Listener
|
|
eg *errgroup.Group
|
|
|
|
// For the specific session.
|
|
fwd *ioset.Forwarder
|
|
connected chan struct{}
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func newShell() *shell {
|
|
sh := &shell{
|
|
sem: semaphore.NewWeighted(1),
|
|
}
|
|
sh.resetSession()
|
|
return sh
|
|
}
|
|
|
|
func (s *shell) resetSession() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.fwd = nil
|
|
s.connected = make(chan struct{})
|
|
}
|
|
|
|
// Init initializes the shell for connections on the client side.
|
|
// Attach will block until the terminal has been initialized.
|
|
func (s *shell) Init() error {
|
|
return s.listen()
|
|
}
|
|
|
|
func (s *shell) listen() error {
|
|
s.once.Do(func() {
|
|
var dir string
|
|
dir, s.err = os.MkdirTemp("", "buildx-dap-exec")
|
|
if s.err != nil {
|
|
return
|
|
}
|
|
defer func() {
|
|
if s.err != nil {
|
|
os.RemoveAll(dir)
|
|
}
|
|
}()
|
|
s.SocketPath = filepath.Join(dir, "s.sock")
|
|
|
|
s.l, s.err = net.Listen("unix", s.SocketPath)
|
|
if s.err != nil {
|
|
return
|
|
}
|
|
|
|
s.eg, _ = errgroup.WithContext(context.Background())
|
|
s.eg.Go(s.acceptLoop)
|
|
})
|
|
return s.err
|
|
}
|
|
|
|
func (s *shell) acceptLoop() error {
|
|
for {
|
|
if err := s.accept(); err != nil {
|
|
if errors.Is(err, net.ErrClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *shell) accept() error {
|
|
conn, err := s.l.Accept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.fwd != nil {
|
|
writeLine(conn, "Error: Already connected to exec instance.")
|
|
conn.Close()
|
|
return nil
|
|
}
|
|
|
|
// Set the input of the forwarder to the connection.
|
|
s.fwd = ioset.NewForwarder()
|
|
s.fwd.SetIn(&ioset.In{
|
|
Stdin: io.NopCloser(conn),
|
|
Stdout: conn,
|
|
Stderr: nopCloser{conn},
|
|
})
|
|
|
|
close(s.connected)
|
|
writeLine(conn, "Attached to build process.")
|
|
return nil
|
|
}
|
|
|
|
// Attach will attach the given thread to the shell.
|
|
// Only one container can attach to a shell at any given time.
|
|
// Other attaches will block until the context is canceled or it is
|
|
// able to reserve the shell for its own use.
|
|
//
|
|
// This method is intended to be called by paused threads.
|
|
func (s *shell) Attach(ctx context.Context, t *thread) {
|
|
rCtx := t.rCtx
|
|
if rCtx == nil {
|
|
return
|
|
}
|
|
|
|
var f dap.StackFrame
|
|
if len(t.stackTrace) > 0 {
|
|
f = t.frames[t.stackTrace[0]].StackFrame
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
for {
|
|
if err := s.attach(ctx, f, rCtx, cfg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *shell) wait(ctx context.Context) error {
|
|
s.mu.RLock()
|
|
connected := s.connected
|
|
s.mu.RUnlock()
|
|
|
|
select {
|
|
case <-connected:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return context.Cause(ctx)
|
|
}
|
|
}
|
|
|
|
func (s *shell) attach(ctx context.Context, f dap.StackFrame, rCtx *build.ResultHandle, cfg *build.InvokeConfig) (retErr error) {
|
|
if err := s.wait(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
in, out := ioset.Pipe()
|
|
defer in.Close()
|
|
defer out.Close()
|
|
|
|
s.mu.RLock()
|
|
fwd := s.fwd
|
|
s.mu.RUnlock()
|
|
|
|
fwd.SetOut(&out)
|
|
defer func() {
|
|
if retErr != nil {
|
|
fwd.SetOut(nil)
|
|
}
|
|
}()
|
|
|
|
// Check if the entrypoint is executable. If it isn't, don't bother
|
|
// trying to invoke.
|
|
if reason, ok := s.canInvoke(ctx, rCtx, cfg); !ok {
|
|
writeLineF(in.Stdout, "Build container is not executable. (reason: %s)", reason)
|
|
<-ctx.Done()
|
|
return context.Cause(ctx)
|
|
}
|
|
|
|
if err := s.sem.Acquire(ctx, 1); err != nil {
|
|
return err
|
|
}
|
|
defer s.sem.Release(1)
|
|
|
|
ctr, err := build.NewContainer(ctx, rCtx, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ctr.Cancel()
|
|
|
|
writeLineF(in.Stdout, "Running %s in build container from line %d.",
|
|
strings.Join(append(cfg.Entrypoint, cfg.Cmd...), " "),
|
|
f.Line,
|
|
)
|
|
|
|
writeLine(in.Stdout, "Changes to the container will be reset after the next step is executed.")
|
|
err = ctr.Exec(ctx, cfg, in.Stdin, in.Stdout, in.Stderr)
|
|
|
|
// Send newline to properly terminate the output.
|
|
writeLine(in.Stdout, "")
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fwd.Close()
|
|
s.resetSession()
|
|
return nil
|
|
}
|
|
|
|
func (s *shell) canInvoke(ctx context.Context, rCtx *build.ResultHandle, cfg *build.InvokeConfig) (reason string, ok bool) {
|
|
var cmd string
|
|
if len(cfg.Entrypoint) > 0 {
|
|
cmd = cfg.Entrypoint[0]
|
|
} else if len(cfg.Cmd) > 0 {
|
|
cmd = cfg.Cmd[0]
|
|
}
|
|
|
|
if cmd == "" {
|
|
return "no command specified", false
|
|
}
|
|
|
|
st, err := rCtx.StatFile(ctx, cmd, cfg)
|
|
if err != nil {
|
|
return fmt.Sprintf("stat error: %s", err), false
|
|
}
|
|
|
|
mode := fs.FileMode(st.Mode)
|
|
if !mode.IsRegular() {
|
|
return fmt.Sprintf("%s: not a file", cmd), false
|
|
}
|
|
if mode&0111 == 0 {
|
|
return fmt.Sprintf("%s: not an executable", cmd), false
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
// SendRunInTerminalRequest will send the request to the client to attach to
|
|
// the socket path that was created by Init. This is intended to be run
|
|
// from the adapter and interact directly with the client.
|
|
func (s *shell) SendRunInTerminalRequest(ctx Context) error {
|
|
// 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", s.SocketPath},
|
|
Env: map[string]any{
|
|
"BUILDX_EXPERIMENTAL": "1",
|
|
},
|
|
},
|
|
}
|
|
|
|
resp := ctx.Request(req)
|
|
if !resp.GetResponse().Success {
|
|
return errors.New(resp.GetResponse().Message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type nopCloser struct {
|
|
io.Writer
|
|
}
|
|
|
|
func (nopCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func writeLine(w io.Writer, msg string) {
|
|
if os.PathSeparator == '\\' {
|
|
fmt.Fprint(w, msg+"\r\n")
|
|
} else {
|
|
fmt.Fprintln(w, msg)
|
|
}
|
|
}
|
|
|
|
func writeLineF(w io.Writer, format string, a ...any) {
|
|
if os.PathSeparator == '\\' {
|
|
fmt.Fprintf(w, format+"\r\n", a...)
|
|
} else {
|
|
fmt.Fprintf(w, format+"\n", a...)
|
|
}
|
|
}
|