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