dap: filesystem inspection when paused on a digest

Add a file explorer to the debugger that allows exploring the filesystem
of the current container. It will show directory contents, file
contents, and symlink destinations. It will also show the file mode
associated with a file.

The file explorer defaults to marking itself as an expensive operation
so the debugger doesn't automatically retrieve the information.

Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
This commit is contained in:
Jonathan A. Sternberg
2025-07-28 09:52:30 -05:00
parent 4c791dce97
commit 8e356c3454
2 changed files with 214 additions and 22 deletions
+15 -4
View File
@@ -50,6 +50,7 @@ type thread struct {
mu sync.Mutex
// Attributes set when a thread is paused.
cancel context.CancelCauseFunc
rCtx *build.ResultHandle
curPos digest.Digest
stackTrace []int32
@@ -264,7 +265,10 @@ func (t *thread) pause(c Context, ref gateway.Reference, err error, pos *step, e
}
}
}
t.collectStackTrace(pos)
ctx, cancel := context.WithCancelCause(c)
t.collectStackTrace(ctx, pos, ref)
t.cancel = cancel
event.ThreadId = t.id
c.C() <- &dap.StoppedEvent{
@@ -490,20 +494,27 @@ func (t *thread) solve(ctx context.Context, target digest.Digest) (gateway.Refer
}
func (t *thread) releaseState() {
if t.cancel != nil {
t.cancel(context.Canceled)
t.cancel = nil
}
if t.rCtx != nil {
t.rCtx.Done()
t.rCtx = nil
}
for _, f := range t.frames {
f.ResetVars()
}
t.stackTrace = t.stackTrace[:0]
t.variables.Reset()
}
func (t *thread) collectStackTrace(pos *step) {
func (t *thread) collectStackTrace(ctx context.Context, pos *step, ref gateway.Reference) {
for pos != nil {
frame := pos.frame
frame.ExportVars(t.variables)
frame.ExportVars(ctx, ref, t.variables)
t.stackTrace = append(t.stackTrace, int32(frame.Id))
pos = pos.out
pos, ref = pos.out, nil
}
}
+199 -18
View File
@@ -1,16 +1,22 @@
package dap
import (
"context"
"fmt"
"io/fs"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/google/go-dap"
"github.com/moby/buildkit/client/llb"
gateway "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/solver/pb"
"github.com/tonistiigi/fsutil/types"
)
type frame struct {
@@ -45,29 +51,34 @@ func (f *frame) fillLocation(def *llb.Definition, loc *pb.Locations, ws string)
}
}
func (f *frame) ExportVars(refs *variableReferences) {
func (f *frame) ExportVars(ctx context.Context, ref gateway.Reference, refs *variableReferences) {
f.fillVarsFromOp(f.op, refs)
if ref != nil {
f.fillVarsFromResult(ctx, ref, refs)
}
}
func (f *frame) ResetVars() {
f.scopes = nil
}
func (f *frame) fillVarsFromOp(op *pb.Op, refs *variableReferences) {
f.scopes = []dap.Scope{
{
Name: "Arguments",
PresentationHint: "arguments",
VariablesReference: refs.New(func() []dap.Variable {
var vars []dap.Variable
if op.Platform != nil {
vars = append(vars, platformVars(op.Platform, refs))
}
f.scopes = append(f.scopes, dap.Scope{
Name: "Arguments",
PresentationHint: "arguments",
VariablesReference: refs.New(func() []dap.Variable {
var vars []dap.Variable
if op.Platform != nil {
vars = append(vars, platformVars(op.Platform, refs))
}
switch op := op.Op.(type) {
case *pb.Op_Exec:
vars = append(vars, execOpVars(op.Exec, refs))
}
return vars
}),
},
}
switch op := op.Op.(type) {
case *pb.Op_Exec:
vars = append(vars, execOpVars(op.Exec, refs))
}
return vars
}),
})
}
func platformVars(platform *pb.Platform, refs *variableReferences) dap.Variable {
@@ -159,6 +170,148 @@ func execOpVars(exec *pb.ExecOp, refs *variableReferences) dap.Variable {
}
}
func (f *frame) fillVarsFromResult(ctx context.Context, ref gateway.Reference, refs *variableReferences) {
f.scopes = append(f.scopes, dap.Scope{
Name: "File Explorer",
PresentationHint: "locals",
VariablesReference: refs.New(func() []dap.Variable {
return fsVars(ctx, ref, "/", refs)
}),
Expensive: true,
})
}
func fsVars(ctx context.Context, ref gateway.Reference, path string, vars *variableReferences) []dap.Variable {
files, err := ref.ReadDir(ctx, gateway.ReadDirRequest{
Path: path,
})
if err != nil {
return []dap.Variable{
{
Name: "error",
Value: err.Error(),
},
}
}
paths := make([]dap.Variable, len(files))
for i, file := range files {
stat := statf(file)
fv := dap.Variable{
Name: file.Path,
}
fullpath := filepath.Join(path, file.Path)
if file.IsDir() {
fv.Name += "/"
fv.VariablesReference = vars.New(func() []dap.Variable {
dvar := dap.Variable{
Name: ".",
Value: statf(file),
VariablesReference: vars.New(func() []dap.Variable {
return statVars(file)
}),
}
return append([]dap.Variable{dvar}, fsVars(ctx, ref, fullpath, vars)...)
})
fv.Value = ""
} else {
fv.Value = stat
fv.VariablesReference = vars.New(func() (dvars []dap.Variable) {
if fs.FileMode(file.Mode).IsRegular() {
// Regular file so display a small blurb of the file.
dvars = append(dvars, fileVars(ctx, ref, fullpath)...)
}
return append(dvars, statVars(file)...)
})
}
paths[i] = fv
}
return paths
}
func statf(st *types.Stat) string {
mode := fs.FileMode(st.Mode)
modTime := time.Unix(0, st.ModTime).UTC()
return fmt.Sprintf("%s %d:%d %s", mode, st.Uid, st.Gid, modTime.Format("Jan 2 15:04:05 2006"))
}
func fileVars(ctx context.Context, ref gateway.Reference, fullpath string) []dap.Variable {
b, err := ref.ReadFile(ctx, gateway.ReadRequest{
Filename: fullpath,
Range: &gateway.FileRange{Length: 512},
})
var (
data string
dataErr error
)
if err != nil {
data = err.Error()
} else if isBinaryData(b) {
data = "binary data"
} else {
if len(b) == 512 {
// Get the remainder of the file.
remaining, err := ref.ReadFile(ctx, gateway.ReadRequest{
Filename: fullpath,
Range: &gateway.FileRange{Offset: 512},
})
if err != nil {
dataErr = err
} else {
b = append(b, remaining...)
}
}
data = string(b)
}
dvars := []dap.Variable{
{
Name: "data",
Value: data,
},
}
if dataErr != nil {
dvars = append(dvars, dap.Variable{
Name: "dataError",
Value: dataErr.Error(),
})
}
return dvars
}
func statVars(st *types.Stat) (vars []dap.Variable) {
if st.Linkname != "" {
vars = append(vars, dap.Variable{
Name: "linkname",
Value: st.Linkname,
})
}
mode := fs.FileMode(st.Mode)
modTime := time.Unix(0, st.ModTime).UTC()
vars = append(vars, []dap.Variable{
{
Name: "mode",
Value: mode.String(),
},
{
Name: "uid",
Value: strconv.FormatUint(uint64(st.Uid), 10),
},
{
Name: "gid",
Value: strconv.FormatUint(uint64(st.Gid), 10),
},
{
Name: "mtime",
Value: modTime.Format("Jan 2 15:04:05 2006"),
},
}...)
return vars
}
func (f *frame) Scopes() []dap.Scope {
if f.scopes == nil {
return []dap.Scope{}
@@ -213,6 +366,34 @@ func (v *variableReferences) Reset() {
v.nextID.Store(0)
}
// isBinaryData uses heuristics to determine if the file
// is binary. Algorithm taken from this blog post:
// https://eli.thegreenplace.net/2011/10/19/perls-guess-if-file-is-text-or-binary-implemented-in-python/
func isBinaryData(b []byte) bool {
odd := 0
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
return true
}
isHighBit := c&128 > 0
if !isHighBit {
if c < 32 && c != '\n' && c != '\t' {
odd++
}
} else {
r, sz := utf8.DecodeRune(b)
if r != utf8.RuneError && sz > 1 {
i += sz - 1
continue
}
odd++
}
}
return float64(odd)/float64(len(b)) > .3
}
func brief(s string) string {
if len(s) >= 64 {
return s[:60] + " ..."