refactor: Phase 3a — absorb checkdefaults into core/app as pure Derive
Problem: check derivation logic lived in internal/checkdefaults with the pure decision logic (buildKind, address/user/port resolution) interleaved with tx I/O (entity_status insert, graph host fallback, check upserts) — and internal/db importing it was the plan's called-out inverted dependency. Change: - internal/core/app/checkdefaults.go: Derive(tree, target, lookup) — the full derivation (monitoring overrides, host fallback via an injected HostLookup thunk, per-kind builders) with zero I/O imports. Types renamed for the app surface: CheckTarget, CheckDef, DeriveResult, Skip; LogDeriveResult. - internal/adapters/postgres/checks.go absorbs the I/O half: EnsureChecks (entity_status row + upsert loop), writeCheck, and hostViaGraph. The db→checkdefaults edge is gone — adapters→core is the ADR 0016 direction (the Phase 7 SeedService note anticipated this; the inversion is fixed a phase early). - seed.go pending-checks loop uses app.CheckTarget + EnsureChecks; mcp formatting/tests follow the renamed types; both test files moved to internal/core/app. - Deliberate behavior note: a hostViaGraph read failure inside the thunk now logs a warning and degrades to 'skipped: no address' instead of aborting the whole entity-create tx — a monitoring derivation gap is visible (warn log + coverage sweep) and self-heals on the next mutation; failing the create over a graph-read blip was disproportionate. Verification: go build/vet, full test suite green (app tests exercise every buildKind branch at their new home).
This commit is contained in:
@@ -2,8 +2,11 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -18,17 +21,187 @@ import (
|
|||||||
// regardless of which surface made the change — previously only the HTTP
|
// regardless of which surface made the change — previously only the HTTP
|
||||||
// path ran check derivation, so entities mutated via MCP silently produced no
|
// path ran check derivation, so entities mutated via MCP silently produced no
|
||||||
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
|
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
|
||||||
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
|
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (app.DeriveResult, error) {
|
||||||
tree, err := LoadTypeTree(ctx, tx)
|
tree, err := LoadTypeTree(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return checkdefaults.Result{}, err
|
return app.DeriveResult{}, err
|
||||||
}
|
}
|
||||||
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
|
res, err := EnsureChecks(ctx, tx, tree, app.CheckTarget{
|
||||||
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
ID: id.String(), Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
checkdefaults.LogResult(slug, entityType, res)
|
app.LogDeriveResult(slug, entityType, res)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureChecks writes the derived check_defs for one entity, idempotently.
|
||||||
|
// Derivation is pure core logic (app.Derive); this function owns the
|
||||||
|
// entity_status row, the graph host fallback, and the upserts.
|
||||||
|
func EnsureChecks(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t app.CheckTarget) (app.DeriveResult, error) {
|
||||||
|
var res app.DeriveResult
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_status (entity_id, health, updated_at)
|
||||||
|
VALUES ($1, 'unknown', now())
|
||||||
|
ON CONFLICT (entity_id) DO NOTHING`, t.ID); err != nil {
|
||||||
|
return res, fmt.Errorf("entity_status %s: %w", t.Slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defs, dres := app.Derive(tree, t, func() map[string]any {
|
||||||
|
attrs, err := hostViaGraph(ctx, tx, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
})
|
||||||
|
res.Skipped, res.Undeclared = dres.Skipped, dres.Undeclared
|
||||||
|
|
||||||
|
for i, def := range defs {
|
||||||
|
created, err := writeCheck(ctx, tx, t, i, def)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("check %s/%s: %w", t.Slug, def.Kind, err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCheck upserts one check_def and its backing check entity.
|
||||||
|
//
|
||||||
|
// The entity upsert MUST return the row's id. The previous version generated
|
||||||
|
// a fresh uuid, inserted ON CONFLICT (slug) DO NOTHING, then wrote a
|
||||||
|
// check_defs row referencing that uuid. On any re-seed the slug already
|
||||||
|
// existed, the entity insert became a no-op, and the check_defs insert
|
||||||
|
// violated its foreign key — which aborted the whole ingest transaction and
|
||||||
|
// made every subsequent statement fail with 25P02. Because the errors were
|
||||||
|
// discarded, the only visible symptom was an unrelated failure much later.
|
||||||
|
func writeCheck(ctx context.Context, tx pgx.Tx, t app.CheckTarget, idx int, def app.CheckDef) (bool, error) {
|
||||||
|
// The full target slug, not a truncation of it. shortSlug() took the last
|
||||||
|
// 8 characters, so all 21 ingress routes collapsed to ".network" and
|
||||||
|
// generated one identical check slug — they overwrote each other and 20
|
||||||
|
// of them ended up with no check at all. It also collided service:jellyfin
|
||||||
|
// with lxc:jellyfin. Entity slugs are unique; use them.
|
||||||
|
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, t.Slug, idx)
|
||||||
|
|
||||||
|
newID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
newID = uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
newID, checkSlug).Scan(&checkID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert check entity %s: %w", checkSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := json.Marshal(def.Config)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is derived from the seed, so the seed wins on re-ingest and
|
||||||
|
// attribute changes propagate. `enabled` is deliberately left alone: it
|
||||||
|
// is operational state an operator may have toggled.
|
||||||
|
// last_run_at is seeded to a random point inside the interval so checks
|
||||||
|
// created together do not stay in lockstep. Every check the seed creates
|
||||||
|
// would otherwise come due in the same instant forever: ~165 probes
|
||||||
|
// landing at once each minute rather than spread across it. Deliberately
|
||||||
|
// absent from the DO UPDATE below — a re-seed must not reset the schedule
|
||||||
|
// and re-herd everything.
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, enabled, last_run_at)
|
||||||
|
VALUES ($1, $2, $6, $3, $4, $5, 30, true,
|
||||||
|
now() - make_interval(secs => random() * $5::int))
|
||||||
|
ON CONFLICT (entity_id) DO UPDATE
|
||||||
|
SET target_id = EXCLUDED.target_id, target_type = EXCLUDED.target_type,
|
||||||
|
kind = EXCLUDED.kind,
|
||||||
|
config = EXCLUDED.config, interval_s = EXCLUDED.interval_s,
|
||||||
|
updated_at = now()`,
|
||||||
|
checkID, t.ID, def.Kind, configJSON, def.IntervalS, t.Type)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("upsert check_def %s: %w", checkSlug, err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostViaGraph returns the attributes of the entity that hosts or provides
|
||||||
|
// this one, so a service can inherit its container's address.
|
||||||
|
func hostViaGraph(ctx context.Context, tx pgx.Tx, entityID string) (map[string]any, error) {
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT e.attributes
|
||||||
|
FROM relationships r
|
||||||
|
JOIN entities e ON e.id = r.source_id
|
||||||
|
WHERE r.target_id = $1
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
-- backs-up-to points from the thing being backed up TO the target,
|
||||||
|
-- so walking it backwards finds the machine that writes the backups
|
||||||
|
-- — which is the only place a freshness check can run.
|
||||||
|
AND r.type IN ('provides', 'hosts', 'runs-on', 'backs-up-to')
|
||||||
|
ORDER BY CASE r.type
|
||||||
|
WHEN 'provides' THEN 0 WHEN 'runs-on' THEN 1
|
||||||
|
WHEN 'backs-up-to' THEN 2 ELSE 3 END`,
|
||||||
|
entityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if json.Unmarshal(raw, &attrs) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resolveGraphHost(attrs) != "" {
|
||||||
|
return attrs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGraphHost mirrors app's address resolution for graph-walk results.
|
||||||
|
// It re-implements the small pure helper rather than exporting internals of
|
||||||
|
// the core package: the shapes it accepts are exactly the seed attribute
|
||||||
|
// shapes hostViaGraph can return.
|
||||||
|
func resolveGraphHost(attrs map[string]any) string {
|
||||||
|
if attrs == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if ip, ok := attrs["public_ipv4"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if mesh, ok := attrs["mesh"].(map[string]any); ok {
|
||||||
|
if nb, ok := mesh["netbird"].(map[string]any); ok {
|
||||||
|
if ip, ok := nb["ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if fqdn, ok := nb["fqdn"].(string); ok && fqdn != "" {
|
||||||
|
return fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip, ok := attrs["mesh_ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
for _, key := range []string{"host", "address", "public_host"} {
|
||||||
|
if v, ok := attrs[key].(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -105,7 +105,7 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
// Entities
|
// Entities
|
||||||
entities, _ := data["entities"].([]any)
|
entities, _ := data["entities"].([]any)
|
||||||
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
entityTypes := make(map[string]string) // slug -> type, for edge validation
|
||||||
var pendingChecks []checkdefaults.Target
|
var pendingChecks []app.CheckTarget
|
||||||
for _, raw := range entities {
|
for _, raw := range entities {
|
||||||
eMap, ok := raw.(map[string]any)
|
eMap, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -157,8 +157,8 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
// Default checks are deferred until after relationships are ingested:
|
// Default checks are deferred until after relationships are ingested:
|
||||||
// a service has no address of its own and inherits its container's,
|
// a service has no address of its own and inherits its container's,
|
||||||
// which means the hosting edge has to exist first.
|
// which means the hosting edge has to exist first.
|
||||||
pendingChecks = append(pendingChecks, checkdefaults.Target{
|
pendingChecks = append(pendingChecks, app.CheckTarget{
|
||||||
ID: entityID, Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes,
|
ID: entityID.String(), Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes,
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Entities++
|
r.Entities++
|
||||||
@@ -228,11 +228,11 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
// transaction while surfacing as an unrelated failure several entities
|
// transaction while surfacing as an unrelated failure several entities
|
||||||
// later.
|
// later.
|
||||||
for _, target := range pendingChecks {
|
for _, target := range pendingChecks {
|
||||||
res, err := checkdefaults.Ensure(ctx, tx, tree, target)
|
res, err := EnsureChecks(ctx, tx, tree, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("default checks for %s: %w", target.Slug, err)
|
return nil, fmt.Errorf("default checks for %s: %w", target.Slug, err)
|
||||||
}
|
}
|
||||||
checkdefaults.LogResult(target.Slug, target.Type, res)
|
app.LogDeriveResult(target.Slug, target.Type, res)
|
||||||
r.Checks += res.Created
|
r.Checks += res.Created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
// Package checkdefaults derives an entity's default check_defs from the
|
package app
|
||||||
// monitoring kinds its type declares in seeds/ontology.yaml.
|
|
||||||
//
|
|
||||||
// The type says WHAT to watch (`service: [http, process]`); this package
|
|
||||||
// works out HOW — which concrete check_defs rows to write, and what host,
|
|
||||||
// script or URL each needs. Deriving config here rather than in YAML keeps
|
|
||||||
// the ontology declarative and keeps address resolution (which has to walk
|
|
||||||
// the graph) in code.
|
|
||||||
package checkdefaults
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/ontology"
|
"github.com/dtoro/oikos/internal/ontology"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Semantic monitoring kinds, as declared on entity types. These are not
|
// Semantic monitoring kinds, as declared on entity types. These are not
|
||||||
@@ -42,9 +30,9 @@ const (
|
|||||||
// override per target with `backup_max_age_s` in the entity's attributes.
|
// override per target with `backup_max_age_s` in the entity's attributes.
|
||||||
const defaultBackupMaxAge = 86400
|
const defaultBackupMaxAge = 86400
|
||||||
|
|
||||||
// Target is the entity default checks are being ensured for.
|
// CheckTarget is the entity default checks are being derived for.
|
||||||
type Target struct {
|
type CheckTarget struct {
|
||||||
ID uuid.UUID
|
ID string
|
||||||
Slug string
|
Slug string
|
||||||
Type string
|
Type string
|
||||||
// Name is the entity's name column, not an attribute. The old code read
|
// Name is the entity's name column, not an attribute. The old code read
|
||||||
@@ -55,9 +43,16 @@ type Target struct {
|
|||||||
Attrs []byte
|
Attrs []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result reports what Ensure did, so callers can log a type that declared
|
// CheckDef is one concrete derived check: kind, config payload, interval.
|
||||||
// monitoring but produced nothing instead of failing silently.
|
type CheckDef struct {
|
||||||
type Result struct {
|
Kind string
|
||||||
|
Config map[string]any
|
||||||
|
IntervalS int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveResult reports what Derive produced, so callers can log a type that
|
||||||
|
// declared monitoring but produced nothing instead of failing silently.
|
||||||
|
type DeriveResult struct {
|
||||||
Created int
|
Created int
|
||||||
// Skipped records kinds that were declared but could not be built, with
|
// Skipped records kinds that were declared but could not be built, with
|
||||||
// the reason. A non-empty Skipped on an active entity is a real gap.
|
// the reason. A non-empty Skipped on an active entity is a real gap.
|
||||||
@@ -73,35 +68,27 @@ type Skip struct {
|
|||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type checkDef struct {
|
// HostLookup resolves the hosting entity's attributes when the entity
|
||||||
kind string
|
// itself carries no address (a service lives on its container; a backup
|
||||||
config map[string]any
|
// target on whatever writes to it). It is invoked lazily — only when the
|
||||||
interval int32
|
// entity's own attributes lack a host — so the pure derivation below stays
|
||||||
}
|
// separated from the graph read the caller performs.
|
||||||
|
type HostLookup func() map[string]any
|
||||||
|
|
||||||
// Ensure writes the default check_defs for one entity, idempotently.
|
// Derive computes the default checks for one entity from its type's
|
||||||
//
|
// monitoring spec (with per-entity `monitoring` attribute overrides).
|
||||||
// Returns the number of checks created. An entity whose type declares
|
// lookup may be nil. It performs no I/O of its own; the caller's lookup
|
||||||
// monitoring it cannot satisfy comes back with a populated Skipped rather
|
// thunk may. The postgres adapter pairs this with writeCheck upserts.
|
||||||
// than an error — a missing address is a modelling gap, not a failure of
|
func Derive(tree *ontology.TypeTree, t CheckTarget, lookup HostLookup) ([]CheckDef, DeriveResult) {
|
||||||
// this call.
|
var res DeriveResult
|
||||||
func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) (Result, error) {
|
|
||||||
var res Result
|
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx,
|
|
||||||
`INSERT INTO entity_status (entity_id, health, updated_at)
|
|
||||||
VALUES ($1, 'unknown', now())
|
|
||||||
ON CONFLICT (entity_id) DO NOTHING`, t.ID); err != nil {
|
|
||||||
return res, fmt.Errorf("entity_status %s: %w", t.Slug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mon := tree.Monitoring(t.Type)
|
mon := tree.Monitoring(t.Type)
|
||||||
if !mon.Declared {
|
if !mon.Declared {
|
||||||
res.Undeclared = true
|
res.Undeclared = true
|
||||||
return res, nil
|
return nil, res
|
||||||
}
|
}
|
||||||
if mon.None() {
|
if mon.None() {
|
||||||
return res, nil
|
return nil, res
|
||||||
}
|
}
|
||||||
|
|
||||||
var attrs map[string]any
|
var attrs map[string]any
|
||||||
@@ -120,18 +107,15 @@ func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) (
|
|||||||
if mo, ok := attrs["monitoring"]; ok {
|
if mo, ok := attrs["monitoring"]; ok {
|
||||||
mon = resolveMonitoringAttr(mo, mon)
|
mon = resolveMonitoringAttr(mo, mon)
|
||||||
if mon.None() {
|
if mon.None() {
|
||||||
return res, nil
|
return nil, res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A service has no address of its own — it lives on the container that
|
// A service has no address of its own — it lives on the container that
|
||||||
// provides it. Fall back to the graph before giving up.
|
// provides it. Fall back to the graph before giving up.
|
||||||
host := resolveHost(attrs)
|
host := resolveHost(attrs)
|
||||||
if host == "" {
|
if host == "" && lookup != nil {
|
||||||
hostAttrs, err := hostViaGraph(ctx, tx, t.ID)
|
hostAttrs := lookup()
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("resolve host for %s: %w", t.Slug, err)
|
|
||||||
}
|
|
||||||
host = resolveHost(hostAttrs)
|
host = resolveHost(hostAttrs)
|
||||||
if user := resolveSSHUser(hostAttrs); host != "" && user != "root" {
|
if user := resolveSSHUser(hostAttrs); host != "" && user != "root" {
|
||||||
attrs["ssh"] = hostAttrs["ssh"]
|
attrs["ssh"] = hostAttrs["ssh"]
|
||||||
@@ -140,7 +124,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) (
|
|||||||
user := resolveSSHUser(attrs)
|
user := resolveSSHUser(attrs)
|
||||||
port := resolveSSHPort(attrs)
|
port := resolveSSHPort(attrs)
|
||||||
|
|
||||||
var defs []checkDef
|
var defs []CheckDef
|
||||||
for _, kind := range mon.Kinds {
|
for _, kind := range mon.Kinds {
|
||||||
built, reason := buildKind(kind, t, attrs, host, user, port)
|
built, reason := buildKind(kind, t, attrs, host, user, port)
|
||||||
if len(built) == 0 {
|
if len(built) == 0 {
|
||||||
@@ -149,17 +133,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) (
|
|||||||
}
|
}
|
||||||
defs = append(defs, built...)
|
defs = append(defs, built...)
|
||||||
}
|
}
|
||||||
|
return defs, res
|
||||||
for i, def := range defs {
|
|
||||||
created, err := writeCheck(ctx, tx, t, i, def)
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("check %s/%s: %w", t.Slug, def.kind, err)
|
|
||||||
}
|
|
||||||
if created {
|
|
||||||
res.Created++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMonitoringAttr turns an entity's `monitoring` attribute into a
|
// resolveMonitoringAttr turns an entity's `monitoring` attribute into a
|
||||||
@@ -183,10 +157,10 @@ func resolveMonitoringAttr(v any, fallback ontology.MonitoringResolution) ontolo
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildKind turns one declared semantic kind into concrete check_defs, or
|
// buildKind turns one declared semantic kind into concrete checks, or
|
||||||
// returns the reason it could not.
|
// returns the reason it could not.
|
||||||
func buildKind(kind string, t Target, attrs map[string]any, host, user string, port int) ([]checkDef, string) {
|
func buildKind(kind string, t CheckTarget, attrs map[string]any, host, user string, port int) ([]CheckDef, string) {
|
||||||
ssh := func(script string, args ...string) checkDef {
|
ssh := func(script string, args ...string) CheckDef {
|
||||||
cfg := map[string]any{"script": script, "host": host}
|
cfg := map[string]any{"script": script, "host": host}
|
||||||
if user != "" && user != "root" {
|
if user != "" && user != "root" {
|
||||||
cfg["user"] = user
|
cfg["user"] = user
|
||||||
@@ -197,7 +171,7 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if len(args) > 0 && args[0] != "" {
|
if len(args) > 0 && args[0] != "" {
|
||||||
cfg["args"] = args[0]
|
cfg["args"] = args[0]
|
||||||
}
|
}
|
||||||
return checkDef{kind: "ssh-script", config: cfg, interval: 60}
|
return CheckDef{Kind: "ssh-script", Config: cfg, IntervalS: 60}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -205,13 +179,13 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
return nil, "no address on the entity or its host"
|
return nil, "no address on the entity or its host"
|
||||||
}
|
}
|
||||||
return []checkDef{{kind: "ping", config: map[string]any{"host": host}, interval: 30}}, ""
|
return []CheckDef{{Kind: "ping", Config: map[string]any{"host": host}, IntervalS: 30}}, ""
|
||||||
|
|
||||||
case KindResource:
|
case KindResource:
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return nil, "no address on the entity or its host"
|
return nil, "no address on the entity or its host"
|
||||||
}
|
}
|
||||||
return []checkDef{
|
return []CheckDef{
|
||||||
ssh("cpu_check.sh"), ssh("memory_check.sh"),
|
ssh("cpu_check.sh"), ssh("memory_check.sh"),
|
||||||
ssh("load_check.sh"), ssh("disk_usage_check.sh"),
|
ssh("load_check.sh"), ssh("disk_usage_check.sh"),
|
||||||
}, ""
|
}, ""
|
||||||
@@ -225,14 +199,14 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
// mirror hits per machine per day to answer a question whose answer
|
// mirror hits per machine per day to answer a question whose answer
|
||||||
// changes about once a day.
|
// changes about once a day.
|
||||||
u := ssh("updates_check.sh")
|
u := ssh("updates_check.sh")
|
||||||
u.interval = 86400
|
u.IntervalS = 86400
|
||||||
return []checkDef{u}, ""
|
return []CheckDef{u}, ""
|
||||||
|
|
||||||
case KindCapacity:
|
case KindCapacity:
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return nil, "no address on the entity or its host"
|
return nil, "no address on the entity or its host"
|
||||||
}
|
}
|
||||||
return []checkDef{ssh("disk_usage_check.sh")}, ""
|
return []CheckDef{ssh("disk_usage_check.sh")}, ""
|
||||||
|
|
||||||
case KindProcess:
|
case KindProcess:
|
||||||
if host == "" {
|
if host == "" {
|
||||||
@@ -267,7 +241,7 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
}
|
}
|
||||||
// process_check.sh takes the unit/container name as $1 and reports
|
// process_check.sh takes the unit/container name as $1 and reports
|
||||||
// "unknown" without it.
|
// "unknown" without it.
|
||||||
return []checkDef{ssh("process_check.sh", unit)}, ""
|
return []CheckDef{ssh("process_check.sh", unit)}, ""
|
||||||
|
|
||||||
case KindBackup:
|
case KindBackup:
|
||||||
// A backup target is checked from the machine that writes to it, so it
|
// A backup target is checked from the machine that writes to it, so it
|
||||||
@@ -293,7 +267,7 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
}
|
}
|
||||||
// Daily. The freshness budget itself is a day, so probing more often
|
// Daily. The freshness budget itself is a day, so probing more often
|
||||||
// cannot surface anything sooner — it just costs an SSH round trip.
|
// cannot surface anything sooner — it just costs an SSH round trip.
|
||||||
return []checkDef{{kind: "backup-freshness", config: cfg, interval: 86400}}, ""
|
return []CheckDef{{Kind: "backup-freshness", Config: cfg, IntervalS: 86400}}, ""
|
||||||
|
|
||||||
case KindHTTP:
|
case KindHTTP:
|
||||||
url := httpURL(t, attrs)
|
url := httpURL(t, attrs)
|
||||||
@@ -302,10 +276,10 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
}
|
}
|
||||||
// max_status rather than an exact expected_status: most services sit
|
// max_status rather than an exact expected_status: most services sit
|
||||||
// behind Authentik and answer 302/401, which is a working service.
|
// behind Authentik and answer 302/401, which is a working service.
|
||||||
return []checkDef{{
|
return []CheckDef{{
|
||||||
kind: "http",
|
Kind: "http",
|
||||||
config: map[string]any{"url": url, "max_status": 500},
|
Config: map[string]any{"url": url, "max_status": 500},
|
||||||
interval: 60,
|
IntervalS: 60,
|
||||||
}}, ""
|
}}, ""
|
||||||
|
|
||||||
case KindDNS:
|
case KindDNS:
|
||||||
@@ -318,11 +292,11 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, "no name to resolve"
|
return nil, "no name to resolve"
|
||||||
}
|
}
|
||||||
return []checkDef{{
|
return []CheckDef{{
|
||||||
kind: "dns",
|
Kind: "dns",
|
||||||
config: map[string]any{"name": name},
|
Config: map[string]any{"name": name},
|
||||||
interval: 300, // 5 min — DNS changes are rare; the cost of a miss
|
IntervalS: 300, // 5 min — DNS changes are rare; the cost of a miss
|
||||||
// is a stale IP, not a service outage.
|
// is a stale IP, not a service outage.
|
||||||
}}, ""
|
}}, ""
|
||||||
|
|
||||||
case KindCertExpiry:
|
case KindCertExpiry:
|
||||||
@@ -343,10 +317,10 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if dial != "" {
|
if dial != "" {
|
||||||
config["dial"] = dial
|
config["dial"] = dial
|
||||||
}
|
}
|
||||||
return []checkDef{{
|
return []CheckDef{{
|
||||||
kind: "cert-expiry",
|
Kind: "cert-expiry",
|
||||||
config: config,
|
Config: config,
|
||||||
interval: 3600,
|
IntervalS: 3600,
|
||||||
}}, ""
|
}}, ""
|
||||||
|
|
||||||
case KindVMStatus:
|
case KindVMStatus:
|
||||||
@@ -356,10 +330,10 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if _, ok := attrs["pve_id"]; !ok {
|
if _, ok := attrs["pve_id"]; !ok {
|
||||||
return nil, "no pve_id to run qm status"
|
return nil, "no pve_id to run qm status"
|
||||||
}
|
}
|
||||||
return []checkDef{{
|
return []CheckDef{{
|
||||||
kind: "vm-status",
|
Kind: "vm-status",
|
||||||
config: map[string]any{},
|
Config: map[string]any{},
|
||||||
interval: 60,
|
IntervalS: 60,
|
||||||
}}, ""
|
}}, ""
|
||||||
|
|
||||||
case KindQuorum:
|
case KindQuorum:
|
||||||
@@ -369,14 +343,14 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
if host == "" {
|
if host == "" {
|
||||||
return nil, "no address on the entity or its host"
|
return nil, "no address on the entity or its host"
|
||||||
}
|
}
|
||||||
return []checkDef{ssh("pvecm_quorum_check.sh")}, ""
|
return []CheckDef{ssh("pvecm_quorum_check.sh")}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, "no builder for this kind yet"
|
return nil, "no builder for this kind yet"
|
||||||
}
|
}
|
||||||
|
|
||||||
// certHost works out the hostname to TLS-dial for a certificate's expiry.
|
// certHost works out the hostname to TLS-dial for a certificate's expiry.
|
||||||
func certHost(t Target, attrs map[string]any) string {
|
func certHost(t CheckTarget, attrs map[string]any) string {
|
||||||
for _, key := range []string{"hostname", "cn", "san"} {
|
for _, key := range []string{"hostname", "cn", "san"} {
|
||||||
if v, ok := attrs[key].(string); ok && v != "" {
|
if v, ok := attrs[key].(string); ok && v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -389,78 +363,15 @@ func certHost(t Target, attrs map[string]any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeCheck upserts one check_def and its backing check entity.
|
|
||||||
//
|
|
||||||
// The entity upsert MUST return the row's id. The previous version generated
|
|
||||||
// a fresh uuid, inserted ON CONFLICT (slug) DO NOTHING, then wrote a
|
|
||||||
// check_defs row referencing that uuid. On any re-seed the slug already
|
|
||||||
// existed, the entity insert became a no-op, and the check_defs insert
|
|
||||||
// violated its foreign key — which aborted the whole ingest transaction and
|
|
||||||
// made every subsequent statement fail with 25P02. Because the errors were
|
|
||||||
// discarded, the only visible symptom was an unrelated failure much later.
|
|
||||||
func writeCheck(ctx context.Context, tx pgx.Tx, t Target, idx int, def checkDef) (bool, error) {
|
|
||||||
// The full target slug, not a truncation of it. shortSlug() took the last
|
|
||||||
// 8 characters, so all 21 ingress routes collapsed to ".network" and
|
|
||||||
// generated one identical check slug — they overwrote each other and 20
|
|
||||||
// of them ended up with no check at all. It also collided service:jellyfin
|
|
||||||
// with lxc:jellyfin. Entity slugs are unique; use them.
|
|
||||||
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.kind, t.Slug, idx)
|
|
||||||
|
|
||||||
newID, err := uuid.NewV7()
|
|
||||||
if err != nil {
|
|
||||||
newID = uuid.New()
|
|
||||||
}
|
|
||||||
|
|
||||||
var checkID uuid.UUID
|
|
||||||
err = tx.QueryRow(ctx,
|
|
||||||
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
|
|
||||||
RETURNING id`,
|
|
||||||
newID, checkSlug).Scan(&checkID)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("upsert check entity %s: %w", checkSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configJSON, err := json.Marshal(def.config)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config is derived from the seed, so the seed wins on re-ingest and
|
|
||||||
// attribute changes propagate. `enabled` is deliberately left alone: it
|
|
||||||
// is operational state an operator may have toggled.
|
|
||||||
// last_run_at is seeded to a random point inside the interval so checks
|
|
||||||
// created together do not stay in lockstep. Every check the seed creates
|
|
||||||
// would otherwise come due in the same instant forever: ~165 probes
|
|
||||||
// landing at once each minute rather than spread across it. Deliberately
|
|
||||||
// absent from the DO UPDATE below — a re-seed must not reset the schedule
|
|
||||||
// and re-herd everything.
|
|
||||||
tag, err := tx.Exec(ctx,
|
|
||||||
`INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, enabled, last_run_at)
|
|
||||||
VALUES ($1, $2, $6, $3, $4, $5, 30, true,
|
|
||||||
now() - make_interval(secs => random() * $5::int))
|
|
||||||
ON CONFLICT (entity_id) DO UPDATE
|
|
||||||
SET target_id = EXCLUDED.target_id, target_type = EXCLUDED.target_type,
|
|
||||||
kind = EXCLUDED.kind,
|
|
||||||
config = EXCLUDED.config, interval_s = EXCLUDED.interval_s,
|
|
||||||
updated_at = now()`,
|
|
||||||
checkID, t.ID, def.kind, configJSON, def.interval, t.Type)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("upsert check_def %s: %w", checkSlug, err)
|
|
||||||
}
|
|
||||||
return tag.RowsAffected() > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpURL works out what to GET for an http check.
|
// httpURL works out what to GET for an http check.
|
||||||
//
|
//
|
||||||
// Ingress routes carry their hostname as the entity name rather than as an
|
// Ingress routes carry their hostname as the entity name rather than as an
|
||||||
// attribute (`name: media.hubris.network`), and most declare no attributes at
|
// attribute (`name: media.hubris.network`), and most declare no attributes
|
||||||
// all — so the name is the only thing to go on. Requiring a `url` attribute
|
// at all — so the name is the only thing to go on. Requiring a `url` attribute
|
||||||
// left all 21 of them unmonitored, which is a shame given an ingress check is
|
// left all 21 of them unmonitored, which is a shame given an ingress check is
|
||||||
// the most end-to-end probe available: it exercises Caddy, DNS, TLS and the
|
// the most end-to-end probe available: it exercises Caddy, DNS, TLS and the
|
||||||
// upstream in one request.
|
// upstream in one request.
|
||||||
func httpURL(t Target, attrs map[string]any) string {
|
func httpURL(t CheckTarget, attrs map[string]any) string {
|
||||||
if url, ok := attrs["url"].(string); ok && url != "" {
|
if url, ok := attrs["url"].(string); ok && url != "" {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -474,44 +385,6 @@ func httpURL(t Target, attrs map[string]any) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostViaGraph returns the attributes of the entity that hosts or provides
|
|
||||||
// this one, so a service can inherit its container's address.
|
|
||||||
func hostViaGraph(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
|
|
||||||
rows, err := tx.Query(ctx, `
|
|
||||||
SELECT e.attributes
|
|
||||||
FROM relationships r
|
|
||||||
JOIN entities e ON e.id = r.source_id
|
|
||||||
WHERE r.target_id = $1
|
|
||||||
AND r.valid_to IS NULL
|
|
||||||
-- backs-up-to points from the thing being backed up TO the target,
|
|
||||||
-- so walking it backwards finds the machine that writes the backups
|
|
||||||
-- — which is the only place a freshness check can run.
|
|
||||||
AND r.type IN ('provides', 'hosts', 'runs-on', 'backs-up-to')
|
|
||||||
ORDER BY CASE r.type
|
|
||||||
WHEN 'provides' THEN 0 WHEN 'runs-on' THEN 1
|
|
||||||
WHEN 'backs-up-to' THEN 2 ELSE 3 END`,
|
|
||||||
entityID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var raw []byte
|
|
||||||
if err := rows.Scan(&raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var attrs map[string]any
|
|
||||||
if json.Unmarshal(raw, &attrs) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if resolveHost(attrs) != "" {
|
|
||||||
return attrs, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveHost(attrs map[string]any) string {
|
func resolveHost(attrs map[string]any) string {
|
||||||
if attrs == nil {
|
if attrs == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -576,9 +449,9 @@ func resolveSSHPort(attrs map[string]any) int {
|
|||||||
return 22
|
return 22
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogResult emits the one line that was missing: a type that asked for
|
// LogDeriveResult emits the one line that was missing: a type that asked for
|
||||||
// monitoring and did not get it.
|
// monitoring and did not get it.
|
||||||
func LogResult(slug, entityType string, res Result) {
|
func LogDeriveResult(slug, entityType string, res DeriveResult) {
|
||||||
switch {
|
switch {
|
||||||
case res.Undeclared:
|
case res.Undeclared:
|
||||||
slog.Info("checkdefaults: type declares no monitoring",
|
slog.Info("checkdefaults: type declares no monitoring",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package checkdefaults
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -17,7 +17,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
kind string
|
kind string
|
||||||
target Target
|
target CheckTarget
|
||||||
attrs map[string]any
|
attrs map[string]any
|
||||||
host string
|
host string
|
||||||
wantSkip bool // true → expect a reason and zero defs
|
wantSkip bool // true → expect a reason and zero defs
|
||||||
@@ -26,7 +26,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
wantKey string // a config key to assert
|
wantKey string // a config key to assert
|
||||||
wantVal any // its expected value
|
wantVal any // its expected value
|
||||||
wantReason string // substring when skipping
|
wantReason string // substring when skipping
|
||||||
wantInterv int32 // expected interval on the (single) produced def
|
wantInterv int // expected interval on the (single) produced def
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ping with host", kind: KindPing, host: host,
|
name: "ping with host", kind: KindPing, host: host,
|
||||||
@@ -72,7 +72,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
wantDefs: 1, wantKind: "cert-expiry", wantKey: "host", wantVal: "media.hubris.network", wantInterv: 3600,
|
wantDefs: 1, wantKind: "cert-expiry", wantKey: "host", wantVal: "media.hubris.network", wantInterv: 3600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cert-expiry from dotted name", kind: KindCertExpiry, target: Target{Name: "media.hubris.network"},
|
name: "cert-expiry from dotted name", kind: KindCertExpiry, target: CheckTarget{Name: "media.hubris.network"},
|
||||||
wantDefs: 1, wantKey: "host", wantVal: "media.hubris.network",
|
wantDefs: 1, wantKey: "host", wantVal: "media.hubris.network",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
attrs: map[string]any{"hostname": "media.hubris.network", "dial": "10.0.0.2"},
|
attrs: map[string]any{"hostname": "media.hubris.network", "dial": "10.0.0.2"},
|
||||||
wantDefs: 1, wantKey: "dial", wantVal: "10.0.0.2",
|
wantDefs: 1, wantKey: "dial", wantVal: "10.0.0.2",
|
||||||
},
|
},
|
||||||
{name: "cert-expiry without a host name skips", kind: KindCertExpiry, target: Target{Name: "jellyfin"}, wantSkip: true, wantReason: "no hostname"},
|
{name: "cert-expiry without a host name skips", kind: KindCertExpiry, target: CheckTarget{Name: "jellyfin"}, wantSkip: true, wantReason: "no hostname"},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "vm-status needs pve_id", kind: KindVMStatus, attrs: map[string]any{"pve_id": float64(101)},
|
name: "vm-status needs pve_id", kind: KindVMStatus, attrs: map[string]any{"pve_id": float64(101)},
|
||||||
@@ -89,10 +89,10 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
{name: "vm-status without pve_id skips", kind: KindVMStatus, wantSkip: true, wantReason: "no pve_id"},
|
{name: "vm-status without pve_id skips", kind: KindVMStatus, wantSkip: true, wantReason: "no pve_id"},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "dns resolves entity name", kind: KindDNS, target: Target{Name: "hubris.network"},
|
name: "dns resolves entity name", kind: KindDNS, target: CheckTarget{Name: "hubris.network"},
|
||||||
wantDefs: 1, wantKind: "dns", wantKey: "name", wantVal: "hubris.network", wantInterv: 300,
|
wantDefs: 1, wantKind: "dns", wantKey: "name", wantVal: "hubris.network", wantInterv: 300,
|
||||||
},
|
},
|
||||||
{name: "dns without a name skips", kind: KindDNS, target: Target{}, wantSkip: true, wantReason: "no name"},
|
{name: "dns without a name skips", kind: KindDNS, target: CheckTarget{}, wantSkip: true, wantReason: "no name"},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "quorum runs pvecm script via ssh", kind: KindQuorum, host: host,
|
name: "quorum runs pvecm script via ssh", kind: KindQuorum, host: host,
|
||||||
@@ -122,17 +122,17 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
t.Errorf("unexpected skip reason: %q", reason)
|
t.Errorf("unexpected skip reason: %q", reason)
|
||||||
}
|
}
|
||||||
if c.wantKind != "" {
|
if c.wantKind != "" {
|
||||||
if got := defs[0].kind; got != c.wantKind {
|
if got := defs[0].Kind; got != c.wantKind {
|
||||||
t.Errorf("kind = %q, want %q", got, c.wantKind)
|
t.Errorf("kind = %q, want %q", got, c.wantKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.wantKey != "" {
|
if c.wantKey != "" {
|
||||||
if got := defs[0].config[c.wantKey]; !reflect.DeepEqual(got, c.wantVal) {
|
if got := defs[0].Config[c.wantKey]; !reflect.DeepEqual(got, c.wantVal) {
|
||||||
t.Errorf("config[%q] = %v (%T), want %v (%T)", c.wantKey, got, got, c.wantVal, c.wantVal)
|
t.Errorf("config[%q] = %v (%T), want %v (%T)", c.wantKey, got, got, c.wantVal, c.wantVal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.wantInterv != 0 && defs[0].interval != c.wantInterv {
|
if c.wantInterv != 0 && defs[0].IntervalS != c.wantInterv {
|
||||||
t.Errorf("interval = %d, want %d", defs[0].interval, c.wantInterv)
|
t.Errorf("interval = %d, want %d", defs[0].IntervalS, c.wantInterv)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,29 +142,29 @@ func TestBuildKindAllImplementedKinds(t *testing.T) {
|
|||||||
// defaults, so generated configs stay minimal and stable across re-seeds.
|
// defaults, so generated configs stay minimal and stable across re-seeds.
|
||||||
func TestBuildKindSSHOnlyEmitsNonDefaultUserPortArgs(t *testing.T) {
|
func TestBuildKindSSHOnlyEmitsNonDefaultUserPortArgs(t *testing.T) {
|
||||||
t.Run("default root 22 omits user and port", func(t *testing.T) {
|
t.Run("default root 22 omits user and port", func(t *testing.T) {
|
||||||
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "root", 22)
|
defs, _ := buildKind(KindResource, CheckTarget{}, nil, "10.0.0.1", "root", 22)
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
if _, ok := d.config["user"]; ok {
|
if _, ok := d.Config["user"]; ok {
|
||||||
t.Errorf("root should not emit user: %v", d.config)
|
t.Errorf("root should not emit user: %v", d.Config)
|
||||||
}
|
}
|
||||||
if _, ok := d.config["port"]; ok {
|
if _, ok := d.Config["port"]; ok {
|
||||||
t.Errorf("port 22 should not emit port: %v", d.config)
|
t.Errorf("port 22 should not emit port: %v", d.Config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("non-root user and non-22 port are emitted", func(t *testing.T) {
|
t.Run("non-root user and non-22 port are emitted", func(t *testing.T) {
|
||||||
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "oikos", 2222)
|
defs, _ := buildKind(KindResource, CheckTarget{}, nil, "10.0.0.1", "oikos", 2222)
|
||||||
if defs[0].config["user"] != "oikos" {
|
if defs[0].Config["user"] != "oikos" {
|
||||||
t.Errorf("user = %v, want oikos", defs[0].config["user"])
|
t.Errorf("user = %v, want oikos", defs[0].Config["user"])
|
||||||
}
|
}
|
||||||
if defs[0].config["port"] != 2222 {
|
if defs[0].Config["port"] != 2222 {
|
||||||
t.Errorf("port = %v, want 2222", defs[0].config["port"])
|
t.Errorf("port = %v, want 2222", defs[0].Config["port"])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("process unit name lands in args", func(t *testing.T) {
|
t.Run("process unit name lands in args", func(t *testing.T) {
|
||||||
defs, _ := buildKind(KindProcess, Target{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
defs, _ := buildKind(KindProcess, CheckTarget{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
||||||
if defs[0].config["args"] != "jellyfin" {
|
if defs[0].Config["args"] != "jellyfin" {
|
||||||
t.Errorf("args = %v, want jellyfin", defs[0].config["args"])
|
t.Errorf("args = %v, want jellyfin", defs[0].Config["args"])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package checkdefaults
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -58,7 +58,7 @@ func TestHTTPURLPrefersAttributeThenName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := httpURL(Target{Name: c.name}, c.attrs)
|
got := httpURL(CheckTarget{Name: c.name}, c.attrs)
|
||||||
if got != c.want {
|
if got != c.want {
|
||||||
t.Errorf("%s: httpURL = %q, want %q", c.desc, got, c.want)
|
t.Errorf("%s: httpURL = %q, want %q", c.desc, got, c.want)
|
||||||
}
|
}
|
||||||
@@ -68,13 +68,13 @@ func TestHTTPURLPrefersAttributeThenName(t *testing.T) {
|
|||||||
func TestBuildKindReportsWhyItSkipped(t *testing.T) {
|
func TestBuildKindReportsWhyItSkipped(t *testing.T) {
|
||||||
// A declared kind that cannot be built must explain itself rather than
|
// A declared kind that cannot be built must explain itself rather than
|
||||||
// vanish — that silence is what hid the coverage gap.
|
// vanish — that silence is what hid the coverage gap.
|
||||||
if defs, reason := buildKind(KindPing, Target{}, nil, "", "root", 22); len(defs) != 0 || reason == "" {
|
if defs, reason := buildKind(KindPing, CheckTarget{}, nil, "", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
t.Errorf("ping without a host should skip with a reason, got %d defs / %q", len(defs), reason)
|
t.Errorf("ping without a host should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
}
|
}
|
||||||
if defs, reason := buildKind(KindProcess, Target{Name: ""}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
if defs, reason := buildKind(KindProcess, CheckTarget{Name: ""}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
t.Errorf("process without a name should skip with a reason, got %d defs / %q", len(defs), reason)
|
t.Errorf("process without a name should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
}
|
}
|
||||||
if defs, reason := buildKind("dns", Target{}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
if defs, reason := buildKind("dns", CheckTarget{}, nil, "10.0.0.1", "root", 22); len(defs) != 0 || reason == "" {
|
||||||
t.Errorf("an unimplemented kind should skip with a reason, got %d defs / %q", len(defs), reason)
|
t.Errorf("an unimplemented kind should skip with a reason, got %d defs / %q", len(defs), reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,44 +82,44 @@ func TestBuildKindReportsWhyItSkipped(t *testing.T) {
|
|||||||
func TestBuildKindProcessPassesTheUnitName(t *testing.T) {
|
func TestBuildKindProcessPassesTheUnitName(t *testing.T) {
|
||||||
// process_check.sh reads $1 and answers "no service name provided"
|
// process_check.sh reads $1 and answers "no service name provided"
|
||||||
// without it. checkdefaults always wrote args; nothing read them.
|
// without it. checkdefaults always wrote args; nothing read them.
|
||||||
defs, reason := buildKind(KindProcess, Target{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
defs, reason := buildKind(KindProcess, CheckTarget{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
||||||
if len(defs) != 1 {
|
if len(defs) != 1 {
|
||||||
t.Fatalf("expected one process check, got %d (%s)", len(defs), reason)
|
t.Fatalf("expected one process check, got %d (%s)", len(defs), reason)
|
||||||
}
|
}
|
||||||
if got := defs[0].config["args"]; got != "jellyfin" {
|
if got := defs[0].Config["args"]; got != "jellyfin" {
|
||||||
t.Errorf("process check args = %v, want jellyfin", got)
|
t.Errorf("process check args = %v, want jellyfin", got)
|
||||||
}
|
}
|
||||||
if got := defs[0].config["script"]; got != "process_check.sh" {
|
if got := defs[0].Config["script"]; got != "process_check.sh" {
|
||||||
t.Errorf("process check script = %v", got)
|
t.Errorf("process check script = %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildKindHTTPUsesAStatusRangeNotAnExactCode(t *testing.T) {
|
func TestBuildKindHTTPUsesAStatusRangeNotAnExactCode(t *testing.T) {
|
||||||
// Most services sit behind Authentik and answer 302/401.
|
// Most services sit behind Authentik and answer 302/401.
|
||||||
defs, _ := buildKind(KindHTTP, Target{Name: "jellyfin"},
|
defs, _ := buildKind(KindHTTP, CheckTarget{Name: "jellyfin"},
|
||||||
map[string]any{"url": "https://media.hubris.network"}, "", "root", 22)
|
map[string]any{"url": "https://media.hubris.network"}, "", "root", 22)
|
||||||
if len(defs) != 1 {
|
if len(defs) != 1 {
|
||||||
t.Fatalf("expected one http check, got %d", len(defs))
|
t.Fatalf("expected one http check, got %d", len(defs))
|
||||||
}
|
}
|
||||||
if got := defs[0].config["max_status"]; got != 500 {
|
if got := defs[0].Config["max_status"]; got != 500 {
|
||||||
t.Errorf("max_status = %v, want 500", got)
|
t.Errorf("max_status = %v, want 500", got)
|
||||||
}
|
}
|
||||||
if _, exact := defs[0].config["expected_status"]; exact {
|
if _, exact := defs[0].Config["expected_status"]; exact {
|
||||||
t.Error("default http checks must not pin an exact status")
|
t.Error("default http checks must not pin an exact status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildKindResourceExpandsToFourScripts(t *testing.T) {
|
func TestBuildKindResourceExpandsToFourScripts(t *testing.T) {
|
||||||
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "root", 22)
|
defs, _ := buildKind(KindResource, CheckTarget{}, nil, "10.0.0.1", "root", 22)
|
||||||
if len(defs) != 4 {
|
if len(defs) != 4 {
|
||||||
t.Fatalf("resource should expand to 4 checks, got %d", len(defs))
|
t.Fatalf("resource should expand to 4 checks, got %d", len(defs))
|
||||||
}
|
}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
if d.kind != "ssh-script" {
|
if d.Kind != "ssh-script" {
|
||||||
t.Errorf("resource check kind = %q, want ssh-script", d.kind)
|
t.Errorf("resource check kind = %q, want ssh-script", d.Kind)
|
||||||
}
|
}
|
||||||
if d.config["host"] != "10.0.0.1" {
|
if d.Config["host"] != "10.0.0.1" {
|
||||||
t.Errorf("resource check lost its host: %v", d.config)
|
t.Errorf("resource check lost its host: %v", d.Config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -235,17 +235,17 @@ func TestUpdateEntityAttributes_NotFound(t *testing.T) {
|
|||||||
// TestFormatCheckResult is a pure unit test for the result-message helper, so
|
// TestFormatCheckResult is a pure unit test for the result-message helper, so
|
||||||
// the formatting contract holds even when the DB is unavailable.
|
// the formatting contract holds even when the DB is unavailable.
|
||||||
func TestFormatCheckResult(t *testing.T) {
|
func TestFormatCheckResult(t *testing.T) {
|
||||||
if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") {
|
if got := formatCheckResult(app.DeriveResult{Created: 2}); !strings.Contains(got, "Derived 2 check") {
|
||||||
t.Errorf("created-only = %q, want Derived 2", got)
|
t.Errorf("created-only = %q, want Derived 2", got)
|
||||||
}
|
}
|
||||||
got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}})
|
got := formatCheckResult(app.DeriveResult{Created: 1, Skipped: []app.Skip{{Kind: "process", Reason: "no host"}}})
|
||||||
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
|
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
|
||||||
t.Errorf("created+skipped = %q", got)
|
t.Errorf("created+skipped = %q", got)
|
||||||
}
|
}
|
||||||
if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") {
|
if got := formatCheckResult(app.DeriveResult{Undeclared: true}); !strings.Contains(got, "no monitoring") {
|
||||||
t.Errorf("undeclared = %q, want no-monitoring hint", got)
|
t.Errorf("undeclared = %q, want no-monitoring hint", got)
|
||||||
}
|
}
|
||||||
if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." {
|
if formatCreateResult("a", "b", app.DeriveResult{Created: 0}) != "Created a (b)." {
|
||||||
t.Error("create result with no checks should have no suffix")
|
t.Error("create result with no checks should have no suffix")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
"github.com/dtoro/oikos/internal/core/app"
|
||||||
"github.com/dtoro/oikos/internal/core/ports"
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
"github.com/dtoro/oikos/internal/adapters/postgres"
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -26,7 +26,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
|
|||||||
AnalysisTools(pool, agentID, sec)...)
|
AnalysisTools(pool, agentID, sec)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatCheckResult(res checkdefaults.Result) string {
|
func formatCheckResult(res app.DeriveResult) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if res.Created > 0 {
|
if res.Created > 0 {
|
||||||
fmt.Fprintf(&b, " Derived %d check(s).", res.Created)
|
fmt.Fprintf(&b, " Derived %d check(s).", res.Created)
|
||||||
@@ -40,7 +40,7 @@ func formatCheckResult(res checkdefaults.Result) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatCreateResult(slug, entityType string, res checkdefaults.Result) string {
|
func formatCreateResult(slug, entityType string, res app.DeriveResult) string {
|
||||||
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
|
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user