Files
DDI/agent/role/internal/command/command.go
T
sir_melonchenandClaude Opus 5.5 3e957d7b33 feat(agent): command runner with stdin and optional role logger
The DNS role needs to feed nsupdate scripts on stdin and to log its
dynamic updates. command.InputRunner adds RunInput (errors never quote the
input); roles implementing core.LogSetter get the core's logger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:47:32 +02:00

156 lines
3.9 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Package command runs the external programs of the managed services
// (named-checkconf, rndc, kea-dhcp4, ...). Programs are executed directly
// with an argument list, never through a shell, with a timeout and with
// their output captured up to a limit.
package command
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// Limits for captured output.
const (
// MaxCapture is the number of output bytes kept from one run.
MaxCapture = 256 << 10
// MaxErrorOutput is the number of output bytes quoted in an error.
MaxErrorOutput = 4 << 10
)
// Runner runs a program and returns its combined stdout and stderr.
type Runner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
// InputRunner is a Runner that can also feed data to the program's
// standard input (e.g. an nsupdate script).
type InputRunner interface {
Runner
RunInput(ctx context.Context, input []byte, name string, args ...string) ([]byte, error)
}
// Exec runs programs on the host. Timeout bounds every run (0 means only
// the context bounds it).
type Exec struct {
Timeout time.Duration
}
var _ InputRunner = Exec{}
// Run implements Runner. A non-zero exit status, a timeout or a missing
// program yields an *Error that quotes the (truncated) output.
func (e Exec) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return e.RunInput(ctx, nil, name, args...)
}
// RunInput implements InputRunner: like Run, with input on the program's
// standard input (nil means none). Errors never quote the input.
func (e Exec) RunInput(ctx context.Context, input []byte, name string, args ...string) ([]byte, error) {
if e.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
defer cancel()
}
var out limitedBuffer
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // programs come from the agent configuration, no shell is involved
cmd.Stdout = &out
cmd.Stderr = &out
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
// Do not wait forever for grandchildren that keep the pipes open.
cmd.WaitDelay = 5 * time.Second
err := cmd.Run()
b := out.Bytes()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
err = fmt.Errorf("%w (%w)", ctxErr, err)
}
return b, &Error{Name: name, Args: args, Err: err, Output: b}
}
return b, nil
}
// Error describes a failed run.
type Error struct {
Name string
Args []string
Err error
Output []byte
}
func (e *Error) Error() string {
var b strings.Builder
b.WriteString(e.Name)
for _, a := range e.Args {
b.WriteByte(' ')
b.WriteString(a)
}
b.WriteString(": ")
b.WriteString(e.Err.Error())
if out := Excerpt(e.Output); out != "" {
b.WriteString(": ")
b.WriteString(out)
}
return b.String()
}
func (e *Error) Unwrap() error { return e.Err }
// Excerpt returns trimmed output for messages, cut to MaxErrorOutput bytes.
// The end of the output is kept because the checkers print the decisive
// line last.
func Excerpt(out []byte) string {
s := strings.TrimSpace(string(out))
if len(s) > MaxErrorOutput {
s = "..." + s[len(s)-MaxErrorOutput:]
}
return s
}
// IsNotFound reports whether err says that the program does not exist.
func IsNotFound(err error) bool {
return errors.Is(err, exec.ErrNotFound)
}
// limitedBuffer keeps the first MaxCapture bytes and discards the rest.
type limitedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
truncated bool
}
func (l *limitedBuffer) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
if room := MaxCapture - l.buf.Len(); room > 0 {
if len(p) > room {
l.buf.Write(p[:room])
l.truncated = true
} else {
l.buf.Write(p)
}
} else if len(p) > 0 {
l.truncated = true
}
return len(p), nil
}
func (l *limitedBuffer) Bytes() []byte {
l.mu.Lock()
defer l.mu.Unlock()
b := bytes.Clone(l.buf.Bytes())
if l.truncated {
b = append(b, "\n[output truncated]"...)
}
return b
}