summaryrefslogtreecommitdiff
path: root/internal/resource/dir
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-04 23:47:32 +0300
committerPaul Buetow <paul@buetow.org>2026-07-04 23:47:32 +0300
commit8228ba742f6b23a93e6a00006feba29225ea89c7 (patch)
tree0147bbb82f99a6056f43492b661ede7fea7e8bea /internal/resource/dir
parent6a2796f31444b21488ab9d383292841613a1d9c7 (diff)
refactor
Diffstat (limited to 'internal/resource/dir')
-rw-r--r--internal/resource/dir/dir.go224
-rw-r--r--internal/resource/dir/dir_test.go319
-rw-r--r--internal/resource/dir/source.go126
3 files changed, 669 insertions, 0 deletions
diff --git a/internal/resource/dir/dir.go b/internal/resource/dir/dir.go
new file mode 100644
index 0000000..0024c6c
--- /dev/null
+++ b/internal/resource/dir/dir.go
@@ -0,0 +1,224 @@
+package dir
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/user"
+ "strconv"
+
+ "codeberg.org/snonux/gonf/internal/resource"
+)
+
+type Dir struct {
+ path string
+ source string
+ user string
+ group string
+ mode os.FileMode // this directory's own mode, default 0o750
+ fileMode os.FileMode // mode for regular files copied from source, default 0o640
+ prune bool // reconciles extra dest files during a source copy, and recursive-remove during IsAbsent()
+ absent bool
+}
+
+type Option func(*Dir)
+
+func WithSource(source string) Option {
+ return func(d *Dir) {
+ d.source = source
+ }
+}
+
+func WithUser(user string) Option {
+ return func(d *Dir) {
+ d.user = user
+ }
+}
+
+func WithGroup(group string) Option {
+ return func(d *Dir) {
+ d.group = group
+ }
+}
+
+func WithMode(mode os.FileMode) Option {
+ return func(d *Dir) {
+ d.mode = mode
+ }
+}
+
+func WithFileMode(mode os.FileMode) Option {
+ return func(d *Dir) {
+ d.fileMode = mode
+ }
+}
+
+func WithPrune() Option {
+ return func(d *Dir) {
+ d.prune = true
+ }
+}
+
+func IsAbsent() Option {
+ return func(d *Dir) {
+ d.absent = true
+ }
+}
+
+func build(path string, opts ...Option) (*Dir, error) {
+ curr, err := user.Current()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current user for default: %w", err)
+ }
+
+ d := &Dir{
+ path: path,
+ mode: 0o750,
+ fileMode: 0o640,
+ user: curr.Username,
+ group: curr.Gid,
+ }
+
+ for _, opt := range opts {
+ opt(d)
+ }
+
+ return d, nil
+}
+
+// apply performs the idempotent OS work for d without registering a
+// resource.
+func (d *Dir) apply() error {
+ if d.absent {
+ return ensureAbsent(d)
+ }
+
+ if err := ensureDirectorySelf(d); err != nil {
+ return err
+ }
+
+ if d.source == "" {
+ return nil
+ }
+
+ if err := copySourceTree(d); err != nil {
+ return err
+ }
+
+ if d.prune {
+ return pruneTree(d)
+ }
+
+ return nil
+}
+
+// ensureDirectorySelf ensures d.path exists as a directory with the desired
+// mode and ownership. It is idempotent: an existing directory only has its
+// attributes re-enforced, and an existing non-directory is an error.
+func ensureDirectorySelf(d *Dir) error {
+ log.Printf("processing directory: %s", d.path)
+
+ info, err := os.Lstat(d.path)
+ switch {
+ case err == nil:
+ if !info.IsDir() {
+ return fmt.Errorf("%s exists and is not a directory", d.path)
+ }
+ log.Printf("directory %s already exists", d.path)
+
+ case os.IsNotExist(err):
+ log.Printf("creating directory %s with mode %v", d.path, d.mode)
+ if err := os.MkdirAll(d.path, d.mode); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", d.path, err)
+ }
+
+ default:
+ return fmt.Errorf("failed to stat %s: %w", d.path, err)
+ }
+
+ return applyAttributesTo(d.path, d.mode, d.user, d.group)
+}
+
+// ensureAbsent removes d.path if it exists. It is idempotent: a missing path
+// is not an error. By default non-empty directories are not removed;
+// combine with WithPrune() to remove a directory and its contents
+// recursively.
+func ensureAbsent(d *Dir) error {
+ log.Printf("ensuring absent: %s", d.path)
+
+ remove := os.Remove
+ if d.prune {
+ remove = os.RemoveAll
+ }
+
+ if err := remove(d.path); err != nil {
+ if os.IsNotExist(err) {
+ log.Printf("%s already absent", d.path)
+ return nil
+ }
+ return fmt.Errorf("failed to remove %s: %w", d.path, err)
+ }
+
+ log.Printf("removed %s", d.path)
+ return nil
+}
+
+// applyAttributesTo is dir's own small chmod/chown helper, deliberately not
+// shared with the file package so the two packages' attribute-application
+// behavior can evolve independently.
+func applyAttributesTo(path string, mode os.FileMode, usr, group string) error {
+ if err := os.Chmod(path, mode); err != nil {
+ return fmt.Errorf("failed to chmod %s to %v: %w", path, mode, err)
+ }
+ log.Printf("set mode %v for %s", mode, path)
+
+ uid, gid := -1, -1
+
+ if usr != "" {
+ u, err := user.Lookup(usr)
+ if err != nil {
+ return fmt.Errorf("failed to lookup user %s: %w", usr, err)
+ }
+ uid, _ = strconv.Atoi(u.Uid)
+ }
+
+ if group != "" {
+ gidInt, err := strconv.Atoi(group)
+ if err != nil {
+ return fmt.Errorf("group must be numeric for now: %s", group)
+ }
+ gid = gidInt
+ }
+
+ if err := os.Chown(path, uid, gid); err != nil {
+ return fmt.Errorf("failed to chown %s to %s:%s: %w", path, usr, group, err)
+ }
+ log.Printf("set owner %s:%s for %s", usr, group, path)
+
+ return nil
+}
+
+// Ensure builds and applies the directory resource described by opts,
+// without registering it.
+func Ensure(path string, opts ...Option) error {
+ d, err := build(path, opts...)
+ if err != nil {
+ return err
+ }
+ return d.apply()
+}
+
+func Have(path string, opts ...Option) resource.Resource {
+ d, err := build(path, opts...)
+ if err != nil {
+ log.Fatalf("failed to apply directory resource %s: %v", path, err)
+ }
+
+ res := resource.Register("Directory", d.path)
+
+ if err := d.apply(); err != nil {
+ log.Fatalf("failed to apply directory resource %s: %v", path, err)
+ }
+
+ return res
+}
diff --git a/internal/resource/dir/dir_test.go b/internal/resource/dir/dir_test.go
new file mode 100644
index 0000000..b93564a
--- /dev/null
+++ b/internal/resource/dir/dir_test.go
@@ -0,0 +1,319 @@
+package dir
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/gonf/internal/resource/file"
+)
+
+func TestHaveDirectoryCreate(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "sub", "nested")
+
+ Have(path)
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if !info.IsDir() {
+ t.Errorf("expected a directory at %s", path)
+ }
+ if info.Mode().Perm() != 0o750 {
+ t.Errorf("expected default dir mode 0750, got %v", info.Mode().Perm())
+ }
+}
+
+func TestHaveDirectoryIdempotentWithMode(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "d")
+
+ // Call ensureDirectorySelf directly to exercise idempotency without the
+ // one-per-process resource registry rejecting a duplicate registration.
+ d1 := &Dir{path: path, mode: 0o755}
+ if err := ensureDirectorySelf(d1); err != nil {
+ t.Fatalf("first apply: %v", err)
+ }
+ d2 := &Dir{path: path, mode: 0o700}
+ if err := ensureDirectorySelf(d2); err != nil {
+ t.Fatalf("second apply: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o700 {
+ t.Errorf("expected mode 0700 enforced, got %v", info.Mode().Perm())
+ }
+}
+
+func TestHaveDirectoryFailsWhenFileExists(t *testing.T) {
+ tmp := t.TempDir()
+ path := filepath.Join(tmp, "afile")
+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Ensure(path); err == nil {
+ t.Error("expected error when a regular file is in the way of a directory")
+ }
+}
+
+func TestHaveAbsentNonEmptyDirWithoutPruneFails(t *testing.T) {
+ tmp := t.TempDir()
+ target := filepath.Join(tmp, "d")
+ if err := os.MkdirAll(filepath.Join(target, "sub"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Ensure(target, IsAbsent()); err == nil {
+ t.Error("expected error removing a non-empty directory without WithPrune()")
+ }
+ if _, err := os.Stat(target); err != nil {
+ t.Errorf("expected %s to still exist, got %v", target, err)
+ }
+}
+
+func TestHaveAbsentPruneDirectoryRecursive(t *testing.T) {
+ tmp := t.TempDir()
+ target := filepath.Join(tmp, "d")
+ if err := os.MkdirAll(filepath.Join(target, "sub", "deep"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(target, "sub", "f.txt"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(target, IsAbsent(), WithPrune())
+ if _, err := os.Stat(target); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be removed recursively", target)
+ }
+
+ // Idempotent: removing a missing tree is not an error.
+ d := &Dir{path: target, absent: true, prune: true}
+ if err := ensureAbsent(d); err != nil {
+ t.Fatalf("prune on missing tree: %v", err)
+ }
+}
+
+func TestHaveDirectoryWithSource(t *testing.T) {
+ t.Run("recursive copy", func(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ srcFile := filepath.Join(src, "file.txt")
+ if err := os.WriteFile(srcFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ srcSub := filepath.Join(src, "sub")
+ if err := os.MkdirAll(srcSub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ srcSubFile := filepath.Join(srcSub, "subfile.txt")
+ if err := os.WriteFile(srcSubFile, []byte("sub hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src))
+
+ if data, err := os.ReadFile(filepath.Join(dst, "file.txt")); err != nil || string(data) != "hello" {
+ t.Errorf("expected 'hello' at %s, got %q err %v", filepath.Join(dst, "file.txt"), string(data), err)
+ }
+ if data, err := os.ReadFile(filepath.Join(dst, "sub", "subfile.txt")); err != nil || string(data) != "sub hello" {
+ t.Errorf("expected 'sub hello' at %s, got %q err %v", filepath.Join(dst, "sub", "subfile.txt"), string(data), err)
+ }
+ })
+
+ t.Run("pruning", func(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ srcFile := filepath.Join(src, "file.txt")
+ if err := os.WriteFile(srcFile, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ extra := filepath.Join(dst, "extra.txt")
+ if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ extraSub := filepath.Join(dst, "extra-sub")
+ if err := os.MkdirAll(extraSub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src), WithPrune())
+
+ if _, err := os.Stat(extra); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be pruned", extra)
+ }
+ if _, err := os.Stat(extraSub); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be pruned", extraSub)
+ }
+ if data, err := os.ReadFile(filepath.Join(dst, "file.txt")); err != nil || string(data) != "hello" {
+ t.Errorf("expected 'hello' at %s, got %q err %v", filepath.Join(dst, "file.txt"), string(data), err)
+ }
+ })
+}
+
+func TestSourceCopyUsesFileModeDefaultNotDirMode(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src))
+
+ dirInfo, err := os.Stat(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirInfo.Mode().Perm() != 0o750 {
+ t.Errorf("expected dir mode 0750, got %v", dirInfo.Mode().Perm())
+ }
+
+ fileInfo, err := os.Stat(filepath.Join(dst, "file.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fileInfo.Mode().Perm() != 0o640 {
+ t.Errorf("expected copied file mode 0640 (not the directory's 0750), got %v", fileInfo.Mode().Perm())
+ }
+}
+
+func TestSourceCopyRespectsExplicitWithFileMode(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src), WithMode(0o755), WithFileMode(0o600))
+
+ dirInfo, err := os.Stat(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirInfo.Mode().Perm() != 0o755 {
+ t.Errorf("expected dir mode 0755, got %v", dirInfo.Mode().Perm())
+ }
+
+ fileInfo, err := os.Stat(filepath.Join(dst, "file.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fileInfo.Mode().Perm() != 0o600 {
+ t.Errorf("expected copied file mode 0600, got %v", fileInfo.Mode().Perm())
+ }
+}
+
+func TestSourceCopyStripsTmplSuffixOnCopiedFile(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ if err := os.WriteFile(filepath.Join(src, "foo.conf.tmpl"), []byte("hello {{.Param}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src))
+
+ if _, err := os.Stat(filepath.Join(dst, "foo.conf")); err != nil {
+ t.Errorf("expected de-suffixed foo.conf to exist: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dst, "foo.conf.tmpl")); !os.IsNotExist(err) {
+ t.Errorf("expected foo.conf.tmpl to NOT exist on disk")
+ }
+}
+
+func TestSourceCopyWithPruneKeepsTemplatedFile(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ if err := os.WriteFile(filepath.Join(src, "foo.conf.tmpl"), []byte("hello {{.Param}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // WithPrune reconciles the destination against the source on every
+ // apply; a de-suffixed templated file must not be pruned just because
+ // its own name has no direct match in the source tree.
+ Have(dst, WithSource(src), WithPrune())
+
+ if _, err := os.Stat(filepath.Join(dst, "foo.conf")); err != nil {
+ t.Errorf("expected foo.conf to survive pruning, got %v", err)
+ }
+}
+
+func TestSourceCopyParamMatchesSingleFilePath(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+ sourcePath := filepath.Join(src, "foo.conf.tmpl")
+ if err := os.WriteFile(sourcePath, []byte("{{.Param}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src))
+ viaDir, err := os.ReadFile(filepath.Join(dst, "foo.conf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ singleTarget := filepath.Join(tmp, "single.conf")
+ if err := file.Ensure(singleTarget, file.WithSource(sourcePath)); err != nil {
+ t.Fatal(err)
+ }
+ viaFile, err := os.ReadFile(singleTarget)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(viaDir) != string(viaFile) {
+ t.Errorf("expected identical .Param rendering via both paths, dir-copy=%q file-direct=%q", viaDir, viaFile)
+ }
+}
+
+func TestSourceCopyRecreatesSymlinkNotContent(t *testing.T) {
+ tmp := t.TempDir()
+ src := t.TempDir()
+ dst := filepath.Join(tmp, "dst")
+
+ if err := os.WriteFile(filepath.Join(src, "target.txt"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("target.txt", filepath.Join(src, "link.txt")); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(dst, WithSource(src))
+
+ linkPath := filepath.Join(dst, "link.txt")
+ info, err := os.Lstat(linkPath)
+ if err != nil {
+ t.Fatalf("lstat: %v", err)
+ }
+ if info.Mode()&os.ModeSymlink == 0 {
+ t.Fatalf("expected %s to be a symlink, got mode %v", linkPath, info.Mode())
+ }
+ got, err := os.Readlink(linkPath)
+ if err != nil {
+ t.Fatalf("readlink: %v", err)
+ }
+ if got != "target.txt" {
+ t.Errorf("expected symlink target %q, got %q", "target.txt", got)
+ }
+}
diff --git a/internal/resource/dir/source.go b/internal/resource/dir/source.go
new file mode 100644
index 0000000..8f9b73a
--- /dev/null
+++ b/internal/resource/dir/source.go
@@ -0,0 +1,126 @@
+package dir
+
+import (
+ "fmt"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+
+ "codeberg.org/snonux/gonf/internal/resource/file"
+ "codeberg.org/snonux/gonf/internal/resource/link"
+)
+
+// copySourceTree mirrors d.source into d.path, dispatching each entry by
+// kind. Symlink-ness is checked before the dir/file branches: fs.DirEntry
+// reports a symlink's own type via Lstat semantics (never following it), so
+// a symlink in the source tree is recreated as a symlink rather than read as
+// file content.
+func copySourceTree(d *Dir) error {
+ log.Printf("installing files from source %s to %s", d.source, d.path)
+
+ return filepath.WalkDir(d.source, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ rel, err := filepath.Rel(d.source, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ return nil
+ }
+
+ target := filepath.Join(d.path, rel)
+
+ switch {
+ case entry.Type()&fs.ModeSymlink != 0:
+ return copySourceSymlink(path, target)
+ case entry.IsDir():
+ return copySourceDir(d, target)
+ default:
+ return copySourceFile(d, path, target)
+ }
+ })
+}
+
+func copySourceDir(d *Dir, target string) error {
+ if err := os.MkdirAll(target, d.mode); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", target, err)
+ }
+ return applyAttributesTo(target, d.mode, d.user, d.group)
+}
+
+// copySourceSymlink recreates the symlink found at sourcePath as a symlink
+// at target, preserving its raw (unresolved) link target string. This is
+// correct as long as the destination tree mirrors the source tree 1:1; an
+// absolute link target pointing back into the source tree itself is not
+// remapped into the destination — a pre-existing conceptual limitation of
+// copying a tree of symlinks.
+func copySourceSymlink(sourcePath, target string) error {
+ rawTarget, err := os.Readlink(sourcePath)
+ if err != nil {
+ return fmt.Errorf("failed to read symlink %s: %w", sourcePath, err)
+ }
+ return link.Ensure(target, link.IsSymlink(rawTarget))
+}
+
+// copySourceFile delegates writing a single copied file to the file
+// package's own primitive, using d's file-mode default (not d's directory
+// mode) and passing the mechanically-derived target path verbatim — file's
+// own resolve() strips a ".tmpl" suffix and computes .Param consistently, so
+// dir needs no special-casing of its own.
+func copySourceFile(d *Dir, sourcePath, target string) error {
+ return file.Ensure(target,
+ file.WithSource(sourcePath),
+ file.WithMode(d.fileMode),
+ file.WithUser(d.user),
+ file.WithGroup(d.group),
+ )
+}
+
+// pruneTree removes anything under d.path that has no counterpart in
+// d.source. A destination entry also counts as having a counterpart if
+// d.source has the same relative path with a ".tmpl" suffix appended, since
+// copySourceFile (via file.Ensure) strips that suffix when writing —
+// otherwise every templated file would be pruned immediately after being
+// copied.
+func pruneTree(d *Dir) error {
+ log.Printf("pruning destination directory %s", d.path)
+
+ return filepath.WalkDir(d.path, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ rel, err := filepath.Rel(d.path, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ return nil // don't prune the root itself
+ }
+
+ if sourceEntryExists(d.source, rel) {
+ return nil
+ }
+
+ log.Printf("pruning %s", path)
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("failed to prune %s: %w", path, err)
+ }
+ if entry.IsDir() {
+ return filepath.SkipDir // already removed
+ }
+ return nil
+ })
+}
+
+func sourceEntryExists(source, rel string) bool {
+ if _, err := os.Lstat(filepath.Join(source, rel)); err == nil {
+ return true
+ }
+ _, err := os.Lstat(filepath.Join(source, rel) + ".tmpl")
+ return err == nil
+}