Files
DDI/agent/role/internal/fsutil/fsutil.go
T
sir_melonchenandClaude Opus 5.5 70643f8146 feat(agent/dns): keys file, secondary zones and their health
The DNS role installs ddi-suite-keys.conf (a keys file without keys for
revisions from before M5, so that named.conf can always include it),
includes it before ddi-suite.conf in the validation named.conf, accepts
secondary zone files in <zone_dir>/secondary/ and creates that
directory with the mode and owner of zone_dir without ever touching its
content. The health check queries secondary zones too; a zone still
waiting for its first transfer is reported but does not fail the
deployment.

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

235 lines
6.6 KiB
Go

// 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)
}
// MkdirLike creates the directory path unless it exists, with the
// permission bits and (when running as root) the owner of the existing
// directory ref, so that the service that owns ref can write there too.
// An existing directory is left as it is; anything else at path is an
// error.
func MkdirLike(path, ref string) error {
fi, err := os.Lstat(path)
switch {
case err == nil && fi.IsDir():
return nil
case err == nil:
return fmt.Errorf("%s exists and is not a directory", path)
case !errors.Is(err, fs.ErrNotExist):
return err
}
rfi, err := os.Stat(ref)
if err != nil {
return err
}
if !rfi.IsDir() {
return fmt.Errorf("%s is not a directory", ref)
}
if err := os.Mkdir(path, rfi.Mode().Perm()); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
// The umask may have removed bits.
if err := os.Chmod(path, rfi.Mode().Perm()); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
if o, ok := ownerOf(ref); ok {
if err := chownPath(path, o); err != nil {
return fmt.Errorf("chown %s: %w", path, err)
}
}
return SyncDir(filepath.Dir(path))
}
// 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...)
}