Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions bV-local-dev-environment-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ podman compose -f docker-compose.bv.yml -p iris-bv logs -f bv-iriswebapp-app

---

## Running the test suite

`tests/*.py` talk to the app container directly on `127.0.0.1:8000` (see `API_URL` in
`tests/iris.py`), so run the stack under its own project name (`iris-test`) and only start the
services the tests need — no nginx, no frontend. Never point this project name at a real dev
stack; treat its volumes as throwaway.

Don't run this alongside a real dev stack (`iris-bv`, `iris-web-bv`, ...) — they'd collide on the
same host ports (8000/5432).

```bash
# Build and start a disposable test stack (no nginx/worker needed to exercise the REST API)
podman compose -f docker-compose.dev.yml -p iris-test up -d --build db rabbitmq app worker

# Wait for the app container to become healthy, then run the suite from the host
# (test files are named tests_*.py, not pytest's default test_*.py pattern)
cd tests
python -m unittest discover -p 'tests_*.py' -v

# Tear down completely when done — safe to delete, this project only ever holds test fixtures
podman compose -f docker-compose.dev.yml -p iris-test down -v
```

---

## Working on UI (JavaScript/Svelte) changes

The `ui/dist` folder is bind-mounted into the container at `/iriswebapp/static`, so you can
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Add incidents, incident rules, and investigation flows.
"""Add alert clusters, cluster rules, and investigation flows.

Creates the tables backing the alert-stacking + guided-triage features:

* `incident_status` — lookup (Open / Investigating / Dismissed / Escalated)
* `incidents` — the alert-container entity
* `alert_incident_association` — N:N alerts↔incidents
* `incident_rules` — flexible auto-stacking + auto-flow-attach rules
* `alert_cluster_status` — lookup (Open / Investigating / Dismissed / Escalated)
* `alert_clusters` — the alert-container entity
* `alert_cluster_association` — N:N alerts↔clusters
* `cluster_rules` — flexible auto-stacking + auto-flow-attach rules
* `investigation_flows` — named checklist definitions
* `investigation_flow_steps` — ordered steps per flow
* `alert_investigation_progress` — per-alert step check-offs
Expand All @@ -14,7 +14,7 @@
Every step is guarded by `_has_table` / `_table_has_column` so the migration
is idempotent — safe to re-run on partially-upgraded environments. Permission
flags are enum bitmasks on `Group.group_permissions` (no DB rows to seed).
The four IncidentStatus rows are seeded by `post_init.create_safe_incident_status()`
The four AlertClusterStatus rows are seeded by `post_init.create_safe_alert_cluster_status()`
at boot, not by the migration, to match how AlertStatus / AlertResolutionStatus
are handled.

Expand All @@ -35,23 +35,23 @@
depends_on = None


def _create_incident_status():
if _has_table('incident_status'):
def _create_alert_cluster_status():
if _has_table('alert_cluster_status'):
return
op.create_table(
'incident_status',
'alert_cluster_status',
sa.Column('status_id', sa.Integer(), primary_key=True),
sa.Column('status_name', sa.Text(), nullable=False),
sa.Column('status_description', sa.Text(), nullable=True),
sa.UniqueConstraint('status_name', name='uq_incident_status_name'),
sa.UniqueConstraint('status_name', name='uq_alert_cluster_status_name'),
)


def _create_incident_rules():
if _has_table('incident_rules'):
def _create_cluster_rules():
if _has_table('cluster_rules'):
return
op.create_table(
'incident_rules',
'cluster_rules',
sa.Column('rule_id', sa.BigInteger(), primary_key=True),
sa.Column('rule_uuid', sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False, server_default=sa.text('gen_random_uuid()')),
Expand All @@ -72,7 +72,7 @@ def _create_incident_rules():
server_default=sa.text('now()')),
sa.Column('rule_updated_at', sa.DateTime(), nullable=False,
server_default=sa.text('now()')),
sa.UniqueConstraint('rule_uuid', name='uq_incident_rules_uuid'),
sa.UniqueConstraint('rule_uuid', name='uq_cluster_rules_uuid'),
)


Expand Down Expand Up @@ -126,45 +126,45 @@ def _add_alert_investigation_flow_column():
)


def _create_incidents():
if _has_table('incidents'):
def _create_alert_clusters():
if _has_table('alert_clusters'):
return
op.create_table(
'incidents',
sa.Column('incident_id', sa.BigInteger(), primary_key=True),
sa.Column('incident_uuid', sa.dialects.postgresql.UUID(as_uuid=True),
'alert_clusters',
sa.Column('cluster_id', sa.BigInteger(), primary_key=True),
sa.Column('cluster_uuid', sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False, server_default=sa.text('gen_random_uuid()')),
sa.Column('incident_title', sa.Text(), nullable=False),
sa.Column('incident_description', sa.Text(), nullable=True),
sa.Column('incident_status_id', sa.Integer(),
sa.ForeignKey('incident_status.status_id'), nullable=False),
sa.Column('incident_severity_id', sa.Integer(),
sa.Column('cluster_title', sa.Text(), nullable=False),
sa.Column('cluster_description', sa.Text(), nullable=True),
sa.Column('cluster_status_id', sa.Integer(),
sa.ForeignKey('alert_cluster_status.status_id'), nullable=False),
sa.Column('cluster_severity_id', sa.Integer(),
sa.ForeignKey('severities.severity_id'), nullable=True),
sa.Column('incident_customer_id', sa.BigInteger(),
sa.Column('cluster_customer_id', sa.BigInteger(),
sa.ForeignKey('client.client_id'), nullable=False),
sa.Column('incident_owner_id', sa.BigInteger(),
sa.Column('cluster_owner_id', sa.BigInteger(),
sa.ForeignKey('user.id'), nullable=True),
sa.Column('incident_creation_time', sa.DateTime(), nullable=False,
sa.Column('cluster_creation_time', sa.DateTime(), nullable=False,
server_default=sa.text('now()')),
sa.Column('incident_source_rule_id', sa.BigInteger(),
sa.ForeignKey('incident_rules.rule_id'), nullable=True),
sa.Column('incident_case_id', sa.BigInteger(),
sa.Column('cluster_source_rule_id', sa.BigInteger(),
sa.ForeignKey('cluster_rules.rule_id'), nullable=True),
sa.Column('cluster_case_id', sa.BigInteger(),
sa.ForeignKey('cases.case_id'), nullable=True),
sa.Column('incident_dedupe_key', sa.Text(), nullable=True, index=True),
sa.Column('cluster_dedupe_key', sa.Text(), nullable=True, index=True),
sa.Column('modification_history', sa.dialects.postgresql.JSON(), nullable=True),
sa.UniqueConstraint('incident_uuid', name='uq_incidents_uuid'),
sa.UniqueConstraint('cluster_uuid', name='uq_alert_clusters_uuid'),
)


def _create_alert_incident_association():
if _has_table('alert_incident_association'):
def _create_alert_cluster_association():
if _has_table('alert_cluster_association'):
return
op.create_table(
'alert_incident_association',
'alert_cluster_association',
sa.Column('alert_id', sa.BigInteger(),
sa.ForeignKey('alerts.alert_id'), primary_key=True, nullable=False),
sa.Column('incident_id', sa.BigInteger(),
sa.ForeignKey('incidents.incident_id'), primary_key=True,
sa.Column('cluster_id', sa.BigInteger(),
sa.ForeignKey('alert_clusters.cluster_id'), primary_key=True,
nullable=False, index=True),
)

Expand Down Expand Up @@ -192,23 +192,23 @@ def _create_alert_investigation_progress():


def upgrade():
_create_incident_status()
_create_incident_rules()
_create_alert_cluster_status()
_create_cluster_rules()
_create_investigation_flows()
_create_investigation_flow_steps()
_add_alert_investigation_flow_column()
_create_incidents()
_create_alert_incident_association()
_create_alert_clusters()
_create_alert_cluster_association()
_create_alert_investigation_progress()


def downgrade():
op.drop_table('alert_investigation_progress')
op.drop_table('alert_incident_association')
op.drop_table('incidents')
op.drop_table('alert_cluster_association')
op.drop_table('alert_clusters')
if _table_has_column('alerts', 'alert_investigation_flow_id'):
op.drop_column('alerts', 'alert_investigation_flow_id')
op.drop_table('investigation_flow_steps')
op.drop_table('investigation_flows')
op.drop_table('incident_rules')
op.drop_table('incident_status')
op.drop_table('cluster_rules')
op.drop_table('alert_cluster_status')
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""custom_dashboard.owner_id FK: SET NULL on user delete instead of blocking.

Deleting a user who owns a custom dashboard raised a raw
ForeignKeyViolation/500 instead of either succeeding or returning a clean
4xx, because `custom_dashboard_owner_id_fkey` had no ON DELETE rule. Shared
dashboards should survive their owner's account being removed, so orphan
them (owner_id -> NULL) rather than blocking the delete or cascading it.

Idempotent via `_has_table`/`_table_has_column`.

Revision ID: a2b3c4d5e6f7
Revises: db034de157b9
Create Date: 2026-07-08 00:00:00.000000
"""
from alembic import op

