mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-14 12:43:03 +02:00
Disable JSON HTML escaping for recorded session files and for the nested session payloads rewritten by bless mode. This keeps CLI text such as "> 1 sat/vByte" readable instead of turning it into "\u003e". Keep the metadata field order aligned with existing fixtures so a bless pass does not rewrite unrelated sessions just because the encoder changed.
712 lines
16 KiB
Go
712 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/urfave/cli/v3"
|
|
statuspb "google.golang.org/genproto/googleapis/rpc/status"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/metadata"
|
|
grpcstatus "google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const (
|
|
sessionEnvVar = "LOOP_SESSION_RECORD"
|
|
sessionDefaultDir = "cmd/loop/testdata/sessions"
|
|
sessionFileExt = ".json"
|
|
defaultSessionSlug = "session"
|
|
)
|
|
|
|
// Event identifiers in recorded sessions.
|
|
const (
|
|
eventStdout = "stdout"
|
|
eventStderr = "stderr"
|
|
eventStdin = "stdin"
|
|
eventGrpc = "grpc"
|
|
eventExit = "exit"
|
|
eventSignal = "signal"
|
|
)
|
|
|
|
// grpcMarshalOptions is marshalling options to encode gRPC messages in recorded
|
|
// sessions.
|
|
var grpcMarshalOptions = protojson.MarshalOptions{
|
|
UseProtoNames: true,
|
|
EmitUnpopulated: true,
|
|
}
|
|
|
|
// marshalSessionJSON encodes nested session payloads without HTML escaping so
|
|
// recorded fixture strings stay byte-for-byte close to the CLI text.
|
|
func marshalSessionJSON(value any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
encoder := json.NewEncoder(&buf)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(value); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
|
|
}
|
|
|
|
// sessionRecorder captures CLI IO and gRPC traffic for replay.
|
|
type sessionRecorder struct {
|
|
mu sync.Mutex
|
|
started time.Time
|
|
filePath string
|
|
slug string
|
|
|
|
metadata sessionMetadata
|
|
events []sessionEvent
|
|
eventErr error
|
|
|
|
finalizeOnce sync.Once
|
|
|
|
hooksMu sync.Mutex
|
|
hooksStarted bool
|
|
stdoutUnhook func() error
|
|
stderrUnhook func() error
|
|
stdinUnhook func() error
|
|
}
|
|
|
|
// sessionFile is a single recorded CLI session in JSON format. They are
|
|
// stored inside sessionDefaultDir.
|
|
type sessionFile struct {
|
|
Metadata sessionMetadata `json:"metadata"`
|
|
Events []sessionEvent `json:"events"`
|
|
}
|
|
|
|
// sessionMetadata stores static session details and runtime metadata.
|
|
type sessionMetadata struct {
|
|
Args []string `json:"args"`
|
|
Env map[string]string `json:"env"`
|
|
Version string `json:"version"`
|
|
RunError *string `json:"run_error,omitempty"`
|
|
Duration *time.Duration `json:"duration,omitempty"`
|
|
ClockStartUnix int64 `json:"clock_start_unix"`
|
|
}
|
|
|
|
// sessionEvent records a single timestamped payload entry.
|
|
type sessionEvent struct {
|
|
TimeMS int64 `json:"time_ms"`
|
|
Kind string `json:"kind"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
// textPayload records stdout/stderr text chunks as newline-preserving lines.
|
|
type textPayload struct {
|
|
Lines []string `json:"lines"`
|
|
}
|
|
|
|
// newTextPayload splits text into newline-preserving lines for fixture output.
|
|
func newTextPayload(text string) textPayload {
|
|
return textPayload{
|
|
Lines: splitTextLines(text),
|
|
}
|
|
}
|
|
|
|
// text joins a recorded text payload back into the original output.
|
|
func (p textPayload) text() string {
|
|
return joinTextLines(p.Lines)
|
|
}
|
|
|
|
// stdinPayload records stdin chunks.
|
|
type stdinPayload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// splitTextLines preserves line endings while splitting text for fixture
|
|
// storage.
|
|
func splitTextLines(text string) []string {
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.SplitAfter(text, "\n")
|
|
if lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// joinTextLines reconstructs text stored as newline-preserving lines.
|
|
func joinTextLines(lines []string) string {
|
|
return strings.Join(lines, "")
|
|
}
|
|
|
|
// grpcPayload records a gRPC message or error event.
|
|
type grpcPayload struct {
|
|
Method string `json:"method"`
|
|
Event string `json:"event"`
|
|
MessageType string `json:"message_type,omitempty"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Status *statuspb.Status `json:"status,omitempty"`
|
|
}
|
|
|
|
// exitPayload records the final run error, if any.
|
|
type exitPayload struct {
|
|
RunError *string `json:"run_error,omitempty"`
|
|
}
|
|
|
|
// signalPayload records handled signals.
|
|
type signalPayload struct {
|
|
Signal string `json:"signal"`
|
|
}
|
|
|
|
// newSessionRecorder creates a recorder when session recording is enabled.
|
|
func newSessionRecorder(args []string) (*sessionRecorder, error) {
|
|
// Session recording is disabled unless the env var is set.
|
|
envValue, ok := os.LookupEnv(sessionEnvVar)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
// Allow explicit disable by setting the env var to false.
|
|
enabled, err := strconv.ParseBool(envValue)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid %s value %q", sessionEnvVar,
|
|
envValue)
|
|
}
|
|
if !enabled {
|
|
return nil, nil
|
|
}
|
|
|
|
// Initialize the recorder before collecting metadata.
|
|
recorder := &sessionRecorder{
|
|
started: time.Now(),
|
|
}
|
|
|
|
// Derive the slug before resolving the output file path.
|
|
recorder.slug = deriveSessionSlug(args)
|
|
|
|
// Capture metadata that remains stable for the session.
|
|
metadata := sessionMetadata{
|
|
Args: append([]string(nil), args...),
|
|
Env: collectSessionEnv(),
|
|
Version: loop.RichVersion(),
|
|
ClockStartUnix: recorder.started.Unix(),
|
|
}
|
|
recorder.metadata = metadata
|
|
|
|
// Resolve the session file location.
|
|
baseDir, fileName, err := recorder.resolveFilePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recorder.filePath = filepath.Join(baseDir, fileName)
|
|
|
|
return recorder, nil
|
|
}
|
|
|
|
// collectSessionEnv extracts the environment variables recorded in sessions.
|
|
func collectSessionEnv() map[string]string {
|
|
env := make(map[string]string)
|
|
|
|
// Record only LOOPCLI_ variables, excluding the recording toggle.
|
|
for _, kv := range os.Environ() {
|
|
parts := strings.SplitN(kv, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := parts[0]
|
|
value := parts[1]
|
|
if key == sessionEnvVar {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(key, "LOOPCLI_") {
|
|
env[key] = value
|
|
}
|
|
}
|
|
|
|
return env
|
|
}
|
|
|
|
// resolveFilePath chooses the output directory and filename.
|
|
func (r *sessionRecorder) resolveFilePath() (string, string, error) {
|
|
if err := ensureSessionBaseDir(sessionDefaultDir); err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
counter, err := nextSessionCounter(sessionDefaultDir)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
slug := r.slug
|
|
if slug == "" {
|
|
slug = defaultSessionSlug
|
|
}
|
|
|
|
name := fmt.Sprintf("%02d_%s%s", counter, slug, sessionFileExt)
|
|
|
|
return sessionDefaultDir, name, nil
|
|
}
|
|
|
|
// ensureSessionBaseDir verifies that recording is running from the repo root.
|
|
func ensureSessionBaseDir(baseDir string) error {
|
|
info, err := os.Stat(baseDir)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("%s does not exist; run session recording "+
|
|
"from the repository root", baseDir)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", baseDir)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// logEvent records a new event with the elapsed timestamp.
|
|
func (r *sessionRecorder) logEvent(kind string, payload any) {
|
|
data, err := marshalSessionJSON(payload)
|
|
if err != nil {
|
|
r.mu.Lock()
|
|
if r.eventErr == nil {
|
|
r.eventErr = fmt.Errorf("marshal session %s event: %w",
|
|
kind, err)
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
return
|
|
}
|
|
|
|
event := sessionEvent{
|
|
TimeMS: time.Since(r.started).Milliseconds(),
|
|
Kind: kind,
|
|
Data: data,
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.events = append(r.events, event)
|
|
}
|
|
|
|
// Start attaches stdin/stdout/stderr hooks for session recording.
|
|
func (r *sessionRecorder) Start(stdinSource io.Reader,
|
|
stdoutForward, stderrForward io.Writer) error {
|
|
|
|
r.hooksMu.Lock()
|
|
defer r.hooksMu.Unlock()
|
|
|
|
if r.hooksStarted {
|
|
return nil
|
|
}
|
|
|
|
// Capture stdout and stderr first, then stdin.
|
|
origStdout := os.Stdout
|
|
if stdoutForward == nil {
|
|
stdoutForward = origStdout
|
|
}
|
|
outHook, err := hookStdout(origStdout, stdoutForward, func(p []byte) {
|
|
r.logEvent(eventStdout, newTextPayload(string(p)))
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
origStderr := os.Stderr
|
|
if stderrForward == nil {
|
|
stderrForward = origStderr
|
|
}
|
|
errHook, err := hookStderr(origStderr, stderrForward, func(p []byte) {
|
|
r.logEvent(eventStderr, newTextPayload(string(p)))
|
|
})
|
|
if err != nil {
|
|
_ = outHook()
|
|
|
|
return err
|
|
}
|
|
|
|
origStdin := os.Stdin
|
|
if stdinSource == nil {
|
|
stdinSource = origStdin
|
|
}
|
|
stdinHook, err := hookStdin(origStdin, stdinSource, func(p []byte) {
|
|
r.logEvent(eventStdin, stdinPayload{Text: string(p)})
|
|
})
|
|
if err != nil {
|
|
_ = errHook()
|
|
_ = outHook()
|
|
|
|
return err
|
|
}
|
|
|
|
r.stdoutUnhook = outHook
|
|
r.stderrUnhook = errHook
|
|
r.stdinUnhook = stdinHook
|
|
r.hooksStarted = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// stopHooks detaches any active IO hooks.
|
|
func (r *sessionRecorder) stopHooks() error {
|
|
r.hooksMu.Lock()
|
|
defer r.hooksMu.Unlock()
|
|
|
|
var firstErr error
|
|
if r.stdoutUnhook != nil {
|
|
if err := r.stdoutUnhook(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
r.stdoutUnhook = nil
|
|
}
|
|
if r.stderrUnhook != nil {
|
|
if err := r.stderrUnhook(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
r.stderrUnhook = nil
|
|
}
|
|
if r.stdinUnhook != nil {
|
|
if err := r.stdinUnhook(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
r.stdinUnhook = nil
|
|
}
|
|
r.hooksStarted = false
|
|
|
|
return firstErr
|
|
}
|
|
|
|
// logExit records the final outcome and duration.
|
|
func (r *sessionRecorder) logExit(runErr error) {
|
|
var payload exitPayload
|
|
if runErr != nil {
|
|
msg := runErr.Error()
|
|
payload.RunError = &msg
|
|
}
|
|
|
|
// Store the exit event first for the event stream.
|
|
r.logEvent(eventExit, payload)
|
|
|
|
// Update metadata with the final run state.
|
|
duration := time.Since(r.started)
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if runErr != nil {
|
|
msg := runErr.Error()
|
|
r.metadata.RunError = &msg
|
|
} else {
|
|
r.metadata.RunError = nil
|
|
}
|
|
r.metadata.Duration = &duration
|
|
}
|
|
|
|
// finalize writes the recorded session to disk once.
|
|
func (r *sessionRecorder) finalize(runErr error) error {
|
|
var finalizeErr error
|
|
r.finalizeOnce.Do(func() {
|
|
hookErr := r.stopHooks()
|
|
|
|
r.logExit(runErr)
|
|
|
|
r.mu.Lock()
|
|
metadata := r.metadata
|
|
events := append([]sessionEvent(nil), r.events...)
|
|
eventErr := r.eventErr
|
|
r.mu.Unlock()
|
|
|
|
fileContent := sessionFile{
|
|
Metadata: metadata,
|
|
Events: events,
|
|
}
|
|
|
|
err := os.MkdirAll(filepath.Dir(r.filePath), 0o755)
|
|
if err != nil {
|
|
finalizeErr = errors.Join(err, eventErr, hookErr)
|
|
|
|
return
|
|
}
|
|
|
|
file, err := os.Create(r.filePath)
|
|
if err != nil {
|
|
finalizeErr = errors.Join(err, eventErr, hookErr)
|
|
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
encoder := json.NewEncoder(file)
|
|
encoder.SetEscapeHTML(false)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(fileContent); err != nil {
|
|
finalizeErr = errors.Join(err, eventErr, hookErr)
|
|
|
|
return
|
|
}
|
|
|
|
finalizeErr = errors.Join(eventErr, hookErr)
|
|
})
|
|
|
|
return finalizeErr
|
|
}
|
|
|
|
// Finalize records the exit event and flushes the session to disk.
|
|
func (r *sessionRecorder) Finalize(runErr error) error {
|
|
return r.finalize(runErr)
|
|
}
|
|
|
|
// ClockStart returns the fixed session clock used while recording CLI actions.
|
|
func (r *sessionRecorder) ClockStart() time.Time {
|
|
return time.Unix(r.metadata.ClockStartUnix, 0)
|
|
}
|
|
|
|
// Dial uses the direct gRPC connection for recording.
|
|
func (r *sessionRecorder) Dial(cmd *cli.Command) (daemonConn, func(), error) {
|
|
return dialDirectConn(cmd)
|
|
}
|
|
|
|
// UnaryInterceptor captures unary RPCs for session playback.
|
|
func (r *sessionRecorder) UnaryInterceptor() grpc.UnaryClientInterceptor {
|
|
return func(ctx context.Context, method string, req, reply any,
|
|
cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
|
|
opts ...grpc.CallOption) error {
|
|
|
|
r.logGRPCMessage(method, "request", req, nil)
|
|
|
|
err := invoker(ctx, method, req, reply, cc, opts...)
|
|
if err != nil {
|
|
r.logGRPCMessage(method, "error", nil, err)
|
|
|
|
return err
|
|
}
|
|
|
|
r.logGRPCMessage(method, "response", reply, nil)
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// StreamInterceptor captures stream RPCs for session playback.
|
|
func (r *sessionRecorder) StreamInterceptor() grpc.StreamClientInterceptor {
|
|
return func(ctx context.Context, desc *grpc.StreamDesc,
|
|
cc *grpc.ClientConn, method string, streamer grpc.Streamer,
|
|
opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
|
|
|
clientStream, err := streamer(ctx, desc, cc, method, opts...)
|
|
if err != nil {
|
|
r.logGRPCMessage(method, "error", nil, err)
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return &recordingClientStream{
|
|
ClientStream: clientStream,
|
|
recorder: r,
|
|
method: method,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// recordingClientStream wraps a gRPC stream and logs message events.
|
|
type recordingClientStream struct {
|
|
grpc.ClientStream
|
|
|
|
recorder *sessionRecorder
|
|
method string
|
|
}
|
|
|
|
// SendMsg records the outgoing stream message.
|
|
func (s *recordingClientStream) SendMsg(m any) error {
|
|
s.recorder.logGRPCMessage(s.method, "send", m, nil)
|
|
err := s.ClientStream.SendMsg(m)
|
|
if err != nil {
|
|
s.recorder.logGRPCMessage(s.method, "error", nil, err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// RecvMsg records the incoming stream message.
|
|
func (s *recordingClientStream) RecvMsg(m any) error {
|
|
err := s.ClientStream.RecvMsg(m)
|
|
if err != nil {
|
|
s.recorder.logGRPCMessage(s.method, "error", nil, err)
|
|
|
|
return err
|
|
}
|
|
|
|
s.recorder.logGRPCMessage(s.method, "recv", m, nil)
|
|
|
|
return nil
|
|
}
|
|
|
|
// logGRPCMessage captures gRPC request/response data in the event stream.
|
|
func (r *sessionRecorder) logGRPCMessage(method, event string, msg any,
|
|
receptionErr error) {
|
|
|
|
payload := grpcPayload{Method: method, Event: event}
|
|
|
|
if receptionErr != nil {
|
|
payload.Error = receptionErr.Error()
|
|
if grpcStatus, ok := grpcstatus.FromError(receptionErr); ok {
|
|
payload.Status = grpcStatus.Proto()
|
|
}
|
|
r.logEvent(eventGrpc, payload)
|
|
|
|
return
|
|
}
|
|
|
|
if msg != nil {
|
|
if protoMsg, ok := msg.(proto.Message); ok {
|
|
payload.MessageType = string(
|
|
proto.MessageName(protoMsg),
|
|
)
|
|
data, err := grpcMarshalOptions.Marshal(protoMsg)
|
|
if err == nil {
|
|
payload.Payload = data
|
|
}
|
|
} else {
|
|
data, err := marshalSessionJSON(msg)
|
|
if err == nil {
|
|
payload.Payload = data
|
|
}
|
|
}
|
|
}
|
|
|
|
r.logEvent(eventGrpc, payload)
|
|
}
|
|
|
|
// LogSignal records an incoming signal event.
|
|
func (r *sessionRecorder) LogSignal(sig os.Signal) {
|
|
r.logEvent(eventSignal, signalPayload{Signal: sig.String()})
|
|
}
|
|
|
|
// InjectContext tags the outgoing context with the session name.
|
|
func (r *sessionRecorder) InjectContext(ctx context.Context) context.Context {
|
|
return metadata.AppendToOutgoingContext(
|
|
ctx, "loop-session", filepath.Base(r.filePath),
|
|
)
|
|
}
|
|
|
|
// deriveSessionSlug builds a stable slug from the command arguments.
|
|
func deriveSessionSlug(args []string) string {
|
|
if len(args) == 0 {
|
|
return ""
|
|
}
|
|
|
|
base := filepath.Base(args[0])
|
|
tokens := []string{base}
|
|
|
|
for _, arg := range args[1:] {
|
|
if strings.HasPrefix(arg, "-") {
|
|
break
|
|
}
|
|
if arg == "" {
|
|
continue
|
|
}
|
|
tokens = append(tokens, arg)
|
|
}
|
|
|
|
return sanitizeSlug(strings.Join(tokens, "-"))
|
|
}
|
|
|
|
// sanitizeSlug normalizes a session slug to a safe filename.
|
|
func sanitizeSlug(value string) string {
|
|
value = strings.ToLower(value)
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
|
|
continue
|
|
}
|
|
|
|
if !lastDash {
|
|
builder.WriteRune('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
|
|
slug := strings.Trim(builder.String(), "-")
|
|
if slug == "" {
|
|
return defaultSessionSlug
|
|
}
|
|
|
|
return slug
|
|
}
|
|
|
|
// nextSessionCounter finds the next available session number.
|
|
func nextSessionCounter(baseDir string) (int, error) {
|
|
maxCounter := 0
|
|
entries, err := os.ReadDir(baseDir)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return 1, nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if filepath.Ext(entry.Name()) != sessionFileExt {
|
|
continue
|
|
}
|
|
|
|
counter, ok := parseSessionCounter(entry.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
if counter > maxCounter {
|
|
maxCounter = counter
|
|
}
|
|
}
|
|
|
|
return maxCounter + 1, nil
|
|
}
|
|
|
|
// parseSessionCounter extracts the numeric prefix from a session filename.
|
|
func parseSessionCounter(name string) (int, bool) {
|
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
|
if base == "" {
|
|
return 0, false
|
|
}
|
|
|
|
parts := strings.SplitN(base, "_", 2)
|
|
if parts[0] == "" {
|
|
return 0, false
|
|
}
|
|
|
|
for _, r := range parts[0] {
|
|
if r < '0' || r > '9' {
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
value, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
if value < 0 {
|
|
return 0, false
|
|
}
|
|
|
|
return value, true
|
|
}
|