refactor(schema): collapse V1..V18 into single V1__init.sql baseline
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m17s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled

The project is still greenfield (no production deployment) so this is
the last safe moment to flatten the migration archaeology before the
checksum history starts mattering for real.

Schema changes
 - 18 migration files (531 lines) → one V1__init.sql (~380 lines)
   declaring the final end-state: RBAC + claim mappings + runtime
   management + config + audit + outbound + alerting, plus seed data
   (system roles, Admins group, default environment).
 - Drops the data-repair statements from V14 (firemode backfill),
   V16 (subjectFingerprint migration), V17 (ACKNOWLEDGED → FIRING
   coercion) — they were no-ops on any DB that starts at V1.
 - Declares condition_kind_enum with AGENT_LIFECYCLE from the start
   (was added retroactively by V18).
 - Declares alert_state_enum with three values only (was five, then
   swapped in V17) and alert_instances with read_at / deleted_at
   columns from day one (was added by V17).
 - alert_reads table never created (V12 created, V17 dropped).
 - alert_instances_open_rule_uq built with the V17 predicate from
   the start.

Test changes
 - Replace V12MigrationIT / V17MigrationIT / V18MigrationIT with one
   SchemaBootstrapIT that asserts the combined invariants: tables
   present, alert_reads absent, enum value sets, alert_instances has
   read_at + deleted_at, open_rule_uq exists and is unique, env-delete
   cascade fires.

Verification
 - pg_dump of the new V1 matches the pg_dump of V1..V18 applied in
   sequence (bytewise modulo column order and Postgres-auto FK names).
 - Full alerting IT suite (53 tests across 6 classes) green against
   the new schema.
 - The 47 pre-existing test failures on main (AgentRegistrationIT,
   SearchControllerIT, ClickHouseStatsStoreIT, …) are unrelated and
   fail identically without this change.

Developer impact
 - Existing local DBs will fail checksum validation on boot. Wipe:
   docker compose down -v  (or drop the tenant_default schema).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 20:52:22 +02:00
