Files
jarl/internal/engine/deploy.go
T
adamandClaude Opus 5.5 b097e10bef Keep options in a fixed order, and say which block a diff hunk is in
Options of a block (server options, a view, a zone) are listed and
written grouped by topic, alphabetical within a group, so the same
option is in the same place everywhere. Top-level statements keep the
order they were added in.

named.conf diffs add the enclosing blocks to each hunk header, like
git's function context: @@ -50,6 +49,8 @@ view "internal" {.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:22 +01:00

343 lines
8.3 KiB
Go

package engine
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
udiff "github.com/aymanbagabas/go-udiff"
"jarl/internal/model"
"jarl/internal/render"
)
// Deployer writes rendered files to disk.
type Deployer struct {
// Group, if set, is the group generated files and directories belong
// to (named must be able to read them; on Debian this is "bind").
Group string
// NamedWritable lists directories (and their subdirectories) named
// must be able to write to: zone files (for journals and DNSSEC
// signing) and secondary zone files. Other directories are only
// readable by the group.
NamedWritable []string
gid int
}
func (d *Deployer) dirMode(dir string) fs.FileMode {
for _, w := range d.NamedWritable {
if w != "" && (dir == w || strings.HasPrefix(dir, w+string(filepath.Separator))) {
return 0o770 | fs.ModeSetgid
}
}
return 0o750 | fs.ModeSetgid
}
func (d *Deployer) groupID() (int, error) {
if d.Group == "" {
return -1, nil
}
if d.gid != 0 {
return d.gid, nil
}
if n, err := strconv.Atoi(d.Group); err == nil {
d.gid = n
return n, nil
}
g, err := user.LookupGroup(d.Group)
if err != nil {
return -1, fmt.Errorf("group %q: %w", d.Group, err)
}
n, _ := strconv.Atoi(g.Gid)
d.gid = n
return n, nil
}
type fileBackup struct {
path string
existed bool
content []byte
mode fs.FileMode
}
// Backup holds the previous contents of files changed by Install.
type Backup struct {
d *Deployer
files []fileBackup
}
func readFile(path string) ([]byte, bool, error) {
b, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return b, true, nil
}
// EnsureDir creates dir (and missing parents) with mode and the deploy
// group. Existing directories are left alone.
func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
if _, err := os.Stat(dir); err == nil {
return nil
}
if parent := filepath.Dir(dir); parent != dir {
if err := d.EnsureDir(parent, d.dirMode(parent)); err != nil {
return err
}
}
if err := os.Mkdir(dir, mode.Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
gid, err := d.groupID()
if err != nil {
return err
}
if gid >= 0 {
if err := os.Chown(dir, -1, gid); err != nil {
return fmt.Errorf("chgrp %s: %w", dir, err)
}
}
fi, err := os.Stat(dir)
if err != nil {
return err
}
// mkdir applies the umask, and a setgid parent passes its bit on, so
// the mode usually needs fixing. Under systemd's RestrictSUIDSGID (as in
// jarl.service) any chmod that includes the setgid bit fails with
// EPERM; the permission bits matter more (named writes journals here),
// and every file Jarl writes gets the group explicitly, so fall back to
// them alone.
if fi.Mode()&(fs.ModePerm|fs.ModeSetgid) == mode&(fs.ModePerm|fs.ModeSetgid) {
return nil
}
err = chmod(dir, mode)
if errors.Is(err, fs.ErrPermission) && mode&fs.ModeSetgid != 0 {
err = chmod(dir, mode.Perm())
}
if err != nil {
return fmt.Errorf("chmod %s: %w", dir, err)
}
return nil
}
// chmod is os.Chmod; tests replace it to simulate a sandbox.
var chmod = os.Chmod
func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".jarl-*")
if err != nil {
return err
}
name := tmp.Name()
ok := false
defer func() {
if !ok {
tmp.Close()
os.Remove(name)
}
}()
if _, err := tmp.Write(content); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Chmod(mode); err != nil {
return err
}
gid, err := d.groupID()
if err != nil {
return err
}
if gid >= 0 {
if err := tmp.Chown(-1, gid); err != nil {
return fmt.Errorf("chgrp %s: %w", path, err)
}
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(name, path); err != nil {
return err
}
ok = true
if df, err := os.Open(dir); err == nil {
df.Sync()
df.Close()
}
return nil
}
// writeNew creates a file that doesn't exist yet (fs.ErrExist if it
// does), with the deploy group.
func (d *Deployer) writeNew(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
_, err = f.Write(content)
if gid, gerr := d.groupID(); err == nil && gerr == nil && gid >= 0 {
err = f.Chown(-1, gid)
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(path)
}
return err
}
// finish gives a temporary file written next to path its mode and the
// deploy group, and moves it into place.
func (d *Deployer) finish(tmp, path string, mode fs.FileMode) error {
if err := os.Chmod(tmp, mode); err != nil {
return err
}
gid, err := d.groupID()
if err != nil {
return err
}
if gid >= 0 {
if err := os.Chown(tmp, -1, gid); err != nil {
return fmt.Errorf("chgrp %s: %w", path, err)
}
}
return os.Rename(tmp, path)
}
// Install writes files and removes paths, returning a backup that undoes
// the changes. On error the files already written are restored.
func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) {
b := &Backup{d: d}
for _, dir := range dirs {
// Directories named writes into (secondary zone files).
if err := d.EnsureDir(dir, 0o770|fs.ModeSetgid); err != nil {
return b, err
}
}
save := func(path string) error {
old, existed, err := readFile(path)
if err != nil {
return err
}
mode := fs.FileMode(0o640)
if fi, err := os.Stat(path); err == nil {
mode = fi.Mode().Perm()
}
b.files = append(b.files, fileBackup{path: path, existed: existed, content: old, mode: mode})
return nil
}
for _, f := range files {
if err := save(f.Path); err != nil {
b.Restore()
return nil, err
}
if err := d.writeAtomic(f.Path, f.Content, f.Mode); err != nil {
b.Restore()
return nil, fmt.Errorf("writing %s: %w", f.Path, err)
}
}
for _, p := range remove {
if err := save(p); err != nil {
b.Restore()
return nil, err
}
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
b.Restore()
return nil, fmt.Errorf("removing %s: %w", p, err)
}
}
return b, nil
}
// Restore puts back the previous file contents.
func (b *Backup) Restore() error {
if b == nil {
return nil
}
var errs []error
for i := len(b.files) - 1; i >= 0; i-- {
f := b.files[i]
if f.existed {
if err := b.d.writeAtomic(f.path, f.content, f.mode); err != nil {
errs = append(errs, err)
}
} else if err := os.Remove(f.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func sha(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
// DriftEntry is a managed file that was changed outside Jarl.
type DriftEntry struct {
Path string `json:"path"`
Reason string `json:"reason"` // modified, missing
}
// CheckDrift compares managed files on disk with what Jarl last wrote.
func CheckDrift(deployed map[string]*model.DeployedFile) []DriftEntry {
var out []DriftEntry
for path, f := range deployed {
b, exists, err := readFile(path)
switch {
case err != nil:
out = append(out, DriftEntry{Path: path, Reason: "unreadable: " + err.Error()})
case !exists:
out = append(out, DriftEntry{Path: path, Reason: "missing"})
case sha(b) != f.SHA256:
out = append(out, DriftEntry{Path: path, Reason: "modified"})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// fileDiff returns a unified diff from old to new for display. Secret
// files only show that they changed.
func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret bool) string {
from, to := path, path
if !existed {
from = "/dev/null"
}
if removed {
to = "/dev/null"
}
if secret {
return fmt.Sprintf("--- %s\n+++ %s\n@@ contents hidden: this file holds TSIG secrets @@\n", from, to)
}
d := udiff.Unified(from, to, string(old), string(new))
if d != "" && !strings.HasSuffix(d, "\n") {
d += "\n"
}
if strings.HasSuffix(path, ".conf") {
d = labelHunks(d, string(old), string(new))
}
return d
}