Files
nmon/internal/api/checks.go

278 lines
9.1 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/checks"
"github.com/v10networks/nmon/internal/store"
)
// CheckLast is the latest outcome of a check since nmon started.
type CheckLast struct {
At time.Time `json:"at"`
Status string `json:"status" enum:"up,down,unsupported"`
LatencySeconds *float64 `json:"latency_seconds" nullable:"true"`
TLSDaysLeft *float64 `json:"tls_days_left" nullable:"true" doc:"Days until the certificate expires; negative once expired."`
Error string `json:"error,omitempty"`
}
// CheckView is a service check and its latest result.
type CheckView struct {
ID string `json:"id"`
Kind string `json:"kind" enum:"ping,http,dns,tls"`
Target string `json:"target" doc:"Address (ping), URL (http), name (dns) or host:port (tls)."`
IntervalS int64 `json:"interval_s"`
Enabled bool `json:"enabled"`
Device DeviceRef `json:"device" doc:"The device the results belong to."`
Params checks.Params `json:"params"`
Last *CheckLast `json:"last,omitempty" doc:"Absent until the check has run in this process."`
}
// CheckList is every check.
type CheckList struct {
Items []CheckView `json:"items"`
}
// CheckBody is the body of POST and PUT /checks.
type CheckBody struct {
Kind string `json:"kind" enum:"ping,http,dns,tls"`
Target string `json:"target" minLength:"1"`
IntervalS int64 `json:"interval_s,omitempty" minimum:"0" doc:"Seconds between runs; 0 or absent means 60. At least 10."`
Device string `json:"device,omitempty" doc:"Slug of the device the results belong to. Absent: a check_only device called name is created."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Name string `json:"name,omitempty" doc:"Name of the check_only device created when device is absent."`
Params checks.Params `json:"params,omitempty"`
Enabled *bool `json:"enabled,omitempty" doc:"Default true."`
}
func (s *server) registerChecks(api huma.API) {
tags := []string{"checks"}
huma.Register(api, huma.Operation{
OperationID: "list-checks", Method: http.MethodGet, Path: "/api/v1/checks", Tags: tags,
Summary: "List service checks with their latest result",
}, s.listChecks)
huma.Register(api, huma.Operation{
OperationID: "create-check", Method: http.MethodPost, Path: "/api/v1/checks", Tags: tags, DefaultStatus: http.StatusCreated,
Summary: "Create a service check",
Description: "Ping, HTTP, DNS or TLS-expiry. The check belongs to a device; without device a check_only device is created " +
"from name. Targets must pass the outbound allow-list when they run.",
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
}, s.createCheck)
huma.Register(api, huma.Operation{
OperationID: "update-check", Method: http.MethodPut, Path: "/api/v1/checks/{id}", Tags: tags,
Summary: "Replace a service check's definition", Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
}, s.updateCheck)
huma.Register(api, huma.Operation{
OperationID: "delete-check", Method: http.MethodDelete, Path: "/api/v1/checks/{id}", Tags: tags, DefaultStatus: http.StatusNoContent,
Summary: "Delete a service check", Errors: []int{http.StatusNotFound},
}, s.deleteCheck)
}
// checkResults indexes the latest results by check id.
func (s *server) checkResults() map[int64]checks.LastResult {
out := map[int64]checks.LastResult{}
if s.Checks == nil {
return out
}
for _, r := range s.Checks.Results() {
out[r.CheckID] = r
}
return out
}
func lastOf(r checks.LastResult, ok bool) *CheckLast {
if !ok {
return nil
}
l := &CheckLast{At: r.At.UTC(), Error: r.Result.Err, Status: "down"}
switch r.Result.Probe {
case checks.ProbeOK:
l.Status = "up"
case checks.ProbeUnsupported:
l.Status = "unsupported"
}
if r.Result.Probe == checks.ProbeOK {
sec := r.Result.Latency.Seconds()
l.LatencySeconds = &sec
}
if r.Result.HasTLSExpiry {
d := r.Result.TLSExpiry.Hours() / 24
l.TLSDaysLeft = &d
}
return l
}
func (s *server) checkView(ctx context.Context, c *store.Check, res map[int64]checks.LastResult) CheckView {
v := CheckView{
ID: strconv.FormatInt(c.ID, 10), Kind: c.Kind, Target: c.Target, IntervalS: c.IntervalS, Enabled: c.Enabled,
Device: DeviceRef{},
}
v.Params, _ = checks.ParseParams(c.Params) // a stored row was validated on the way in
if c.DeviceID != nil {
if d, err := s.Store.GetDeviceByID(ctx, *c.DeviceID); err == nil {
v.Device = DeviceRef{Slug: d.Slug, Name: d.Name}
}
}
r, ok := res[c.ID]
v.Last = lastOf(r, ok)
return v
}
func (s *server) listChecks(ctx context.Context, _ *struct{}) (*struct{ Body CheckList }, error) {
rows, err := s.Store.ListChecks(ctx, true)
if err != nil {
return nil, s.storeErr(err)
}
res := s.checkResults()
out := CheckList{Items: make([]CheckView, 0, len(rows))}
for i := range rows {
out.Items = append(out.Items, s.checkView(ctx, &rows[i], res))
}
return &struct{ Body CheckList }{out}, nil
}
// checkRow validates the body and builds the row, creating the check_only device when asked (inside tx).
func (s *server) checkRow(ctx context.Context, tx store.Store, b CheckBody) (*store.Check, error) {
params, err := encodeParams(b.Params)
if err != nil {
return nil, huma.Error422UnprocessableEntity(err.Error())
}
c := &store.Check{Kind: b.Kind, Target: strings.TrimSpace(b.Target), IntervalS: b.IntervalS, Params: params, Enabled: true}
if c.IntervalS == 0 {
c.IntervalS = checks.DefaultIntervalS
}
if b.Enabled != nil {
c.Enabled = *b.Enabled
}
if c.IntervalS < store.MinCheckIntervalS {
return nil, huma.Error422UnprocessableEntity("interval_s must be at least " + strconv.Itoa(store.MinCheckIntervalS))
}
if err := checks.Validate(*c); err != nil {
return nil, huma.Error422UnprocessableEntity(err.Error())
}
d, err := s.checkDevice(ctx, tx, b)
if err != nil {
return nil, err
}
c.DeviceID = &d.ID
return c, nil
}
// checkDevice resolves the body's device, or creates the check_only one.
func (s *server) checkDevice(ctx context.Context, tx store.Store, b CheckBody) (*store.Device, error) {
if b.Device != "" {
d, err := tx.GetDeviceBySlug(ctx, b.Device)
if err != nil || d.RetiredAt != nil || d.PurgedAt != nil {
if err != nil && !isNotFound(err) {
return nil, s.storeErr(err)
}
return nil, huma.Error422UnprocessableEntity("device: no active device with slug " + quote(b.Device))
}
return d, nil
}
name := strings.TrimSpace(b.Name)
if name == "" {
return nil, huma.Error422UnprocessableEntity("give device (a slug) or name (for a new check_only device)")
}
d := &store.Device{Name: name, Kind: store.KindCheckOnly, Enabled: true, Profile: "generic"}
if err := tx.CreateDevice(ctx, d); err != nil {
return nil, s.storeErr(err)
}
return d, nil
}
func (s *server) createCheck(ctx context.Context, in *struct{ Body CheckBody }) (*struct{ Body CheckView }, error) {
var row *store.Check
err := s.Store.WithTx(ctx, func(tx store.Store) error {
c, err := s.checkRow(ctx, tx, in.Body)
if err != nil {
return err
}
if err := tx.CreateCheck(ctx, c); err != nil {
return err
}
row = c
return tx.AppendAudit(ctx, actorOf(ctx), "check.create", "check:"+strconv.FormatInt(c.ID, 10), nil, in.Body)
})
if err != nil {
return nil, s.txErr(err)
}
s.inventoryChanged(ctx)
return &struct{ Body CheckView }{s.checkView(ctx, row, s.checkResults())}, nil
}
func (s *server) updateCheck(ctx context.Context, in *struct {
ID string `path:"id" doc:"Check id."`
Body CheckBody
}) (*struct{ Body CheckView }, error) {
id, err := idParam(in.ID)
if err != nil {
return nil, err
}
var row *store.Check
err = s.Store.WithTx(ctx, func(tx store.Store) error {
if _, err := tx.GetCheck(ctx, id); err != nil {
return err
}
c, err := s.checkRow(ctx, tx, in.Body)
if err != nil {
return err
}
c.ID = id
if err := tx.UpdateCheck(ctx, c); err != nil {
return err
}
row = c
return tx.AppendAudit(ctx, actorOf(ctx), "check.update", "check:"+in.ID, nil, in.Body)
})
if err != nil {
return nil, s.txErr(err)
}
s.inventoryChanged(ctx)
return &struct{ Body CheckView }{s.checkView(ctx, row, s.checkResults())}, nil
}
func (s *server) deleteCheck(ctx context.Context, in *struct {
ID string `path:"id" doc:"Check 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.DeleteCheck(ctx, id); err != nil {
return err
}
return tx.AppendAudit(ctx, actorOf(ctx), "check.delete", "check:"+in.ID, nil, nil)
})
if err != nil {
return nil, s.txErr(err)
}
return &struct{}{}, nil
}
// txErr passes problems built inside a transaction through and maps store errors.
func (s *server) txErr(err error) error {
var se huma.StatusError
if errors.As(err, &se) {
return se
}
return s.storeErr(err)
}
// encodeParams is the stored form of p: JSON, or empty when every setting is the default.
func encodeParams(p checks.Params) (string, error) {
if p == (checks.Params{}) {
return "", nil
}
b, err := json.Marshal(p)
return string(b), err
}