parent 74bfabf618
commit 90083f886a
24 changed files with 468 additions and 638 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-server** (8801 symbols, 22808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -57,24 +57,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
## Database Migrations ## Database Migrations
PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/` PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
- V1 — RBAC (users, roles, groups, audit_log). `application_config` PK is `(application, environment)`; `app_settings` PK is `(application_id, environment)` — both tables are env-scoped. - V1 — Consolidated baseline schema. All prior V1V18 evolution was collapsed before first prod deploy. Contains: RBAC (users, roles, groups, user_roles, user_groups, group_roles, claim_mapping_rules), runtime management (environments, apps, app_versions, deployments), env-scoped application config (application_config PK `(application, environment)`, app_settings PK `(application_id, environment)`), audit_log, outbound_connections, server_config, and the full alerting subsystem (alert_rules, alert_rule_targets, alert_instances, alert_silences, alert_notifications). Seeds the 4 system roles (AGENT/VIEWER/OPERATOR/ADMIN), the `Admins` group with ADMIN role, and a default environment. Invariants covered by `SchemaBootstrapIT`.
- V2 — Claim mappings (OIDC)
- V3 — Runtime management (apps, environments, deployments, app_versions)
- V4 — Environment config (default_container_config JSONB)
- V5 — App container config (container_config JSONB on apps)
- V6 — JAR retention policy (jar_retention_count on environments)
- V7 — Deployment orchestration (target_state, deployment_strategy, replica_states JSONB, deploy_stage)
- V8 — Deployment active config (resolved_config JSONB on deployments)
- V9 — Password hardening (failed_login_attempts, locked_until, token_revoked_before on users)
- V10 — Runtime type detection (detected_runtime_type, detected_main_class on app_versions)
- V11 — Outbound connections (outbound_connections table, enums)
- V12 — Alerting tables (alert_rules, alert_rule_targets, alert_instances, alert_notifications, alert_reads, alert_silences)
- V13 — alert_instances open-rule unique index (alert_instances_open_rule_uq partial index on rule_id WHERE state IN PENDING/FIRING/ACKNOWLEDGED)
- V14 — Repair EXCHANGE_MATCH alert_rules persisted with fireMode=null (sets fireMode=PER_EXCHANGE + perExchangeLingerSeconds=300); paired with stricter `ExchangeMatchCondition` ctor that now rejects null fireMode.
- V15 — Discriminate open-instance uniqueness by `context->'exchange'->>'id'` so EXCHANGE_MATCH/PER_EXCHANGE emits one alert_instance per matching exchange; scalar kinds resolve to `''` and keep one-open-per-rule.
- V16 — Generalise the V15 discriminator to prefer `context->>'_subjectFingerprint'` (falls back to the V15 `exchange.id` expression for legacy rows). Enables AGENT_LIFECYCLE to emit one alert_instance per `(agent, eventType, timestamp)` via a canonical fingerprint in the evaluator firing's context.
- V17 — Alerts inbox redesign: drop `ACKNOWLEDGED` from `alert_state_enum` (ack is now orthogonal via `acked_at`), add `read_at` + `deleted_at` timestamp columns (global, no per-user tracking), drop `alert_reads` table entirely, rework the V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL` so ack doesn't close the slot and soft-delete frees it.
- V18 — Add `AGENT_LIFECYCLE` to `condition_kind_enum`. The Java `ConditionKind` enum had shipped with this value since the alerting branch, but no migration ever extended the Postgres type — any insert of a rule with `conditionKind=AGENT_LIFECYCLE` failed with `invalid input value for enum condition_kind_enum`. Single-statement migration because `ALTER TYPE ... ADD VALUE` can't coexist with usage of the new value in the same transaction.
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup) ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
@@ -102,7 +85,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8780 symbols, 22753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-server** (8801 symbols, 22808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,2 +0,0 @@
ALTER TABLE app_versions ADD COLUMN detected_runtime_type VARCHAR;
ALTER TABLE app_versions ADD COLUMN detected_main_class VARCHAR;

View File

@@ -1,30 +0,0 @@
-- V11 — Outbound connections (admin-managed HTTPS destinations)
-- See: docs/superpowers/specs/2026-04-19-alerting-design.md §6
CREATE TYPE trust_mode_enum AS ENUM ('SYSTEM_DEFAULT','TRUST_ALL','TRUST_PATHS');
CREATE TYPE outbound_method_enum AS ENUM ('POST','PUT','PATCH');
CREATE TYPE outbound_auth_kind_enum AS ENUM ('NONE','BEARER','BASIC');
CREATE TABLE outbound_connections (
id uuid PRIMARY KEY,
tenant_id varchar(64) NOT NULL,
name varchar(100) NOT NULL,
description text,
url text NOT NULL CHECK (url ~ '^https://'),
method outbound_method_enum NOT NULL,
default_headers jsonb NOT NULL DEFAULT '{}',
default_body_tmpl text,
tls_trust_mode trust_mode_enum NOT NULL DEFAULT 'SYSTEM_DEFAULT',
tls_ca_pem_paths jsonb NOT NULL DEFAULT '[]',
hmac_secret_ciphertext text,
auth_kind outbound_auth_kind_enum NOT NULL DEFAULT 'NONE',
auth_config jsonb NOT NULL DEFAULT '{}',
allowed_environment_ids uuid[] 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),
CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name)
);
CREATE INDEX outbound_connections_tenant_idx ON outbound_connections (tenant_id);

View File

@@ -1,110 +0,0 @@
-- 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)
);

View File

@@ -1,7 +0,0 @@
-- V13 — Unique partial index: at most one open alert_instance per rule
-- Prevents duplicate FIRING rows in multi-replica deployments.
-- The Java save() path catches DuplicateKeyException and log-and-skips the losing insert.
CREATE UNIQUE INDEX alert_instances_open_rule_uq
ON alert_instances (rule_id)
WHERE rule_id IS NOT NULL
AND state IN ('PENDING','FIRING','ACKNOWLEDGED');

View File

@@ -1,19 +0,0 @@
-- V14 — Repair EXCHANGE_MATCH rules persisted with fireMode=null.
-- Root cause: the rule editor wizard reset the condition payload on kind
-- change without seeding a fireMode default, and the backend ctor allowed
-- null to pass. Stricter ctor in ExchangeMatchCondition now rejects null
-- fireMode — existing rows need to be repaired so startup doesn't fail
-- deserialization and the evaluator stops NPE-looping on them.
--
-- Default to PER_EXCHANGE + perExchangeLingerSeconds=300 — the same default
-- the UI now seeds when a user picks EXCHANGE_MATCH.
UPDATE alert_rules
SET condition = jsonb_set(
jsonb_set(condition, '{fireMode}', '"PER_EXCHANGE"', true),
'{perExchangeLingerSeconds}',
COALESCE(condition->'perExchangeLingerSeconds', '300'::jsonb),
true
),
updated_at = now()
WHERE condition_kind = 'EXCHANGE_MATCH'
AND (condition->>'fireMode') IS NULL;

View File

