diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-08 23:17:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-08 23:17:39 +0300 |
| commit | 3f8b79f2b1384e3abc2b1bb2fe913688c7ec251c (patch) | |
| tree | 5cff57875a1f5b63536423c0b7fe7a3788ff15ac | |
| parent | 920f2ea88c45e972cd87b56580c90a0277b7130e (diff) | |
add multi which can return multiple resources
| -rw-r--r-- | api/api.go | 119 | ||||
| -rw-r--r-- | examples/examples.go | 32 | ||||
| -rw-r--r-- | internal/resource/multi.go | 39 | ||||
| -rw-r--r-- | internal/resource/multi_test.go | 81 | ||||
| -rw-r--r-- | internal/resource/repository.go | 2 | ||||
| -rw-r--r-- | internal/resource/repository_test.go | 14 | ||||
| -rw-r--r-- | internal/resource/resource.go | 8 |
7 files changed, 261 insertions, 34 deletions
@@ -2,48 +2,119 @@ package api import ( "codeberg.org/snonux/gonf/api/options" + "codeberg.org/snonux/gonf/internal/resource" "codeberg.org/snonux/gonf/internal/resource/dir" "codeberg.org/snonux/gonf/internal/resource/file" "codeberg.org/snonux/gonf/internal/resource/link" "codeberg.org/snonux/gonf/internal/resource/pkg" ) -// File creates a file resource. -func File(path string, opts ...options.Option) Resource { - return file.Present(path, opts...) +// Path constraint for resources that can be defined as a single item or a list. +type Path interface { + string | []string } -// NoFile creates a file resource that is ensured to be absent. -func NoFile(path string, opts ...options.Option) Resource { - return file.Absent(path, opts...) +// File creates one or more file resources. +func File[T Path](path T, opts ...options.Option) Resource { + switch v := any(path).(type) { + case string: + return file.Present(v, opts...) + case []string: + return Files(v, opts...) + default: + panic("File: path must be string or []string") + } } -// Dir creates a directory resource. -func Dir(path string, opts ...options.Option) Resource { - return dir.Present(path, opts...) +func Files(paths []string, opts ...options.Option) Resource { + var resources []resource.Resource + for _, path := range paths { + resources = append(resources, file.Present(path, opts...)) + } + return resource.Multi(resources) } -// NoDir creates a directory resource that is ensured to be absent. -func NoDir(path string, opts ...options.Option) Resource { - return dir.Absent(path, opts...) +// NoFile creates one or more file resources that are ensured to be absent. +func NoFile[T Path](path T, opts ...options.Option) Resource { + return File(path, append(opts, options.IsAbsent)...) } -// Link creates a link resource (symbolic or hard). -func Link(path string, opts ...options.Option) Resource { - return link.Present(path, opts...) +// Dir creates one or more directory resources. +func Dir[T Path](path T, opts ...options.Option) Resource { + switch v := any(path).(type) { + case string: + return dir.Present(v, opts...) + case []string: + return Dirs(v, opts...) + default: + panic("Dir: path must be string or []string") + } } -// NoLink creates a link resource that is ensured to be absent. -func NoLink(path string, opts ...options.Option) Resource { - return link.Absent(path, opts...) +func Dirs(paths []string, opts ...options.Option) Resource { + var resources []resource.Resource + for _, path := range paths { + resources = append(resources, dir.Present(path, opts...)) + } + return resource.Multi(resources) } -// Package creates a package resource. -func Package(name string, opts ...options.Option) Resource { - return pkg.Present(name, opts...) +// NoDir creates one or more directory resources that are ensured to be absent. +func NoDir[T Path](path T, opts ...options.Option) Resource { + return Dir(path, append(opts, options.IsAbsent)...) } -// NoPackage creates a package resource that is ensured to be absent. -func NoPackage(name string, opts ...options.Option) Resource { - return pkg.Absent(name, opts...) +// Link creates one or more link resources (symbolic or hard). +func Link[T Path](path T, opts ...options.Option) Resource { + switch v := any(path).(type) { + case string: + return link.Present(v, opts...) + case []string: + return Links(v, opts...) + default: + panic("Link: path must be string or []string") + } +} + +func Links(paths []string, opts ...options.Option) Resource { + var resources []resource.Resource + for _, path := range paths { + resources = append(resources, link.Present(path, opts...)) + } + return resource.Multi(resources) +} + +// NoLink creates one or more link resources that are ensured to be absent. +func NoLink[T Path](path T, opts ...options.Option) Resource { + return Link(path, append(opts, options.IsAbsent)...) +} + +// Elems is a helper to create a slice of strings from variadic arguments. +func Elems(paths ...string) []string { + return paths +} + +// Package creates one or more package resources. +func Package[T Path](name T, opts ...options.Option) Resource { + switch v := any(name).(type) { + case string: + return pkg.Present(v, opts...) + case []string: + return Packages(v, opts...) + default: + panic("Package: name must be string or []string") + } +} + +func Packages(names []string, opts ...options.Option) Resource { + var resources []resource.Resource + for _, name := range names { + resources = append(resources, pkg.Present(name, opts...)) + } + return resource.Multi(resources) +} + +// NoPackage creates one or more package resources that are ensured to be absent. +func NoPackage[T Path](name T, opts ...options.Option) Resource { + return Package(name, append(opts, options.IsAbsent)...) } diff --git a/examples/examples.go b/examples/examples.go index 9fd9e40..7bbfa46 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -72,5 +72,37 @@ func Run() error { Package("vim", IsLatest) // Ensure installed and latest version NoPackage("nano") // Ensure absent + // 14. Multi-resource declarations + // Create multiple files with the same options + File(Elems( + "/tmp/gonf_multi1.txt", + "/tmp/gonf_multi2.txt", + ), WithContent("Multi-file content"), WithMode(0o644)) + + // Create multiple directories + Dir(Elems( + "/tmp/gonf_multi_dir1", + "/tmp/gonf_multi_dir2", + ), WithMode(0o755)) + + // Install multiple packages and ensure they are latest + Package(Elems( + "htop", + "curl", + "wget", + ), IsLatest) + + // Remove multiple packages + NoPackage(Elems( + "old-pkg1", + "old-pkg2", + )) + + // Remove multiple files + NoFile(Elems( + "/tmp/stale1.txt", + "/tmp/stale2.txt", + )) + return Apply() } diff --git a/internal/resource/multi.go b/internal/resource/multi.go new file mode 100644 index 0000000..1694814 --- /dev/null +++ b/internal/resource/multi.go @@ -0,0 +1,39 @@ +package resource + +import ( + "errors" + "strings" +) + +// Multi is a collection of resources that satisfies the api.Resource interface. +type Multi []Resource + +func (m Multi) String() string { + strs := make([]string, 0, len(m)) + + for _, res := range m { + strs = append(strs, res.String()) + } + + return strings.Join(strs, ", ") +} + +func (m Multi) ID() string { + ids := make([]string, 0, len(m)) + + for _, res := range m { + ids = append(ids, res.String()) + } + + return strings.Join(ids, "+") +} + +func (m Multi) Apply() error { + var errs []error + + for _, res := range m { + errs = append(errs, res.Apply()) + } + + return errors.Join(errs...) +} diff --git a/internal/resource/multi_test.go b/internal/resource/multi_test.go new file mode 100644 index 0000000..3443afb --- /dev/null +++ b/internal/resource/multi_test.go @@ -0,0 +1,81 @@ +package resource + +import ( + "errors" + "testing" +) + +func TestMultiString(t *testing.T) { + m := Multi{ + Resource{Type: "File", Name: "/tmp/a"}, + Resource{Type: "File", Name: "/tmp/b"}, + } + + want := "File[/tmp/a], File[/tmp/b]" + if got := m.String(); got != want { + t.Errorf("Multi.String() = %q, want %q", got, want) + } +} + +func TestMultiID(t *testing.T) { + m := Multi{ + Resource{Type: "File", Name: "/tmp/a"}, + Resource{Type: "File", Name: "/tmp/b"}, + } + + want := "File[/tmp/a]+File[/tmp/b]" + if got := m.ID(); got != want { + t.Errorf("Multi.ID() = %q, want %q", got, want) + } +} + +func TestMultiApply(t *testing.T) { + tests := []struct { + name string + appliers []Applier + wantErr bool + }{ + { + name: "all success", + appliers: []Applier{ + ApplierFunc(func() error { return nil }), + ApplierFunc(func() error { return nil }), + }, + wantErr: false, + }, + { + name: "one failure", + appliers: []Applier{ + ApplierFunc(func() error { return nil }), + ApplierFunc(func() error { return errors.New("fail 1") }), + }, + wantErr: true, + }, + { + name: "multiple failures", + appliers: []Applier{ + ApplierFunc(func() error { return errors.New("fail 1") }), + ApplierFunc(func() error { return errors.New("fail 2") }), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var resources []Resource + for _, app := range tt.appliers { + resources = append(resources, Resource{ + applier: app, + }) + } + + m := Multi(resources) + err := m.Apply() + + if (err != nil) != tt.wantErr { + t.Errorf("Multi.Apply() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/resource/repository.go b/internal/resource/repository.go index 215f7e1..0771ef8 100644 --- a/internal/resource/repository.go +++ b/internal/resource/repository.go @@ -89,7 +89,7 @@ func (r *repository) apply() error { for _, res := range order { log.Printf("Applying resource %v", res) - if err := res.Apply.Apply(); err != nil { + if err := res.Apply(); err != nil { return fmt.Errorf("failed to apply %v: %w", res, err) } } diff --git a/internal/resource/repository_test.go b/internal/resource/repository_test.go index 8f1aef6..f939fca 100644 --- a/internal/resource/repository_test.go +++ b/internal/resource/repository_test.go @@ -32,17 +32,17 @@ func TestApply(t *testing.T) { // A depends on B, B depends on C r.registered["A"] = Resource{ Type: "T", Name: "A", - Apply: &mockApplier{name: "A", logs: logs}, + applier: &mockApplier{name: "A", logs: logs}, dependsOn: map[string]struct{}{"B": {}}, } r.registered["B"] = Resource{ Type: "T", Name: "B", - Apply: &mockApplier{name: "B", logs: logs}, + applier: &mockApplier{name: "B", logs: logs}, dependsOn: map[string]struct{}{"C": {}}, } r.registered["C"] = Resource{ Type: "T", Name: "C", - Apply: &mockApplier{name: "C", logs: logs}, + applier: &mockApplier{name: "C", logs: logs}, } }, wantOrder: []string{"C", "B", "A"}, @@ -52,12 +52,12 @@ func TestApply(t *testing.T) { setup: func(r *repository, logs *[]string) { r.registered["A"] = Resource{ Type: "T", Name: "A", - Apply: &mockApplier{name: "A", logs: logs}, + applier: &mockApplier{name: "A", logs: logs}, dependsOn: map[string]struct{}{"B": {}}, } r.registered["B"] = Resource{ Type: "T", Name: "B", - Apply: &mockApplier{name: "B", logs: logs}, + applier: &mockApplier{name: "B", logs: logs}, dependsOn: map[string]struct{}{"A": {}}, } }, @@ -69,7 +69,7 @@ func TestApply(t *testing.T) { setup: func(r *repository, logs *[]string) { r.registered["A"] = Resource{ Type: "T", Name: "A", - Apply: &mockApplier{name: "A", logs: logs}, + applier: &mockApplier{name: "A", logs: logs}, dependsOn: map[string]struct{}{"Missing": {}}, } }, @@ -81,7 +81,7 @@ func TestApply(t *testing.T) { setup: func(r *repository, logs *[]string) { r.registered["A"] = Resource{ Type: "T", Name: "A", - Apply: &mockApplier{name: "A", err: errors.New("fail A"), logs: logs}, + applier: &mockApplier{name: "A", err: errors.New("fail A"), logs: logs}, } }, wantError: true, diff --git a/internal/resource/resource.go b/internal/resource/resource.go index 4e72485..2a01629 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -18,7 +18,7 @@ func (f ApplierFunc) Apply() error { type Resource struct { Type string Name string - Apply Applier + applier Applier dependsOn map[string]struct{} } @@ -26,7 +26,7 @@ func Register(type_, name string, apply Applier) Resource { r := Resource{ Type: type_, Name: name, - Apply: apply, + applier: apply, dependsOn: make(map[string]struct{}), } @@ -44,3 +44,7 @@ func (r Resource) String() string { func (r Resource) ID() string { return fmt.Sprintf("%s[%s]", r.Type, r.Name) } + +func (r Resource) Apply() error { + return r.applier.Apply() +} |
