diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-04 23:47:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-04 23:47:32 +0300 |
| commit | 8228ba742f6b23a93e6a00006feba29225ea89c7 (patch) | |
| tree | 0147bbb82f99a6056f43492b661ede7fea7e8bea /internal/resource/dir | |
| parent | 6a2796f31444b21488ab9d383292841613a1d9c7 (diff) | |
refactor
Diffstat (limited to 'internal/resource/dir')
| -rw-r--r-- | internal/resource/dir/dir.go | 224 | ||||
| -rw-r--r-- | internal/resource/dir/dir_test.go | 319 | ||||
| -rw-r--r-- | internal/resource/dir/source.go | 126 |
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 +} |