@@ -1,24 +0,0 @@
-- V15 — Discriminate open-alert_instance uniqueness by exchange id for PER_EXCHANGE rules.
--
-- V13 enforced "one open alert_instance per rule" via a partial unique on
-- (rule_id). That is correct for every scalar condition kind (ROUTE_METRIC,
-- AGENT_STATE, DEPLOYMENT_STATE, LOG_PATTERN, JVM_METRIC, and EXCHANGE_MATCH
-- in COUNT_IN_WINDOW mode) but wrong for EXCHANGE_MATCH / PER_EXCHANGE, which
-- by design emits one alert_instance per matching exchange. Under V13 every
-- PER_EXCHANGE tick with >1 match logged "Skipped duplicate open alert_instance
-- for rule …" at evaluator cadence and silently lost alert fidelity — only the
-- first matching exchange per tick got an AlertInstance + webhook dispatch.
--
-- New discriminator: (rule_id, COALESCE(context->'exchange'->>'id', '')).
-- Scalar evaluators emit Map.of() (no exchange subtree), the expression
-- resolves to '' for all of them, so they continue to get "one open per rule".
-- ExchangeMatchEvaluator.evaluatePerExchange emits {exchange.id = <execId>}
-- per firing, so PER_EXCHANGE instances for distinct exchanges coexist.
-- Two attempts to open an instance for the same (rule, exchange) still collapse
-- to one — the repo's DuplicateKeyException fallback preserves defense-in-depth.
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
CREATE UNIQUE INDEX alert_instances_open_rule_uq
ON alert_instances (rule_id, (COALESCE(context->'exchange'->>'id', '')))
WHERE rule_id IS NOT NULL
AND state IN ('PENDING','FIRING','ACKNOWLEDGED');

View File

@@ -1,27 +0,0 @@
-- V16 — Generalise open-alert_instance uniqueness via `_subjectFingerprint`.
--
-- V15 discriminated open instances by `context->'exchange'->>'id'` so that
-- EXCHANGE_MATCH / PER_EXCHANGE could emit one instance per exchange. The new
-- AGENT_LIFECYCLE / PER_AGENT condition has the same shape but a different
-- subject key (agentId + eventType + eventTs). Rather than bolt condition-kind
-- knowledge into the index, we introduce a canonical `_subjectFingerprint`
-- field in `context` that every "per-subject" evaluator writes. The index
-- prefers it over the legacy exchange.id discriminator.
--
-- Precedence in the COALESCE:
-- 1. context->>'_subjectFingerprint' — explicit per-subject key (new)
-- 2. context->'exchange'->>'id' — legacy EXCHANGE_MATCH instances (pre-V16)
-- 3. '' — scalar condition kinds (one open per rule)
--
-- Existing open PER_EXCHANGE instances keep working because they never set
-- `_subjectFingerprint` but do carry `context.exchange.id`, so the index
-- still discriminates them correctly.
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
CREATE UNIQUE INDEX alert_instances_open_rule_uq
ON alert_instances (rule_id, (COALESCE(
context->>'_subjectFingerprint',
context->'exchange'->>'id',
'')))
WHERE rule_id IS NOT NULL
AND state IN ('PENDING','FIRING','ACKNOWLEDGED');

View File

