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>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:14:23 +02:00
co-authored by Claude Opus 5.5
parent e843502cbf
commit b5e57c3cb7
6 changed files with 593 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// 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
}
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: Apache-2.0
package command
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
"time"
)
func TestMissingProgram(t *testing.T) {
_, err := Exec{Timeout: time.Second}.Run(context.Background(), "ddi-no-such-program-xyz", "-v")
var ce *Error
if !errors.As(err, &ce) || !IsNotFound(err) {
t.Fatalf("err = %v, want *Error wrapping exec.ErrNotFound", err)
}
if !strings.Contains(err.Error(), "ddi-no-such-program-xyz -v") {
t.Errorf("error does not name the command: %v", err)
}
}
func TestFailingProgramQuotesOutput(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not available")
}
out, err := Exec{Timeout: 10 * time.Second}.Run(context.Background(), sh, "-c", "echo out; echo err >&2; exit 3")
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(string(out), "out") || !strings.Contains(string(out), "err") {
t.Errorf("output = %q", out)
}
if !strings.Contains(err.Error(), "exit status 3") || !strings.Contains(err.Error(), "err") {
t.Errorf("error = %v", err)
}
}
func TestTimeout(t *testing.T) {
sh, err := exec.LookPath("sh")
if err != nil {
t.Skip("sh not available")
}
start := time.Now()
_, err = Exec{Timeout: 200 * time.Millisecond}.Run(context.Background(), sh, "-c", "sleep 10")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want deadline exceeded", err)
}
if time.Since(start) > 8*time.Second {
t.Error("timeout did not stop the program")
}
}
func TestExcerptAndLimit(t *testing.T) {
long := strings.Repeat("a", MaxErrorOutput) + "END"
ex := Excerpt([]byte(long))
if !strings.HasPrefix(ex, "...") || !strings.HasSuffix(ex, "END") || len(ex) != MaxErrorOutput+3 {
t.Errorf("excerpt has length %d, prefix %q", len(ex), ex[:5])
}
var l limitedBuffer
_, _ = l.Write([]byte(strings.Repeat("b", MaxCapture-1)))
_, _ = l.Write([]byte("cc"))
_, _ = l.Write([]byte("dd"))
b := l.Bytes()
if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b), "c") != 1 {
t.Errorf("limited buffer kept %d bytes", len(b))
}
}
+197
View File
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: Apache-2.0
// Package fsutil writes configuration files atomically and keeps a backup of
// the files an activation touches so that it can be undone.
package fsutil
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
// tempPrefix starts the names of temporary files next to the targets.
const tempPrefix = ".ddi-tmp-"
// Owner is a numeric file owner. Valid is false where ownership is unknown
// (non-Unix systems).
type Owner struct {
UID, GID int
Valid bool
}
// OwnerFor returns the owner a file written to path should get: the owner
// of the existing file, otherwise the owner of its directory. Installers
// thereby decide ownership once by creating the directories (e.g.
// root:bind for configuration, bind:bind for zone files).
func OwnerFor(path string) Owner {
if o, ok := ownerOf(path); ok {
return o
}
if o, ok := ownerOf(filepath.Dir(path)); ok {
return o
}
return Owner{}
}
// WriteFile replaces path atomically: it writes a temporary file in the same
// directory, sets mode and owner, syncs it, renames it over path and syncs
// the directory. The owner is applied only when the process runs as root.
func WriteFile(path string, data []byte, mode fs.FileMode, owner Owner) (err error) {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, tempPrefix+"*")
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
tmp := f.Name()
defer func() {
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
}
}()
if _, err = f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err = f.Chmod(mode.Perm()); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
if err = chown(f, owner); err != nil {
return fmt.Errorf("chown %s: %w", path, err)
}
if err = f.Sync(); err != nil {
return fmt.Errorf("sync %s: %w", path, err)
}
if err = f.Close(); err != nil {
return fmt.Errorf("close %s: %w", path, err)
}
if err = os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename %s: %w", path, err)
}
return SyncDir(dir)
}
// Remove deletes path (absent files are fine) and syncs its directory.
func Remove(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return SyncDir(filepath.Dir(path))
}
// Exists reports whether something exists at path (without following a
// final symbolic link).
func Exists(path string) (bool, error) {
_, err := os.Lstat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, fs.ErrNotExist):
return false, nil
default:
return false, err
}
}
// ReadFile reads a file of a revision or a live configuration file. The
// paths come from the agent configuration and from revision manifests that
// passed bundle.CheckPath.
func ReadFile(path string) ([]byte, error) {
return os.ReadFile(path) //nolint:gosec // see above
}
// Same reports whether path is a regular file with exactly this content and
// permission bits.
func Same(path string, data []byte, mode fs.FileMode) bool {
fi, err := os.Lstat(path)
if err != nil || !fi.Mode().IsRegular() || fi.Size() != int64(len(data)) || fi.Mode().Perm() != mode.Perm() {
return false
}
cur, err := ReadFile(path)
return err == nil && bytes.Equal(cur, data)
}
// RequireDir returns an error unless path is an existing directory.
func RequireDir(path string) error {
fi, err := os.Stat(path)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("%s is not a directory", path)
}
return nil
}
// Backup remembers the state of files before they are changed: content,
// mode and owner of existing files, or that a file did not exist. It is
// held in memory; configuration files are small, and after a crash the
// agent core rolls back to the last good revision anyway.
type Backup struct {
order []string
saved map[string]saved
}
type saved struct {
exists bool
data []byte
mode fs.FileMode
owner Owner
}
// Save records path unless it was recorded before. Only regular files can
// be saved; anything else at path is an error, so the caller never replaces
// a directory or a symbolic link it could not restore.
func (b *Backup) Save(path string) error {
if _, ok := b.saved[path]; ok {
return nil
}
if b.saved == nil {
b.saved = map[string]saved{}
}
fi, err := os.Lstat(path)
switch {
case errors.Is(err, fs.ErrNotExist):
b.saved[path] = saved{}
case err != nil:
return fmt.Errorf("back up %s: %w", path, err)
case !fi.Mode().IsRegular():
return fmt.Errorf("back up %s: not a regular file (%s)", path, fi.Mode().Type())
default:
data, err := ReadFile(path)
if err != nil {
return fmt.Errorf("back up %s: %w", path, err)
}
o, _ := ownerOf(path)
b.saved[path] = saved{exists: true, data: data, mode: fi.Mode().Perm(), owner: o}
}
b.order = append(b.order, path)
return nil
}
// Paths returns the recorded paths in the order they were saved.
func (b *Backup) Paths() []string { return append([]string(nil), b.order...) }
// Restore puts every recorded file back: existing files get their old
// content, mode and owner, files that did not exist are removed. It goes
// on after errors and returns all of them.
func (b *Backup) Restore() error {
var errs []error
for i := len(b.order) - 1; i >= 0; i-- {
p := b.order[i]
s := b.saved[p]
if s.exists {
if err := WriteFile(p, s.data, s.mode, s.owner); err != nil {
errs = append(errs, fmt.Errorf("restore %s: %w", p, err))
}
continue
}
if err := Remove(p); err != nil {
errs = append(errs, fmt.Errorf("restore %s (remove): %w", p, err))
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
//go:build !unix
package fsutil
import "os"
// The agents run on Linux; on other systems (development builds) ownership
// is not handled and directories are not synced.
func ownerOf(string) (Owner, bool) { return Owner{}, false }
func chown(*os.File, Owner) error { return nil }
// SyncDir is a no-op on systems without directory sync.
func SyncDir(string) error { return nil }
+130
View File
@@ -0,0 +1,130 @@
// SPDX-License-Identifier: Apache-2.0
package fsutil
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWriteFileReplacesAtomically(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.conf")
if err := os.WriteFile(p, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := WriteFile(p, []byte("new"), 0o640, OwnerFor(p)); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(p)
if err != nil || string(got) != "new" {
t.Fatalf("content = %q, %v", got, err)
}
if runtime.GOOS != "windows" {
fi, _ := os.Stat(p)
if fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %o, want 640", fi.Mode().Perm())
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), tempPrefix) {
t.Errorf("temporary file left behind: %s", e.Name())
}
}
}
func TestWriteFileFailsWithoutDirectory(t *testing.T) {
if err := WriteFile(filepath.Join(t.TempDir(), "missing", "f"), []byte("x"), 0o640, Owner{}); err == nil {
t.Fatal("expected an error")
}
}
func TestBackupRestore(t *testing.T) {
dir := t.TempDir()
existing := filepath.Join(dir, "existing")
created := filepath.Join(dir, "created")
removed := filepath.Join(dir, "removed")
for _, p := range []string{existing, removed} {
if err := os.WriteFile(p, []byte("orig "+filepath.Base(p)), 0o640); err != nil {
t.Fatal(err)
}
}
var b Backup
for _, p := range []string{existing, created, removed, existing} {
if err := b.Save(p); err != nil {
t.Fatal(err)
}
}
if got := len(b.Paths()); got != 3 {
t.Fatalf("saved %d paths, want 3 (duplicates ignored)", got)
}
// Change everything.
if err := WriteFile(existing, []byte("changed"), 0o600, Owner{}); err != nil {
t.Fatal(err)
}
if err := WriteFile(created, []byte("new"), 0o600, Owner{}); err != nil {
t.Fatal(err)
}
if err := Remove(removed); err != nil {
t.Fatal(err)
}
if err := b.Restore(); err != nil {
t.Fatal(err)
}
for p, want := range map[string]string{existing: "orig existing", removed: "orig removed"} {
got, err := os.ReadFile(p)
if err != nil || string(got) != want {
t.Errorf("%s = %q, %v; want %q", p, got, err, want)
}
if runtime.GOOS != "windows" {
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o640 {
t.Errorf("%s: mode %o not restored", p, fi.Mode().Perm())
}
}
}
if ok, _ := Exists(created); ok {
t.Error("file created after the backup was not removed")
}
}
func TestBackupRefusesDirectories(t *testing.T) {
var b Backup
if err := b.Save(t.TempDir()); err == nil {
t.Fatal("expected an error for a directory")
}
}
func TestSame(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
if Same(p, []byte("x"), 0o640) {
t.Error("missing file reported as same")
}
if err := WriteFile(p, []byte("x"), 0o640, Owner{}); err != nil {
t.Fatal(err)
}
if !Same(p, []byte("x"), 0o640) && runtime.GOOS != "windows" {
t.Error("identical file not recognized")
}
if Same(p, []byte("y"), 0o640) {
t.Error("different content reported as same")
}
if runtime.GOOS != "windows" && Same(p, []byte("x"), 0o600) {
t.Error("different mode reported as same")
}
}
func TestRequireDir(t *testing.T) {
dir := t.TempDir()
if err := RequireDir(dir); err != nil {
t.Fatal(err)
}
f := filepath.Join(dir, "f")
_ = os.WriteFile(f, nil, 0o600)
if RequireDir(f) == nil || RequireDir(filepath.Join(dir, "nope")) == nil {
t.Fatal("expected errors")
}
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: Apache-2.0
//go:build unix
package fsutil
import (
"os"
"syscall"
)
func ownerOf(path string) (Owner, bool) {
fi, err := os.Lstat(path)
if err != nil {
return Owner{}, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return Owner{}, false
}
return Owner{UID: int(st.Uid), GID: int(st.Gid), Valid: true}, true
}
// chown applies owner if the process may change ownership (root). Other
// users cannot give files away, and their files belong to them anyway.
func chown(f *os.File, owner Owner) error {
if !owner.Valid || os.Geteuid() != 0 {
return nil
}
return f.Chown(owner.UID, owner.GID)
}
// SyncDir flushes directory entries (renames, removals) to disk.
func SyncDir(dir string) error {
d, err := os.Open(dir) //nolint:gosec // dir is a configured directory of the role
if err != nil {
return err
}
defer func() { _ = d.Close() }()
return d.Sync()
}