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>
138 lines
3.3 KiB
Go
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
|
|
}
|