diff options
Diffstat (limited to 'internal/resource/file')
| -rw-r--r-- | internal/resource/file/checksum.go | 67 | ||||
| -rw-r--r-- | internal/resource/file/file.go | 246 | ||||
| -rw-r--r-- | internal/resource/file/file_test.go | 247 |
3 files changed, 560 insertions, 0 deletions
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.Fatalf("unexpected error: %v", err) + } + + deSuffixed := filepath.Join(filepath.Dir(targetPath), "foo.conf") + if _, err := os.Stat(deSuffixed); err != nil { + t.Errorf("expected de-suffixed file to exist at %s: %v", deSuffixed, err) + } + if _, err := os.Stat(targetPath); !os.IsNotExist(err) { + t.Errorf("expected %s to NOT exist on disk", targetPath) + } +} + +func TestResolveDoesNotStripTmplWhenOnlyContentTriggersTemplate(t *testing.T) { + dir := t.TempDir() + targetPath := filepath.Join(dir, "foo.conf.tmpl") + + if err := Ensure(targetPath, WithContent("hello {{.Param}}")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(targetPath); err != nil { + t.Errorf("expected %s to exist (not stripped), got %v", targetPath, err) + } +} + +func TestParamIsBareSourcePathNoPrefix(t *testing.T) { + dir := t.TempDir() + sourcePath := filepath.Join(dir, "src.tmpl") + if err := os.WriteFile(sourcePath, []byte("{{.Param}}"), 0o644); err != nil { + t.Fatal(err) + } + targetPath := filepath.Join(dir, "dst.conf") + + if err := Ensure(targetPath, WithSource(sourcePath)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatalf("reading file: %v", err) + } + if string(got) != sourcePath { + t.Errorf("expected Param to equal bare source path %q, got %q", sourcePath, string(got)) + } + if strings.Contains(string(got), "source://") { + t.Errorf("Param unexpectedly contains the removed \"source://\" prefix: %q", string(got)) + } +} |
