Files
nmon/internal/api/parents.go

104 lines
3.2 KiB
Go

package api
import (
"context"
"errors"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/deps"
"github.com/v10networks/nmon/internal/store"
)
// DependencyGraph is the device dependency graph the API consults before it
// writes parents. In nmon serve it is the reachability evaluator's graph, so
// the cycle check and the suppression logic see the same edges.
type DependencyGraph interface {
// Check returns an error wrapping *deps.CycleError when making the listed
// slugs the network parents of each key slug would create a cycle.
Check(changes map[string][]string) error
// Refresh reloads the graph after a write.
Refresh(ctx context.Context) error
}
// storeGraph is the default DependencyGraph: it reads the edges from the store on every check.
type storeGraph struct{ src deps.Source }
func (g storeGraph) Check(changes map[string][]string) error {
gr := deps.New()
if err := gr.Load(context.Background(), g.src); err != nil {
return err
}
return gr.Check(changes)
}
func (g storeGraph) Refresh(context.Context) error { return nil }
// parentCycle returns the path dev -> parent -> ... -> dev that making parents
// the parents of dev would close, or nil when the graph stays acyclic. Only
// network parents count: hosted_on is not part of the dependency graph, so for
// a VM or container only a device hosted on itself is a cycle.
func (s *server) parentCycle(dev *store.Device, parents []*store.Device) ([]string, error) {
slugs := make([]string, len(parents))
for i, p := range parents {
slugs[i] = p.Slug
if p.ID == dev.ID {
return []string{dev.Slug, dev.Slug}, nil
}
}
if dev.Kind == store.KindVM || dev.Kind == store.KindContainer {
return nil, nil
}
err := s.Dependencies.Check(map[string][]string{dev.Slug: slugs})
var ce *deps.CycleError
switch {
case err == nil:
return nil, nil
case errors.As(err, &ce):
return rotateTo(ce.Path, dev.Slug), nil
}
return nil, err
}
// rotateTo turns a closed path (it ends where it starts) so that it starts at slug.
func rotateTo(path []string, slug string) []string {
loop := path[:len(path)-1]
for i, n := range loop {
if n == slug {
out := append(append([]string{}, loop[i:]...), loop[:i]...)
return append(out, slug)
}
}
return path
}
// resolveParents maps parent slugs to unretired devices, rejecting unknown
// slugs, duplicates and any cycle with a 422 that names the path.
func (s *server) resolveParents(ctx context.Context, dev *store.Device, slugs []string) ([]*store.Device, error) {
seen := map[string]bool{}
var out []*store.Device
for _, slug := range slugs {
if seen[slug] {
continue
}
seen[slug] = true
p, err := s.Store.GetDeviceBySlug(ctx, slug)
if err != nil || p.PurgedAt != nil || p.RetiredAt != nil {
if err != nil && !isNotFound(err) {
return nil, s.storeErr(err)
}
return nil, huma.Error422UnprocessableEntity("parents: no active device with slug " + quote(slug))
}
out = append(out, p)
}
cyc, err := s.parentCycle(dev, out)
if err != nil {
return nil, s.storeErr(err)
}
if cyc != nil {
return nil, huma.Error422UnprocessableEntity("parents would create a dependency cycle: " + strings.Join(cyc, " -> "))
}
return out, nil
}