Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
104 lines
3.2 KiB
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
|
|
}
|