This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
const (
|
||||
defaultMaxConns = 10
|
||||
defaultMinConns = 1
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
MaxConns int32
|
||||
MinConns int32
|
||||
MaxConnIdle time.Duration
|
||||
}
|
||||
|
||||
func NewPool(ctx context.Context, config Config) (*pgxpool.Pool, error) {
|
||||
if strings.TrimSpace(config.URL) == "" {
|
||||
return nil, errors.New("database URL is required")
|
||||
}
|
||||
poolConfig, err := pgxpool.ParseConfig(config.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database URL: %w", err)
|
||||
}
|
||||
if config.MaxConns == 0 {
|
||||
config.MaxConns = defaultMaxConns
|
||||
}
|
||||
if config.MinConns == 0 {
|
||||
config.MinConns = defaultMinConns
|
||||
}
|
||||
if config.MaxConns < config.MinConns || config.MinConns < 0 {
|
||||
return nil, errors.New("database pool limits are invalid")
|
||||
}
|
||||
poolConfig.MaxConns = config.MaxConns
|
||||
poolConfig.MinConns = config.MinConns
|
||||
if config.MaxConnIdle > 0 {
|
||||
poolConfig.MaxConnIdleTime = config.MaxConnIdle
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database pool: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func Ping(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if pool == nil {
|
||||
return errors.New("database pool is nil")
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("database ping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
if pool == nil {
|
||||
return errors.New("database pool is nil")
|
||||
}
|
||||
entries, err := fs.Glob(migrationFiles, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("list migrations: %w", err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
for _, entry := range entries {
|
||||
if err := applyMigration(ctx, pool, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, pool *pgxpool.Pool, entry string) error {
|
||||
migrationID := strings.TrimSuffix(path.Base(entry), path.Ext(entry))
|
||||
sqlBytes, err := migrationFiles.ReadFile(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", migrationID, err)
|
||||
}
|
||||
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", migrationID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('itworx-pulse:schema-migrations'))`); err != nil {
|
||||
return fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create migration table: %w", err)
|
||||
}
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE id = $1)`, migrationID).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", migrationID, err)
|
||||
}
|
||||
if exists {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration check %s: %w", migrationID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", migrationID, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (id) VALUES ($1)`, migrationID); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", migrationID, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", migrationID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMigrationsAreEmbeddedAndOrdered(t *testing.T) {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded migrations: %v", err)
|
||||
}
|
||||
expected := []string{
|
||||
"0001_foundation.sql", "0002_inventory.sql", "0003_dashboard_immutability.sql", "0004_dashboard_revision.sql",
|
||||
"0005_services_probes.sql", "0006_alert_rules.sql", "0007_alert_evaluator_leases.sql", "0008_alert_state.sql",
|
||||
"0009_alert_hysteresis.sql", "0010_alert_controls.sql", "0011_alert_unacknowledge.sql", "0012_notifications.sql",
|
||||
"0013_incidents.sql", "0014_incident_notes.sql", "0015_entity_listing_index.sql", "0016_agent_snapshots.sql",
|
||||
"0017_worker_runtime.sql", "0018_inventory_read_indexes.sql", "0019_capacity_samples.sql",
|
||||
"0020_service_certificate_history_index.sql",
|
||||
}
|
||||
if len(entries) != len(expected) {
|
||||
t.Fatalf("migration count = %d, want %d: %#v", len(entries), len(expected), entries)
|
||||
}
|
||||
for index, name := range expected {
|
||||
if entries[index].Name() != name {
|
||||
t.Fatalf("migration %d = %q, want %q", index, entries[index].Name(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCertificateHistoryMigrationMatchesStatusQuery(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0020_service_certificate_history_index.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"service_certificates_service_history_idx", "service_id", "observed_at DESC", "id ASC"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("service certificate history migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "CONCURRENTLY") {
|
||||
t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerRuntimeMigrationContainsAliasAndJobLookupSafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0017_worker_runtime.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE TABLE container_aliases", "PRIMARY KEY (source_id, runtime_id)", "tombstoned_at", "ON DELETE CASCADE", "container_aliases_active_idx", "CREATE INDEX job_runs_recent_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("worker runtime migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "CONCURRENTLY") {
|
||||
t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertControlsMigrationContainsExpiryAndIndexes(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0010_alert_controls.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE TABLE alert_silences", "CREATE TABLE maintenance_windows", "expires_at", "ends_at", "status", "alert_silences_active_expiry_idx", "maintenance_windows_active_expiry_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("control migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNotificationsMigrationContainsOutboxAndAuditConstraints(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0012_notifications.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE TABLE notification_channels", "CREATE TABLE notification_outbox", "CREATE TABLE notification_deliveries", "UNIQUE (outbox_id, attempt)", "ON DELETE RESTRICT", "notification_outbox_due_idx", "notification_deliveries_history_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("notification migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestIncidentsMigrationContainsCorrelationAndAssociationSafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0013_incidents.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE TABLE incidents", "incidents_active_correlation_key_uq", "CREATE TABLE incident_alerts", "CREATE TABLE incident_entities", "ON DELETE RESTRICT", "confidence", "rationale"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("incident migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestIncidentNotesMigrationContainsBoundedNotes(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0014_incident_notes.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE TABLE incident_notes", "incident_notes_history_idx", "ON DELETE CASCADE", "char_length(body) BETWEEN 1 AND 2000"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("incident notes migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestEntityListingMigrationIndexesKeysetPagination(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0015_entity_listing_index.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"CREATE INDEX IF NOT EXISTS entities_canonical_name_idx", "ON entities (canonical_name ASC, id ASC)"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("entity listing migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "CONCURRENTLY") {
|
||||
t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
|
||||
}
|
||||
}
|
||||
func TestPostgreSQLMigrationsAreRestartSafe(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := NewPool(ctx, Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := Ping(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, pool); err != nil {
|
||||
t.Fatalf("repeated migration: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations WHERE id = '0001_foundation'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("migration count = %d, want 1", count)
|
||||
}
|
||||
var inventoryCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations WHERE id = '0002_inventory'`).Scan(&inventoryCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inventoryCount != 1 {
|
||||
t.Fatalf("inventory migration count = %d, want 1", inventoryCount)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO system_settings (key, value) VALUES ('test.persistence', '{"ok":true}') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value bool
|
||||
if err := pool.QueryRow(ctx, `SELECT value->>'ok' = 'true' FROM system_settings WHERE key = 'test.persistence'`).Scan(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !value {
|
||||
t.Fatal("persisted setting was not retained")
|
||||
}
|
||||
pool.Close()
|
||||
restartedPool, err := NewPool(ctx, Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen database pool: %v", err)
|
||||
}
|
||||
defer restartedPool.Close()
|
||||
var afterRestart bool
|
||||
if err := restartedPool.QueryRow(ctx, `SELECT value->>'ok' = 'true' FROM system_settings WHERE key = 'test.persistence'`).Scan(&afterRestart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !afterRestart {
|
||||
t.Fatal("persisted setting was not retained after pool restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleMigrationContainsVersionSafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0006_alert_rules.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, table := range []string{"alert_rules", "alert_rule_versions"} {
|
||||
if !strings.Contains(sql, "CREATE TABLE "+table) {
|
||||
t.Fatalf("migration is missing table %s", table)
|
||||
}
|
||||
}
|
||||
for _, constraint := range []string{"UNIQUE (rule_id, version_number)", "ON DELETE RESTRICT", "DEFERRABLE INITIALLY DEFERRED", "WHERE enabled = true"} {
|
||||
if !strings.Contains(sql, constraint) {
|
||||
t.Fatalf("migration is missing safety constraint %q", constraint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertEvaluatorLeaseMigrationContainsExpiryIndex(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0007_alert_evaluator_leases.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"ADD COLUMN lease_owner text", "ADD COLUMN lease_until timestamptz", "CREATE INDEX job_runs_lease_idx", "status IN ('queued', 'running')"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("lease migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStateMigrationContainsLifecycleAndHistorySafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0008_alert_state.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, table := range []string{"alert_instances", "alert_occurrences"} {
|
||||
if !strings.Contains(sql, "CREATE TABLE "+table) {
|
||||
t.Fatalf("migration is missing table %s", table)
|
||||
}
|
||||
}
|
||||
for _, fragment := range []string{"UNIQUE (rule_id, fingerprint)", "UNIQUE (instance_id, evaluation_key)", "ON DELETE RESTRICT", "current_state text NOT NULL", "CREATE INDEX alert_occurrences_history_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("state migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAlertHysteresisMigrationContainsCooldownSafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0009_alert_hysteresis.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{"ADD COLUMN cooldown_seconds", "ADD COLUMN cooldown_until", "cooldown_seconds BETWEEN 0 AND 2592000", "CREATE INDEX alert_instances_cooldown_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("hysteresis migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAlertUnacknowledgeMigrationContainsConstraintSafety(t *testing.T) {
|
||||
sqlBytes, err := migrationFiles.ReadFile("migrations/0011_alert_unacknowledge.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(sqlBytes)
|
||||
for _, fragment := range []string{"DROP CONSTRAINT alert_occurrences_event_type_check", "unacknowledge", "alert_instances_acknowledged_idx"} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("unacknowledge migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestServiceProbeMigrationContainsHistoryAndAccessSafety(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0005_services_probes.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, table := range []string{"services", "service_endpoints", "probes", "probe_results", "service_certificates", "service_dependencies", "service_permissions"} {
|
||||
if !strings.Contains(sql, "CREATE TABLE "+table) {
|
||||
t.Fatalf("migration is missing table %s", table)
|
||||
}
|
||||
}
|
||||
for _, constraint := range []string{"revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0)", "ON DELETE RESTRICT", "WHERE archived_at IS NULL", "UNIQUE (probe_id, observed_at)", "permission IN ('view', 'operate', 'edit', 'admin')"} {
|
||||
if !strings.Contains(sql, constraint) {
|
||||
t.Fatalf("migration is missing safety constraint %q", constraint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSnapshotMigrationBoundsPayloadAndCapability(t *testing.T) {
|
||||
content, err := migrationFiles.ReadFile("migrations/0016_agent_snapshots.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sql := string(content)
|
||||
for _, fragment := range []string{
|
||||
"CREATE TABLE agent_snapshots",
|
||||
"observed_at timestamptz NOT NULL",
|
||||
"received_at timestamptz NOT NULL",
|
||||
"jsonb_typeof(payload) = 'object'",
|
||||
"pg_column_size(payload) <= 2097152",
|
||||
"capability IN ('host', 'processes', 'containers', 'array', 'disks', 'pools', 'shares')",
|
||||
"PRIMARY KEY (agent_id, capability)",
|
||||
"agent_snapshots_capability_freshness_idx",
|
||||
} {
|
||||
if !strings.Contains(sql, fragment) {
|
||||
t.Fatalf("agent snapshot migration is missing %q", fragment)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "CONCURRENTLY") {
|
||||
t.Fatal("migrations run inside a transaction and cannot create indexes concurrently")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY,
|
||||
external_subject text NOT NULL UNIQUE,
|
||||
display_name text NOT NULL,
|
||||
email text,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE CHECK (name IN ('viewer', 'operator', 'editor', 'administrator')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE data_sources (
|
||||
id uuid PRIMARY KEY,
|
||||
type text NOT NULL,
|
||||
name text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
configuration_ref text NOT NULL,
|
||||
capability_document jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
health_state text NOT NULL DEFAULT 'unknown' CHECK (health_state IN ('healthy', 'degraded', 'unhealthy', 'unknown')),
|
||||
last_success_at timestamptz,
|
||||
last_error_code text,
|
||||
last_error_message text,
|
||||
freshness_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE collectors (
|
||||
id uuid PRIMARY KEY,
|
||||
datasource_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
version text NOT NULL,
|
||||
heartbeat timestamptz,
|
||||
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
status text NOT NULL DEFAULT 'unknown',
|
||||
UNIQUE (datasource_id, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE entities (
|
||||
id uuid PRIMARY KEY,
|
||||
entity_type text NOT NULL,
|
||||
canonical_name text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'unknown',
|
||||
status_reasons jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
first_seen_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
tombstoned_at timestamptz,
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE entity_aliases (
|
||||
entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
|
||||
external_type text NOT NULL,
|
||||
external_id text NOT NULL,
|
||||
PRIMARY KEY (source_id, external_type, external_id)
|
||||
);
|
||||
|
||||
CREATE TABLE dashboards (
|
||||
id uuid PRIMARY KEY,
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
owner_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
scope text NOT NULL CHECK (scope IN ('personal', 'shared', 'system')),
|
||||
archived_at timestamptz,
|
||||
current_version_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE dashboard_versions (
|
||||
id uuid PRIMARY KEY,
|
||||
dashboard_id uuid NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
|
||||
version_number integer NOT NULL CHECK (version_number > 0),
|
||||
schema_version integer NOT NULL CHECK (schema_version > 0),
|
||||
document jsonb NOT NULL,
|
||||
change_summary text NOT NULL DEFAULT '',
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (dashboard_id, version_number)
|
||||
);
|
||||
|
||||
ALTER TABLE dashboards
|
||||
ADD CONSTRAINT dashboards_current_version_fk
|
||||
FOREIGN KEY (current_version_id) REFERENCES dashboard_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE events (
|
||||
id uuid PRIMARY KEY,
|
||||
event_type text NOT NULL,
|
||||
severity text NOT NULL,
|
||||
entity_id uuid REFERENCES entities(id) ON DELETE SET NULL,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
dedup_key text NOT NULL,
|
||||
summary text NOT NULL,
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
correlation_id text,
|
||||
UNIQUE (source_id, dedup_key, occurred_at)
|
||||
);
|
||||
|
||||
CREATE TABLE audit_events (
|
||||
id uuid PRIMARY KEY,
|
||||
actor text NOT NULL,
|
||||
action text NOT NULL,
|
||||
resource_type text NOT NULL,
|
||||
resource_id uuid,
|
||||
result text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
correlation_id text,
|
||||
before_diff jsonb,
|
||||
after_diff jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE job_runs (
|
||||
id uuid PRIMARY KEY,
|
||||
job_type text NOT NULL,
|
||||
job_key text NOT NULL,
|
||||
scheduled_at timestamptz NOT NULL,
|
||||
started_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
status text NOT NULL,
|
||||
counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_code text,
|
||||
correlation_id text,
|
||||
UNIQUE (job_type, job_key, scheduled_at)
|
||||
);
|
||||
|
||||
CREATE TABLE system_settings (
|
||||
key text PRIMARY KEY,
|
||||
value jsonb NOT NULL,
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX entities_type_status_idx ON entities (entity_type, status);
|
||||
CREATE INDEX events_occurred_at_idx ON events (occurred_at DESC);
|
||||
CREATE INDEX audit_events_occurred_at_idx ON audit_events (occurred_at DESC);
|
||||
CREATE INDEX job_runs_status_idx ON job_runs (status, scheduled_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE entity_facts (
|
||||
entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
field_name text NOT NULL,
|
||||
source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
|
||||
value jsonb NOT NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
valid_until timestamptz,
|
||||
PRIMARY KEY (entity_id, field_name, source_id)
|
||||
);
|
||||
|
||||
CREATE TABLE entity_overrides (
|
||||
entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
field_name text NOT NULL,
|
||||
value jsonb NOT NULL,
|
||||
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (entity_id, field_name)
|
||||
);
|
||||
|
||||
CREATE TABLE entity_relations (
|
||||
id uuid PRIMARY KEY,
|
||||
source_entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
relation_type text NOT NULL,
|
||||
target_entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
|
||||
confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
confirmed boolean NOT NULL DEFAULT false,
|
||||
first_seen_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
tombstoned_at timestamptz,
|
||||
UNIQUE (source_entity_id, relation_type, target_entity_id, source_id)
|
||||
);
|
||||
|
||||
CREATE INDEX entity_facts_source_observed_idx ON entity_facts (source_id, observed_at DESC);
|
||||
CREATE INDEX entity_relations_source_idx ON entity_relations (source_id, last_seen_at DESC);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE OR REPLACE FUNCTION prevent_dashboard_version_mutation() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'dashboard versions are immutable';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER dashboard_versions_immutable_update
|
||||
BEFORE UPDATE ON dashboard_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_dashboard_version_mutation();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dashboard_versions_created_at_idx ON dashboard_versions (dashboard_id, created_at DESC, version_number DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE dashboards ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0);
|
||||
CREATE INDEX IF NOT EXISTS dashboards_owner_revision_idx ON dashboards (owner_user_id, revision DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dashboards_name_id_idx ON dashboards (name ASC, id ASC);
|
||||
CREATE INDEX IF NOT EXISTS dashboards_scope_name_id_idx ON dashboards (scope, name ASC, id ASC);
|
||||
CREATE INDEX IF NOT EXISTS dashboards_owner_name_id_idx ON dashboards (owner_user_id, name ASC, id ASC);
|
||||
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE services (
|
||||
id uuid PRIMARY KEY,
|
||||
entity_id uuid REFERENCES entities(id) ON DELETE SET NULL,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||
description text NOT NULL DEFAULT '',
|
||||
state text NOT NULL DEFAULT 'unknown' CHECK (state IN ('up', 'degraded', 'down', 'unknown')),
|
||||
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
archived_at timestamptz,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX services_entity_idx ON services (entity_id) WHERE archived_at IS NULL;
|
||||
CREATE INDEX services_source_state_idx ON services (source_id, state, updated_at DESC);
|
||||
|
||||
CREATE TABLE service_endpoints (
|
||||
id uuid PRIMARY KEY,
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||
endpoint_type text NOT NULL CHECK (endpoint_type IN ('http', 'tcp', 'dns', 'icmp', 'tls')),
|
||||
target jsonb NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX service_endpoints_active_idx ON service_endpoints (service_id, enabled) WHERE archived_at IS NULL;
|
||||
CREATE UNIQUE INDEX service_endpoints_active_name_idx ON service_endpoints (service_id, name) WHERE archived_at IS NULL;
|
||||
|
||||
CREATE TABLE probes (
|
||||
id uuid PRIMARY KEY,
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
|
||||
endpoint_id uuid REFERENCES service_endpoints(id) ON DELETE SET NULL,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||
probe_type text NOT NULL CHECK (probe_type IN ('http', 'tcp', 'dns', 'icmp', 'tls')),
|
||||
target jsonb NOT NULL,
|
||||
interval_seconds integer NOT NULL CHECK (interval_seconds BETWEEN 5 AND 86400),
|
||||
timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 120),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
expected_status_codes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
follow_redirects boolean NOT NULL DEFAULT false,
|
||||
verify_tls boolean NOT NULL DEFAULT true,
|
||||
content_assertion jsonb,
|
||||
secret_reference text,
|
||||
network_policy_id uuid,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
archived_at timestamptz,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX probes_schedule_idx ON probes (enabled, interval_seconds, updated_at) WHERE archived_at IS NULL;
|
||||
CREATE INDEX probes_service_idx ON probes (service_id, updated_at DESC);
|
||||
CREATE UNIQUE INDEX probes_active_name_idx ON probes (service_id, name) WHERE archived_at IS NULL;
|
||||
|
||||
CREATE TABLE probe_results (
|
||||
id uuid PRIMARY KEY,
|
||||
probe_id uuid NOT NULL REFERENCES probes(id) ON DELETE RESTRICT,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
completed_at timestamptz NOT NULL,
|
||||
state text NOT NULL CHECK (state IN ('up', 'degraded', 'down', 'unknown')),
|
||||
response_time_ms integer CHECK (response_time_ms IS NULL OR response_time_ms >= 0),
|
||||
status_code integer CHECK (status_code IS NULL OR status_code BETWEEN 100 AND 599),
|
||||
error_class text,
|
||||
error_message text,
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (probe_id, observed_at)
|
||||
);
|
||||
|
||||
CREATE INDEX probe_results_history_idx ON probe_results (probe_id, observed_at DESC);
|
||||
CREATE INDEX probe_results_state_idx ON probe_results (state, observed_at DESC);
|
||||
|
||||
CREATE TABLE service_certificates (
|
||||
id uuid PRIMARY KEY,
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
|
||||
endpoint_id uuid REFERENCES service_endpoints(id) ON DELETE SET NULL,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
expires_at timestamptz,
|
||||
issuer text,
|
||||
subject text,
|
||||
hostname_valid boolean,
|
||||
verification_state text NOT NULL CHECK (verification_state IN ('valid', 'attention', 'invalid', 'unknown')),
|
||||
attributes jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (service_id, endpoint_id, observed_at)
|
||||
);
|
||||
|
||||
CREATE INDEX service_certificates_expiry_idx ON service_certificates (expires_at, observed_at DESC);
|
||||
CREATE UNIQUE INDEX service_certificates_without_endpoint_unique_idx ON service_certificates (service_id, observed_at) WHERE endpoint_id IS NULL;
|
||||
|
||||
CREATE TABLE service_dependencies (
|
||||
id uuid PRIMARY KEY,
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
|
||||
depends_on_service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
|
||||
source_id uuid REFERENCES data_sources(id) ON DELETE SET NULL,
|
||||
relation_type text NOT NULL CHECK (relation_type IN ('depends_on', 'backs', 'exposes')),
|
||||
confidence numeric(5,4) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
confirmed boolean NOT NULL DEFAULT false,
|
||||
first_seen_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
archived_at timestamptz,
|
||||
UNIQUE (service_id, depends_on_service_id, relation_type, source_id),
|
||||
CHECK (service_id <> depends_on_service_id)
|
||||
);
|
||||
|
||||
CREATE INDEX service_dependencies_source_idx ON service_dependencies (source_id, last_seen_at DESC);
|
||||
CREATE UNIQUE INDEX service_dependencies_manual_unique_idx ON service_dependencies (service_id, depends_on_service_id, relation_type) WHERE source_id IS NULL;
|
||||
|
||||
CREATE TABLE service_permissions (
|
||||
service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission text NOT NULL CHECK (permission IN ('view', 'operate', 'edit', 'admin')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (service_id, role_id, permission)
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE alert_rules (
|
||||
id uuid PRIMARY KEY,
|
||||
schema_version integer NOT NULL CHECK (schema_version = 1),
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
severity text NOT NULL CHECK (severity IN ('attention', 'degraded', 'critical')),
|
||||
scope jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
condition jsonb NOT NULL,
|
||||
evaluation_interval_seconds integer NOT NULL CHECK (evaluation_interval_seconds BETWEEN 5 AND 3600),
|
||||
pending_seconds integer NOT NULL CHECK (pending_seconds BETWEEN 0 AND 2592000),
|
||||
resolve_seconds integer NOT NULL CHECK (resolve_seconds BETWEEN 0 AND 2592000),
|
||||
unknown_behavior text NOT NULL CHECK (unknown_behavior IN ('retain-firing-as-unknown', 'become-unknown', 'ignore-short-gap')),
|
||||
group_by jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
suppress_when jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
message jsonb NOT NULL,
|
||||
current_version_id uuid,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE alert_rule_versions (
|
||||
id uuid PRIMARY KEY,
|
||||
rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE RESTRICT,
|
||||
version_number integer NOT NULL CHECK (version_number > 0),
|
||||
document jsonb NOT NULL,
|
||||
change_summary text NOT NULL DEFAULT '' CHECK (length(change_summary) <= 500),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (rule_id, version_number)
|
||||
);
|
||||
|
||||
ALTER TABLE alert_rules
|
||||
ADD CONSTRAINT alert_rules_current_version_fk
|
||||
FOREIGN KEY (current_version_id) REFERENCES alert_rule_versions(id)
|
||||
ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX alert_rules_enabled_idx ON alert_rules (enabled, evaluation_interval_seconds, updated_at DESC) WHERE enabled = true;
|
||||
CREATE INDEX alert_rules_severity_idx ON alert_rules (severity, updated_at DESC);
|
||||
CREATE INDEX alert_rule_versions_history_idx ON alert_rule_versions (rule_id, version_number DESC);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE job_runs
|
||||
ADD COLUMN lease_owner text,
|
||||
ADD COLUMN lease_until timestamptz;
|
||||
|
||||
CREATE INDEX job_runs_lease_idx ON job_runs (job_type, job_key, scheduled_at, lease_until)
|
||||
WHERE status IN ('queued', 'running');
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE alert_instances (
|
||||
id uuid PRIMARY KEY,
|
||||
rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE RESTRICT,
|
||||
rule_version_id uuid NOT NULL REFERENCES alert_rule_versions(id) ON DELETE RESTRICT,
|
||||
fingerprint text NOT NULL CHECK (length(fingerprint) BETWEEN 1 AND 160),
|
||||
entity_id uuid REFERENCES entities(id) ON DELETE RESTRICT,
|
||||
current_state text NOT NULL DEFAULT 'inactive' CHECK (current_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
|
||||
retained_state text NOT NULL DEFAULT 'inactive' CHECK (retained_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
|
||||
active_since timestamptz,
|
||||
recovery_since timestamptz,
|
||||
last_evaluated_at timestamptz NOT NULL,
|
||||
last_known_at timestamptz,
|
||||
last_value jsonb NOT NULL DEFAULT 'null'::jsonb,
|
||||
reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 500),
|
||||
source_health jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
acknowledged_by text,
|
||||
acknowledged_at timestamptz,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (rule_id, fingerprint)
|
||||
);
|
||||
|
||||
CREATE TABLE alert_occurrences (
|
||||
id uuid PRIMARY KEY,
|
||||
instance_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE RESTRICT,
|
||||
evaluation_key text NOT NULL CHECK (length(evaluation_key) BETWEEN 1 AND 160),
|
||||
event_type text NOT NULL CHECK (event_type IN ('evaluation', 'transition', 'acknowledge')),
|
||||
from_state text NOT NULL CHECK (from_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
|
||||
to_state text NOT NULL CHECK (to_state IN ('inactive', 'pending', 'firing', 'acknowledged', 'resolved', 'unknown')),
|
||||
observed_at timestamptz NOT NULL,
|
||||
value jsonb NOT NULL DEFAULT 'null'::jsonb,
|
||||
reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 500),
|
||||
source_health jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (instance_id, evaluation_key)
|
||||
);
|
||||
|
||||
CREATE INDEX alert_instances_state_idx ON alert_instances (current_state, last_evaluated_at DESC, id ASC);
|
||||
CREATE INDEX alert_instances_rule_idx ON alert_instances (rule_id, current_state, updated_at DESC, id ASC);
|
||||
CREATE INDEX alert_occurrences_history_idx ON alert_occurrences (instance_id, observed_at DESC, id ASC);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE alert_rules
|
||||
ADD COLUMN cooldown_seconds integer NOT NULL DEFAULT 0 CHECK (cooldown_seconds BETWEEN 0 AND 2592000);
|
||||
|
||||
ALTER TABLE alert_instances
|
||||
ADD COLUMN cooldown_until timestamptz;
|
||||
|
||||
CREATE INDEX alert_instances_cooldown_idx ON alert_instances (cooldown_until, current_state, id)
|
||||
WHERE cooldown_until IS NOT NULL;
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE alert_silences (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
|
||||
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
|
||||
owner text NOT NULL CHECK (char_length(owner) BETWEEN 1 AND 255),
|
||||
matchers jsonb NOT NULL CHECK (jsonb_typeof(matchers) = 'object'),
|
||||
starts_at timestamptz NOT NULL,
|
||||
expires_at timestamptz NOT NULL CHECK (expires_at > starts_at),
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
|
||||
created_by text NOT NULL CHECK (char_length(created_by) BETWEEN 1 AND 255),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_by text,
|
||||
revoked_at timestamptz,
|
||||
expired_at timestamptz,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
CHECK ((status = 'revoked' AND revoked_at IS NOT NULL) OR (status <> 'revoked' AND revoked_at IS NULL)),
|
||||
CHECK ((status = 'expired' AND expired_at IS NOT NULL) OR (status <> 'expired' AND expired_at IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX alert_silences_active_expiry_idx ON alert_silences (expires_at, id) WHERE status = 'active';
|
||||
CREATE INDEX alert_silences_listing_idx ON alert_silences (starts_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE maintenance_windows (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
|
||||
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
|
||||
selector jsonb NOT NULL CHECK (jsonb_typeof(selector) = 'object'),
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz NOT NULL CHECK (ends_at > starts_at),
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'revoked')),
|
||||
created_by text NOT NULL CHECK (char_length(created_by) BETWEEN 1 AND 255),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_by text,
|
||||
revoked_at timestamptz,
|
||||
expired_at timestamptz,
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
CHECK ((status = 'revoked' AND revoked_at IS NOT NULL) OR (status <> 'revoked' AND revoked_at IS NULL)),
|
||||
CHECK ((status = 'expired' AND expired_at IS NOT NULL) OR (status <> 'expired' AND expired_at IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX maintenance_windows_active_expiry_idx ON maintenance_windows (ends_at, id) WHERE status = 'active';
|
||||
CREATE INDEX maintenance_windows_listing_idx ON maintenance_windows (starts_at DESC, id DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE alert_occurrences
|
||||
DROP CONSTRAINT alert_occurrences_event_type_check;
|
||||
|
||||
ALTER TABLE alert_occurrences
|
||||
ADD CONSTRAINT alert_occurrences_event_type_check
|
||||
CHECK (event_type IN ('evaluation', 'transition', 'acknowledge', 'unacknowledge'));
|
||||
|
||||
CREATE INDEX alert_instances_acknowledged_idx ON alert_instances (acknowledged_at DESC, id ASC) WHERE current_state = 'acknowledged';
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE notification_channels (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 160),
|
||||
channel_type text NOT NULL CHECK (channel_type IN ('memory', 'webhook', 'email')),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
secret_ref text NOT NULL CHECK (char_length(secret_ref) BETWEEN 1 AND 255),
|
||||
configuration jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(configuration) = 'object'),
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE notification_outbox (
|
||||
id uuid PRIMARY KEY,
|
||||
idempotency_key text NOT NULL UNIQUE CHECK (char_length(idempotency_key) BETWEEN 1 AND 255),
|
||||
channel_id uuid NOT NULL REFERENCES notification_channels(id) ON DELETE RESTRICT,
|
||||
event_type text NOT NULL CHECK (event_type IN ('firing', 'recovery', 'unknown')),
|
||||
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 240),
|
||||
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 8000),
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivering', 'retry', 'delivered', 'failed')),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0 AND attempts <= 10),
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
locked_until timestamptz,
|
||||
last_error text CHECK (last_error IS NULL OR char_length(last_error) <= 500),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
delivered_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE notification_deliveries (
|
||||
id uuid PRIMARY KEY,
|
||||
outbox_id uuid NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE,
|
||||
attempt integer NOT NULL CHECK (attempt > 0),
|
||||
status text NOT NULL CHECK (status IN ('delivering', 'delivered', 'failed')),
|
||||
error text CHECK (error IS NULL OR char_length(error) <= 500),
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (outbox_id, attempt)
|
||||
);
|
||||
|
||||
CREATE INDEX notification_outbox_due_idx ON notification_outbox (next_attempt_at, id) WHERE status IN ('pending', 'retry');
|
||||
CREATE INDEX notification_outbox_channel_idx ON notification_outbox (channel_id, status, updated_at DESC, id ASC);
|
||||
CREATE INDEX notification_deliveries_history_idx ON notification_deliveries (outbox_id, occurred_at DESC, id ASC);
|
||||
@@ -0,0 +1,48 @@
|
||||
CREATE TABLE incidents (
|
||||
id uuid PRIMARY KEY,
|
||||
correlation_key text NOT NULL CHECK (char_length(correlation_key) BETWEEN 1 AND 255),
|
||||
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 240),
|
||||
summary text NOT NULL DEFAULT '' CHECK (char_length(summary) <= 2000),
|
||||
severity text NOT NULL CHECK (severity IN ('attention', 'degraded', 'critical')),
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
started_at timestamptz NOT NULL,
|
||||
resolved_at timestamptz,
|
||||
owner_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
correlation_method text NOT NULL CHECK (char_length(correlation_method) BETWEEN 1 AND 80),
|
||||
confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (status <> 'resolved' OR resolved_at IS NOT NULL),
|
||||
CHECK (status = 'resolved' OR resolved_at IS NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX incidents_active_correlation_key_uq ON incidents (correlation_key) WHERE status <> 'resolved';
|
||||
CREATE INDEX incidents_list_idx ON incidents (status, severity, updated_at DESC, id ASC);
|
||||
CREATE INDEX incidents_correlation_idx ON incidents (correlation_key, updated_at DESC, id ASC);
|
||||
|
||||
CREATE TABLE incident_alerts (
|
||||
incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
alert_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE RESTRICT,
|
||||
rationale text NOT NULL CHECK (char_length(rationale) BETWEEN 1 AND 500),
|
||||
confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
correlation_method text NOT NULL CHECK (char_length(correlation_method) BETWEEN 1 AND 80),
|
||||
is_manual boolean NOT NULL DEFAULT false,
|
||||
added_by text NOT NULL DEFAULT '' CHECK (char_length(added_by) <= 160),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (incident_id, alert_id)
|
||||
);
|
||||
|
||||
CREATE INDEX incident_alerts_alert_idx ON incident_alerts (alert_id, incident_id);
|
||||
CREATE INDEX incident_alerts_incident_idx ON incident_alerts (incident_id, created_at ASC, alert_id ASC);
|
||||
|
||||
CREATE TABLE incident_entities (
|
||||
incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
|
||||
rationale text NOT NULL CHECK (char_length(rationale) BETWEEN 1 AND 500),
|
||||
confidence numeric(4,3) NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (incident_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX incident_entities_entity_idx ON incident_entities (entity_id, incident_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE incident_notes (
|
||||
id uuid PRIMARY KEY,
|
||||
incident_id uuid NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
||||
author text NOT NULL CHECK (char_length(author) BETWEEN 1 AND 160),
|
||||
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX incident_notes_history_idx ON incident_notes (incident_id, created_at ASC, id ASC);
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX IF NOT EXISTS entities_canonical_name_idx ON entities (canonical_name ASC, id ASC);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- pulse-agent writes the newest bounded telemetry snapshot per capability here and
|
||||
-- pulse-api reads it. There is exactly one row per (agent, capability): history lives in
|
||||
-- Prometheus, not in this table, so the transport cannot grow without bound.
|
||||
CREATE TABLE agent_snapshots (
|
||||
agent_id text NOT NULL CHECK (char_length(agent_id) BETWEEN 1 AND 128),
|
||||
capability text NOT NULL CHECK (capability IN ('host', 'processes', 'containers', 'array', 'disks', 'pools', 'shares')),
|
||||
observed_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- The authoritative size bound is enforced by the writer against the encoded payload
|
||||
-- (agentstore.MaxPayloadBytes). This check is a storage backstop against a writer that
|
||||
-- bypasses the store; pg_column_size reports the stored, possibly compressed size.
|
||||
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object' AND pg_column_size(payload) <= 2097152),
|
||||
PRIMARY KEY (agent_id, capability)
|
||||
);
|
||||
|
||||
-- The API reads the newest snapshot for one capability across agents on every request.
|
||||
CREATE INDEX agent_snapshots_capability_freshness_idx ON agent_snapshots (capability, observed_at DESC);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Worker runtime support.
|
||||
--
|
||||
-- container_aliases is the durable memory the background discovery job needs to
|
||||
-- be idempotent across restarts: reconciliation.ReconcileContainers must be able
|
||||
-- to compare the current runtime snapshot against the previous one to keep a
|
||||
-- stable entity identity across container recreation, and lifecycle event
|
||||
-- derivation must compare the previous observed state/health/restart count to
|
||||
-- decide whether anything actually changed. Both inputs are per runtime alias,
|
||||
-- not per entity, so they cannot be expressed with entity_aliases (which has no
|
||||
-- runtime identity or observation columns).
|
||||
--
|
||||
-- A runtime alias that stops being observed is tombstoned, never deleted, which
|
||||
-- is what keeps a temporarily unhealthy source from erasing inventory.
|
||||
CREATE TABLE container_aliases (
|
||||
source_id uuid NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
|
||||
runtime_id text NOT NULL CHECK (length(runtime_id) BETWEEN 1 AND 255),
|
||||
entity_id uuid NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 255),
|
||||
project text NOT NULL DEFAULT '' CHECK (length(project) <= 255),
|
||||
service text NOT NULL DEFAULT '' CHECK (length(service) <= 255),
|
||||
image_digest text NOT NULL DEFAULT '' CHECK (length(image_digest) <= 255),
|
||||
observed_state text NOT NULL DEFAULT '' CHECK (length(observed_state) <= 64),
|
||||
observed_health text NOT NULL DEFAULT '' CHECK (length(observed_health) <= 64),
|
||||
restart_count integer NOT NULL DEFAULT 0 CHECK (restart_count >= 0),
|
||||
intentional_stop boolean NOT NULL DEFAULT false,
|
||||
first_seen_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz NOT NULL,
|
||||
tombstoned_at timestamptz,
|
||||
PRIMARY KEY (source_id, runtime_id)
|
||||
);
|
||||
|
||||
CREATE INDEX container_aliases_entity_idx ON container_aliases (entity_id, last_seen_at DESC);
|
||||
CREATE INDEX container_aliases_active_idx ON container_aliases (source_id, last_seen_at DESC) WHERE tombstoned_at IS NULL;
|
||||
|
||||
-- The system status endpoint reports each background job's last outcome by
|
||||
-- reading the newest job_runs row per job_type. job_runs_status_idx leads with
|
||||
-- status, so it cannot serve that lookup; this index can.
|
||||
CREATE INDEX job_runs_recent_idx ON job_runs (job_type, scheduled_at DESC, id ASC);
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE INDEX IF NOT EXISTS entities_type_canonical_idx
|
||||
ON entities (entity_type, canonical_name, id)
|
||||
WHERE tombstoned_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entities_status_canonical_idx
|
||||
ON entities (status, canonical_name, id)
|
||||
WHERE tombstoned_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_relations_source_entity_idx
|
||||
ON entity_relations (source_entity_id, relation_type, target_entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_relations_target_entity_idx
|
||||
ON entity_relations (target_entity_id, relation_type, source_entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_facts_freshness_idx
|
||||
ON entity_facts (entity_id, valid_until, field_name, observed_at DESC);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE capacity_samples (
|
||||
entity_kind text NOT NULL CHECK (entity_kind IN ('share', 'pool', 'disk')),
|
||||
entity_id text NOT NULL CHECK (length(entity_id) BETWEEN 1 AND 128),
|
||||
entity_name text NOT NULL CHECK (length(entity_name) BETWEEN 1 AND 255),
|
||||
source_id text NOT NULL CHECK (length(source_id) BETWEEN 1 AND 128),
|
||||
sampled_at timestamptz NOT NULL,
|
||||
observed_at timestamptz NOT NULL,
|
||||
used_bytes bigint NOT NULL CHECK (used_bytes >= 0),
|
||||
capacity_bytes bigint NOT NULL CHECK (capacity_bytes >= 0),
|
||||
PRIMARY KEY (entity_kind, entity_id, source_id, sampled_at)
|
||||
);
|
||||
|
||||
CREATE INDEX capacity_samples_history_idx
|
||||
ON capacity_samples (entity_kind, entity_id, sampled_at DESC);
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX service_certificates_service_history_idx
|
||||
ON service_certificates (service_id, observed_at DESC, id ASC);
|
||||
Reference in New Issue
Block a user