summaryrefslogtreecommitdiff
path: root/internal/resource
diff options
context:
space:
mode:
Diffstat (limited to 'internal/resource')
-rw-r--r--internal/resource/dependencies_test.go75
-rw-r--r--internal/resource/dir/dir.go11
-rw-r--r--internal/resource/embed/embed.go24
-rw-r--r--internal/resource/file/file.go11
-rw-r--r--internal/resource/link/link.go11
-rw-r--r--internal/resource/multi.go20
-rw-r--r--internal/resource/pkg/dnf.go2
-rw-r--r--internal/resource/pkg/dnf_test.go26
-rw-r--r--internal/resource/pkg/pkg.go7
-rw-r--r--internal/resource/repository.go28
-rw-r--r--internal/resource/repository_test.go47
-rw-r--r--internal/resource/resource.go27
12 files changed, 251 insertions, 38 deletions
diff --git a/internal/resource/dependencies_test.go b/internal/resource/dependencies_test.go
new file mode 100644
index 0000000..e298740
--- /dev/null
+++ b/internal/resource/dependencies_test.go
@@ -0,0 +1,75 @@
+package resource
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestResourceDependencies(t *testing.T) {
+ r := Resource{Type: "File", Name: "/tmp/a"}
+
+ got := r.Dependencies()
+ want := []string{"File[/tmp/a]"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Resource.Dependencies() = %v, want %v", got, want)
+ }
+}
+
+// TestMultiDependencies ensures a Multi flattens into each member's individual
+// ID, so depending on a Multi records a dependency on every member.
+func TestMultiDependencies(t *testing.T) {
+ m := Multi{
+ Resource{Type: "File", Name: "/tmp/a"},
+ Resource{Type: "File", Name: "/tmp/b"},
+ }
+
+ got := m.Dependencies()
+ want := []string{"File[/tmp/a]", "File[/tmp/b]"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Multi.Dependencies() = %v, want %v", got, want)
+ }
+}
+
+// TestRegisterWithDeps ensures Register seeds dependsOn from the variadic deps
+// and de-duplicates repeated IDs.
+func TestRegisterWithDeps(t *testing.T) {
+ ResetRepository()
+
+ res := Register("File", "/tmp/a", &mockApplier{}, "File[b]", "File[c]", "File[b]")
+
+ if _, ok := res.dependsOn["File[b]"]; !ok {
+ t.Error("expected dependency File[b]")
+ }
+ if _, ok := res.dependsOn["File[c]"]; !ok {
+ t.Error("expected dependency File[c]")
+ }
+ if len(res.dependsOn) != 2 {
+ t.Errorf("expected 2 unique dependencies, got %d: %v", len(res.dependsOn), res.dependsOn)
+ }
+}
+
+func TestRegisterNoDeps(t *testing.T) {
+ ResetRepository()
+
+ res := Register("File", "/tmp/a", &mockApplier{})
+ if res.dependsOn == nil {
+ t.Error("expected dependsOn to be initialized, got nil")
+ }
+ if len(res.dependsOn) != 0 {
+ t.Errorf("expected no dependencies, got %v", res.dependsOn)
+ }
+}
+
+// TestSortedDependsOn ensures dependency IDs are returned sorted for stable log
+// output regardless of insertion/map order.
+func TestSortedDependsOn(t *testing.T) {
+ ResetRepository()
+
+ res := Register("File", "/tmp/a", &mockApplier{}, "File[c]", "File[a]", "File[b]")
+
+ got := res.sortedDependsOn()
+ want := []string{"File[a]", "File[b]", "File[c]"}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("sortedDependsOn() = %v, want %v", got, want)
+ }
+}
diff --git a/internal/resource/dir/dir.go b/internal/resource/dir/dir.go
index 83c017c..5506dbb 100644
--- a/internal/resource/dir/dir.go
+++ b/internal/resource/dir/dir.go
@@ -9,9 +9,12 @@ import (
opt "codeberg.org/snonux/gonf/api/options"
"codeberg.org/snonux/gonf/internal/resource"
+ "codeberg.org/snonux/gonf/internal/resource/embed"
)
type Dir struct {
+ embed.DependsOn
+ embed.Absence
resource resource.Resource
path string
source string
@@ -20,7 +23,6 @@ type Dir struct {
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
}
// SetSource implements opt.Sourced.
@@ -41,9 +43,6 @@ func (d *Dir) SetFileMode(mode os.FileMode) { d.fileMode = mode }
// SetPrune implements opt.Prunable.
func (d *Dir) SetPrune() { d.prune = true }
-// SetAbsent implements opt.Absentable.
-func (d *Dir) SetAbsent() { d.absent = true }
-
func build(path string, opts ...opt.Option) (*Dir, error) {
curr, err := user.Current()
if err != nil {
@@ -68,7 +67,7 @@ func build(path string, opts ...opt.Option) (*Dir, error) {
// apply performs the idempotent OS work for d without registering a
// resource.
func (d *Dir) apply() error {
- if d.absent {
+ if d.Absent {
return ensureAbsent(d)
}
@@ -194,7 +193,7 @@ func Present(path string, opts ...opt.Option) resource.Resource {
}
d.resource = resource.Register("Directory", d.path,
- resource.ApplierFunc(func() error { return d.apply() }))
+ resource.ApplierFunc(func() error { return d.apply() }), d.DependsOn.IDs...)
return d.resource
}
diff --git a/internal/resource/embed/embed.go b/internal/resource/embed/embed.go
new file mode 100644
index 0000000..49fe3eb
--- /dev/null
+++ b/internal/resource/embed/embed.go
@@ -0,0 +1,24 @@
+package embed
+
+// DependsOn is embedded into concrete resource types to give them the ability
+// to accumulate dependency IDs supplied via the DependsOn option.
+type DependsOn struct {
+ IDs []string
+}
+
+// AddDependency records a single resource ID this resource depends on. It uses
+// a pointer receiver so the mutation is visible to the embedding value.
+func (d *DependsOn) AddDependency(id string) {
+ d.IDs = append(d.IDs, id)
+}
+
+// Absence is embedded into concrete resource types that can be marked for
+// removal via the IsAbsent option. It promotes an Absent field and a
+// SetAbsent method to the embedding type.
+type Absence struct {
+ Absent bool
+}
+
+// SetAbsent implements opt.Absentable, marking the resource for removal. It
+// uses a pointer receiver so the mutation is visible to the embedding value.
+func (a *Absence) SetAbsent() { a.Absent = true }
diff --git a/internal/resource/file/file.go b/internal/resource/file/file.go
index 88b0bca..2dc1df5 100644
--- a/internal/resource/file/file.go
+++ b/internal/resource/file/file.go
@@ -12,9 +12,12 @@ import (
opt "codeberg.org/snonux/gonf/api/options"
"codeberg.org/snonux/gonf/internal/resource"
+ "codeberg.org/snonux/gonf/internal/resource/embed"
)
type File struct {
+ embed.DependsOn
+ embed.Absence
resource resource.Resource
path string
content string
@@ -22,7 +25,6 @@ type File struct {
user string
group string
mode os.FileMode
- absent bool
}
// SetContent implements opt.Contented. Setting literal content clears any
@@ -48,9 +50,6 @@ func (f *File) SetGroup(group string) { f.group = group }
// SetMode implements opt.Moded.
func (f *File) SetMode(mode os.FileMode) { f.mode = mode }
-// SetAbsent implements opt.Absentable.
-func (f *File) SetAbsent() { f.absent = true }
-
func build(path string, opts ...opt.Option) (*File, error) {
curr, err := user.Current()
if err != nil {
@@ -74,7 +73,7 @@ func build(path string, opts ...opt.Option) (*File, error) {
// apply performs the idempotent OS work for f without registering a
// resource.
func (f *File) apply() error {
- if f.absent {
+ if f.Absent {
return ensureAbsent(f.targetPath())
}
@@ -225,7 +224,7 @@ func Present(path string, opts ...opt.Option) resource.Resource {
}
f.resource = resource.Register("File", f.targetPath(),
- resource.ApplierFunc(func() error { return f.apply() }))
+ resource.ApplierFunc(func() error { return f.apply() }), f.DependsOn.IDs...)
return f.resource
}
diff --git a/internal/resource/link/link.go b/internal/resource/link/link.go
index b40ce65..836a925 100644
--- a/internal/resource/link/link.go
+++ b/internal/resource/link/link.go
@@ -7,6 +7,7 @@ import (
opt "codeberg.org/snonux/gonf/api/options"
"codeberg.org/snonux/gonf/internal/resource"
+ "codeberg.org/snonux/gonf/internal/resource/embed"
)
type kind int
@@ -18,11 +19,12 @@ const (
)
type Link struct {
+ embed.DependsOn
+ embed.Absence
resource resource.Resource
path string
target string
kind kind
- absent bool
}
// SetSymlink implements opt.Linkable.
@@ -37,9 +39,6 @@ func (l *Link) SetHardlink(target string) {
l.target = target
}
-// SetAbsent implements opt.Absentable.
-func (l *Link) SetAbsent() { l.absent = true }
-
func build(path string, opts ...opt.Option) *Link {
l := &Link{path: path}
for _, o := range opts {
@@ -54,7 +53,7 @@ func build(path string, opts ...opt.Option) *Link {
// was validated.
func (l *Link) apply() error {
switch {
- case l.absent:
+ case l.Absent:
return ensureAbsent(l.path)
case l.kind == symlinkKind:
return ensureSymlink(l)
@@ -89,7 +88,7 @@ func Ensure(path string, opts ...opt.Option) error {
func Present(path string, opts ...opt.Option) resource.Resource {
l := build(path, opts...)
l.resource = resource.Register(l.resourceType(), l.path,
- resource.ApplierFunc(func() error { return l.apply() }))
+ resource.ApplierFunc(func() error { return l.apply() }), l.DependsOn.IDs...)
return l.resource
}
diff --git a/internal/resource/multi.go b/internal/resource/multi.go
index 1694814..3916287 100644
--- a/internal/resource/multi.go
+++ b/internal/resource/multi.go
@@ -5,6 +5,13 @@ import (
"strings"
)
+// Dependency is implemented by anything that can be depended upon. It returns
+// the flattened list of individual resource IDs, so that depending on a Multi
+// expands into a dependency on each of its members individually.
+type Dependency interface {
+ Dependencies() []string
+}
+
// Multi is a collection of resources that satisfies the api.Resource interface.
type Multi []Resource
@@ -28,6 +35,19 @@ func (m Multi) ID() string {
return strings.Join(ids, "+")
}
+// Dependencies flattens the Multi into the IDs of each of its members, so a
+// dependency on a Multi becomes an individual dependency on every resource it
+// contains.
+func (m Multi) Dependencies() []string {
+ ids := make([]string, 0, len(m))
+
+ for _, res := range m {
+ ids = append(ids, res.Dependencies()...)
+ }
+
+ return ids
+}
+
func (m Multi) Apply() error {
var errs []error
diff --git a/internal/resource/pkg/dnf.go b/internal/resource/pkg/dnf.go
index e970b10..34f0a5d 100644
--- a/internal/resource/pkg/dnf.go
+++ b/internal/resource/pkg/dnf.go
@@ -9,7 +9,7 @@ import (
func applyDNF(p *Package) error {
var args []string
- if p.absent {
+ if p.Absent {
args = []string{"remove", "-y", p.name}
} else if p.latest {
// update ensures the package is installed and updated to the latest version.
diff --git a/internal/resource/pkg/dnf_test.go b/internal/resource/pkg/dnf_test.go
index 6c929f2..ddd84e6 100644
--- a/internal/resource/pkg/dnf_test.go
+++ b/internal/resource/pkg/dnf_test.go
@@ -3,6 +3,8 @@ package pkg
import (
"os"
"testing"
+
+ "codeberg.org/snonux/gonf/internal/resource/embed"
)
func TestApplyDNF(t *testing.T) {
@@ -21,7 +23,7 @@ func TestApplyDNF(t *testing.T) {
}
t.Run("Present", func(t *testing.T) {
- p.absent = false
+ p.Absent = false
p.latest = false
if err := applyDNF(p); err != nil {
t.Errorf("applyDNF Present failed: %v", err)
@@ -29,7 +31,7 @@ func TestApplyDNF(t *testing.T) {
})
t.Run("Latest", func(t *testing.T) {
- p.absent = false
+ p.Absent = false
p.latest = true
if err := applyDNF(p); err != nil {
t.Errorf("applyDNF Latest failed: %v", err)
@@ -37,7 +39,7 @@ func TestApplyDNF(t *testing.T) {
})
t.Run("Absent", func(t *testing.T) {
- p.absent = true
+ p.Absent = true
p.latest = false
if err := applyDNF(p); err != nil {
t.Errorf("applyDNF Absent failed: %v", err)
@@ -46,9 +48,9 @@ func TestApplyDNF(t *testing.T) {
t.Run("NonExistentPresent", func(t *testing.T) {
pErr := &Package{
- name: "non-existent-package-gonf-12345",
- absent: false,
- latest: false,
+ name: "non-existent-package-gonf-12345",
+ Absence: embed.Absence{Absent: false},
+ latest: false,
}
if err := applyDNF(pErr); err == nil {
t.Error("applyDNF Present should have failed for non-existent package")
@@ -57,9 +59,9 @@ func TestApplyDNF(t *testing.T) {
t.Run("NonExistentLatest", func(t *testing.T) {
pErr := &Package{
- name: "non-existent-package-gonf-12345",
- absent: false,
- latest: true,
+ name: "non-existent-package-gonf-12345",
+ Absence: embed.Absence{Absent: false},
+ latest: true,
}
if err := applyDNF(pErr); err == nil {
t.Error("applyDNF Latest should have failed for non-existent package")
@@ -68,9 +70,9 @@ func TestApplyDNF(t *testing.T) {
t.Run("NonExistentAbsent", func(t *testing.T) {
pErr := &Package{
- name: "non-existent-package-gonf-12345",
- absent: true,
- latest: false,
+ name: "non-existent-package-gonf-12345",
+ Absence: embed.Absence{Absent: true},
+ latest: false,
}
// dnf remove is typically idempotent; removing a non-existent package should not error.
if err := applyDNF(pErr); err != nil {
diff --git a/internal/resource/pkg/pkg.go b/internal/resource/pkg/pkg.go
index b010c7a..c4799c8 100644
--- a/internal/resource/pkg/pkg.go
+++ b/internal/resource/pkg/pkg.go
@@ -6,15 +6,16 @@ import (
opt "codeberg.org/snonux/gonf/api/options"
"codeberg.org/snonux/gonf/internal/resource"
+ "codeberg.org/snonux/gonf/internal/resource/embed"
)
type Package struct {
+ embed.DependsOn
+ embed.Absence
name string
- absent bool
latest bool
}
-func (p *Package) SetAbsent() { p.absent = true }
func (p *Package) SetLatest() { p.latest = true }
func (p *Package) apply() error {
@@ -41,7 +42,7 @@ func Present(name string, opts ...opt.Option) resource.Resource {
}
return resource.Register("Package", p.name,
- resource.ApplierFunc(func() error { return p.apply() }))
+ resource.ApplierFunc(func() error { return p.apply() }), p.DependsOn.IDs...)
}
func Absent(name string, opts ...opt.Option) resource.Resource {
diff --git a/internal/resource/repository.go b/internal/resource/repository.go
index 0771ef8..ece44c0 100644
--- a/internal/resource/repository.go
+++ b/internal/resource/repository.go
@@ -3,6 +3,8 @@ package resource
import (
"fmt"
"log"
+ "sort"
+ "strings"
"sync"
)
@@ -70,7 +72,10 @@ func (r *repository) apply() error {
}
visiting[id] = true
- for depID := range res.dependsOn {
+ // Visit dependencies in sorted order so the resulting apply order is
+ // stable and the log output is reproducible.
+ for _, depID := range res.sortedDependsOn() {
+ log.Printf("Resolving dependency of %v: needs %s first", res, depID)
if err := visit(depID); err != nil {
return err
}
@@ -81,14 +86,33 @@ func (r *repository) apply() error {
return nil
}
+ // Seed the traversal from a sorted list of roots so the overall order is
+ // deterministic regardless of map iteration order.
+ roots := make([]string, 0, len(r.registered))
for id := range r.registered {
+ roots = append(roots, id)
+ }
+ sort.Strings(roots)
+
+ for _, id := range roots {
if err := visit(id); err != nil {
return err
}
}
+ orderIDs := make([]string, 0, len(order))
+ for _, res := range order {
+ orderIDs = append(orderIDs, res.ID())
+ }
+ log.Printf("Resolved apply order: %s", strings.Join(orderIDs, " -> "))
+
for _, res := range order {
- log.Printf("Applying resource %v", res)
+ if deps := res.sortedDependsOn(); len(deps) > 0 {
+ log.Printf("Applying resource %v (dependencies already applied: %s)",
+ res, strings.Join(deps, ", "))
+ } else {
+ log.Printf("Applying resource %v (no dependencies)", res)
+ }
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 f939fca..ed2755e 100644
--- a/internal/resource/repository_test.go
+++ b/internal/resource/repository_test.go
@@ -48,6 +48,53 @@ func TestApply(t *testing.T) {
wantOrder: []string{"C", "B", "A"},
},
{
+ name: "independent resources apply in sorted order",
+ setup: func(r *repository, logs *[]string) {
+ // Registered/visited in a deterministic, sorted order
+ // regardless of map iteration.
+ r.registered["C"] = Resource{
+ Type: "T", Name: "C",
+ applier: &mockApplier{name: "C", logs: logs},
+ }
+ r.registered["A"] = Resource{
+ Type: "T", Name: "A",
+ applier: &mockApplier{name: "A", logs: logs},
+ }
+ r.registered["B"] = Resource{
+ Type: "T", Name: "B",
+ applier: &mockApplier{name: "B", logs: logs},
+ }
+ },
+ wantOrder: []string{"A", "B", "C"},
+ },
+ {
+ name: "diamond dependency",
+ setup: func(r *repository, logs *[]string) {
+ // D depends on B and C; both B and C depend on A. A must run
+ // once, before B and C, which run before D.
+ r.registered["A"] = Resource{
+ Type: "T", Name: "A",
+ applier: &mockApplier{name: "A", logs: logs},
+ }
+ r.registered["B"] = Resource{
+ Type: "T", Name: "B",
+ applier: &mockApplier{name: "B", logs: logs},
+ dependsOn: map[string]struct{}{"A": {}},
+ }
+ r.registered["C"] = Resource{
+ Type: "T", Name: "C",
+ applier: &mockApplier{name: "C", logs: logs},
+ dependsOn: map[string]struct{}{"A": {}},
+ }
+ r.registered["D"] = Resource{
+ Type: "T", Name: "D",
+ applier: &mockApplier{name: "D", logs: logs},
+ dependsOn: map[string]struct{}{"B": {}, "C": {}},
+ }
+ },
+ wantOrder: []string{"A", "B", "C", "D"},
+ },
+ {
name: "circular dependency",
setup: func(r *repository, logs *[]string) {
r.registered["A"] = Resource{
diff --git a/internal/resource/resource.go b/internal/resource/resource.go
index 2a01629..1cf451a 100644
--- a/internal/resource/resource.go
+++ b/internal/resource/resource.go
@@ -3,6 +3,7 @@ package resource
import (
"fmt"
"log"
+ "sort"
)
type Applier interface {
@@ -22,12 +23,17 @@ type Resource struct {
dependsOn map[string]struct{}
}
-func Register(type_, name string, apply Applier) Resource {
+func Register(type_, name string, apply Applier, deps ...string) Resource {
+ dependsOn := make(map[string]struct{}, len(deps))
+ for _, id := range deps {
+ dependsOn[id] = struct{}{}
+ }
+
r := Resource{
Type: type_,
Name: name,
applier: apply,
- dependsOn: make(map[string]struct{}),
+ dependsOn: dependsOn,
}
if err := getRepository().register(r); err != nil {
@@ -45,6 +51,23 @@ func (r Resource) ID() string {
return fmt.Sprintf("%s[%s]", r.Type, r.Name)
}
+// Dependencies returns this resource's own ID. It lets a single Resource be
+// used as a DependsOn target, mirroring Multi.Dependencies.
+func (r Resource) Dependencies() []string {
+ return []string{r.ID()}
+}
+
+// sortedDependsOn returns the IDs this resource depends on, sorted for stable
+// and readable log output.
+func (r Resource) sortedDependsOn() []string {
+ ids := make([]string, 0, len(r.dependsOn))
+ for id := range r.dependsOn {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ return ids
+}
+
func (r Resource) Apply() error {
return r.applier.Apply()
}