from app.alembic.alembic_utils import _has_table
from app.alembic.alembic_utils import _table_has_column


revision = 'a2b3c4d5e6f7'
down_revision = 'db034de157b9'
branch_labels = None
depends_on = None


def upgrade():
if not _has_table('custom_dashboard') or not _table_has_column('custom_dashboard', 'owner_id'):
return
op.drop_constraint('custom_dashboard_owner_id_fkey', 'custom_dashboard', type_='foreignkey')
op.create_foreign_key(
'custom_dashboard_owner_id_fkey',
'custom_dashboard', 'user',
['owner_id'], ['id'],
ondelete='SET NULL',
)


def downgrade():
if not _has_table('custom_dashboard') or not _table_has_column('custom_dashboard', 'owner_id'):
return
op.drop_constraint('custom_dashboard_owner_id_fkey', 'custom_dashboard', type_='foreignkey')
op.create_foreign_key(
'custom_dashboard_owner_id_fkey',
'custom_dashboard', 'user',
['owner_id'], ['id'],
)
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Decouple investigation flows from incident rules.
"""Decouple investigation flows from cluster rules.

Flows now own their own matching conditions and can attach to alerts
and/or incidents on their own — the `attach_flow` rule action is
retired (rules only stack alerts into incidents). This migration adds:
and/or alert clusters on their own — the `attach_flow` rule action is
retired (rules only stack alerts into clusters). This migration adds:

* `investigation_flows.flow_target` — 'alert' / 'incident' / 'both'
* `investigation_flows.flow_target` — 'alert' / 'alert_cluster' / 'both'
* `investigation_flows.flow_conditions` — JSONB, same DSL as rules
* `investigation_flows.flow_priority` — tie-breaker
* `incidents.incident_investigation_flow_id` — cached FK
* `incident_investigation_progress` — per-incident check-off table
* `alert_clusters.cluster_investigation_flow_id` — cached FK
* `alert_cluster_investigation_progress` — per-cluster check-off table

Every step is guarded by `_has_table`/`_table_has_column` so the
migration is idempotent — safe to re-run.
Expand Down Expand Up @@ -54,24 +54,24 @@ def _add_flow_columns():
)


def _add_incident_flow_column():
if _table_has_column('incidents', 'incident_investigation_flow_id'):
def _add_cluster_flow_column():
if _table_has_column('alert_clusters', 'cluster_investigation_flow_id'):
return
op.add_column(
'incidents',
sa.Column('incident_investigation_flow_id', sa.BigInteger(),
'alert_clusters',
sa.Column('cluster_investigation_flow_id', sa.BigInteger(),
sa.ForeignKey('investigation_flows.flow_id'), nullable=True),
)


def _create_incident_investigation_progress():
if _has_table('incident_investigation_progress'):
def _create_alert_cluster_investigation_progress():
if _has_table('alert_cluster_investigation_progress'):
return
op.create_table(
'incident_investigation_progress',
'alert_cluster_investigation_progress',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('incident_id', sa.BigInteger(),
sa.ForeignKey('incidents.incident_id', ondelete='CASCADE'),
sa.Column('cluster_id', sa.BigInteger(),
sa.ForeignKey('alert_clusters.cluster_id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('step_id', sa.BigInteger(),
sa.ForeignKey('investigation_flow_steps.step_id', ondelete='CASCADE'),
Expand All @@ -81,21 +81,21 @@ def _create_incident_investigation_progress():
sa.Column('completed_at', sa.DateTime(), nullable=False,
server_default=sa.text('now()')),
sa.Column('note', sa.Text(), nullable=True),
sa.UniqueConstraint('incident_id', 'step_id',
name='uq_incident_investigation_progress_incident_step'),
sa.UniqueConstraint('cluster_id', 'step_id',
name='uq_alert_cluster_investigation_progress_cluster_step'),
)


def upgrade():
_add_flow_columns()
_add_incident_flow_column()
_create_incident_investigation_progress()
_add_cluster_flow_column()
_create_alert_cluster_investigation_progress()


def downgrade():
op.drop_table('incident_investigation_progress')
if _table_has_column('incidents', 'incident_investigation_flow_id'):
op.drop_column('incidents', 'incident_investigation_flow_id')
op.drop_table('alert_cluster_investigation_progress')
if _table_has_column('alert_clusters', 'cluster_investigation_flow_id'):
op.drop_column('alert_clusters', 'cluster_investigation_flow_id')
if _table_has_column('investigation_flows', 'flow_priority'):
op.drop_column('investigation_flows', 'flow_priority')
if _table_has_column('investigation_flows', 'flow_conditions'):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Merge dashboard_owner_fk and collab_doc_seeder_version heads

Our custom_dashboard.owner_id FK fix (a2b3c4d5e6f7) was based on
db034de157b9, but merging in 3 newer upstream commits added
f3a1b2c3d4e5 (add_collab_doc_seeder_version) on a sibling branch off
e2f3g4h5i6j7, re-forking the alembic head into two.

Revision ID: b5c6d7e8f9a0
Revises: a2b3c4d5e6f7, f3a1b2c3d4e5
Create Date: 2026-07-08 00:00:00.000000

"""

