feat(observability): restore monitoring coverage, make gaps visible, stream executions
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>
This commit is contained in:
309
internal/scheduler/coverage_test.go
Normal file
309
internal/scheduler/coverage_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user