diff --git a/AGENTS.md b/AGENTS.md index 1bd0b9f6..c00dba62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # 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. diff --git a/CLAUDE.md b/CLAUDE.md index 19232aac..2b25b9d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,24 +57,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar ## Database Migrations 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. -- 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. +- V1 — Consolidated baseline schema. All prior V1–V18 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`. 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 — 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. diff --git a/cameleer-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql b/cameleer-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql deleted file mode 100644 index 6ad5b3c0..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE app_versions ADD COLUMN detected_runtime_type VARCHAR; -ALTER TABLE app_versions ADD COLUMN detected_main_class VARCHAR; diff --git a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql deleted file mode 100644 index 48c803cd..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql +++ /dev/null @@ -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); 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 deleted file mode 100644 index 35caf76b..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V12__alerting_tables.sql +++ /dev/null @@ -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) -); diff --git a/cameleer-server-app/src/main/resources/db/migration/V13__alert_instances_open_unique.sql b/cameleer-server-app/src/main/resources/db/migration/V13__alert_instances_open_unique.sql deleted file mode 100644 index 9881f9a1..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V13__alert_instances_open_unique.sql +++ /dev/null @@ -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'); diff --git a/cameleer-server-app/src/main/resources/db/migration/V14__fix_null_firemode_alert_rules.sql b/cameleer-server-app/src/main/resources/db/migration/V14__fix_null_firemode_alert_rules.sql deleted file mode 100644 index dbe76313..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V14__fix_null_firemode_alert_rules.sql +++ /dev/null @@ -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; diff --git a/cameleer-server-app/src/main/resources/db/migration/V15__alert_instances_open_rule_exchange_unique.sql b/cameleer-server-app/src/main/resources/db/migration/V15__alert_instances_open_rule_exchange_unique.sql deleted file mode 100644 index 544ed196..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V15__alert_instances_open_rule_exchange_unique.sql +++ /dev/null @@ -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 = } --- 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'); diff --git a/cameleer-server-app/src/main/resources/db/migration/V16__alert_instances_subject_fingerprint.sql b/cameleer-server-app/src/main/resources/db/migration/V16__alert_instances_subject_fingerprint.sql deleted file mode 100644 index c9e27777..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V16__alert_instances_subject_fingerprint.sql +++ /dev/null @@ -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'); diff --git a/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql b/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql deleted file mode 100644 index 35a9e1dd..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql +++ /dev/null @@ -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; diff --git a/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql b/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql deleted file mode 100644 index b9c208a4..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V18__condition_kind_add_agent_lifecycle.sql +++ /dev/null @@ -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'; diff --git a/cameleer-server-app/src/main/resources/db/migration/V1__init.sql b/cameleer-server-app/src/main/resources/db/migration/V1__init.sql index 7b3be360..8e02e0c4 100644 --- a/cameleer-server-app/src/main/resources/db/migration/V1__init.sql +++ b/cameleer-server-app/src/main/resources/db/migration/V1__init.sql @@ -1,34 +1,61 @@ -- 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 ( - user_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - email TEXT, - display_name TEXT, - password_hash TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + user_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + email TEXT, + display_name TEXT, + password_hash TEXT, + failed_login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + token_revoked_before TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT 'custom', - system BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'custom', + system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); 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-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); CREATE TABLE groups ( @@ -37,58 +64,145 @@ CREATE TABLE groups ( parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE INDEX idx_groups_parent ON groups (parent_group_id); INSERT INTO groups (id, name) VALUES ('00000000-0000-0000-0000-000000000010', 'Admins'); CREATE TABLE group_roles ( 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) ); +CREATE INDEX idx_group_roles_group_id ON group_roles (group_id); INSERT INTO group_roles (group_id, role_id) VALUES ('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004'); -CREATE TABLE user_groups ( - user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, group_id) +-- Claim-mapping rules (OIDC). Declared before user_roles/user_groups +-- because those tables carry an FK to claim_mapping_rules.id. +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 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 ( - user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, role_id) + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + 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 INDEX idx_user_groups_user_id ON user_groups(user_id); -CREATE INDEX idx_group_roles_group_id ON group_roles(group_id); -CREATE INDEX idx_groups_parent ON groups(parent_group_id); +CREATE TABLE user_groups ( + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + 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 -- ============================================================= CREATE TABLE server_config ( - config_key TEXT PRIMARY KEY, - config_val JSONB NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_by TEXT + config_key TEXT PRIMARY KEY, + config_val JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + 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 ( - application TEXT NOT NULL, - environment TEXT NOT NULL, - config_val JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_by TEXT, + application TEXT NOT NULL, + environment TEXT NOT NULL, + config_val JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT, PRIMARY KEY (application, environment) ); @@ -110,20 +224,169 @@ CREATE TABLE app_settings ( -- ============================================================= CREATE TABLE audit_log ( - id BIGSERIAL PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - username TEXT NOT NULL, - action TEXT NOT NULL, - category TEXT NOT NULL, - target TEXT, - detail JSONB, - result TEXT NOT NULL, - ip_address TEXT, - user_agent TEXT + id BIGSERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + username TEXT NOT NULL, + action TEXT NOT NULL, + category TEXT NOT NULL, + target TEXT, + detail JSONB, + result TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT ); - 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_category ON audit_log (category); -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_username ON audit_log (username); +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_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'; diff --git a/cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql b/cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql deleted file mode 100644 index 8ce903f0..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql +++ /dev/null @@ -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); diff --git a/cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql b/cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql deleted file mode 100644 index 1f03ce42..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql +++ /dev/null @@ -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'); diff --git a/cameleer-server-app/src/main/resources/db/migration/V4__environment_config.sql b/cameleer-server-app/src/main/resources/db/migration/V4__environment_config.sql deleted file mode 100644 index bf1969d7..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V4__environment_config.sql +++ /dev/null @@ -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; diff --git a/cameleer-server-app/src/main/resources/db/migration/V5__app_container_config.sql b/cameleer-server-app/src/main/resources/db/migration/V5__app_container_config.sql deleted file mode 100644 index 5026bd23..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V5__app_container_config.sql +++ /dev/null @@ -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 '{}'; diff --git a/cameleer-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql b/cameleer-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql deleted file mode 100644 index 09deb468..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE environments ADD COLUMN jar_retention_count INTEGER DEFAULT 5; diff --git a/cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql b/cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql deleted file mode 100644 index 79b5aa59..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql +++ /dev/null @@ -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; diff --git a/cameleer-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql b/cameleer-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql deleted file mode 100644 index b66bf4d8..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE deployments ADD COLUMN resolved_config JSONB; diff --git a/cameleer-server-app/src/main/resources/db/migration/V9__password_hardening.sql b/cameleer-server-app/src/main/resources/db/migration/V9__password_hardening.sql deleted file mode 100644 index feb395b8..00000000 --- a/cameleer-server-app/src/main/resources/db/migration/V9__password_hardening.sql +++ /dev/null @@ -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; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java new file mode 100644 index 00000000..5c547c77 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/SchemaBootstrapIT.java @@ -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 + } +} 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 deleted file mode 100644 index a6d5bcd7..00000000 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java +++ /dev/null @@ -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; - } -} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java deleted file mode 100644 index 4854a9ce..00000000 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java +++ /dev/null @@ -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(); - } -} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java deleted file mode 100644 index 1551143d..00000000 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V18MigrationIT.java +++ /dev/null @@ -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"); - } -}