summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api/api.go119
-rw-r--r--examples/examples.go32
-rw-r--r--internal/resource/multi.go39
-rw-r--r--internal/resource/multi_test.go81
-rw-r--r--internal/resource/repository.go2
-rw-r--r--internal/resource/repository_test.go14
-rw-r--r--internal/resource/resource.go8
7 files changed, 261 insertions, 34 deletions
diff --git a/api/api.go b/api/api.go
index 043b693..baa7cc7 100644
--- a/api/api.go
+++ b/api/api.go
@@ -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()
+}