Files
DDI/agent/role/internal/command/command.go
T
beug.k@beugsiud.deandClaude Opus 5.5 b5e57c3cb7 feat(agent/role): command runner and atomic file helpers for the roles
Shared, role-internal helpers: programs run without a shell, with a
timeout and captured, truncated output quoted in errors; configuration
files are replaced atomically (temp file, fsync, rename, directory sync)
with explicit mode and the owner of the existing file or its directory;
an in-memory backup restores touched files after a failed activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:23 +02:00

138 lines
3.3 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)
}
// Exec runs programs on the host. Timeout bounds every run (0 means only
// the context bounds it).
type Exec struct {
Timeout time.Duration
}
// 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) {
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
// 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
}