diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/file/directory.go | 57 | ||||
| -rw-r--r-- | internal/file/file.go | 122 | ||||
| -rw-r--r-- | internal/file/file_test.go | 268 | ||||
| -rw-r--r-- | internal/file/hardlink.go | 59 | ||||
| -rw-r--r-- | internal/file/regular_file.go | 26 | ||||
| -rw-r--r-- | internal/file/symlink.go | 56 |
6 files changed, 559 insertions, 29 deletions
diff --git a/internal/file/directory.go b/internal/file/directory.go new file mode 100644 index 0000000..052af06 --- /dev/null +++ b/internal/file/directory.go @@ -0,0 +1,57 @@ +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 index cf46f19..e6547c3 100644 --- a/internal/file/file.go +++ b/internal/file/file.go @@ -15,12 +15,23 @@ import ( ) type File struct { - path string - param string - source string - user string - group string - mode os.FileMode + 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) @@ -57,6 +68,39 @@ func WithGroup(group string) Option { 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 Hardlink(target string) Option { + return func(f *File) { + f.hardlink = true + f.hardlinkTarget = target + } +} + +func PruneDirectory() Option { + return func(f *File) { + f.pruneDirectory = true } } @@ -80,15 +124,53 @@ func Have(path string, opts ...Option) error { 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() error { - _ = resource.Register("File", f.path) + switch { + case f.absent: + _ = resource.Register(f.resourceType(), f.path) + return f.haveAbsent() + + case f.symlink: + _ = resource.Register("Symlink", f.path) + return f.haveSymlink() + + case f.hardlink: + _ = resource.Register("Hardlink", f.path) + return f.haveHardlink() + + case f.directory: + if !f.modeSet { + f.mode = 0o750 + } + _ = resource.Register("Directory", f.path) + return f.haveDirectory() - content, err := f.resolveContent() - if err != nil { - log.Fatalf("failed to resolve content for %s: %v", f.path, err) + default: + _ = resource.Register("File", f.path) + content, err := f.resolveContent() + if err != nil { + return fmt.Errorf("failed to resolve content for %s: %w", f.path, err) + } + return f.haveRegularFile(content) } +} - return f.have(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) { @@ -134,24 +216,6 @@ func (f *File) applyTemplate(content []byte) ([]byte, error) { return buf.Bytes(), nil } -func (f *File) have(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() -} - func (f *File) applyAttributes() error { // Apply Mode if err := os.Chmod(f.path, f.mode); err != nil { diff --git a/internal/file/file_test.go b/internal/file/file_test.go index a9397ba..d9e09ac 100644 --- a/internal/file/file_test.go +++ b/internal/file/file_test.go @@ -171,3 +171,271 @@ func TestHaveTemplateFile(t *testing.T) { 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") + + if err := Have(path, IsDirectory()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + 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) + } + + if err := Have(link, IsSymlink(target)); err != nil { + t.Fatalf("create: %v", err) + } + 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) + } + + if err := Have(link, IsSymlink(newT)); err != nil { + t.Fatalf("repoint: %v", err) + } + 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) + } + + if err := Have(link, IsSymlink(target)); err != nil { + t.Fatalf("symlink over real file: %v", err) + } + + 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) + } + + if err := Have(path, IsAbsent()); err != nil { + t.Fatalf("absent: %v", err) + } + 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) + } + + if err := Have(link, Hardlink(target)); err != nil { + t.Fatalf("create: %v", err) + } + + 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) + } + + if err := Have(link, Hardlink(target)); err != nil { + t.Fatalf("hardlink over real file: %v", err) + } + + 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, Hardlink(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) + } + + if err := Have(target, IsAbsent(), PruneDirectory()); err != nil { + t.Fatalf("prune remove: %v", err) + } + 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 new file mode 100644 index 0000000..8913674 --- /dev/null +++ b/internal/file/hardlink.go @@ -0,0 +1,59 @@ +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 "<path>.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 new file mode 100644 index 0000000..7747b9e --- /dev/null +++ b/internal/file/regular_file.go @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..8d014c2 --- /dev/null +++ b/internal/file/symlink.go @@ -0,0 +1,56 @@ +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 "<path>.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 +} |
