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).
208 lines
7.3 KiB
Go
208 lines
7.3 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/dtoro/oikos/internal/core/app"
|
|
"github.com/dtoro/oikos/internal/ontology"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// EnsureEntityChecks derives an entity's default check_defs from the
|
|
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
|
|
//
|
|
// This is the single shared hook that keeps the check graph in sync with
|
|
// entity mutations. Both the HTTP create/patch handlers and the MCP
|
|
// entity-mutation tools (create_entity, update_entity_attributes) call it so
|
|
// that flipping an entity's `monitoring` attribute regenerates checks
|
|
// regardless of which surface made the change — previously only the HTTP
|
|
// 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).
|
|
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)
|
|
if err != nil {
|
|
return app.DeriveResult{}, err
|
|
}
|
|
res, err := EnsureChecks(ctx, tx, tree, app.CheckTarget{
|
|
ID: id.String(), Slug: slug, Type: entityType, Name: name, Attrs: attrs,
|
|
})
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
app.LogDeriveResult(slug, entityType, res)
|
|
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 ""
|
|
}
|