Monitoring coverage was 3 of 89 active entities. Three bugs, each hidden by discarded errors in checkdefaults: - writeCheck generated a fresh uuid, inserted the check entity ON CONFLICT (slug) DO NOTHING, then wrote a check_defs row referencing it. On any re-seed the slug already existed, the entity insert no-oped, and the FK violated — aborting the ingest transaction and surfacing as an unrelated failure several entities later. Re-seeding has been broken since; prod's coverage was frozen at its first successful seed. This is what TestSeedIngestIdempotentAndNoDuplicateEdges had been reporting. - shortSlug truncated to the last 8 chars, so all 21 ingress routes collapsed to ".network" and overwrote each other; service:jellyfin collided with lxc:jellyfin. - The ssh-script checker never read the `args` config checkdefaults wrote, so process_check.sh always ran without its unit name and returned "unknown". Coverage is now 75/89. Monitoring is declared per entity type in seeds/ontology.yaml and resolved through the is-a hierarchy, so a type can say it warrants nothing (site, lan, mesh, cluster) and never be reported as a gap. coverageSweep raises an `unmonitored` signal only where a type declares monitoring it lacks — 8 real gaps, no false positives. Also: - entity_types.attribute_schema was never ingested: the seed loader read "attribute_schema" but the YAML says "attributes", so all 60 types stored JSON null. - ListExecutions ignored its declared target/action/correlation_id filters and paginated on a non-unique target slug, dropping and repeating rows. - started_at was captured but only written at terminal state, so a running execution reported NULL for its whole life. The three MCP auto-run copies wrote no timing at all; they are now one autoRun helper. - SSH output was buffered to completion and discarded entirely on timeout. Both sshExec copies now stream through a shared execlog sink into execution_logs, and keep partial output when a command is cancelled. - executions.correlation_id was a random per-execution uuid that correlated nothing; it is now the chat session id, which is what lets the chat tail live output. - reversible_low had no auto-run branch despite policy declaring it unattended. Since computeCommandRisk never returns it, the class only arises when an agent declares it over a read_only command — so gating it penalised candor without adding safety. - backup-target gains a backup-freshness checker (portable find -mmin, since the first target is on macOS), resolving its host by walking backs-up-to backwards. The pre-deploy pg_dump is now a tracked backup target. UI: an Executions section on entity detail with live output tailing, and streamed output under a running `run` call in the chat timeline. Migrations 022-024. Ops.svelte and context.ts exclude execution.output from their refetch triggers, which would otherwise fire once a second per command. Co-Authored-By: Claude <noreply@anthropic.com>
310 lines
10 KiB
Go
310 lines
10 KiB
Go
package scheduler
|
|
|
|
// Integration tests for coverageSweep against a real TimescaleDB. Guarded by
|
|
// OIKOS_TEST_DATABASE_URL — skipped when unset, same convention as
|
|
// internal/db/integration_test.go. Each run creates a throwaway database.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func newCoveragePool(t *testing.T) *db.Pool {
|
|
t.Helper()
|
|
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
|
if baseURL == "" {
|
|
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
admin, err := pgx.Connect(ctx, baseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect admin: %v", err)
|
|
}
|
|
dbName := fmt.Sprintf("oikos_cov_%08x", rand.Int63())
|
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
|
admin.Close(ctx)
|
|
t.Fatalf("create test db: %v", err)
|
|
}
|
|
admin.Close(ctx)
|
|
|
|
at := strings.LastIndex(baseURL, "/")
|
|
testURL := baseURL[:at+1] + dbName
|
|
if q := strings.Index(baseURL[at:], "?"); q >= 0 {
|
|
testURL += baseURL[at+q:]
|
|
}
|
|
|
|
pool, err := db.New(ctx, testURL)
|
|
if err != nil {
|
|
t.Fatalf("connect test db: %v", err)
|
|
}
|
|
if err := pool.Migrate(ctx); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
pool.Close()
|
|
if admin, err := pgx.Connect(ctx, baseURL); err == nil {
|
|
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
|
admin.Close(ctx)
|
|
}
|
|
})
|
|
return pool
|
|
}
|
|
|
|
// fixture builds the four cases that matter, without the full seed:
|
|
// a declared+monitored type, a declared+unmonitored one, an explicitly
|
|
// unmonitorable one, and one that inherits its declaration from a parent.
|
|
func coverageFixture(t *testing.T, pool *db.Pool) map[string]uuid.UUID {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
exec := func(sql string, args ...any) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
|
t.Fatalf("fixture %q: %v", sql, err)
|
|
}
|
|
}
|
|
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('t-container', NULL, true, 'compute', 'infrastructure', '["resource"]', 'active')
|
|
ON CONFLICT (name) DO UPDATE SET monitoring_spec = EXCLUDED.monitoring_spec`)
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('t-lxc', 't-container', false, 'compute', 'infrastructure', NULL, 'active')
|
|
ON CONFLICT (name) DO NOTHING`)
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('t-service', NULL, false, 'software', 'infrastructure', '["http"]', 'active')
|
|
ON CONFLICT (name) DO NOTHING`)
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('t-site', NULL, false, 'physical', 'infrastructure', '[]', 'active')
|
|
ON CONFLICT (name) DO NOTHING`)
|
|
// `check` and `signal` normally arrive with the ontology seed, which this
|
|
// fixture skips; the sweep creates signal entities and needs both.
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('check', NULL, false, 'operations', 'cognition', '[]', 'active')
|
|
ON CONFLICT (name) DO NOTHING`)
|
|
exec(`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, monitoring_spec, status)
|
|
VALUES ('signal', NULL, false, 'operations', 'cognition', '[]', 'active')
|
|
ON CONFLICT (name) DO NOTHING`)
|
|
|
|
ids := map[string]uuid.UUID{}
|
|
for _, e := range []struct{ slug, typ string }{
|
|
{"monitored-svc", "t-service"}, // declared + has a check
|
|
{"unmonitored-svc", "t-service"}, // declared + no check → signal
|
|
{"inheriting-lxc", "t-lxc"}, // inherits [resource], no check → signal
|
|
{"a-site", "t-site"}, // explicitly none → never a signal
|
|
} {
|
|
id := uuid.New()
|
|
exec(`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
|
VALUES ($1,$2,$3,$2,'active','{}',1,now(),now())`, id, e.slug, e.typ)
|
|
ids[e.slug] = id
|
|
}
|
|
|
|
// Only monitored-svc gets a check.
|
|
checkID := uuid.New()
|
|
exec(`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
|
VALUES ($1,'check:t:monitored-svc:0','check','c-monitored-svc','active','{}',1,now(),now())`, checkID)
|
|
exec(`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
|
VALUES ($1,$2,'http','{}',60,30,true)`, checkID, ids["monitored-svc"])
|
|
|
|
return ids
|
|
}
|
|
|
|
func openSignals(t *testing.T, pool *db.Pool, targetID uuid.UUID) (int, int) {
|
|
t.Helper()
|
|
var count, occurrences int
|
|
err := pool.QueryRow(context.Background(),
|
|
`SELECT count(*), COALESCE(max(occurrence_count),0) FROM signals
|
|
WHERE target_entity_id = $1 AND kind = $2 AND state NOT IN ('resolved','failed')`,
|
|
targetID, UnmonitoredKind).Scan(&count, &occurrences)
|
|
if err != nil {
|
|
t.Fatalf("count signals: %v", err)
|
|
}
|
|
return count, occurrences
|
|
}
|
|
|
|
func TestCoverageSweepOnlyFlagsDeclaredButUnmonitored(t *testing.T) {
|
|
pool := newCoveragePool(t)
|
|
ids := coverageFixture(t, pool)
|
|
ctx := context.Background()
|
|
|
|
coverageSweep(ctx, pool)
|
|
|
|
for _, c := range []struct {
|
|
slug string
|
|
want int
|
|
why string
|
|
}{
|
|
{"unmonitored-svc", 1, "declares http, has no check"},
|
|
{"inheriting-lxc", 1, "inherits [resource] from its parent, has no check"},
|
|
{"monitored-svc", 0, "has a check"},
|
|
{"a-site", 0, "declares monitoring: none — flagging it would be a permanent false positive"},
|
|
} {
|
|
got, _ := openSignals(t, pool, ids[c.slug])
|
|
if got != c.want {
|
|
t.Errorf("%s (%s): %d open unmonitored signals, want %d", c.slug, c.why, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoverageSweepDedupsRatherThanDuplicating(t *testing.T) {
|
|
pool := newCoveragePool(t)
|
|
ids := coverageFixture(t, pool)
|
|
ctx := context.Background()
|
|
|
|
coverageSweep(ctx, pool)
|
|
coverageSweep(ctx, pool)
|
|
coverageSweep(ctx, pool)
|
|
|
|
count, occurrences := openSignals(t, pool, ids["unmonitored-svc"])
|
|
if count != 1 {
|
|
t.Errorf("three sweeps produced %d signals, want 1", count)
|
|
}
|
|
if occurrences != 3 {
|
|
t.Errorf("occurrence_count = %d after three sweeps, want 3", occurrences)
|
|
}
|
|
}
|
|
|
|
func TestCoverageSweepResolvesWhenACheckAppears(t *testing.T) {
|
|
pool := newCoveragePool(t)
|
|
ids := coverageFixture(t, pool)
|
|
ctx := context.Background()
|
|
|
|
coverageSweep(ctx, pool)
|
|
if count, _ := openSignals(t, pool, ids["unmonitored-svc"]); count != 1 {
|
|
t.Fatalf("expected an open signal before the check is added, got %d", count)
|
|
}
|
|
|
|
// The entity acquires a check. The scheduler's normal auto-resolve keys on
|
|
// the check entity id and only from 'raised', so it could never clear this.
|
|
checkID := uuid.New()
|
|
if _, err := pool.Exec(ctx,
|
|
// entities has a unique (type, name), so this cannot reuse the
|
|
// fixture check's name.
|
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
|
VALUES ($1,'check:t:unmonitored-svc:0','check','c-unmonitored-svc','active','{}',1,now(),now())`, checkID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
|
VALUES ($1,$2,'http','{}',60,30,true)`, checkID, ids["unmonitored-svc"]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
coverageSweep(ctx, pool)
|
|
|
|
if count, _ := openSignals(t, pool, ids["unmonitored-svc"]); count != 0 {
|
|
t.Errorf("signal should have resolved once the entity had a check, %d still open", count)
|
|
}
|
|
}
|
|
|
|
// Against the real ontology and inventory rather than a fixture: the sweep
|
|
// must stay silent for types that opted out and speak up for the genuine gaps.
|
|
// Asserted as properties, not exact counts, so it does not break every time
|
|
// the fleet changes.
|
|
func TestCoverageSweepAgainstTheRealSeed(t *testing.T) {
|
|
pool := newCoveragePool(t)
|
|
ctx := context.Background()
|
|
|
|
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
|
|
content, err := os.ReadFile("../../seeds/" + f)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", f, err)
|
|
}
|
|
err = pool.SeedIngest(ctx, f, content,
|
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
|
switch f {
|
|
case "ontology.yaml":
|
|
_, err := db.IngestOntologySeed(ctx, tx, data)
|
|
return err
|
|
case "inventory.yaml":
|
|
_, err := db.IngestInventorySeed(ctx, tx, data)
|
|
return err
|
|
default:
|
|
_, err := db.IngestPolicySeed(ctx, tx, data)
|
|
return err
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ingest %s: %v", f, err)
|
|
}
|
|
}
|
|
|
|
coverageSweep(ctx, pool)
|
|
|
|
flagged := map[string]int{}
|
|
rows, err := pool.Query(ctx, `
|
|
SELECT e.type, count(*)
|
|
FROM signals s JOIN entities e ON e.id = s.target_entity_id
|
|
WHERE s.kind = $1 AND s.state NOT IN ('resolved','failed')
|
|
GROUP BY e.type`, UnmonitoredKind)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for rows.Next() {
|
|
var typ string
|
|
var n int
|
|
if err := rows.Scan(&typ, &n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
flagged[typ] = n
|
|
}
|
|
rows.Close()
|
|
|
|
t.Logf("unmonitored signals by entity type: %v", flagged)
|
|
|
|
// Declared `monitoring: none` — flagging these would be a permanent,
|
|
// unresolvable false positive, which is the failure mode that would make
|
|
// the signal worthless.
|
|
for _, typ := range []string{"site", "lan", "mesh", "cluster"} {
|
|
if n := flagged[typ]; n != 0 {
|
|
t.Errorf("%s declares monitoring: none but %d were flagged unmonitored", typ, n)
|
|
}
|
|
}
|
|
|
|
// Types that now get real checks must not be flagged either.
|
|
for _, typ := range []string{"service", "lxc", "ingress-route", "proxmox-host"} {
|
|
if n := flagged[typ]; n != 0 {
|
|
t.Errorf("%s should be covered by checkdefaults, but %d were flagged", typ, n)
|
|
}
|
|
}
|
|
|
|
// Genuine gaps: no `dns` checker, no edge from a pool to its machine.
|
|
for _, typ := range []string{"dns-zone", "storage-pool"} {
|
|
if flagged[typ] == 0 {
|
|
t.Errorf("%s is a known gap and should have been flagged", typ)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A resolved signal must be re-raisable. The (target, kind) partial unique
|
|
// index only covers open signals, so a resolved row does not conflict there —
|
|
// it collides on the primary key instead, which is why the upsert targets the
|
|
// PK.
|
|
func TestCoverageSweepReRaisesAfterResolution(t *testing.T) {
|
|
pool := newCoveragePool(t)
|
|
ids := coverageFixture(t, pool)
|
|
ctx := context.Background()
|
|
|
|
coverageSweep(ctx, pool)
|
|
if _, err := pool.Exec(ctx,
|
|
`UPDATE signals SET state='resolved' WHERE target_entity_id=$1 AND kind=$2`,
|
|
ids["unmonitored-svc"], UnmonitoredKind); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
coverageSweep(ctx, pool)
|
|
|
|
count, _ := openSignals(t, pool, ids["unmonitored-svc"])
|
|
if count != 1 {
|
|
t.Errorf("a resolved signal should be re-raisable, got %d open", count)
|
|
}
|
|
}
|