diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/resource/dependencies_test.go | 75 | ||||
| -rw-r--r-- | internal/resource/dir/dir.go | 11 | ||||
| -rw-r--r-- | internal/resource/embed/embed.go | 24 | ||||
| -rw-r--r-- | internal/resource/file/file.go | 11 | ||||
| -rw-r--r-- | internal/resource/link/link.go | 11 | ||||
| -rw-r--r-- | internal/resource/multi.go | 20 | ||||
| -rw-r--r-- | internal/resource/pkg/dnf.go | 2 | ||||
| -rw-r--r-- | internal/resource/pkg/dnf_test.go | 26 | ||||
| -rw-r--r-- | internal/resource/pkg/pkg.go | 7 | ||||
| -rw-r--r-- | internal/resource/repository.go | 28 | ||||
| -rw-r--r-- | internal/resource/repository_test.go | 47 | ||||
| -rw-r--r-- | internal/resource/resource.go | 27 |
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() } |