# revision identifiers, used by Alembic.
revision = 'b5c6d7e8f9a0'
down_revision = ('a2b3c4d5e6f7', 'f3a1b2c3d4e5')
branch_labels = None
depends_on = None


def upgrade():
# No schema changes — merge point. Both branches are additive and
# schema-compatible up to this point.
pass


def downgrade():
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add cluster scope to comments.

Mirrors the alert path (`comment_alert_id`) so alert clusters can carry a
first-class analyst comment stream, feeding the cluster detail
Activity tab.

Revision ID: c8f3a2d47b19
Revises: b2d0e8a9f4c1
Create Date: 2026-07-02 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa

from app.alembic.alembic_utils import _table_has_column


revision = 'c8f3a2d47b19'
down_revision = 'b2d0e8a9f4c1'
branch_labels = None
depends_on = None


def upgrade():
if not _table_has_column('comments', 'comment_cluster_id'):
op.add_column(
'comments',
sa.Column('comment_cluster_id', sa.BigInteger(), nullable=True),
)
op.create_foreign_key(
'fk_comments_cluster_id',
'comments', 'alert_clusters',
['comment_cluster_id'], ['cluster_id'],
)


def downgrade():
op.drop_constraint('fk_comments_cluster_id', 'comments', type_='foreignkey')
op.drop_column('comments', 'comment_cluster_id')
Loading
Loading