@@ -1,53 +0,0 @@
-- V17 — Alerts: drop ACKNOWLEDGED state, add read_at/deleted_at, drop alert_reads,
-- rework open-rule unique index predicate to survive ack (acked no longer "closed").
-- 1. Coerce ACKNOWLEDGED rows → FIRING (acked_at already set on these rows)
UPDATE alert_instances SET state = 'FIRING' WHERE state = 'ACKNOWLEDGED';
-- 2. Swap alert_state_enum to remove ACKNOWLEDGED (Postgres can't drop enum values in place)
-- First drop all indexes that reference alert_state_enum so ALTER COLUMN can proceed.
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
DROP INDEX IF EXISTS alert_instances_inbox_idx;
DROP INDEX IF EXISTS alert_instances_open_rule_idx;
DROP INDEX IF EXISTS alert_instances_resolved_idx;
CREATE TYPE alert_state_enum_v2 AS ENUM ('PENDING','FIRING','RESOLVED');
ALTER TABLE alert_instances
ALTER COLUMN state TYPE alert_state_enum_v2
USING state::text::alert_state_enum_v2;
DROP TYPE alert_state_enum;
ALTER TYPE alert_state_enum_v2 RENAME TO alert_state_enum;
-- Recreate the non-unique indexes that were dropped above
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';
-- 3. New orthogonal flag columns
ALTER TABLE alert_instances
ADD COLUMN read_at timestamptz NULL,
ADD COLUMN deleted_at timestamptz NULL;
CREATE INDEX alert_instances_unread_idx
ON alert_instances (environment_id, read_at)
WHERE read_at IS NULL AND deleted_at IS NULL;
CREATE INDEX alert_instances_deleted_idx
ON alert_instances (deleted_at)
WHERE deleted_at IS NOT NULL;
-- 4. Rework the V13/V15/V16 open-rule uniqueness index:
-- - drop ACKNOWLEDGED from the predicate (ack no longer "closes")
-- - add "AND deleted_at IS NULL" so a soft-deleted row frees the slot
DROP INDEX IF EXISTS alert_instances_open_rule_uq;
CREATE UNIQUE INDEX alert_instances_open_rule_uq
ON alert_instances (rule_id, (COALESCE(
context->>'_subjectFingerprint',
context->'exchange'->>'id',
'')))
WHERE rule_id IS NOT NULL
AND state IN ('PENDING','FIRING')
AND deleted_at IS NULL;
-- 5. Drop the per-user reads table — read is now global on alert_instances.read_at
DROP TABLE alert_reads;

View File

@@ -1,10 +0,0 @@
-- V18 — Add AGENT_LIFECYCLE to condition_kind_enum.
--
-- The Java ConditionKind enum shipped AGENT_LIFECYCLE with the alerting
-- branch, but no migration ever extended the Postgres type. Inserting a
-- rule with conditionKind=AGENT_LIFECYCLE failed with
-- ERROR: invalid input value for enum condition_kind_enum: "AGENT_LIFECYCLE"
-- ALTER TYPE ... ADD VALUE must live alone in its migration — Postgres won't
-- allow the new value to be referenced in the same transaction that adds it.
ALTER TYPE condition_kind_enum ADD VALUE IF NOT EXISTS 'AGENT_LIFECYCLE' AFTER 'AGENT_STATE';

View File

@@ -1,34 +1,61 @@
-- V1__init.sql — PostgreSQL schema for Cameleer Server -- V1__init.sql — PostgreSQL schema for Cameleer Server
-- PostgreSQL stores RBAC, configuration, and audit data only. --
-- All observability data (executions, metrics, diagrams, logs, stats) is in ClickHouse. -- PostgreSQL stores RBAC, configuration, audit, runtime management,
-- outbound connections, and alerting. All observability data
-- (executions, metrics, diagrams, logs, stats) lives in ClickHouse.
--
-- This file is the consolidated baseline — the project was kept greenfield
-- and the V1..V18 evolution was collapsed before first prod deployment.
-- See commit history for the ordered migration archaeology.
-- ============================================================= -- =============================================================
-- RBAC -- Enums
-- =============================================================
CREATE TYPE alert_state_enum AS ENUM ('PENDING', 'FIRING', 'RESOLVED');
CREATE TYPE severity_enum AS ENUM ('CRITICAL', 'WARNING', 'INFO');
CREATE TYPE target_kind_enum AS ENUM ('USER', 'GROUP', 'ROLE');
CREATE TYPE notification_status_enum AS ENUM ('PENDING', 'DELIVERED', 'FAILED');
CREATE TYPE condition_kind_enum AS ENUM (
'ROUTE_METRIC', 'EXCHANGE_MATCH', 'AGENT_STATE', 'AGENT_LIFECYCLE',
'DEPLOYMENT_STATE', 'LOG_PATTERN', 'JVM_METRIC'
);
CREATE TYPE outbound_method_enum AS ENUM ('POST', 'PUT', 'PATCH');
CREATE TYPE outbound_auth_kind_enum AS ENUM ('NONE', 'BEARER', 'BASIC');
CREATE TYPE trust_mode_enum AS ENUM ('SYSTEM_DEFAULT', 'TRUST_ALL', 'TRUST_PATHS');
-- =============================================================
-- RBAC — users, roles, groups, assignments
-- ============================================================= -- =============================================================
CREATE TABLE users ( CREATE TABLE users (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
provider TEXT NOT NULL, provider TEXT NOT NULL,
email TEXT, email TEXT,
display_name TEXT, display_name TEXT,
password_hash TEXT, password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), failed_login_attempts INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() locked_until TIMESTAMPTZ,
token_revoked_before TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE TABLE roles ( CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'custom', scope TEXT NOT NULL DEFAULT 'custom',
system BOOLEAN NOT NULL DEFAULT false, system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
INSERT INTO roles (id, name, description, scope, system) VALUES INSERT INTO roles (id, name, description, scope, system) VALUES
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true), ('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true), ('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true), ('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true); ('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
CREATE TABLE groups ( CREATE TABLE groups (
@@ -37,58 +64,145 @@ CREATE TABLE groups (
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL, parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_groups_parent ON groups (parent_group_id);
INSERT INTO groups (id, name) VALUES INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins'); ('00000000-0000-0000-0000-000000000010', 'Admins');
CREATE TABLE group_roles ( CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id) PRIMARY KEY (group_id, role_id)
); );
CREATE INDEX idx_group_roles_group_id ON group_roles (group_id);
INSERT INTO group_roles (group_id, role_id) VALUES INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004'); ('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
CREATE TABLE user_groups ( -- Claim-mapping rules (OIDC). Declared before user_roles/user_groups
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, -- because those tables carry an FK to claim_mapping_rules.id.
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, CREATE TABLE claim_mapping_rules (
PRIMARY KEY (user_id, group_id) id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
claim TEXT NOT NULL,
match_type TEXT NOT NULL,
match_value TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_match_type CHECK (match_type IN ('equals', 'contains', 'regex')),
CONSTRAINT chk_action CHECK (action IN ('assignRole', 'addToGroup'))
); );
CREATE TABLE user_roles ( CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id) origin TEXT NOT NULL DEFAULT 'direct',
mapping_id UUID REFERENCES claim_mapping_rules(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id, origin)
); );
CREATE INDEX idx_user_roles_user_id ON user_roles (user_id);
CREATE INDEX idx_user_roles_origin ON user_roles (user_id, origin);
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); CREATE TABLE user_groups (
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id); user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id); group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
CREATE INDEX idx_groups_parent ON groups(parent_group_id); origin TEXT NOT NULL DEFAULT 'direct',
mapping_id UUID REFERENCES claim_mapping_rules(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id, origin)
);
CREATE INDEX idx_user_groups_user_id ON user_groups (user_id);
CREATE INDEX idx_user_groups_origin ON user_groups (user_id, origin);
-- ============================================================= -- =============================================================
-- Server configuration -- Server configuration
-- ============================================================= -- =============================================================
CREATE TABLE server_config ( CREATE TABLE server_config (
config_key TEXT PRIMARY KEY, config_key TEXT PRIMARY KEY,
config_val JSONB NOT NULL, config_val JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT updated_by TEXT
); );
-- ============================================================= -- =============================================================
-- Application configuration -- Runtime management — environments, apps, versions, deployments
-- =============================================================
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
production BOOLEAN NOT NULL DEFAULT false,
enabled BOOLEAN NOT NULL DEFAULT true,
default_container_config JSONB NOT NULL DEFAULT '{}'::jsonb,
jar_retention_count INTEGER DEFAULT 5,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Default environment — standalone mode always has at least one
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
container_config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps (environment_id);
CREATE TABLE app_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
jar_path VARCHAR(500) NOT NULL,
jar_checksum VARCHAR(64) NOT NULL,
jar_filename VARCHAR(255),
jar_size_bytes BIGINT,
detected_runtime_type VARCHAR,
detected_main_class VARCHAR,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_id, version)
);
CREATE INDEX idx_app_versions_app_id ON app_versions (app_id);
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
app_version_id UUID NOT NULL REFERENCES app_versions(id),
environment_id UUID NOT NULL REFERENCES environments(id),
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
target_state VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
deployment_strategy VARCHAR(20) NOT NULL DEFAULT 'BLUE_GREEN',
deploy_stage VARCHAR(30),
replica_states JSONB NOT NULL DEFAULT '[]'::jsonb,
resolved_config JSONB,
container_id VARCHAR(100),
container_name VARCHAR(255),
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deployments_app_id ON deployments (app_id);
CREATE INDEX idx_deployments_env_id ON deployments (environment_id);
-- =============================================================
-- Application configuration (env-scoped)
-- ============================================================= -- =============================================================
CREATE TABLE application_config ( CREATE TABLE application_config (
application TEXT NOT NULL, application TEXT NOT NULL,
environment TEXT NOT NULL, environment TEXT NOT NULL,
config_val JSONB NOT NULL, config_val JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1, version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT, updated_by TEXT,
PRIMARY KEY (application, environment) PRIMARY KEY (application, environment)
); );
@@ -110,20 +224,169 @@ CREATE TABLE app_settings (
-- ============================================================= -- =============================================================
CREATE TABLE audit_log ( CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL, username TEXT NOT NULL,
action TEXT NOT NULL, action TEXT NOT NULL,
category TEXT NOT NULL, category TEXT NOT NULL,
target TEXT, target TEXT,
detail JSONB, detail JSONB,
result TEXT NOT NULL, result TEXT NOT NULL,
ip_address TEXT, ip_address TEXT,
user_agent TEXT user_agent TEXT
); );
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC); CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username); CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category); CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action); CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target); CREATE INDEX idx_audit_log_target ON audit_log (target);
-- =============================================================
-- Outbound connections (admin-managed HTTPS targets for webhooks)
-- =============================================================
CREATE TABLE outbound_connections (
id UUID PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
url TEXT NOT NULL,
method outbound_method_enum NOT NULL,
default_headers JSONB NOT NULL DEFAULT '{}'::jsonb,
default_body_tmpl TEXT,
tls_trust_mode trust_mode_enum NOT NULL DEFAULT 'SYSTEM_DEFAULT',
tls_ca_pem_paths JSONB NOT NULL DEFAULT '[]'::jsonb,
hmac_secret_ciphertext TEXT,
auth_kind outbound_auth_kind_enum NOT NULL DEFAULT 'NONE',
auth_config JSONB NOT NULL DEFAULT '{}'::jsonb,
allowed_environment_ids UUID[] NOT NULL DEFAULT '{}'::uuid[],
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),
CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name),
CONSTRAINT outbound_connections_url_check CHECK (url ~ '^https://')
);
CREATE INDEX outbound_connections_tenant_idx ON outbound_connections (tenant_id);
-- =============================================================
-- Alerting
-- =============================================================
CREATE TABLE alert_rules (
id UUID NOT NULL 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 INTEGER NOT NULL DEFAULT 60 CHECK (evaluation_interval_seconds >= 5),
for_duration_seconds INTEGER NOT NULL DEFAULT 0 CHECK (for_duration_seconds >= 0),
re_notify_minutes INTEGER 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 '[]'::jsonb,
next_evaluation_at TIMESTAMPTZ NOT NULL DEFAULT now(),
claimed_by VARCHAR(64),
claimed_until TIMESTAMPTZ,
eval_state JSONB NOT NULL DEFAULT '{}'::jsonb,
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 NOT NULL 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 NOT NULL 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 '{}'::text[],
target_group_ids UUID[] NOT NULL DEFAULT '{}'::uuid[],
target_role_names TEXT[] NOT NULL DEFAULT '{}'::text[],
read_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
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_unread_idx ON alert_instances (environment_id, read_at)
WHERE read_at IS NULL AND deleted_at IS NULL;
CREATE INDEX alert_instances_deleted_idx ON alert_instances (deleted_at) WHERE deleted_at IS NOT NULL;
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);
-- Per-rule open-instance uniqueness. The discriminator prefers
-- context->>'_subjectFingerprint' (populated by the evaluator for
-- PER_EXCHANGE / PER_AGENT condition kinds), with a fall-through to
-- the legacy exchange-id path for rules predating the fingerprint.
-- Scalar kinds resolve to '' and retain strict one-open-per-rule.
-- Soft-deleted rows (deleted_at IS NOT NULL) free the slot so a
-- deleted alert can be re-raised.
CREATE UNIQUE INDEX alert_instances_open_rule_uq
ON alert_instances (rule_id, COALESCE(
context->>'_subjectFingerprint',
context->'exchange'->>'id',
''))
WHERE rule_id IS NOT NULL
AND state IN ('PENDING', 'FIRING')
AND deleted_at IS NULL;
CREATE TABLE alert_silences (
id UUID NOT NULL 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,
created_by TEXT NOT NULL REFERENCES users(user_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (ends_at > starts_at)
);
CREATE INDEX alert_silences_active_idx ON alert_silences (environment_id, ends_at);
CREATE TABLE alert_notifications (
id UUID NOT NULL 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 INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
claimed_by VARCHAR(64),
claimed_until TIMESTAMPTZ,
last_response_status INTEGER,
last_response_snippet TEXT,
payload JSONB NOT NULL,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX alert_notifications_instance_idx ON alert_notifications (alert_instance_id);
CREATE INDEX alert_notifications_pending_idx ON alert_notifications (next_attempt_at) WHERE status = 'PENDING';

View File

@@ -1,39 +0,0 @@
-- V2__claim_mapping.sql
-- Add origin tracking to assignment tables
ALTER TABLE user_roles ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
ALTER TABLE user_roles ADD COLUMN mapping_id UUID;
ALTER TABLE user_groups ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
ALTER TABLE user_groups ADD COLUMN mapping_id UUID;
-- Drop old primary keys (they don't include origin)
ALTER TABLE user_roles DROP CONSTRAINT user_roles_pkey;
ALTER TABLE user_roles ADD PRIMARY KEY (user_id, role_id, origin);
ALTER TABLE user_groups DROP CONSTRAINT user_groups_pkey;
ALTER TABLE user_groups ADD PRIMARY KEY (user_id, group_id, origin);
-- Claim mapping rules table
CREATE TABLE claim_mapping_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
claim TEXT NOT NULL,
match_type TEXT NOT NULL,
match_value TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_match_type CHECK (match_type IN ('equals', 'contains', 'regex')),
CONSTRAINT chk_action CHECK (action IN ('assignRole', 'addToGroup'))
);
-- Foreign key from assignments to mapping rules
ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_mapping
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
ALTER TABLE user_groups ADD CONSTRAINT fk_user_groups_mapping
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
-- Index for fast managed assignment cleanup
CREATE INDEX idx_user_roles_origin ON user_roles(user_id, origin);
CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin);

View File

@@ -1,54 +0,0 @@
-- V3__runtime_management.sql
-- Runtime management: environments, apps, app versions, deployments
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
CREATE TABLE app_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
jar_path VARCHAR(500) NOT NULL,
jar_checksum VARCHAR(64) NOT NULL,
jar_filename VARCHAR(255),
jar_size_bytes BIGINT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_app_versions_app_id ON app_versions(app_id);
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
app_version_id UUID NOT NULL REFERENCES app_versions(id),
environment_id UUID NOT NULL REFERENCES environments(id),
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
container_id VARCHAR(100),
container_name VARCHAR(255),
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
CREATE INDEX idx_deployments_env_id ON deployments(environment_id);
-- Default environment (standalone mode always has at least one)
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');

View File

@@ -1,6 +0,0 @@
-- V4__environment_config.sql
-- Add production flag and enabled flag to environments, drop unused status column
ALTER TABLE environments ADD COLUMN production BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE environments ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE environments DROP COLUMN status;

View File

@@ -1,4 +0,0 @@
-- Add container config to apps and environment defaults
ALTER TABLE apps ADD COLUMN container_config JSONB NOT NULL DEFAULT '{}';
ALTER TABLE environments ADD COLUMN default_container_config JSONB NOT NULL DEFAULT '{}';

View File

@@ -1 +0,0 @@
ALTER TABLE environments ADD COLUMN jar_retention_count INTEGER DEFAULT 5;

View File

@@ -1,12 +0,0 @@
-- Deployment orchestration: status model, replicas, strategies, progress tracking
ALTER TABLE deployments ADD COLUMN target_state VARCHAR(20) NOT NULL DEFAULT 'RUNNING';
ALTER TABLE deployments ADD COLUMN deployment_strategy VARCHAR(20) NOT NULL DEFAULT 'BLUE_GREEN';
ALTER TABLE deployments ADD COLUMN replica_states JSONB NOT NULL DEFAULT '[]';
ALTER TABLE deployments ADD COLUMN deploy_stage VARCHAR(30);
-- Backfill existing deployments
UPDATE deployments SET target_state = CASE
WHEN status = 'STOPPED' THEN 'STOPPED'
ELSE 'RUNNING'
END;

View File

@@ -1 +0,0 @@
ALTER TABLE deployments ADD COLUMN resolved_config JSONB;

View File

@@ -1,3 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_revoked_before TIMESTAMPTZ;

View File

@@ -0,0 +1,147 @@
package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Invariants of the consolidated V1 bootstrap schema. Replaces the per-migration
* ITs (V12/V17/V18) that existed while the schema evolved across 18 files.
*/
class SchemaBootstrapIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseLogStore")
ClickHouseLogStore clickHouseLogStore;
private UUID testEnvId;
private String testUserId;
@AfterEach
void cleanup() {
if (testEnvId != null) jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
if (testUserId != null) jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", testUserId);
}
@Test
void all_alerting_tables_exist() {
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')
""", String.class);
assertThat(tables).containsExactlyInAnyOrder(
"alert_rules", "alert_rule_targets", "alert_instances",
"alert_silences", "alert_notifications");
}
@Test
void alert_reads_table_absent() {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)::int FROM information_schema.tables
WHERE table_name = 'alert_reads'
""", Integer.class);
assertThat(count).isZero();
}
@Test
void alerting_enums_exist() {
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).containsExactlyInAnyOrder(
"severity_enum", "condition_kind_enum", "alert_state_enum",
"target_kind_enum", "notification_status_enum");
}
@Test
void alert_state_enum_values() {
var values = jdbcTemplate.queryForList("""
SELECT unnest(enum_range(NULL::alert_state_enum))::text
""", String.class);
assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED");
}
@Test
void condition_kind_enum_values() {
var values = jdbcTemplate.queryForList("""
SELECT unnest(enum_range(NULL::condition_kind_enum))::text
""", String.class);
assertThat(values).containsExactlyInAnyOrder(
"ROUTE_METRIC", "EXCHANGE_MATCH", "AGENT_STATE", "AGENT_LIFECYCLE",
"DEPLOYMENT_STATE", "LOG_PATTERN", "JVM_METRIC");
}
@Test
void alert_instances_has_read_at_and_deleted_at() {
var cols = jdbcTemplate.queryForList("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'alert_instances'
AND column_name IN ('read_at','deleted_at')
""", String.class);
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
}
@Test
void open_rule_unique_index_exists() {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)::int FROM pg_indexes
WHERE indexname = 'alert_instances_open_rule_uq'
AND tablename = 'alert_instances'
""", Integer.class);
assertThat(count).isEqualTo(1);
Boolean isUnique = jdbcTemplate.queryForObject("""
SELECT indisunique FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
""", Boolean.class);
assertThat(isUnique).isTrue();
}
@Test
void deleting_environment_cascades_alerting_rows() {
testEnvId = UUID.randomUUID();
testUserId = UUID.randomUUID().toString();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
testEnvId, "test-cascade-env-" + testEnvId, "Test Cascade Env");
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES (?, ?, ?)",
testUserId, "local", "cascade@example.com");
var ruleId = 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', ?, ?)",
ruleId, testEnvId, testUserId, testUserId);
var instanceId = 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, testEnvId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
assertThat(jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_rules WHERE environment_id = ?",
Integer.class, testEnvId)).isZero();
assertThat(jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_instances WHERE environment_id = ?",
Integer.class, testEnvId)).isZero();
testEnvId = null; // already deleted; skip @AfterEach
}
}

