From 8228ba742f6b23a93e6a00006feba29225ea89c7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 4 Jul 2026 23:47:32 +0300 Subject: refactor --- TODO.md | 43 ++-- examples/examples.go | 40 +++- internal/file/directory.go | 57 ----- internal/file/file.go | 305 -------------------------- internal/file/file_test.go | 417 ------------------------------------ internal/file/hardlink.go | 59 ----- internal/file/regular_file.go | 26 --- internal/file/symlink.go | 56 ----- internal/resource/dir/dir.go | 224 +++++++++++++++++++ internal/resource/dir/dir_test.go | 319 +++++++++++++++++++++++++++ internal/resource/dir/source.go | 126 +++++++++++ internal/resource/file/checksum.go | 67 ++++++ internal/resource/file/file.go | 246 +++++++++++++++++++++ internal/resource/file/file_test.go | 247 +++++++++++++++++++++ internal/resource/link/hardlink.go | 59 +++++ internal/resource/link/link.go | 118 ++++++++++ internal/resource/link/link_test.go | 189 ++++++++++++++++ internal/resource/link/symlink.go | 56 +++++ 18 files changed, 1699 insertions(+), 955 deletions(-) delete mode 100644 internal/file/directory.go delete mode 100644 internal/file/file.go delete mode 100644 internal/file/file_test.go delete mode 100644 internal/file/hardlink.go delete mode 100644 internal/file/regular_file.go delete mode 100644 internal/file/symlink.go create mode 100644 internal/resource/dir/dir.go create mode 100644 internal/resource/dir/dir_test.go create mode 100644 internal/resource/dir/source.go create mode 100644 internal/resource/file/checksum.go create mode 100644 internal/resource/file/file.go create mode 100644 internal/resource/file/file_test.go create mode 100644 internal/resource/link/hardlink.go create mode 100644 internal/resource/link/link.go create mode 100644 internal/resource/link/link_test.go create mode 100644 internal/resource/link/symlink.go diff --git a/TODO.md b/TODO.md index 6f42ecf..153bf71 100644 --- a/TODO.md +++ b/TODO.md @@ -7,39 +7,19 @@ Perl [Rex](https://www.rexify.org/) `Rexfile` used to install ## 1. File resource — missing capabilities -The Rexfile uses `file` for far more than "write these bytes". gonf's -`file.Have` currently only manages content + checksum idempotency. Missing: - -- **`ensure => 'absent'`.** Remove a file if present. Used by `prune_dir`. -- **`ensure => 'directory'`.** See section 2. +DONE! ## 2. Directory resource -Rexfile creates directories with a mode all over the place -(`~/.config/*`, `~/scripts`, `~/QuickEdit`, `~/.config/systemd/user`, agent -tool dirs). gonf has no directory concept. Need: - -- `Have`-style directory resource: create if missing, enforce mode, - idempotent, register in the resource registry. +DONE! `internal/resource/dir` provides a `Have`-style directory resource: +create if missing, enforce mode, idempotent, registers in the resource +registry. It also supports installing a source tree (`WithSource`), pruning +stale destination entries (`WithPrune`), and a file mode independent of the +directory's own mode (`WithFileMode`). ## 3. Symlink resource -The Rexfile does a lot of symlink management, none of which gonf supports: - -- fish `conf.d` → `~/.config/fish/conf.d` (with rename-to-`.old` fallback). -- gitsyncer config dir symlink. -- Agent tool dirs: `~/.cursor`, `~/.claude`, `~/.agents`, `~/.opencode`, - `~/.pi`, `~/.amp`, `~/.codex` each get `commands`/`skills`/`prompts` - symlinks into `~/Notes/Prompts/...`. -- `~/QuickEdit/*` symlinks to many source dirs. - -Needs a symlink resource that: - -- Creates a symlink to a target. -- Is idempotent: leaves it alone if it already points at the right place. -- Repoints if it points elsewhere. -- Refuses (or has an explicit policy) to clobber a real file/dir; supports the - Rexfile's rename-existing-dir-to-`.old` behavior where needed. +DONE! ## 4. Glob / multi-file installs @@ -50,10 +30,7 @@ sway, waybar, scripts, systemd units, calendar, pipewire). ## 5. Prune / reconcile stale files -`prune_dir` removes regular files in a destination whose basename is not in the -source glob (used for `~/scripts`), while leaving dotfiles and subdirectories -untouched. gonf needs a prune/reconcile operation so removed source files also -disappear from the destination. +DONE! ## 6. Package resource (multi-OS) @@ -135,3 +112,7 @@ just calls a hardcoded `examples.Run()`. Need: 5. Package resource with per-OS backends (section 6). 6. Tasks + CLI (section 11), then git-config / line-in-file / polish (sections 9, 10, 12). + +## More ideas: + +* Have file.Absent instead or as an alias for file.Have(path, IsAbsent()) or so diff --git a/examples/examples.go b/examples/examples.go index 8fe0f0d..3fe2d50 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -1,7 +1,11 @@ package examples import ( - "codeberg.org/snonux/gonf/internal/file" + "os" + + "codeberg.org/snonux/gonf/internal/resource/dir" + "codeberg.org/snonux/gonf/internal/resource/file" + "codeberg.org/snonux/gonf/internal/resource/link" ) func Run() error { @@ -19,16 +23,44 @@ func Run() error { ) // 4. A directory - file.Have("/tmp/gonf_dir", file.IsDirectory(), file.WithMode(0o755)) + dir.Have("/tmp/gonf_dir", dir.WithMode(0o755)) // 5. A symlink - file.Have("/tmp/gonf_link", file.IsSymlink("/tmp/gonf_hello.txt")) + link.Have("/tmp/gonf_link", link.IsSymlink("/tmp/gonf_hello.txt")) // 6. A hardlink - file.Have("/tmp/gonf_hardlink", file.IsHardlink("/tmp/gonf_hello.txt")) + link.Have("/tmp/gonf_hardlink", link.IsHardlink("/tmp/gonf_hello.txt")) // 7. Ensuring something is absent file.Have("/tmp/gonf_old.txt", file.IsAbsent()) + // 8. A directory tree copied from source, reconciled, with a distinct + // file mode from the directory's own mode + dir.Have( + "/tmp/gonf_dir_from_source", + dir.WithSource("assets/testfiles"), + dir.WithPrune(), + dir.WithFileMode(0o644), + ) + + // 9. Recursively removing a directory tree. Each resource path can only + // be declared once per run, so this pre-populates its own scratch tree + // (rather than reusing #8's path) to give WithPrune's recursive removal + // something real to demonstrate. + _ = os.MkdirAll("/tmp/gonf_stale_dir/nested", 0o755) + dir.Have("/tmp/gonf_stale_dir", dir.IsAbsent(), dir.WithPrune()) + + // 10. Non-recursively removing an empty directory + _ = os.Mkdir("/tmp/gonf_stale_empty_dir", 0o755) + dir.Have("/tmp/gonf_stale_empty_dir", dir.IsAbsent()) + + // 11. Ensuring a symlink is absent + _ = os.Symlink("/tmp/gonf_hello.txt", "/tmp/gonf_stale_link") + link.Have("/tmp/gonf_stale_link", link.IsAbsent()) + + // 12. Ensuring a hardlink is absent + _ = os.Link("/tmp/gonf_hello.txt", "/tmp/gonf_stale_hardlink") + link.Have("/tmp/gonf_stale_hardlink", link.IsAbsent()) + return nil } diff --git a/internal/file/directory.go b/internal/file/directory.go deleted file mode 100644 index 052af06..0000000 --- a/internal/file/directory.go +++ /dev/null @@ -1,57 +0,0 @@ -package file - -import ( - "fmt" - "log" - "os" -) - -// haveDirectory ensures f.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 (f *File) haveDirectory() error { - log.Printf("processing directory: %s", f.path) - - info, err := os.Lstat(f.path) - switch { - case err == nil: - if !info.IsDir() { - return fmt.Errorf("%s exists and is not a directory", f.path) - } - log.Printf("directory %s already exists", f.path) - - case os.IsNotExist(err): - log.Printf("creating directory %s with mode %v", f.path, f.mode) - if err := os.MkdirAll(f.path, f.mode); err != nil { - return fmt.Errorf("failed to create directory %s: %w", f.path, err) - } - - default: - return fmt.Errorf("failed to stat %s: %w", f.path, err) - } - - return f.applyAttributes() -} - -// haveAbsent removes f.path if it exists. It is idempotent: a missing path is -// not an error. By default non-empty directories are not removed; combine with -// PruneDirectory() to remove a directory and its contents recursively. -func (f *File) haveAbsent() error { - log.Printf("ensuring absent: %s", f.path) - - remove := os.Remove - if f.pruneDirectory { - remove = os.RemoveAll - } - - if err := remove(f.path); err != nil { - if os.IsNotExist(err) { - log.Printf("%s already absent", f.path) - return nil - } - return fmt.Errorf("failed to remove %s: %w", f.path, err) - } - - log.Printf("removed %s", f.path) - return nil -} diff --git a/internal/file/file.go b/internal/file/file.go deleted file mode 100644 index 7f86d41..0000000 --- a/internal/file/file.go +++ /dev/null @@ -1,305 +0,0 @@ -package file - -import ( - "bytes" - "crypto/sha256" - "fmt" - "log" - "os" - "os/user" - "strconv" - "strings" - "text/template" - - "codeberg.org/snonux/gonf/internal/resource" -) - -type File struct { - path string - param string - source string - user string - group string - mode os.FileMode - modeSet bool - absent bool - - symlink bool - symlinkTarget string - - hardlink bool - hardlinkTarget string - - directory bool - pruneDirectory bool // with Absent(): remove directory recursively -} - -type Option func(*File) - -func WithContent(content string) Option { - return func(f *File) { - f.param = content - f.source = "" - } -} - -func WithSource(source string) Option { - return func(f *File) { - if !strings.HasPrefix(source, "source://") { - source = "source://" + source - } - f.source = source - f.param = source - } -} - -func WithUser(user string) Option { - return func(f *File) { - f.user = user - } -} - -func WithGroup(group string) Option { - return func(f *File) { - f.group = group - } -} - -func WithMode(mode os.FileMode) Option { - return func(f *File) { - f.mode = mode - f.modeSet = true - } -} - -func IsAbsent() Option { - return func(f *File) { - f.absent = true - } -} - -func IsDirectory() Option { - return func(f *File) { - f.directory = true - } -} - -func IsSymlink(target string) Option { - return func(f *File) { - f.symlink = true - f.symlinkTarget = target - } -} - -func IsHardlink(target string) Option { - return func(f *File) { - f.hardlink = true - f.hardlinkTarget = target - } -} - -func PruneDirectory() Option { - return func(f *File) { - f.pruneDirectory = true - } -} - -func Have(path string, opts ...Option) resource.Resource { - res, err := have(path, opts...) - if err != nil { - log.Fatalf("failed to apply file resource %s: %v", path, err) - } - - return res -} - -func have(path string, opts ...Option) (resource.Resource, error) { - curr, err := user.Current() - if err != nil { - return resource.Resource{}, fmt.Errorf("failed to get current user for default: %w", err) - } - - f := &File{ - path: path, - mode: 0o640, - user: curr.Username, - group: curr.Gid, - } - - for _, opt := range opts { - opt(f) - } - - return f.Apply() -} - -// Apply dispatches to the concrete resource implementation based on the -// options that were set. Each kind lives in its own file: -// regular_file.go, directory.go and symlink.go. -func (f *File) Apply() (resource.Resource, error) { - var res resource.Resource - - switch { - case f.absent: - res = resource.Register(f.resourceType(), f.path) - return res, f.haveAbsent() - - case f.symlink: - res = resource.Register("Symlink", f.path) - return res, f.haveSymlink() - - case f.hardlink: - res = resource.Register("Hardlink", f.path) - return res, f.haveHardlink() - - case f.directory: - if !f.modeSet { - f.mode = 0o750 - } - res = resource.Register("Directory", f.path) - return res, f.haveDirectory() - - default: - res = resource.Register("File", f.path) - content, err := f.resolveContent() - if err != nil { - return res, fmt.Errorf("failed to resolve content for %s: %w", f.path, err) - } - return res, f.haveRegularFile(content) - } -} - -// resourceType returns the registry type name for this resource, used when the -// concrete kind matters for registration (e.g. absent works for any kind). -func (f *File) resourceType() string { - switch { - case f.symlink: - return "Symlink" - case f.hardlink: - return "Hardlink" - case f.directory: - return "Directory" - default: - return "File" - } -} - -func (f *File) resolveContent() ([]byte, error) { - var content []byte - var err error - - if strings.HasPrefix(f.param, "source://") { - sourcePath := strings.TrimPrefix(f.param, "source://") - content, err = os.ReadFile(sourcePath) - if err != nil { - return nil, fmt.Errorf("failed to read source file %s: %w", sourcePath, err) - } - } else { - content = []byte(f.param) - } - - if strings.HasSuffix(f.path, ".tmpl") || (strings.HasPrefix(f.param, "source://") && strings.HasSuffix(strings.TrimPrefix(f.param, "source://"), ".tmpl")) { - return f.applyTemplate(content) - } - - return content, nil -} - -func (f *File) applyTemplate(content []byte) ([]byte, error) { - data := make(map[string]string) - for _, env := range os.Environ() { - pair := strings.SplitN(env, "=", 2) - if len(pair) == 2 { - data[pair[0]] = pair[1] - } - } - data["Param"] = f.param - - tmpl, err := template.New("resource").Parse(string(content)) - if err != nil { - return nil, fmt.Errorf("template parse error: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("template execute error: %w", err) - } - return buf.Bytes(), nil -} - -func (f *File) applyAttributes() error { - // Apply Mode - if err := os.Chmod(f.path, f.mode); err != nil { - return fmt.Errorf("failed to chmod %s to %v: %w", f.path, f.mode, err) - } - log.Printf("set mode %v for %s", f.mode, f.path) - - // Apply User and Group - uid, gid := -1, -1 - - if f.user != "" { - u, err := user.Lookup(f.user) - if err != nil { - return fmt.Errorf("failed to lookup user %s: %w", f.user, err) - } - uid, _ = strconv.Atoi(u.Uid) - } - - if f.group != "" { - gidInt, err := strconv.Atoi(f.group) - if err != nil { - return fmt.Errorf("group must be numeric for now: %s", f.group) - } - gid = gidInt - } - - if err := os.Chown(f.path, uid, gid); err != nil { - return fmt.Errorf("failed to chown %s to %s:%s: %w", f.path, f.user, f.group, err) - } - log.Printf("set owner %s:%s for %s", f.user, f.group, f.path) - - return nil -} - -func getChecksum(path string) [32]byte { - var checksum [32]byte - data, err := os.ReadFile(path) - if err != nil { - log.Printf("reading %s: %v (file does not exist or cannot be read)", path, err) - return checksum - } - checksum = sha256.Sum256(data) - log.Printf("computed checksum for %s: %x", path, checksum) - return checksum -} - -func writeTmpFile(tmpPath string, content []byte, mode os.FileMode) error { - log.Printf("writing %d bytes to temporary file %s with mode %v", len(content), tmpPath, mode) - if err := os.WriteFile(tmpPath, content, mode); err != nil { - log.Printf("failed to write temporary file %s: %v", tmpPath, err) - return err - } - log.Printf("successfully wrote temporary file %s", tmpPath) - return nil -} - -func updateFromTmp(tmpPath, path string, checksumChanged bool) error { - if !checksumChanged { - log.Printf("checksums match, removing temporary file %s", tmpPath) - if err := os.Remove(tmpPath); err != nil { - log.Printf("failed to remove temporary file %s: %v", tmpPath, err) - return err - } - log.Printf("no changes needed for %s", path) - return nil - } - - log.Printf("checksums differ, renaming %s to %s", tmpPath, path) - if err := os.Rename(tmpPath, path); err != nil { - log.Printf("failed to rename %s to %s: %v", tmpPath, path, err) - os.Remove(tmpPath) - return err - } - log.Printf("successfully updated %s", path) - return nil -} diff --git a/internal/file/file_test.go b/internal/file/file_test.go deleted file mode 100644 index a6488ce..0000000 --- a/internal/file/file_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package file - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestGetChecksum(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.txt") - - if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { - t.Fatal(err) - } - checksum := getChecksum(path) - if checksum == [32]byte{} { - t.Error("expected non-zero checksum") - } - - zeroChecksum := getChecksum(filepath.Join(dir, "no-such-file")) - var expectedZero [32]byte - if zeroChecksum != expectedZero { - t.Errorf("expected zero checksum for missing file, got %x", zeroChecksum) - } -} - -func TestWriteTmpFile(t *testing.T) { - dir := t.TempDir() - tmpPath := filepath.Join(dir, "test.tmp") - content := []byte("temp content") - - if err := writeTmpFile(tmpPath, content, 0o644); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got, err := os.ReadFile(tmpPath) - if err != nil { - t.Fatalf("reading tmp file: %v", err) - } - if string(got) != string(content) { - t.Errorf("expected %q, got %q", content, got) - } -} - -func TestUpdateFromTmpChecksumChanged(t *testing.T) { - dir := t.TempDir() - tmpPath := filepath.Join(dir, "test.tmp") - path := filepath.Join(dir, "test.txt") - - if err := os.WriteFile(tmpPath, []byte("new content"), 0o644); err != nil { - t.Fatal(err) - } - - if err := updateFromTmp(tmpPath, path, true); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, err := os.Stat(tmpPath); err == nil { - t.Error("tmp file should have been removed") - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading target file: %v", err) - } - if string(got) != "new content" { - t.Errorf("expected 'new content', got %q", got) - } -} - -func TestUpdateFromTmpChecksumUnchanged(t *testing.T) { - dir := t.TempDir() - tmpPath := filepath.Join(dir, "test.tmp") - path := filepath.Join(dir, "test.txt") - - if err := os.WriteFile(tmpPath, []byte("same"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("same"), 0o644); err != nil { - t.Fatal(err) - } - - if err := updateFromTmp(tmpPath, path, false); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, err := os.Stat(tmpPath); err == nil { - t.Error("tmp file should have been removed") - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading target file: %v", err) - } - if string(got) != "same" { - t.Errorf("expected 'same', got %q", got) - } -} - -func TestHaveStringCreateNewFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "new.txt") - - Have(path, WithContent("hello world")) - - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading file: %v", err) - } - if string(got) != "hello world" { - t.Errorf("expected 'hello world', got %q", got) - } -} - -func TestHaveMode(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "mode.txt") - mode := os.FileMode(0o600) - - Have(path, WithContent("mode test"), WithMode(mode)) - - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - // Mask to check only permission bits - if info.Mode().Perm() != mode { - t.Errorf("expected mode %v, got %v", mode, info.Mode().Perm()) - } -} - -func TestHaveSourceFile(t *testing.T) { - dir := t.TempDir() - sourcePath := filepath.Join("..", "..", "assets", "testfiles", "test.txt") - targetPath := filepath.Join(dir, "target.txt") - - Have(targetPath, WithSource(sourcePath)) - - got, err := os.ReadFile(targetPath) - if err != nil { - t.Fatalf("reading file: %v", err) - } - expected, _ := os.ReadFile(sourcePath) - if string(got) != string(expected) { - t.Errorf("expected %q, got %q", string(expected), string(got)) - } -} - -func TestHaveTemplateFile(t *testing.T) { - dir := t.TempDir() - sourcePath := filepath.Join("..", "..", "assets", "testfiles", "test.tmpl") - targetPath := filepath.Join(dir, "target.conf") - - Have(targetPath, WithSource(sourcePath)) - - got, err := os.ReadFile(targetPath) - if err != nil { - t.Fatalf("reading file: %v", err) - } - - expectedParam := sourcePath - if !strings.Contains(string(got), expectedParam) { - t.Errorf("expected content to contain Param %q, got %q", expectedParam, string(got)) - } -} - -func TestHaveDirectoryCreate(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sub", "nested") - - Have(path, IsDirectory()) - - 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) { - dir := t.TempDir() - path := filepath.Join(dir, "d") - - // Call the resource logic directly to exercise idempotency without the - // one-per-process resource registry rejecting a duplicate registration. - f1 := &File{path: path, mode: 0o755} - if err := f1.haveDirectory(); err != nil { - t.Fatalf("first apply: %v", err) - } - f2 := &File{path: path, mode: 0o700} - if err := f2.haveDirectory(); 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) { - dir := t.TempDir() - path := filepath.Join(dir, "afile") - if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - - if _, err := have(path, IsDirectory()); err == nil { - t.Error("expected error when a regular file is in the way of a directory") - } -} - -func TestHaveSymlinkCreateAndIdempotent(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "target.txt") - link := filepath.Join(dir, "link.txt") - if err := os.WriteFile(target, []byte("t"), 0o644); err != nil { - t.Fatal(err) - } - - Have(link, IsSymlink(target)) - got, err := os.Readlink(link) - if err != nil { - t.Fatalf("readlink: %v", err) - } - if got != target { - t.Errorf("expected link -> %s, got %s", target, got) - } - - // Re-applying the same link should be a no-op (tested directly to avoid the - // one-per-process resource registry rejecting a duplicate registration). - f := &File{path: link, symlink: true, symlinkTarget: target} - if err := f.haveSymlink(); err != nil { - t.Fatalf("idempotent apply: %v", err) - } -} - -func TestHaveSymlinkRepoints(t *testing.T) { - dir := t.TempDir() - old := filepath.Join(dir, "old.txt") - newT := filepath.Join(dir, "new.txt") - link := filepath.Join(dir, "link") - for _, p := range []string{old, newT} { - if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink(old, link); err != nil { - t.Fatal(err) - } - - Have(link, IsSymlink(newT)) - got, err := os.Readlink(link) - if err != nil { - t.Fatal(err) - } - if got != newT { - t.Errorf("expected repoint to %s, got %s", newT, got) - } -} - -func TestHaveSymlinkMovesRealFileAside(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "target.txt") - link := filepath.Join(dir, "real") - if err := os.WriteFile(target, []byte("t"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(link, []byte("original"), 0o644); err != nil { - t.Fatal(err) - } - - Have(link, IsSymlink(target)) - - got, err := os.Readlink(link) - if err != nil { - t.Fatalf("expected %s to be a symlink: %v", link, err) - } - if got != target { - t.Errorf("expected link -> %s, got %s", target, got) - } - if data, err := os.ReadFile(link + ".old"); err != nil || string(data) != "original" { - t.Errorf("expected original content preserved in %s.old, got %q err %v", link, string(data), err) - } -} - -func TestHaveAbsent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "gone.txt") - if err := os.WriteFile(path, []byte("bye"), 0o644); err != nil { - t.Fatal(err) - } - - Have(path, IsAbsent()) - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("expected %s to be removed", path) - } - - // Idempotent: removing a missing file is not an error (direct call to avoid - // duplicate registration in the one-per-process registry). - f := &File{path: path} - if err := f.haveAbsent(); err != nil { - t.Fatalf("absent on missing file: %v", err) - } -} - -func TestHaveHardlinkCreateAndIdempotent(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "target.txt") - link := filepath.Join(dir, "link.txt") - if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil { - t.Fatal(err) - } - - Have(link, IsHardlink(target)) - - ti, err := os.Stat(target) - if err != nil { - t.Fatal(err) - } - li, err := os.Stat(link) - if err != nil { - t.Fatal(err) - } - if !sameInode(ti, li) { - t.Errorf("expected %s and %s to share an inode", link, target) - } - - // Re-applying the same link should be a no-op (direct call to avoid the - // one-per-process resource registry rejecting a duplicate registration). - f := &File{path: link, hardlink: true, hardlinkTarget: target} - if err := f.haveHardlink(); err != nil { - t.Fatalf("idempotent apply: %v", err) - } -} - -func TestHaveHardlinkMovesRealFileAside(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "target.txt") - link := filepath.Join(dir, "real") - if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(link, []byte("original"), 0o644); err != nil { - t.Fatal(err) - } - - Have(link, IsHardlink(target)) - - ti, err := os.Stat(target) - if err != nil { - t.Fatal(err) - } - li, err := os.Stat(link) - if err != nil { - t.Fatal(err) - } - if !sameInode(ti, li) { - t.Errorf("expected %s to be hardlinked to %s", link, target) - } - if data, err := os.ReadFile(link + ".old"); err != nil || string(data) != "original" { - t.Errorf("expected original content preserved in %s.old, got %q err %v", link, string(data), err) - } -} - -func TestHaveHardlinkMissingTarget(t *testing.T) { - dir := t.TempDir() - link := filepath.Join(dir, "link") - if _, err := have(link, IsHardlink(filepath.Join(dir, "nope"))); err == nil { - t.Error("expected error when hardlink target does not exist") - } -} - -func TestHaveAbsentNonEmptyDirWithoutPruneFails(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "d") - if err := os.MkdirAll(filepath.Join(target, "sub"), 0o755); err != nil { - t.Fatal(err) - } - - if _, err := have(target, IsAbsent()); err == nil { - t.Error("expected error removing a non-empty directory without PruneDirectory()") - } - if _, err := os.Stat(target); err != nil { - t.Errorf("expected %s to still exist, got %v", target, err) - } -} - -func TestHaveAbsentPruneDirectoryRecursive(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "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(), PruneDirectory()) - 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. - f := &File{path: target, absent: true, pruneDirectory: true} - if err := f.haveAbsent(); err != nil { - t.Fatalf("prune on missing tree: %v", err) - } -} diff --git a/internal/file/hardlink.go b/internal/file/hardlink.go deleted file mode 100644 index 8913674..0000000 --- a/internal/file/hardlink.go +++ /dev/null @@ -1,59 +0,0 @@ -package file - -import ( - "fmt" - "log" - "os" - "syscall" -) - -// haveHardlink ensures f.path is a hard link to f.hardlinkTarget. -// -// Idempotency and clobber policy: -// - already the same inode as the target: nothing to do. -// - a different file/link is in the way: it is renamed to ".old" -// before the link is created (matching the symlink resource's behavior). -func (f *File) haveHardlink() error { - log.Printf("processing hardlink: %s -> %s", f.path, f.hardlinkTarget) - - if f.hardlinkTarget == "" { - return fmt.Errorf("hardlink %s has no target", f.path) - } - - targetInfo, err := os.Stat(f.hardlinkTarget) - if err != nil { - return fmt.Errorf("failed to stat hardlink target %s: %w", f.hardlinkTarget, err) - } - - if info, err := os.Lstat(f.path); err == nil { - if sameInode(info, targetInfo) { - log.Printf("hardlink %s already links to %s", f.path, f.hardlinkTarget) - return nil - } - old := f.path + ".old" - log.Printf("%s already exists, renaming to %s", f.path, old) - if err := os.Rename(f.path, old); err != nil { - return fmt.Errorf("failed to move existing %s aside: %w", f.path, err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to stat %s: %w", f.path, err) - } - - if err := os.Link(f.hardlinkTarget, f.path); err != nil { - return fmt.Errorf("failed to create hardlink %s -> %s: %w", f.path, f.hardlinkTarget, err) - } - - log.Printf("created hardlink %s -> %s", f.path, f.hardlinkTarget) - return nil -} - -// sameInode reports whether two FileInfos refer to the same underlying inode -// (same device and inode number), i.e. they are already hard-linked. -func sameInode(a, b os.FileInfo) bool { - as, aok := a.Sys().(*syscall.Stat_t) - bs, bok := b.Sys().(*syscall.Stat_t) - if !aok || !bok { - return false - } - return as.Dev == bs.Dev && as.Ino == bs.Ino -} diff --git a/internal/file/regular_file.go b/internal/file/regular_file.go deleted file mode 100644 index 7747b9e..0000000 --- a/internal/file/regular_file.go +++ /dev/null @@ -1,26 +0,0 @@ -package file - -import ( - "crypto/sha256" - "log" -) - -// haveRegularFile writes content to f.path idempotently (via a checksum-guarded -// temp file) and enforces mode/ownership. -func (f *File) haveRegularFile(content []byte) error { - log.Printf("processing file: %s", f.path) - existingChecksum := getChecksum(f.path) - newChecksum := sha256.Sum256(content) - log.Printf("computed checksum for new content: %x", newChecksum) - - tmpPath := f.path + ".tmp" - if err := writeTmpFile(tmpPath, content, f.mode); err != nil { - return err - } - - if err := updateFromTmp(tmpPath, f.path, existingChecksum != newChecksum); err != nil { - return err - } - - return f.applyAttributes() -} diff --git a/internal/file/symlink.go b/internal/file/symlink.go deleted file mode 100644 index 8d014c2..0000000 --- a/internal/file/symlink.go +++ /dev/null @@ -1,56 +0,0 @@ -package file - -import ( - "fmt" - "log" - "os" -) - -// haveSymlink ensures f.path is a symlink pointing at f.symlinkTarget. -// -// Idempotency and clobber policy: -// - already points at the target: nothing to do. -// - points elsewhere: the link is removed and recreated. -// - a real file/dir is in the way: it is renamed to ".old" before the -// link is created (matching the Rexfile's rename-existing behavior). -func (f *File) haveSymlink() error { - log.Printf("processing symlink: %s -> %s", f.path, f.symlinkTarget) - - if f.symlinkTarget == "" { - return fmt.Errorf("symlink %s has no target", f.path) - } - - info, err := os.Lstat(f.path) - switch { - case err == nil && info.Mode()&os.ModeSymlink != 0: - current, err := os.Readlink(f.path) - if err != nil { - return fmt.Errorf("failed to read symlink %s: %w", f.path, err) - } - if current == f.symlinkTarget { - log.Printf("symlink %s already points at %s", f.path, f.symlinkTarget) - return nil - } - log.Printf("repointing symlink %s from %s to %s", f.path, current, f.symlinkTarget) - if err := os.Remove(f.path); err != nil { - return fmt.Errorf("failed to remove stale symlink %s: %w", f.path, err) - } - - case err == nil: - old := f.path + ".old" - log.Printf("%s is a real file/dir, renaming to %s", f.path, old) - if err := os.Rename(f.path, old); err != nil { - return fmt.Errorf("failed to move existing %s aside: %w", f.path, err) - } - - case !os.IsNotExist(err): - return fmt.Errorf("failed to stat %s: %w", f.path, err) - } - - if err := os.Symlink(f.symlinkTarget, f.path); err != nil { - return fmt.Errorf("failed to create symlink %s -> %s: %w", f.path, f.symlinkTarget, err) - } - - log.Printf("created symlink %s -> %s", f.path, f.symlinkTarget) - return nil -} 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 +} diff --git a/internal/resource/file/checksum.go b/internal/resource/file/checksum.go new file mode 100644 index 0000000..0cbf135 --- /dev/null +++ b/internal/resource/file/checksum.go @@ -0,0 +1,67 @@ +package file + +import ( + "crypto/sha256" + "log" + "os" +) + +func getChecksum(path string) [32]byte { + var checksum [32]byte + data, err := os.ReadFile(path) + if err != nil { + log.Printf("reading %s: %v (file does not exist or cannot be read)", path, err) + return checksum + } + checksum = sha256.Sum256(data) + log.Printf("computed checksum for %s: %x", path, checksum) + return checksum +} + +func (f *File) ensureFile(path string, content []byte) error { + existingChecksum := getChecksum(path) + newChecksum := sha256.Sum256(content) + log.Printf("computed checksum for new content: %x", newChecksum) + + tmpPath := path + ".tmp" + if err := writeTmpFile(tmpPath, content, f.mode); err != nil { + return err + } + + if err := updateFromTmp(tmpPath, path, existingChecksum != newChecksum); err != nil { + return err + } + + return f.applyAttributesTo(path) +} + +func writeTmpFile(tmpPath string, content []byte, mode os.FileMode) error { + log.Printf("writing %d bytes to temporary file %s with mode %v", len(content), tmpPath, mode) + if err := os.WriteFile(tmpPath, content, mode); err != nil { + log.Printf("failed to write temporary file %s: %v", tmpPath, err) + return err + } + log.Printf("successfully wrote temporary file %s", tmpPath) + return nil +} + +func updateFromTmp(tmpPath, path string, checksumChanged bool) error { + if !checksumChanged { + log.Printf("checksums match, removing temporary file %s", tmpPath) + if err := os.Remove(tmpPath); err != nil { + log.Printf("failed to remove temporary file %s: %v", tmpPath, err) + return err + } + log.Printf("no changes needed for %s", path) + return nil + } + + log.Printf("checksums differ, renaming %s to %s", tmpPath, path) + if err := os.Rename(tmpPath, path); err != nil { + log.Printf("failed to rename %s to %s: %v", tmpPath, path, err) + os.Remove(tmpPath) + return err + } + log.Printf("successfully updated %s", path) + return nil +} diff --git a/internal/resource/file/file.go b/internal/resource/file/file.go new file mode 100644 index 0000000..b3c1e44 --- /dev/null +++ b/internal/resource/file/file.go @@ -0,0 +1,246 @@ +package file + +import ( + "bytes" + "fmt" + "log" + "os" + "os/user" + "strconv" + "strings" + "text/template" + + "codeberg.org/snonux/gonf/internal/resource" +) + +type File struct { + path string + content string + source string // bare path, no "source://" prefix + user string + group string + mode os.FileMode + absent bool +} + +type Option func(*File) + +func WithContent(content string) Option { + return func(f *File) { + f.content = content + f.source = "" + } +} + +func WithSource(source string) Option { + return func(f *File) { + f.source = source + f.content = "" + } +} + +func WithUser(user string) Option { + return func(f *File) { + f.user = user + } +} + +func WithGroup(group string) Option { + return func(f *File) { + f.group = group + } +} + +func WithMode(mode os.FileMode) Option { + return func(f *File) { + f.mode = mode + } +} + +func IsAbsent() Option { + return func(f *File) { + f.absent = true + } +} + +func build(path string, opts ...Option) (*File, error) { + curr, err := user.Current() + if err != nil { + return nil, fmt.Errorf("failed to get current user for default: %w", err) + } + + f := &File{ + path: path, + mode: 0o640, + user: curr.Username, + group: curr.Gid, + } + + for _, opt := range opts { + opt(f) + } + + return f, nil +} + +// apply performs the idempotent OS work for f without registering a +// resource. +func (f *File) apply() error { + if f.absent { + return ensureAbsent(f.targetPath()) + } + + finalPath, content, err := f.resolve() + if err != nil { + return fmt.Errorf("failed to resolve content for %s: %w", f.path, err) + } + + return f.ensureFile(finalPath, content) +} + +// targetPath returns the actual on-disk path f writes to. It strips a +// trailing ".tmpl" suffix from the caller-given path whenever templating was +// triggered by a ".tmpl"-suffixed source, so a source foo.conf.tmpl never +// leaves a foo.conf.tmpl behind on disk — whether that source is written via +// a direct Have/Ensure call or delegated to from a directory source-tree +// copy, since both routes go through this same function. +func (f *File) targetPath() string { + if strings.HasSuffix(f.source, ".tmpl") { + return strings.TrimSuffix(f.path, ".tmpl") + } + return f.path +} + +// shouldRenderTemplate reports whether content should be rendered through +// text/template: either the destination path or the source path ends in +// ".tmpl". +func (f *File) shouldRenderTemplate() bool { + return strings.HasSuffix(f.path, ".tmpl") || strings.HasSuffix(f.source, ".tmpl") +} + +// resolve reads f's content (from source or literal content), renders it as +// a template if applicable, and returns the final on-disk path alongside the +// resulting bytes. Param is always the bare source path when source-based +// (never a "source://"-prefixed string), or the literal content when +// content-based — one definition used by both the single-file path and by +// dir's per-file delegation. +func (f *File) resolve() (string, []byte, error) { + var content []byte + param := f.content + + if f.source != "" { + data, err := os.ReadFile(f.source) + if err != nil { + return "", nil, fmt.Errorf("failed to read source file %s: %w", f.source, err) + } + content = data + param = f.source + } else { + content = []byte(f.content) + } + + if f.shouldRenderTemplate() { + rendered, err := f.applyTemplateToContent(content, param) + if err != nil { + return "", nil, err + } + content = rendered + } + + return f.targetPath(), content, nil +} + +func (f *File) applyTemplateToContent(content []byte, param string) ([]byte, error) { + data := make(map[string]string) + for _, env := range os.Environ() { + pair := strings.SplitN(env, "=", 2) + if len(pair) == 2 { + data[pair[0]] = pair[1] + } + } + data["Param"] = param + + tmpl, err := template.New("resource").Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("template parse error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("template execute error: %w", err) + } + return buf.Bytes(), nil +} + +func (f *File) applyAttributesTo(path string) error { + if err := os.Chmod(path, f.mode); err != nil { + return fmt.Errorf("failed to chmod %s to %v: %w", path, f.mode, err) + } + log.Printf("set mode %v for %s", f.mode, path) + + uid, gid := -1, -1 + + if f.user != "" { + u, err := user.Lookup(f.user) + if err != nil { + return fmt.Errorf("failed to lookup user %s: %w", f.user, err) + } + uid, _ = strconv.Atoi(u.Uid) + } + + if f.group != "" { + gidInt, err := strconv.Atoi(f.group) + if err != nil { + return fmt.Errorf("group must be numeric for now: %s", f.group) + } + gid = gidInt + } + + if err := os.Chown(path, uid, gid); err != nil { + return fmt.Errorf("failed to chown %s to %s:%s: %w", path, f.user, f.group, err) + } + log.Printf("set owner %s:%s for %s", f.user, f.group, path) + + return nil +} + +func ensureAbsent(path string) error { + log.Printf("ensuring absent: %s", path) + + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + log.Printf("%s already absent", path) + return nil + } + return fmt.Errorf("failed to remove %s: %w", path, err) + } + + log.Printf("removed %s", path) + return nil +} + +// Ensure builds and applies the file resource described by opts, without +// registering it. Used by other resource packages (e.g. dir) to write an +// individual file without it becoming its own top-level resource. +func Ensure(path string, opts ...Option) error { + f, err := build(path, opts...) + if err != nil { + return err + } + return f.apply() +} + +func Have(path string, opts ...Option) resource.Resource { + f, err := build(path, opts...) + if err != nil { + log.Fatalf("failed to apply file resource %s: %v", path, err) + } + + res := resource.Register("File", f.targetPath()) + + if err := f.apply(); err != nil { + log.Fatalf("failed to apply file resource %s: %v", path, err) + } + + return res +} diff --git a/internal/resource/file/file_test.go b/internal/resource/file/file_test.go new file mode 100644 index 0000000..662aee7 --- /dev/null +++ b/internal/resource/file/file_test.go @@ -0,0 +1,247 @@ +package file + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGetChecksum(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + checksum := getChecksum(path) + if checksum == [32]byte{} { + t.Error("expected non-zero checksum") + } + + zeroChecksum := getChecksum(filepath.Join(dir, "no-such-file")) + var expectedZero [32]byte + if zeroChecksum != expectedZero { + t.Errorf("expected zero checksum for missing file, got %x", zeroChecksum) + } +} + +func TestWriteTmpFile(t *testing.T) { + dir := t.TempDir() + tmpPath := filepath.Join(dir, "test.tmp") + content := []byte("temp content") + + if err := writeTmpFile(tmpPath, content, 0o644); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(tmpPath) + if err != nil { + t.Fatalf("reading tmp file: %v", err) + } + if string(got) != string(content) { + t.Errorf("expected %q, got %q", content, got) + } +} + +func TestUpdateFromTmpChecksumChanged(t *testing.T) { + dir := t.TempDir() + tmpPath := filepath.Join(dir, "test.tmp") + path := filepath.Join(dir, "test.txt") + + if err := os.WriteFile(tmpPath, []byte("new content"), 0o644); err != nil { + t.Fatal(err) + } + + if err := updateFromTmp(tmpPath, path, true); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(tmpPath); err == nil { + t.Error("tmp file should have been removed") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading target file: %v", err) + } + if string(got) != "new content" { + t.Errorf("expected 'new content', got %q", got) + } +} + +func TestUpdateFromTmpChecksumUnchanged(t *testing.T) { + dir := t.TempDir() + tmpPath := filepath.Join(dir, "test.tmp") + path := filepath.Join(dir, "test.txt") + + if err := os.WriteFile(tmpPath, []byte("same"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("same"), 0o644); err != nil { + t.Fatal(err) + } + + if err := updateFromTmp(tmpPath, path, false); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(tmpPath); err == nil { + t.Error("tmp file should have been removed") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading target file: %v", err) + } + if string(got) != "same" { + t.Errorf("expected 'same', got %q", got) + } +} + +func TestHaveStringCreateNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "new.txt") + + Have(path, WithContent("hello world")) + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading file: %v", err) + } + if string(got) != "hello world" { + t.Errorf("expected 'hello world', got %q", got) + } +} + +func TestHaveMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mode.txt") + mode := os.FileMode(0o600) + + Have(path, WithContent("mode test"), WithMode(mode)) + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != mode { + t.Errorf("expected mode %v, got %v", mode, info.Mode().Perm()) + } +} + +func TestHaveSourceFile(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join("..", "..", "..", "assets", "testfiles", "test.txt") + targetPath := filepath.Join(dir, "target.txt") + + Have(targetPath, WithSource(sourcePath)) + + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("reading file: %v", err) + } + expected, _ := os.ReadFile(sourcePath) + if string(got) != string(expected) { + t.Errorf("expected %q, got %q", string(expected), string(got)) + } +} + +func TestHaveTemplateFile(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join("..", "..", "..", "assets", "testfiles", "test.tmpl") + targetPath := filepath.Join(dir, "target.conf") + + Have(targetPath, WithSource(sourcePath)) + + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("reading file: %v", err) + } + + expected := "Hello, " + sourcePath + "!\nWelcome to " + os.Getenv("USER") + ".\n" + if string(got) != expected { + t.Errorf("expected %q, got %q", expected, string(got)) + } +} + +func TestHaveAbsent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gone.txt") + if err := os.WriteFile(path, []byte("bye"), 0o644); err != nil { + t.Fatal(err) + } + + Have(path, IsAbsent()) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed", path) + } + + // Idempotent: removing a missing file is not an error (direct call to + // avoid duplicate registration in the one-per-process registry). + if err := ensureAbsent(path); err != nil { + t.Fatalf("absent on missing file: %v", err) + } +} + +func TestResolveStripsTmplSuffixWhenSourceHasTmplSuffix(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "foo.conf.tmpl") + if err := os.WriteFile(sourcePath, []byte("hello {{.Param}}"), 0o644); err != nil { + t.Fatal(err) + } + // Mirrors what dir's copySourceTree mechanically passes: a target path + // that still carries the source's own ".tmpl" suffix. + dstDir := filepath.Join(dir, "dst") + if err := os.Mkdir(dstDir, 0o750); err != nil { + t.Fatal(err) + } + targetPath := filepath.Join(dstDir, "foo.conf.tmpl") + + if err := Ensure(targetPath, WithSource(sourcePath)); err != nil { + t