Files
nmon/internal/api/groups.go

317 lines
11 KiB
Go

package api
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
// GroupSummary is a group in a list.
type GroupSummary struct {
GroupView
MemberCount int `json:"member_count" doc:"Interfaces in the group now."`
}
// GroupList is every group, by name.
type GroupList struct {
Items []GroupSummary `json:"items"`
}
// GroupMemberIface names a member interface and its device.
type GroupMemberIface struct {
ID string `json:"id"`
IfName string `json:"ifname"`
IfAlias string `json:"ifalias"`
Device DeviceRef `json:"device"`
}
// GroupMemberView is one membership: an interface in force for [effective_from, effective_to).
type GroupMemberView struct {
Interface GroupMemberIface `json:"interface"`
Direction string `json:"direction" enum:"toward,away" doc:"toward: the port's in is traffic toward the group's subject; away: it is traffic away, so in and out swap in the sum."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
EffectiveFrom time.Time `json:"effective_from"`
EffectiveTo *time.Time `json:"effective_to" nullable:"true" doc:"Null while the membership is open."`
}
// GroupFlag is a warning about a group's membership.
type GroupFlag struct {
Kind string `json:"kind" enum:"both_ends_of_link" doc:"both_ends_of_link: the group holds both ends of one link and would count it twice."`
A string `json:"a" doc:"First interface id."`
B string `json:"b" doc:"Second interface id."`
}
// GroupDetail is a group with its membership history.
type GroupDetail struct {
GroupSummary
Members []GroupMemberView `json:"members" doc:"Every membership, past and present, ordered by interface and start."`
Flags []GroupFlag `json:"flags"`
}
// GroupBody is the body of POST and PUT /groups.
type GroupBody struct {
Name string `json:"name" minLength:"1" maxLength:"100"`
Kind string `json:"kind" enum:"site,customer,role,custom" default:"custom"`
}
// GroupMemberBody is the body of POST /groups/{id}/members.
type GroupMemberBody struct {
InterfaceID string `json:"interface_id" minLength:"1"`
Direction string `json:"direction" enum:"toward,away"`
EffectiveFrom *time.Time `json:"effective_from,omitempty" doc:"Default now. A past date applies the membership to history."`
}
func (s *server) registerGroups(api huma.API) {
tags := []string{"groups"}
huma.Register(api, huma.Operation{
OperationID: "list-groups", Method: http.MethodGet, Path: "/api/v1/groups", Tags: tags,
Summary: "List groups",
}, s.listGroups)
huma.Register(api, huma.Operation{
OperationID: "create-group", Method: http.MethodPost, Path: "/api/v1/groups", Tags: tags, DefaultStatus: http.StatusCreated,
Summary: "Create a group", Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity},
}, s.createGroup)
huma.Register(api, huma.Operation{
OperationID: "get-group", Method: http.MethodGet, Path: "/api/v1/groups/{id}", Tags: tags,
Summary: "A group with its membership history and warnings", Errors: []int{http.StatusNotFound},
}, s.getGroup)
huma.Register(api, huma.Operation{
OperationID: "update-group", Method: http.MethodPut, Path: "/api/v1/groups/{id}", Tags: tags,
Summary: "Rename a group or change its kind", Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
}, s.updateGroup)
huma.Register(api, huma.Operation{
OperationID: "delete-group", Method: http.MethodDelete, Path: "/api/v1/groups/{id}", Tags: tags, DefaultStatus: http.StatusNoContent,
Summary: "Delete a group and its membership history",
Description: "A group named by a maintenance window cannot be deleted.", Errors: []int{http.StatusNotFound, http.StatusConflict},
}, s.deleteGroup)
huma.Register(api, huma.Operation{
OperationID: "add-group-member", Method: http.MethodPost, Path: "/api/v1/groups/{id}/members", Tags: tags,
Summary: "Add an interface to a group", DefaultStatus: http.StatusCreated,
Description: "Opens a membership at effective_from (default now). A port that changes direction is ended and added again, " +
"so the history of the sums never changes.",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
}, s.addGroupMember)
huma.Register(api, huma.Operation{
OperationID: "end-group-member", Method: http.MethodDelete, Path: "/api/v1/groups/{id}/members", Tags: tags,
Summary: "End an interface's open membership", DefaultStatus: http.StatusNoContent,
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
}, s.endGroupMember)
}
func summaryOf(now time.Time, g store.Group, members []store.GroupMember) GroupSummary {
n := 0
for _, m := range members {
if m.GroupID == g.ID && m.Covers(now) {
n++
}
}
return GroupSummary{GroupView: GroupView{ID: strconv.FormatInt(g.ID, 10), Name: g.Name, Kind: g.Kind}, MemberCount: n}
}
func (s *server) listGroups(ctx context.Context, _ *struct{}) (*struct{ Body GroupList }, error) {
gs, err := s.Store.ListGroups(ctx)
if err != nil {
return nil, s.storeErr(err)
}
members, err := s.Store.GroupMembers(ctx, 0, time.Time{}, time.Time{})
if err != nil {
return nil, s.storeErr(err)
}
out := GroupList{Items: make([]GroupSummary, 0, len(gs))}
for _, g := range gs {
out.Items = append(out.Items, summaryOf(s.Clock.Now(), g, members))
}
return &struct{ Body GroupList }{out}, nil
}
// groupBody validates name and kind.
func groupBody(b GroupBody) (store.Group, error) {
g := store.Group{Name: strings.TrimSpace(b.Name), Kind: b.Kind}
if g.Kind == "" {
g.Kind = store.GroupCustom
}
if g.Name == "" {
return g, huma.Error422UnprocessableEntity("name must not be empty")
}
return g, nil
}
func (s *server) createGroup(ctx context.Context, in *struct{ Body GroupBody }) (*struct{ Body GroupSummary }, error) {
g, err := groupBody(in.Body)
if err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.CreateGroup(ctx, &g); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "group.create", "group:"+strconv.FormatInt(g.ID, 10), nil, in.Body)
})
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body GroupSummary }{summaryOf(s.Clock.Now(), g, nil)}, nil
}
func (s *server) getGroup(ctx context.Context, in *struct {
ID string `path:"id" doc:"Group id."`
}) (*struct{ Body GroupDetail }, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
ref, err := s.Store.GetGroup(ctx, id)
if err != nil {
return nil, s.storeErr(err)
}
members, err := s.Store.GroupMembers(ctx, id, time.Time{}, time.Time{})
if err != nil {
return nil, s.storeErr(err)
}
d := GroupDetail{GroupSummary: summaryOf(s.Clock.Now(), store.Group{ID: ref.ID, Name: ref.Name, Kind: ref.Kind}, members),
Members: make([]GroupMemberView, 0, len(members)), Flags: []GroupFlag{}}
for _, m := range members {
d.Members = append(d.Members, s.memberView(ctx, m))
}
d.Flags = s.groupFlags(ctx, id)
return &struct{ Body GroupDetail }{d}, nil
}
func (s *server) memberView(ctx context.Context, m store.GroupMember) GroupMemberView {
v := GroupMemberView{
Interface: GroupMemberIface{ID: m.InterfaceID}, Direction: m.Direction, EffectiveFrom: m.EffectiveFrom.UTC(),
EffectiveTo: utcp(m.EffectiveTo),
}
if i, err := s.Store.GetInterface(ctx, m.InterfaceID); err == nil {
v.Interface.IfName, v.Interface.IfAlias = i.IfName, i.IfAlias
if d, err := s.Store.GetDeviceByID(ctx, i.DeviceID); err == nil {
v.Interface.Device = DeviceRef{Slug: d.Slug, Name: d.Name}
}
}
return v
}
// groupFlags are the membership warnings; without the traffic service there are none to show.
func (s *server) groupFlags(ctx context.Context, id int64) []GroupFlag {
out := []GroupFlag{}
if s.Groups == nil {
return out
}
fs, err := s.Groups.Flags(ctx, id)
if err != nil {
s.Logger.Warn("group flags", "group", id, "err", err)
return out
}
for _, f := range fs {
out = append(out, GroupFlag{Kind: f.Kind, A: f.A, B: f.B})
}
return out
}
func (s *server) updateGroup(ctx context.Context, in *struct {
ID string `path:"id" doc:"Group id."`
Body GroupBody
}) (*struct{ Body GroupSummary }, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
g, err := groupBody(in.Body)
if err != nil {
return nil, err
}
g.ID = id
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.UpdateGroup(ctx, g); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "group.update", "group:"+in.ID, nil, in.Body)
})
if err != nil {
return nil, s.storeErr(err)
}
members, err := s.Store.GroupMembers(ctx, id, time.Time{}, time.Time{})
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body GroupSummary }{summaryOf(s.Clock.Now(), g, members)}, nil
}
func (s *server) deleteGroup(ctx context.Context, in *struct {
ID string `path:"id" doc:"Group id."`
}) (*struct{}, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.DeleteGroup(ctx, id); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "group.delete", "group:"+in.ID, nil, nil)
})
if err != nil {
return nil, s.storeErr(err)
}
return &struct{}{}, nil
}
func (s *server) addGroupMember(ctx context.Context, in *struct {
ID string `path:"id" doc:"Group id."`
Body GroupMemberBody
}) (*struct{ Body GroupMemberView }, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
m := store.GroupMember{GroupID: id, InterfaceID: in.Body.InterfaceID, Direction: in.Body.Direction}
if in.Body.EffectiveFrom != nil {
m.EffectiveFrom = in.Body.EffectiveFrom.UTC()
} else {
m.EffectiveFrom = s.Clock.Now().UTC()
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if _, err := tx.GetGroup(ctx, id); err != nil {
return err
}
if err := tx.AddGroupMember(ctx, m); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "group.member.add", "group:"+in.ID, nil, in.Body)
})
if err != nil {
return nil, s.storeErr(err)
}
return &struct{ Body GroupMemberView }{s.memberView(ctx, m)}, nil
}
func (s *server) endGroupMember(ctx context.Context, in *struct {
ID string `path:"id" doc:"Group id."`
InterfaceID string `query:"interface_id" required:"true" doc:"Interface whose open membership ends."`
At time.Time `query:"at" doc:"End instant, RFC 3339. Default now."`
}) (*struct{}, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
at := in.At.UTC()
if in.At.IsZero() {
at = time.Time{}
}
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if err := tx.EndGroupMember(ctx, id, in.InterfaceID, at); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "group.member.end", "group:"+in.ID, nil, map[string]string{"interface_id": in.InterfaceID})
})
if err != nil {
return nil, s.storeErr(err)
}
return &struct{}{}, nil
}