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:
2026-08-15 23:13:45 +02:00
parent 23c8144436
commit d4f5084a6d
7 changed files with 301 additions and 255 deletions

View File

@@ -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 ""
}

View File

@@ -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
} }

View File

@@ -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,10 +292,10 @@ 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.
}}, "" }}, ""
@@ -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",

View File

@@ -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"])
} }
}) })
} }

View File

@@ -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)
} }
} }
} }

View File

@@ -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")
} }
} }

View File

@@ -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))
} }