View File

@@ -1,83 +0,0 @@
package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
class V12MigrationIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private java.util.UUID testEnvId;
private String testUserId;
@AfterEach
void cleanup() {
if (testEnvId != null) jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
if (testUserId != null) jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", testUserId);
}
@Test
void allAlertingTablesAndEnumsExist() {
// Note: alert_reads was created in V12 but dropped by V17 (superseded by read_at column).
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')",
String.class);
assertThat(tables).containsExactlyInAnyOrder(
"alert_rules","alert_rule_targets","alert_instances",
"alert_silences","alert_notifications");
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).containsExactlyInAnyOrder(
"severity_enum", "condition_kind_enum", "alert_state_enum",
"target_kind_enum", "notification_status_enum");
}
@Test
void deletingEnvironmentCascadesAlertingRows() {
testEnvId = java.util.UUID.randomUUID();
testUserId = java.util.UUID.randomUUID().toString();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
testEnvId, "test-cascade-env-" + testEnvId, "Test Cascade Env");
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES (?, ?, ?)",
testUserId, "local", "test@example.com");
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', ?, ?)",
ruleId, testEnvId, testUserId, testUserId);
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, testEnvId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", testEnvId);
assertThat(jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_rules WHERE environment_id = ?",
Integer.class, testEnvId)).isZero();
assertThat(jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_instances WHERE environment_id = ?",
Integer.class, testEnvId)).isZero();
// testEnvId already deleted; null it so @AfterEach doesn't attempt a no-op delete
testEnvId = null;
}
}

