From 756ae621bd97a8b776fefb15e5e7e2292f98ee45 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 9 Jul 2026 00:14:33 +0300 Subject: dependencies --- AGENTS.md | 18 ++++++++ api/options/option.go | 21 ++++++++++ api/resource.go | 4 ++ examples/examples.go | 47 ++++++++++++++------- internal/resource/dependencies_test.go | 75 ++++++++++++++++++++++++++++++++++ internal/resource/dir/dir.go | 11 +++-- internal/resource/embed/embed.go | 24 +++++++++++ internal/resource/file/file.go | 11 +++-- internal/resource/link/link.go | 11 +++-- internal/resource/multi.go | 20 +++++++++ internal/resource/pkg/dnf.go | 2 +- internal/resource/pkg/dnf_test.go | 26 ++++++------ internal/resource/pkg/pkg.go | 7 ++-- internal/resource/repository.go | 28 ++++++++++++- internal/resource/repository_test.go | 47 +++++++++++++++++++++ internal/resource/resource.go | 27 +++++++++++- 16 files changed, 326 insertions(+), 53 deletions(-) create mode 100644 internal/resource/dependencies_test.go create mode 100644 internal/resource/embed/embed.go diff --git a/AGENTS.md b/AGENTS.md index 3d3cd6d..338280b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,21 @@ ## Resource Management All public functions which start with `Have` should be responsible for registering the resource they create. + +## Dependencies +Concrete resource types (e.g. `file.File`, `dir.Dir`, `link.Link`, `pkg.Package`) +embed `embed.DependsOn` to accumulate dependency IDs. The `DependsOn` option +records those IDs, and each `Present` function forwards them into +`resource.Register(..., x.DependsOn.IDs...)`. A `Multi` dependency is expanded +via `Dependencies()` so each member is depended upon individually, and +`repository.apply()` topologically sorts resources by their `dependsOn` set. + +## Shared embeds +State common to all concrete resource types lives in the `embed` package and is +embedded rather than redeclared: +- `embed.DependsOn` — dependency IDs (`IDs` field) plus the `AddDependency` method. +- `embed.Absence` — the `Absent` field plus the `SetAbsent` method (implements + `opt.Absentable`). + +When adding a field or capability shared by every resource type, prefer a new +embed type here instead of duplicating the field and its setter in each resource. diff --git a/api/options/option.go b/api/options/option.go index 1d9b931..22156a8 100644 --- a/api/options/option.go +++ b/api/options/option.go @@ -5,6 +5,8 @@ package options import ( "log" "os" + + "codeberg.org/snonux/gonf/internal/resource" ) // Option configures a resource. It is applied to the concrete resource value @@ -22,12 +24,31 @@ type ( Prunable interface{ SetPrune() } Absentable interface{ SetAbsent() } Latestable interface{ SetLatest() } + Dependable interface{ AddDependency(id string) } Linkable interface { SetSymlink(target string) SetHardlink(target string) } ) +// DependsOn declares that the resource being configured must be applied only +// after every given resource has been applied. Each argument may be a single +// resource or a Multi; a Multi is expanded so the dependency is recorded for +// each of its members individually. +func DependsOn(deps ...resource.Dependency) Option { + return func(t any) { + r, ok := t.(Dependable) + if !ok { + log.Fatalf("%T does not support DependsOn", t) + } + for _, dep := range deps { + for _, id := range dep.Dependencies() { + r.AddDependency(id) + } + } + } +} + // WithOwner sets the owning user of the resource. func WithOwner(owner string) Option { return func(t any) { diff --git a/api/resource.go b/api/resource.go index e254e1b..5c95211 100644 --- a/api/resource.go +++ b/api/resource.go @@ -10,6 +10,10 @@ import ( type Resource interface { ID() string String() string + // Dependencies returns the flattened resource IDs this value represents, + // so it can be passed to the DependsOn option. A single resource yields + // its own ID; a Multi yields the IDs of all its members. + Dependencies() []string } func Apply() error { diff --git a/examples/examples.go b/examples/examples.go index 7bbfa46..dee9984 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -67,10 +67,10 @@ func Run() error { _ = os.Link("/tmp/gonf_hello.txt", "/tmp/gonf_stale_hardlink") Link("/tmp/gonf_stale_hardlink", IsAbsent) - // 13. Package management - Package("tig") // Ensure installed (Present) - Package("vim", IsLatest) // Ensure installed and latest version - NoPackage("nano") // Ensure absent + // // 13. Package management + // Package("tig") // Ensure installed (Present) + // Package("vim", IsLatest) // Ensure installed and latest version + // NoPackage("nano") // Ensure absent // 14. Multi-resource declarations // Create multiple files with the same options @@ -86,17 +86,17 @@ func Run() error { ), 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", - )) + // Package(Elems( + // "htop", + // "curl", + // "wget", + // ), IsLatest) + + // // Remove multiple packages + // NoPackage(Elems( + // "old-pkg1", + // "old-pkg2", + // )) // Remove multiple files NoFile(Elems( @@ -104,5 +104,22 @@ func Run() error { "/tmp/stale2.txt", )) + // 15. Ordering with DependsOn. bar.txt is only applied after foo.txt. + fooRes := File("/tmp/gonf_foo.txt", WithContent("foo")) + File("/tmp/gonf_bar.txt", WithContent("bar"), DependsOn(fooRes)) + + // DependsOn also accepts multi-resources; the dependent then waits for + // every individual member of the multi. + multiRes := File(Elems( + "/tmp/gonf_dep1.txt", + "/tmp/gonf_dep2.txt", + ), WithContent("dep")) + File("/tmp/gonf_after_multi.txt", WithContent("after"), DependsOn(multiRes)) + + // DependsOn works across resource types and accepts several resources at + // once: this directory is created only after both the file and the multi + // above have been applied. + Dir("/tmp/gonf_after_dir", DependsOn(fooRes, multiRes)) + return Apply() } 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 @@ -47,6 +47,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) { 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() } -- cgit v1.2.3