diff --git a/internal/adapters/postgres/checks.go b/internal/adapters/postgres/checks.go index d93ef463..26e11be2 100644 --- a/internal/adapters/postgres/checks.go +++ b/internal/adapters/postgres/checks.go @@ -2,8 +2,11 @@ package db import ( "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/jackc/pgx/v5" ) @@ -18,17 +21,187 @@ import ( // 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) (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) if err != nil { - return checkdefaults.Result{}, err + return app.DeriveResult{}, err } - res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{ - ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs, + 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 } - checkdefaults.LogResult(slug, entityType, res) + 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 "" +} diff --git a/internal/adapters/postgres/seed.go b/internal/adapters/postgres/seed.go index f4c8e4c8..aff47c2d 100644 --- a/internal/adapters/postgres/seed.go +++ b/internal/adapters/postgres/seed.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/dtoro/oikos/internal/checkdefaults" + "github.com/dtoro/oikos/internal/core/app" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -105,7 +105,7 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (* // Entities entities, _ := data["entities"].([]any) entityTypes := make(map[string]string) // slug -> type, for edge validation - var pendingChecks []checkdefaults.Target + var pendingChecks []app.CheckTarget for _, raw := range entities { eMap, ok := raw.(map[string]any) 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: // a service has no address of its own and inherits its container's, // which means the hosting edge has to exist first. - pendingChecks = append(pendingChecks, checkdefaults.Target{ - ID: entityID, Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes, + pendingChecks = append(pendingChecks, app.CheckTarget{ + ID: entityID.String(), Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes, }) 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 // later. for _, target := range pendingChecks { - res, err := checkdefaults.Ensure(ctx, tx, tree, target) + res, err := EnsureChecks(ctx, tx, tree, target) if err != nil { 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 } diff --git a/internal/checkdefaults/defaults.go b/internal/core/app/checkdefaults.go similarity index 61% rename from internal/checkdefaults/defaults.go rename to internal/core/app/checkdefaults.go index 524570bd..99e6d0ee 100644 --- a/internal/checkdefaults/defaults.go +++ b/internal/core/app/checkdefaults.go @@ -1,23 +1,11 @@ -// Package checkdefaults derives an entity's default check_defs from the -// 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 +package app import ( - "context" "encoding/json" - "fmt" "log/slog" "strings" "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 @@ -42,9 +30,9 @@ const ( // override per target with `backup_max_age_s` in the entity's attributes. const defaultBackupMaxAge = 86400 -// Target is the entity default checks are being ensured for. -type Target struct { - ID uuid.UUID +// CheckTarget is the entity default checks are being derived for. +type CheckTarget struct { + ID string Slug string Type string // Name is the entity's name column, not an attribute. The old code read @@ -55,9 +43,16 @@ type Target struct { Attrs []byte } -// Result reports what Ensure did, so callers can log a type that declared -// monitoring but produced nothing instead of failing silently. -type Result struct { +// CheckDef is one concrete derived check: kind, config payload, interval. +type CheckDef 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 // 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. @@ -73,35 +68,27 @@ type Skip struct { Reason string } -type checkDef struct { - kind string - config map[string]any - interval int32 -} +// HostLookup resolves the hosting entity's attributes when the entity +// itself carries no address (a service lives on its container; a backup +// target on whatever writes to it). It is invoked lazily — only when the +// 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. -// -// Returns the number of checks created. An entity whose type declares -// monitoring it cannot satisfy comes back with a populated Skipped rather -// than an error — a missing address is a modelling gap, not a failure of -// this call. -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) - } +// Derive computes the default checks for one entity from its type's +// monitoring spec (with per-entity `monitoring` attribute overrides). +// lookup may be nil. It performs no I/O of its own; the caller's lookup +// thunk may. The postgres adapter pairs this with writeCheck upserts. +func Derive(tree *ontology.TypeTree, t CheckTarget, lookup HostLookup) ([]CheckDef, DeriveResult) { + var res DeriveResult mon := tree.Monitoring(t.Type) if !mon.Declared { res.Undeclared = true - return res, nil + return nil, res } if mon.None() { - return res, nil + return nil, res } 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 { mon = resolveMonitoringAttr(mo, mon) if mon.None() { - return res, nil + return nil, res } } // A service has no address of its own — it lives on the container that // provides it. Fall back to the graph before giving up. host := resolveHost(attrs) - if host == "" { - hostAttrs, err := hostViaGraph(ctx, tx, t.ID) - if err != nil { - return res, fmt.Errorf("resolve host for %s: %w", t.Slug, err) - } + if host == "" && lookup != nil { + hostAttrs := lookup() host = resolveHost(hostAttrs) if user := resolveSSHUser(hostAttrs); host != "" && user != "root" { attrs["ssh"] = hostAttrs["ssh"] @@ -140,7 +124,7 @@ func Ensure(ctx context.Context, tx pgx.Tx, tree *ontology.TypeTree, t Target) ( user := resolveSSHUser(attrs) port := resolveSSHPort(attrs) - var defs []checkDef + var defs []CheckDef for _, kind := range mon.Kinds { built, reason := buildKind(kind, t, attrs, host, user, port) 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...) } - - 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 + return defs, res } // resolveMonitoringAttr turns an entity's `monitoring` attribute into a @@ -183,10 +157,10 @@ func resolveMonitoringAttr(v any, fallback ontology.MonitoringResolution) ontolo 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. -func buildKind(kind string, t Target, attrs map[string]any, host, user string, port int) ([]checkDef, string) { - ssh := func(script string, args ...string) checkDef { +func buildKind(kind string, t CheckTarget, attrs map[string]any, host, user string, port int) ([]CheckDef, string) { + ssh := func(script string, args ...string) CheckDef { cfg := map[string]any{"script": script, "host": host} if user != "" && user != "root" { 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] != "" { cfg["args"] = args[0] } - return checkDef{kind: "ssh-script", config: cfg, interval: 60} + return CheckDef{Kind: "ssh-script", Config: cfg, IntervalS: 60} } switch kind { @@ -205,13 +179,13 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p if 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: if host == "" { return nil, "no address on the entity or its host" } - return []checkDef{ + return []CheckDef{ ssh("cpu_check.sh"), ssh("memory_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 // changes about once a day. u := ssh("updates_check.sh") - u.interval = 86400 - return []checkDef{u}, "" + u.IntervalS = 86400 + return []CheckDef{u}, "" case KindCapacity: if 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: 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 // "unknown" without it. - return []checkDef{ssh("process_check.sh", unit)}, "" + return []CheckDef{ssh("process_check.sh", unit)}, "" case KindBackup: // 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 // 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: 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 // behind Authentik and answer 302/401, which is a working service. - return []checkDef{{ - kind: "http", - config: map[string]any{"url": url, "max_status": 500}, - interval: 60, + return []CheckDef{{ + Kind: "http", + Config: map[string]any{"url": url, "max_status": 500}, + IntervalS: 60, }}, "" case KindDNS: @@ -318,11 +292,11 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p if name == "" { return nil, "no name to resolve" } - return []checkDef{{ - kind: "dns", - config: map[string]any{"name": name}, - interval: 300, // 5 min — DNS changes are rare; the cost of a miss - // is a stale IP, not a service outage. + return []CheckDef{{ + Kind: "dns", + Config: map[string]any{"name": name}, + IntervalS: 300, // 5 min — DNS changes are rare; the cost of a miss + // is a stale IP, not a service outage. }}, "" case KindCertExpiry: @@ -343,10 +317,10 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p if dial != "" { config["dial"] = dial } - return []checkDef{{ - kind: "cert-expiry", - config: config, - interval: 3600, + return []CheckDef{{ + Kind: "cert-expiry", + Config: config, + IntervalS: 3600, }}, "" 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 { return nil, "no pve_id to run qm status" } - return []checkDef{{ - kind: "vm-status", - config: map[string]any{}, - interval: 60, + return []CheckDef{{ + Kind: "vm-status", + Config: map[string]any{}, + IntervalS: 60, }}, "" case KindQuorum: @@ -369,14 +343,14 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p if 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" } // 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"} { if v, ok := attrs[key].(string); ok && v != "" { return v @@ -389,78 +363,15 @@ func certHost(t Target, attrs map[string]any) string { 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. // // Ingress routes carry their hostname as the entity name rather than as an -// attribute (`name: media.hubris.network`), and most declare no attributes at -// all — so the name is the only thing to go on. Requiring a `url` attribute +// attribute (`name: media.hubris.network`), and most declare no attributes +// 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 // the most end-to-end probe available: it exercises Caddy, DNS, TLS and the // 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 != "" { return url } @@ -474,44 +385,6 @@ func httpURL(t Target, attrs map[string]any) string { 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 { if attrs == nil { return "" @@ -576,9 +449,9 @@ func resolveSSHPort(attrs map[string]any) int { 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. -func LogResult(slug, entityType string, res Result) { +func LogDeriveResult(slug, entityType string, res DeriveResult) { switch { case res.Undeclared: slog.Info("checkdefaults: type declares no monitoring", diff --git a/internal/checkdefaults/build_test.go b/internal/core/app/checkdefaults_build_test.go similarity index 82% rename from internal/checkdefaults/build_test.go rename to internal/core/app/checkdefaults_build_test.go index 28c2af97..bb248890 100644 --- a/internal/checkdefaults/build_test.go +++ b/internal/core/app/checkdefaults_build_test.go @@ -1,4 +1,4 @@ -package checkdefaults +package app import ( "reflect" @@ -17,7 +17,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) { cases := []struct { name string kind string - target Target + target CheckTarget attrs map[string]any host string 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 wantVal any // its expected value 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, @@ -72,7 +72,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) { 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", }, { @@ -80,7 +80,7 @@ func TestBuildKindAllImplementedKinds(t *testing.T) { attrs: map[string]any{"hostname": "media.hubris.network", "dial": "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)}, @@ -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: "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, }, - {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, @@ -122,17 +122,17 @@ func TestBuildKindAllImplementedKinds(t *testing.T) { t.Errorf("unexpected skip reason: %q", reason) } 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) } } 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) } } - if c.wantInterv != 0 && defs[0].interval != c.wantInterv { - t.Errorf("interval = %d, want %d", defs[0].interval, c.wantInterv) + if c.wantInterv != 0 && defs[0].IntervalS != 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. func TestBuildKindSSHOnlyEmitsNonDefaultUserPortArgs(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 { - if _, ok := d.config["user"]; ok { - t.Errorf("root should not emit user: %v", d.config) + if _, ok := d.Config["user"]; ok { + t.Errorf("root should not emit user: %v", d.Config) } - if _, ok := d.config["port"]; ok { - t.Errorf("port 22 should not emit port: %v", d.config) + if _, ok := d.Config["port"]; ok { + 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) { - defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "oikos", 2222) - if defs[0].config["user"] != "oikos" { - t.Errorf("user = %v, want oikos", defs[0].config["user"]) + defs, _ := buildKind(KindResource, CheckTarget{}, nil, "10.0.0.1", "oikos", 2222) + if defs[0].Config["user"] != "oikos" { + t.Errorf("user = %v, want oikos", defs[0].Config["user"]) } - if defs[0].config["port"] != 2222 { - t.Errorf("port = %v, want 2222", defs[0].config["port"]) + if defs[0].Config["port"] != 2222 { + t.Errorf("port = %v, want 2222", defs[0].Config["port"]) } }) 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) - if defs[0].config["args"] != "jellyfin" { - t.Errorf("args = %v, want jellyfin", defs[0].config["args"]) + defs, _ := buildKind(KindProcess, CheckTarget{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22) + if defs[0].Config["args"] != "jellyfin" { + t.Errorf("args = %v, want jellyfin", defs[0].Config["args"]) } }) } diff --git a/internal/checkdefaults/defaults_test.go b/internal/core/app/checkdefaults_defaults_test.go similarity index 77% rename from internal/checkdefaults/defaults_test.go rename to internal/core/app/checkdefaults_defaults_test.go index c8263597..4bd2bb75 100644 --- a/internal/checkdefaults/defaults_test.go +++ b/internal/core/app/checkdefaults_defaults_test.go @@ -1,4 +1,4 @@ -package checkdefaults +package app import ( "testing" @@ -58,7 +58,7 @@ func TestHTTPURLPrefersAttributeThenName(t *testing.T) { } for _, c := range cases { - got := httpURL(Target{Name: c.name}, c.attrs) + got := httpURL(CheckTarget{Name: c.name}, c.attrs) if 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) { // A declared kind that cannot be built must explain itself rather than // 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) } - 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) } - 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) } } @@ -82,44 +82,44 @@ func TestBuildKindReportsWhyItSkipped(t *testing.T) { func TestBuildKindProcessPassesTheUnitName(t *testing.T) { // process_check.sh reads $1 and answers "no service name provided" // 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 { 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) } - 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) } } func TestBuildKindHTTPUsesAStatusRangeNotAnExactCode(t *testing.T) { // 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) if len(defs) != 1 { 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) } - 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") } } 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 { t.Fatalf("resource should expand to 4 checks, got %d", len(defs)) } for _, d := range defs { - if d.kind != "ssh-script" { - t.Errorf("resource check kind = %q, want ssh-script", d.kind) + if d.Kind != "ssh-script" { + t.Errorf("resource check kind = %q, want ssh-script", d.Kind) } - if d.config["host"] != "10.0.0.1" { - t.Errorf("resource check lost its host: %v", d.config) + if d.Config["host"] != "10.0.0.1" { + t.Errorf("resource check lost its host: %v", d.Config) } } } diff --git a/internal/mcp/create_entity_test.go b/internal/mcp/create_entity_test.go index 74cb4707..8a99349a 100644 --- a/internal/mcp/create_entity_test.go +++ b/internal/mcp/create_entity_test.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/checkdefaults" + "github.com/dtoro/oikos/internal/core/app" "github.com/dtoro/oikos/internal/adapters/postgres" "github.com/google/uuid" "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 // the formatting contract holds even when the DB is unavailable. 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) } - 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") { 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) } - 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") } } diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e88737af..fccb6db7 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -5,7 +5,7 @@ import ( "fmt" "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/adapters/postgres" "github.com/google/uuid" @@ -26,7 +26,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg { AnalysisTools(pool, agentID, sec)...) } -func formatCheckResult(res checkdefaults.Result) string { +func formatCheckResult(res app.DeriveResult) string { var b strings.Builder if res.Created > 0 { fmt.Fprintf(&b, " Derived %d check(s).", res.Created) @@ -40,7 +40,7 @@ func formatCheckResult(res checkdefaults.Result) 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)) }