View File

@@ -1,58 +0,0 @@
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 V17MigrationIT extends AbstractPostgresIT {
@Test
void alert_state_enum_drops_acknowledged() {
var values = jdbcTemplate.queryForList("""
SELECT unnest(enum_range(NULL::alert_state_enum))::text AS v
""", String.class);
assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED");
}
@Test
void read_at_and_deleted_at_columns_exist() {
var cols = jdbcTemplate.queryForList("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'alert_instances'
AND column_name IN ('read_at','deleted_at')
""", String.class);
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
}
@Test
void alert_reads_table_is_gone() {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)::int FROM information_schema.tables
WHERE table_name = 'alert_reads'
""", Integer.class);
assertThat(count).isZero();
}
@Test
void open_rule_index_exists_and_is_unique() {
// Structural check only — the pg_get_indexdef pretty-printer varies across
// Postgres versions. Predicate semantics (ack doesn't close; soft-delete
// frees the slot; RESOLVED excluded) are covered behaviorally by
// PostgresAlertInstanceRepositoryIT#findOpenForRule_* and
// #save_rejectsSecondOpenInstanceForSameRuleAndExchange.
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)::int FROM pg_indexes
WHERE indexname = 'alert_instances_open_rule_uq'
AND tablename = 'alert_instances'
""", Integer.class);
assertThat(count).isEqualTo(1);
Boolean isUnique = jdbcTemplate.queryForObject("""
SELECT indisunique FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
""", Boolean.class);
assertThat(isUnique).isTrue();
}
}

View File

@@ -1,20 +0,0 @@
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 V18MigrationIT extends AbstractPostgresIT {
@Test
void condition_kind_enum_includes_agent_lifecycle() {
var values = jdbcTemplate.queryForList("""
SELECT unnest(enum_range(NULL::condition_kind_enum))::text AS v
""", String.class);
assertThat(values).contains("AGENT_LIFECYCLE");
assertThat(values).containsExactlyInAnyOrder(
"ROUTE_METRIC", "EXCHANGE_MATCH", "AGENT_STATE", "AGENT_LIFECYCLE",
"DEPLOYMENT_STATE", "LOG_PATTERN", "JVM_METRIC");
}
}