Files
releases/v1/debug/debugger.go
T
Anders Eknert afb30d3f9d Add gocritic linter, fix a bunch of stuff (#7377)
Brace yourselves! For there are many touched files here. No changes
in semantics however.

Spent a long time trying out the various optional rules gocritic
provides, and settled for a few of them. There are more I really
like, but that would take many hours to address across the codebase.

Perhaps others find gocritic too pedantic? If so, we can merge the
fixes without enabling the rule.

Signed-off-by: Anders Eknert <anders@styra.com>
2025-02-24 16:28:41 +01:00

918 lines
21 KiB
Go

// Copyright 2024 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
// Package debug
// EXPERIMENTAL: This package is under active development and is subject to change.
package debug
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strings"
"sync"
fileurl "github.com/open-policy-agent/opa/internal/file/url"
"github.com/open-policy-agent/opa/v1/ast/location"
"github.com/open-policy-agent/opa/v1/logging"
"github.com/open-policy-agent/opa/v1/rego"
"github.com/open-policy-agent/opa/v1/storage"
"github.com/open-policy-agent/opa/v1/storage/inmem"
"github.com/open-policy-agent/opa/v1/topdown"
prnt "github.com/open-policy-agent/opa/v1/topdown/print"
"github.com/open-policy-agent/opa/v1/util"
)
// Debugger is the interface for launching OPA debugger Session(s).
// This implementation is similar in structure to the Debug Adapter Protocol (DAP)
// to make such integrations easier, but is not intended to be a direct implementation.
// See: https://microsoft.github.io/debug-adapter-protocol/specification
//
// EXPERIMENTAL: These interfaces are under active development and is subject to change.
type Debugger interface {
// LaunchEval starts a new eval debug session with the given LaunchEvalProperties.
// The returned session is in a stopped state, and must be resumed to start execution.
LaunchEval(ctx context.Context, props LaunchEvalProperties, opts ...LaunchOption) (Session, error)
}
type debugger struct {
logger logging.Logger
printHook *printHook
eventHandler EventHandler
}
type Session interface {
// Resume resumes execution of the thread with the given ID.
Resume(threadID ThreadID) error
// ResumeAll resumes execution of all threads in the session.
ResumeAll() error
// StepOver executes the next expression in the current scope and then stops on the next expression in the same scope,
// not stopping on expressions in sub-scopes; e.g. execution of referenced rule, called function, comprehension, or every expression.
//
// Example 1:
//
// allow if {
// x := f(input) >-+
// x == 1 <-+
// }
//
// Example 2:
//
// allow if {
// every x in l { >-+
// x < 10 |
// } |
// input.x == 1 <-+
// }
StepOver(threadID ThreadID) error
// StepIn executes the next expression in the current scope and then stops on the next expression in the same scope or sub-scope;
// stepping into any referenced rule, called function, comprehension, or every expression.
//
// Example 1:
//
// allow if {
// x := f(input) >-+
// x == 1 |
// } |
// |
// f(x) := y if { <-+
// y := x + 1
// }
//
// Example 2:
//
// allow if {
// every x in l { >-+
// x < 10 <-+
// }
// input.x == 1
// }
StepIn(threadID ThreadID) error
// StepOut steps out of the current scope (rule, function, comprehension, every expression) and stops on the next expression in the parent scope.
//
// Example 1:
//
// allow if {
// x := f(input) <-+
// x == 1 |
// } |
// |
// f(x) := y if { |
// y := x + 1 >-+
// }
//
// Example 2:
//
// allow if {
// every x in l {
// x < 10 >-+
// } |
// input.x == 1 <-+
// }
StepOut(threadID ThreadID) error
// Threads returns a list of all threads in the session.
Threads() ([]Thread, error)
// Breakpoints returns a list of all set breakpoints.
Breakpoints() ([]Breakpoint, error)
// AddBreakpoint sets a breakpoint at the given location.
AddBreakpoint(loc location.Location) (Breakpoint, error)
// RemoveBreakpoint removes a given breakpoint.
// The removed breakpoint is returned. If the breakpoint does not exist, nil is returned.
RemoveBreakpoint(ID BreakpointID) (Breakpoint, error)
// ClearBreakpoints removes all breakpoints.
ClearBreakpoints() error
// StackTrace returns the StackTrace for the thread with the given ID.
// The stack trace is ordered from the most recent frame to the least recent frame.
StackTrace(threadID ThreadID) (StackTrace, error)
// Scopes returns the Scope list for the frame with the given ID.
Scopes(frameID FrameID) ([]Scope, error)
// Variables returns the Variable list for the given reference.
Variables(varRef VarRef) ([]Variable, error)
// Terminate stops all threads in the session.
Terminate() error
}
type printHook struct {
prnt.Hook
d *debugger
}
func (h *printHook) Print(_ prnt.Context, str string) error {
if h == nil || h.d == nil {
return nil
}
h.d.eventHandler(Event{Type: StdoutEventType, Message: str})
return nil
}
type DebuggerOption func(*debugger)
func NewDebugger(options ...DebuggerOption) Debugger {
return newDebugger(options...)
}
func newDebugger(options ...DebuggerOption) *debugger {
d := &debugger{
eventHandler: newNopEventHandler(),
logger: logging.NewNoOpLogger(),
}
d.printHook = &printHook{d: d}
for _, option := range options {
option(d)
}
return d
}
func SetLogger(logger logging.Logger) DebuggerOption {
return func(d *debugger) {
d.logger = logger
}
}
func SetEventHandler(handler EventHandler) DebuggerOption {
return func(d *debugger) {
d.eventHandler = handler
}
}
type LaunchEvalProperties struct {
LaunchProperties
Query string
Input interface{}
InputPath string
}
type LaunchTestProperties struct {
LaunchProperties
Run string
}
type LaunchProperties struct {
BundlePaths []string
DataPaths []string
StopOnResult bool
StopOnEntry bool
StopOnFail bool
EnablePrint bool
SkipOps []topdown.Op
StrictBuiltinErrors bool
RuleIndexing bool
}
type LaunchOption func(options *launchOptions)
type launchOptions struct {
regoOptions []func(*rego.Rego)
}
func newLaunchOptions(opts []LaunchOption) *launchOptions {
options := &launchOptions{}
for _, opt := range opts {
opt(options)
}
return options
}
// RegoOption adds a rego option to the internal Rego instance.
// Options may be overridden by the debugger, and it is recommended to
// use LaunchEvalProperties for commonly used options.
func RegoOption(opt func(*rego.Rego)) LaunchOption {
return func(options *launchOptions) {
options.regoOptions = append(options.regoOptions, opt)
}
}
func (lp LaunchProperties) String() string {
b, err := json.Marshal(lp)
if err != nil {
return "{}"
}
return string(b)
}
func (d *debugger) LaunchEval(ctx context.Context, props LaunchEvalProperties, opts ...LaunchOption) (Session, error) {
options := newLaunchOptions(opts)
store := inmem.New()
txn, err := store.NewTransaction(ctx, storage.TransactionParams{Write: true})
if err != nil {
return nil, fmt.Errorf("failed to create store transaction: %v", err)
}
regoArgs := make([]func(*rego.Rego), 0, 4)
// We apply all user options first, so the debugger can make overrides if necessary.
regoArgs = append(regoArgs, options.regoOptions...)
regoArgs = append(regoArgs, rego.Query(props.Query))
regoArgs = append(regoArgs, rego.Store(store))
regoArgs = append(regoArgs, rego.Transaction(txn))
regoArgs = append(regoArgs, rego.StrictBuiltinErrors(props.StrictBuiltinErrors))
if props.SkipOps == nil {
props.SkipOps = []topdown.Op{topdown.IndexOp, topdown.RedoOp, topdown.SaveOp, topdown.UnifyOp}
}
if props.EnablePrint {
regoArgs = append(regoArgs, rego.EnablePrintStatements(true),
rego.PrintHook(d.printHook))
}
if len(props.DataPaths) > 0 {
regoArgs = append(regoArgs, rego.Load(props.DataPaths, nil))
}
for _, bundlePath := range props.BundlePaths {
regoArgs = append(regoArgs, rego.LoadBundle(bundlePath))
}
if props.InputPath != "" && props.Input != nil {
return nil, errors.New("cannot specify both input and input path")
}
if props.Input != nil {
regoArgs = append(regoArgs, rego.Input(props.Input))
} else if props.InputPath != "" {
input, err := readInput(props.InputPath)
if err != nil {
return nil, fmt.Errorf("failed to read input: %v", err)
}
regoArgs = append(regoArgs, rego.Input(input))
}
r := rego.New(regoArgs...)
pq, err := r.PrepareForEval(ctx)
if err != nil {
return nil, fmt.Errorf("failed to prepare query for evaluation: %v", err)
}
// Committing the store transaction here to make any data added in previous steps are available during eval.
if err := store.Commit(ctx, txn); err != nil {
return nil, fmt.Errorf("failed to commit store transaction: %v", err)
}
tracer := newDebugTracer()
vc := topdown.NewVirtualCache()
evalArgs := []rego.EvalOption{
rego.EvalRuleIndexing(true),
rego.EvalEarlyExit(true),
rego.EvalQueryTracer(tracer),
rego.EvalRuleIndexing(props.RuleIndexing),
rego.EvalVirtualCache(vc),
}
varManager := newVariableManager()
// Threads are 1-indexed.
t := newThread(1, "main", tracer, varManager, vc, store, d.logger)
s := newSession(ctx, d, varManager, props.LaunchProperties, []*thread{t})
go func() {
defer func() { _ = tracer.Close() }()
rs, evalErr := pq.Eval(s.ctx, evalArgs...)
if evalErr != nil {
var topdownErr *topdown.Error
if errors.As(evalErr, &topdownErr) && topdownErr.Code == topdown.CancelErr {
return
}
d.logger.Error("Evaluation failed: %v", evalErr)
return
}
tracer.resultSet = rs
s.result(t, rs)
}()
if err := s.start(); err != nil {
return nil, err
}
return s, nil
}
func readInput(path string) (any, error) {
path, err := fileurl.Clean(path)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var input any
if err := util.Unmarshal(data, &input); err != nil {
return nil, err
}
return input, nil
}
type session struct {
d *debugger
properties LaunchProperties
threads []*thread
frames []*stackFrame
framesByThread map[ThreadID][]*stackFrame
breakpoints *breakpointCollection
ctx context.Context
cancel context.CancelFunc
varManager *variableManager
mtx sync.Mutex
}
func newSession(ctx context.Context, debugger *debugger, varManager *variableManager, props LaunchProperties, threads []*thread) *session {
ctx, cancel := context.WithCancel(ctx)
s := &session{
d: debugger,
varManager: varManager,
properties: props,
threads: threads,
frames: []*stackFrame{},
framesByThread: map[ThreadID][]*stackFrame{},
breakpoints: newBreakpointCollection(),
ctx: ctx,
cancel: cancel,
}
for _, t := range threads {
t.eventHandler = s.handleEvent
}
return s
}
func (s *session) start() error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
for _, t := range s.threads {
go func() {
s.d.logger.Debug("Thread %d started", t.id)
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "started"})
if err := t.run(s.ctx); err != nil {
s.d.logger.Error("Thread %d failed: %v", t.id, err)
}
s.d.logger.Debug("Thread %d stopped", t.id)
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "exited"})
allStopped := true
for _, t := range s.threads {
if !t.done() {
allStopped = false
break
}
}
if allStopped {
s.d.logger.Debug("All threads stopped")
s.d.sendEvent(Event{Type: TerminatedEventType})
}
}()
}
return nil
}
func (s *session) thread(id ThreadID) (*thread, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
index := int(id - 1)
if index < 0 || index >= len(s.threads) {
return nil, fmt.Errorf("invalid thread id: %d", id)
}
return s.threads[index], nil
}
func (s *session) Resume(threadID ThreadID) error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
t, err := s.thread(threadID)
if err != nil {
return err
}
return t.resume()
}
func (s *session) ResumeAll() error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
for _, t := range s.threads {
if err := t.resume(); err != nil {
return err
}
}
return nil
}
func (s *session) StepOver(threadID ThreadID) error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
t, err := s.thread(threadID)
if err != nil {
return err
}
err = t.stepOver()
if err == nil {
i, e, _ := t.current()
if e != nil {
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "step", stackIndex: i, stackEvent: e})
}
}
if t.done() {
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "exited"})
allStopped := true
for _, t := range s.threads {
if !t.done() {
allStopped = false
break
}
}
if allStopped {
s.d.sendEvent(Event{Type: TerminatedEventType})
}
}
return err
}
func (s *session) StepIn(threadID ThreadID) error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
t, err := s.thread(threadID)
if err != nil {
return err
}
_, err = t.stepIn()
if err == nil {
i, e, _ := t.current()
if e != nil {
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "step", stackIndex: i, stackEvent: e})
}
}
if t.done() {
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "exited"})
allStopped := true
for _, t := range s.threads {
if !t.done() {
allStopped = false
break
}
}
if allStopped {
s.d.sendEvent(Event{Type: TerminatedEventType})
}
}
return err
}
func (s *session) StepOut(threadID ThreadID) error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
t, err := s.thread(threadID)
if err != nil {
return err
}
err = t.stepOut()
if err == nil {
i, e, _ := t.current()
if e != nil {
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "step", stackIndex: i, stackEvent: e})
}
}
if t.done() {
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "exited"})
allStopped := true
for _, t := range s.threads {
if !t.done() {
allStopped = false
break
}
}
if allStopped {
s.d.sendEvent(Event{Type: TerminatedEventType})
}
}
return err
}
func (s *session) Threads() ([]Thread, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
threads := make([]Thread, 0, len(s.threads))
for _, t := range s.threads {
threads = append(threads, t)
}
return threads, nil
}
type sessionThreadState struct {
entered bool
ended bool
prevQueryID uint64
}
func (s *sessionThreadState) String() string {
return fmt.Sprintf("{entered: %v, ended: %v, prevQueryID: %d}", s.entered, s.ended, s.prevQueryID)
}
func (s *session) handleEvent(t *thread, stackIndex int, e *topdown.Event, ts threadState) (eventAction, threadState, error) {
state, ok := ts.(*sessionThreadState)
if state != nil && !ok {
s.d.logger.Warn("invalid thread state: %v", s)
}
if state == nil {
state = &sessionThreadState{}
}
defer func() {
if e != nil {
state.prevQueryID = e.QueryID
} else {
state.prevQueryID = 0
}
}()
if e == nil {
handleEnd := func() (eventAction, threadState, error) {
_ = t.close()
return stopAction, state, nil
}
if state.ended {
s.d.logger.Debug("End of trace already handled")
return handleEnd()
}
s.d.logger.Debug("Handling end of trace")
state.ended = true
if s.properties.StopOnResult {
s.d.logger.Info("Thread %d stopped at end of trace", t.id)
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "result", stackIndex: stackIndex, stackEvent: e})
return breakAction, state, nil
}
return handleEnd()
}
if e.Location == nil {
s.d.logger.Debug("Handling event:\n%v\n\nstate:\n%s", e, state)
} else {
s.d.logger.Debug("Handling event:\n%v\n\nloc:\n%s\n\nstate:\n%s", e, e.Location, state)
}
if s.skipOp(e.Op) {
s.d.logger.Debug("Skipping event (op: %v)", e.Op)
return skipAction, state, nil
}
if s.properties.StopOnEntry && !state.entered && e.Location != nil && e.Location.File != "" {
state.entered = true
s.d.logger.Info("Thread %d stopped at entry", t.id)
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "entry", stackIndex: stackIndex, stackEvent: e})
return breakAction, state, nil
}
if s.properties.StopOnFail && e.Op == topdown.FailOp {
s.d.logger.Info("Thread %d stopped on failure", t.id)
s.d.sendEvent(Event{Type: ExceptionEventType, Thread: t.id, Message: string(e.Op), stackIndex: stackIndex, stackEvent: e})
return breakAction, state, nil
}
if e.Location != nil && e.Location.File != "" {
for _, bp := range s.breakpoints.allForFilePath(e.Location.File) {
if bp.Location().Row == e.Location.Row {
// if the last event also caused a breakpoint AND we're still on the same line, skip this breakpoint.
s.d.logger.Info("Thread %d stopped at breakpoint: %s:%d", t.id, e.Location.File, e.Location.Row)
s.d.sendEvent(Event{Type: StoppedEventType, Thread: t.id, Message: "breakpoint", stackIndex: stackIndex, stackEvent: e})
return breakAction, state, nil
}
}
}
return nopAction, state, nil
}
func (s *session) skipOp(op topdown.Op) bool {
for _, skip := range s.properties.SkipOps {
if skip == op {
return true
}
}
return false
}
func (s *session) result(t *thread, rs rego.ResultSet) {
if rsJSON, err := json.MarshalIndent(rs, "", " "); err == nil {
s.d.logger.Debug("Result: %s\n", rsJSON)
s.d.sendEvent(Event{Type: StdoutEventType, Thread: t.id, Message: string(rsJSON)})
} else {
s.d.logger.Debug("Result: %v\n", rs)
}
}
func (s *session) StackTrace(threadID ThreadID) (StackTrace, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
t, err := s.thread(threadID)
if err != nil {
return nil, err
}
threadFrames := s.framesByThread[t.id]
if threadFrames == nil {
threadFrames = []*stackFrame{}
}
stackIndex := 0
if len(threadFrames) > 0 {
stackIndex = threadFrames[len(threadFrames)-1].stackIndex + 1
}
newEvents := t.stackEvents(stackIndex)
for _, e := range newEvents {
info := s.newStackFrame(e, t, stackIndex)
stackIndex++
threadFrames = append(threadFrames, info)
}
s.framesByThread[t.id] = threadFrames
frames := make([]StackFrame, 0, len(threadFrames))
for _, f := range threadFrames {
frames = append(frames, f)
}
slices.Reverse(frames)
return frames, nil
}
func (s *session) newStackFrame(e *topdown.Event, t *thread, stackIndex int) *stackFrame {
id := len(s.frames) + 1 // frames are 1-indexed
var expl string
if e.Node != nil {
pretty := new(bytes.Buffer)
topdown.PrettyTrace(pretty, []*topdown.Event{e})
expl = strings.Trim(pretty.String(), "\n")
} else {
expl = fmt.Sprintf("%s, %s", e.Op, e.Location)
}
frame := &stackFrame{
id: FrameID(id),
name: fmt.Sprintf("#%d: %d %s", id, e.QueryID, expl),
location: e.Location,
thread: t.id,
stackIndex: stackIndex,
e: e,
}
s.frames = append(s.frames, frame)
return frame
}
func (s *session) frame(id FrameID) (*stackFrame, error) {
index := int(id) - 1
if index < 0 || index >= len(s.frames) {
return nil, fmt.Errorf("invalid frame id: %d", id)
}
return s.frames[index], nil
}
func (s *session) Scopes(frameID FrameID) ([]Scope, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
f, err := s.frame(frameID)
if err != nil {
return nil, err
}
t, err := s.thread(f.thread)
if err != nil {
return nil, err
}
return t.scopes(f.stackIndex), nil
}
func (s *session) Variables(varRef VarRef) ([]Variable, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
s.d.logger.Debug("Variables requested: %d", varRef)
vars, err := s.varManager.vars(varRef)
if err != nil {
return nil, err
}
return vars, nil
}
func (s *session) Breakpoints() ([]Breakpoint, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
return s.breakpoints.all(), nil
}
func (s *session) AddBreakpoint(loc location.Location) (Breakpoint, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
return s.breakpoints.add(loc), nil
}
func (s *session) RemoveBreakpoint(id BreakpointID) (Breakpoint, error) {
if s == nil {
return nil, errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
bp := s.breakpoints.remove(id)
if bp == nil {
return nil, fmt.Errorf("breakpoint %d not found", id)
}
return bp, nil
}
func (s *session) ClearBreakpoints() error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
s.d.logger.Debug("Clearing existing breakpoints")
s.breakpoints.clear()
return nil
}
func (s *session) Terminate() error {
if s == nil {
return errors.New("no active debug session")
}
s.mtx.Lock()
defer s.mtx.Unlock()
s.cancel()
var hasErrors bool
for _, t := range s.threads {
if err := t.close(); err != nil {
hasErrors = true
s.d.logger.Error("Failed to stop thread %d: %v", t.id, err)
} else {
s.d.sendEvent(Event{Type: ThreadEventType, Thread: t.id, Message: "exited"})
}
}
if !hasErrors {
s.d.sendEvent(Event{Type: TerminatedEventType})
}
return nil
}
func (d *debugger) sendEvent(e Event) {
if d == nil || d.eventHandler == nil {
return
}
d.logger.Debug("Sending event: %v", e)
d.eventHandler(e)
}