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>
235 lines
6.6 KiB
Go
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...)
|
|
}
|