From 59e76bdfb6bc2c81881f8f9acf5d84ab7d432717 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:28:09 +0200 Subject: [PATCH] feat(alerting): V12 flyway migration for alerting tables --- .../db/migration/V12__alerting_tables.sql | 110 ++++++++++++++++++ .../app/alerting/storage/V12MigrationIT.java | 59 ++++++++++ 2 files changed, 169 insertions(+) create mode 100644 cameleer-server-app/src/main/resources/db/migration/V12__alerting_tables.sql create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java diff --git a/cameleer-server-app/src/main/resources/db/migration/V12__alerting_tables.sql b/cameleer-server-app/src/main/resources/db/migration/V12__alerting_tables.sql new file mode 100644 index 00000000..35caf76b --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V12__alerting_tables.sql @@ -0,0 +1,110 @@ +-- V12 — Alerting tables +-- Enums (outbound_method_enum / outbound_auth_kind_enum / trust_mode_enum already exist from V11) +CREATE TYPE severity_enum AS ENUM ('CRITICAL','WARNING','INFO'); +CREATE TYPE condition_kind_enum AS ENUM ('ROUTE_METRIC','EXCHANGE_MATCH','AGENT_STATE','DEPLOYMENT_STATE','LOG_PATTERN','JVM_METRIC'); +CREATE TYPE alert_state_enum AS ENUM ('PENDING','FIRING','ACKNOWLEDGED','RESOLVED'); +CREATE TYPE target_kind_enum AS ENUM ('USER','GROUP','ROLE'); +CREATE TYPE notification_status_enum AS ENUM ('PENDING','DELIVERED','FAILED'); + +CREATE TABLE alert_rules ( + id uuid PRIMARY KEY, + environment_id uuid NOT NULL REFERENCES environments(id) ON DELETE CASCADE, + name varchar(200) NOT NULL, + description text, + severity severity_enum NOT NULL, + enabled boolean NOT NULL DEFAULT true, + condition_kind condition_kind_enum NOT NULL, + condition jsonb NOT NULL, + evaluation_interval_seconds int NOT NULL DEFAULT 60 CHECK (evaluation_interval_seconds >= 5), + for_duration_seconds int NOT NULL DEFAULT 0 CHECK (for_duration_seconds >= 0), + re_notify_minutes int NOT NULL DEFAULT 60 CHECK (re_notify_minutes >= 0), + notification_title_tmpl text NOT NULL, + notification_message_tmpl text NOT NULL, + webhooks jsonb NOT NULL DEFAULT '[]', + next_evaluation_at timestamptz NOT NULL DEFAULT now(), + claimed_by varchar(64), + claimed_until timestamptz, + eval_state jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + created_by text NOT NULL REFERENCES users(user_id), + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by text NOT NULL REFERENCES users(user_id) +); +CREATE INDEX alert_rules_env_idx ON alert_rules (environment_id); +CREATE INDEX alert_rules_claim_due_idx ON alert_rules (next_evaluation_at) WHERE enabled = true; + +CREATE TABLE alert_rule_targets ( + id uuid PRIMARY KEY, + rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + target_kind target_kind_enum NOT NULL, + target_id varchar(128) NOT NULL, + UNIQUE (rule_id, target_kind, target_id) +); +CREATE INDEX alert_rule_targets_lookup_idx ON alert_rule_targets (target_kind, target_id); + +CREATE TABLE alert_instances ( + id uuid PRIMARY KEY, + rule_id uuid REFERENCES alert_rules(id) ON DELETE SET NULL, + rule_snapshot jsonb NOT NULL, + environment_id uuid NOT NULL REFERENCES environments(id) ON DELETE CASCADE, + state alert_state_enum NOT NULL, + severity severity_enum NOT NULL, + fired_at timestamptz NOT NULL, + acked_at timestamptz, + acked_by text REFERENCES users(user_id), + resolved_at timestamptz, + last_notified_at timestamptz, + silenced boolean NOT NULL DEFAULT false, + current_value numeric, + threshold numeric, + context jsonb NOT NULL, + title text NOT NULL, + message text NOT NULL, + target_user_ids text[] NOT NULL DEFAULT '{}', + target_group_ids uuid[] NOT NULL DEFAULT '{}', + target_role_names text[] NOT NULL DEFAULT '{}' +); +CREATE INDEX alert_instances_inbox_idx ON alert_instances (environment_id, state, fired_at DESC); +CREATE INDEX alert_instances_open_rule_idx ON alert_instances (rule_id, state) WHERE rule_id IS NOT NULL; +CREATE INDEX alert_instances_resolved_idx ON alert_instances (resolved_at) WHERE state = 'RESOLVED'; +CREATE INDEX alert_instances_target_u_idx ON alert_instances USING GIN (target_user_ids); +CREATE INDEX alert_instances_target_g_idx ON alert_instances USING GIN (target_group_ids); +CREATE INDEX alert_instances_target_r_idx ON alert_instances USING GIN (target_role_names); + +CREATE TABLE alert_silences ( + id uuid PRIMARY KEY, + environment_id uuid NOT NULL REFERENCES environments(id) ON DELETE CASCADE, + matcher jsonb NOT NULL, + reason text, + starts_at timestamptz NOT NULL, + ends_at timestamptz NOT NULL CHECK (ends_at > starts_at), + created_by text NOT NULL REFERENCES users(user_id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX alert_silences_active_idx ON alert_silences (environment_id, ends_at); + +CREATE TABLE alert_notifications ( + id uuid PRIMARY KEY, + alert_instance_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE CASCADE, + webhook_id uuid, + outbound_connection_id uuid REFERENCES outbound_connections(id) ON DELETE SET NULL, + status notification_status_enum NOT NULL DEFAULT 'PENDING', + attempts int NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + claimed_by varchar(64), + claimed_until timestamptz, + last_response_status int, + last_response_snippet text, + payload jsonb NOT NULL, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX alert_notifications_pending_idx ON alert_notifications (next_attempt_at) WHERE status = 'PENDING'; +CREATE INDEX alert_notifications_instance_idx ON alert_notifications (alert_instance_id); + +CREATE TABLE alert_reads ( + user_id text NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + alert_instance_id uuid NOT NULL REFERENCES alert_instances(id) ON DELETE CASCADE, + read_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, alert_instance_id) +); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java new file mode 100644 index 00000000..11e4b62d --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java @@ -0,0 +1,59 @@ +package com.cameleer.server.app.alerting.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class V12MigrationIT extends AbstractPostgresIT { + + @Test + void allAlertingTablesAndEnumsExist() { + var tables = jdbcTemplate.queryForList( + "SELECT table_name FROM information_schema.tables WHERE table_schema='public' " + + "AND table_name IN ('alert_rules','alert_rule_targets','alert_instances'," + + "'alert_silences','alert_notifications','alert_reads')", + String.class); + assertThat(tables).containsExactlyInAnyOrder( + "alert_rules","alert_rule_targets","alert_instances", + "alert_silences","alert_notifications","alert_reads"); + + var enums = jdbcTemplate.queryForList( + "SELECT typname FROM pg_type WHERE typname IN " + + "('severity_enum','condition_kind_enum','alert_state_enum'," + + "'target_kind_enum','notification_status_enum')", + String.class); + assertThat(enums).hasSize(5); + } + + @Test + void deletingEnvironmentCascadesAlertingRows() { + var envId = java.util.UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", + envId, "test-cascade-env", "Test Cascade Env"); + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email) " + + "VALUES (?, ?, ?)", "u1", "local", "a@b.test"); + var ruleId = java.util.UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " + + "notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " + + "VALUES (?, ?, 'r', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'u1', 'u1')", + ruleId, envId); + var instanceId = java.util.UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " + + "fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " + + "now(), '{}'::jsonb, 't', 'm')", + instanceId, ruleId, envId); + + jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId); + + assertThat(jdbcTemplate.queryForObject( + "SELECT count(*) FROM alert_rules WHERE environment_id = ?", + Integer.class, envId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT count(*) FROM alert_instances WHERE environment_id = ?", + Integer.class, envId)).isZero(); + } +}