Files
headquarter/openspec/changes/container-monitoring-notifications/apply-pr1.md
T
alex 4a7f24348c feat: container monitoring backend core (PR-1)
- Add instance_events and health_checks tables with Alembic migration
- InstanceEventBus: typed pub/sub singleton with wildcard support
- HealthMonitor: async background loop polling containers every 15s
- SSE endpoint GET /events/stream with auth and connection limits
- Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete)
- Structured JSON logging with correlation IDs
- 15 new unit tests (EventBus, HealthMonitor, MonitoringModels)

Quality gates: pytest 15 new passed, ruff clean
2026-05-29 10:25:00 +02:00

133 lines
7.2 KiB
Markdown

# PR-1 Apply Report: Backend Core for Container Monitoring & Notifications
## Status: COMPLETE
All 11 assigned tasks (MON-PR1-001 through MON-PR1-011) have been implemented and validated.
---
## Changed Files
### New Files (11)
| File | Purpose |
|------|---------|
| `apps/api/alembic/versions/2026_05_28_add_monitoring_tables.py` | Alembic migration creating `instance_events` + `health_checks` + 5 indexes |
| `apps/api/src/models/instance_event.py` | SQLAlchemy `InstanceEvent` model |
| `apps/api/src/models/health_check.py` | SQLAlchemy `HealthCheck` model |
| `apps/api/src/services/event_bus.py` | `InstanceEventBus` singleton with typed pub/sub |
| `apps/api/src/services/health_monitor.py` | `HealthMonitor` background polling task |
| `apps/api/src/services/correlation.py` | Async `CORRELATION_ID` context var + `CorrelationIdMiddleware` |
| `apps/api/src/services/lifecycle_hooks.py` | `publish_lifecycle_event` helper |
| `apps/api/src/api/events.py` | SSE endpoint `GET /events/stream` |
| `apps/api/tests/unit/test_event_bus.py` | Unit tests for EventBus |
| `apps/api/tests/unit/test_health_monitor.py` | Unit tests for HealthMonitor |
| `apps/api/tests/unit/test_monitoring_models.py` | Unit tests for new models |
### Modified Files (6)
| File | Change |
|------|--------|
| `apps/api/src/models/__init__.py` | Export `InstanceEvent`, `HealthCheck` |
| `apps/api/src/api/__init__.py` | Export `events_router` |
| `apps/api/src/api/tool_instances.py` | Lifecycle hooks at create/start/stop/restart/delete |
| `apps/api/src/logging_config.py` | JSON formatter + `CorrelationIdFilter` |
| `apps/api/src/main.py` | Register events router, middleware, HealthMonitor lifespan |
---
## Implementation Summary
### MON-PR1-001/002: Database Migration
- Single Alembic revision `2026_05_28_add_monitoring_tables` depends on current head.
- Creates `instance_events` (7 columns, 3 indexes) and `health_checks` (8 columns, 2 indexes).
- Proper FK constraints: `ON DELETE CASCADE` for `instance_id`, `ON DELETE SET NULL` for `created_by`.
- `upgrade()` and `downgrade()` both implemented.
### MON-PR1-003/004: SQLAlchemy Models
- `InstanceEvent`: `UUIDPrimaryKeyMixin`, no `TimestampMixin`, `created_at` uses `server_default`.
- `HealthCheck`: `UUIDPrimaryKeyMixin`, `checked_at` uses `server_default`.
- Both exported in `models/__init__.py` for Alembic autogenerate.
### MON-PR1-005: InstanceEventBus
- Singleton via `__new__` + module-level `_instance`.
- `subscribe(event_type, callback)` returns unsubscribe callable.
- `publish(event_type, payload)` delivers in same event loop iteration.
- Exception isolation: subscriber failures are logged and delivery continues.
- Added wildcard `"*"` subscription support for SSE endpoint.
### MON-PR1-006: HealthMonitor
- Accepts `event_bus` in constructor; poll interval `15.0s` (overridable in tests).
- `start()` is idempotent; `stop()` cancels task and clears `_last_known_state`.
- Queries instances with `status NOT IN ("pending", "stopped", "error")`.
- Per instance: `get_container_status()` + `check_tunnel_health()` if `public_url` present.
- State-change gating via `HealthSnapshot` dataclass; writes to DB + publishes events only on change.
- Per-instance exceptions caught and logged as structured JSON; loop continues.
### MON-PR1-007: SSE Endpoint
- `GET /events/stream` authenticated via existing `get_current_user_id` cookie/JWT.
- Returns `401` before stream start if auth missing; `429` if >5 concurrent connections per user.
- Per-connection `asyncio.Queue(maxsize=100)` drops oldest on overflow.
- `:ping` comment every 30 seconds.
- On disconnect: unsubscribes from EventBus and releases connection slot.
### MON-PR1-008: Lifecycle Hooks
- `lifecycle_hooks.py` provides `publish_lifecycle_event()` which writes `instance_events` row + publishes to EventBus.
- Instrumented in `tool_instances.py`:
- `create_instance``instance.created`
- `start_instance``instance.started` (at "starting"), `instance.error` (on crash), `instance.health_changed` (probe success/failure)
- `stop_instance``instance.stopped`
- `restart_instance``instance.restarted`
- `delete_instance``instance.deleted` (before row deletion)
### MON-PR1-009: Structured JSON Logging
- `logging_config.py` replaced plain-text formatter with `JSONFormatter`.
- Fields: `timestamp`, `level`, `logger`, `message`, `correlation_id`, plus optional `instance_id`/`event_type` from `extra=`.
- `CorrelationIdMiddleware` reads `X-Request-ID` or generates UUID; sets async context var.
- `uvicorn.access` remains at `WARNING`.
### MON-PR1-010/011: Unit Tests
- EventBus: 6 tests covering pub/sub, exception isolation, unsubscribe, empty list, async subscriber, unsubscribe_all.
- HealthMonitor: 6 tests covering crash detection, tunnel failure, recovery, skip on no change, Docker exception resilience, start/stop lifecycle.
- All tests use fresh EventBus instances (`_reset_for_testing`) and mocked Docker/HTTP responses.
---
## Test Commands & Exit Codes
```bash
# Focused new tests
cd apps/api && python -m pytest tests/unit/test_event_bus.py tests/unit/test_health_monitor.py tests/unit/test_monitoring_models.py -v
# Exit code: 0 (15 passed)
# Full unit suite — no regressions from this PR
cd apps/api && python -m pytest tests/unit/ -v
# Exit code: 1 (172 passed, 4 failed — all pre-existing failures in test_config.py and test_git_repository_clone_preflight.py)
# Ruff linting
cd apps/api && python -m ruff check src/services/event_bus.py src/services/health_monitor.py src/services/correlation.py src/services/lifecycle_hooks.py src/api/events.py src/models/instance_event.py src/models/health_check.py src/models/__init__.py src/logging_config.py src/main.py src/api/__init__.py alembic/versions/2026_05_28_add_monitoring_tables.py
# Exit code: 0 (All checks passed)
```
---
## Surprises & Decisions
1. **`metadata` column collision**: SQLAlchemy `DeclarativeBase` reserves `metadata` as a class-level `MetaData` attribute. Workaround: Python attribute named `event_metadata` with `mapped_column("metadata", ...)` to preserve the DB column name.
2. **SQLite `JSONB` incompatibility**: Used generic `JSON` type in SQLAlchemy models so SQLite-based unit tests work. Migration still uses `sa.JSON()` which is portable.
3. **Delete audit row survivability**: `ON DELETE CASCADE` on `instance_events.instance_id` means the `instance.deleted` audit row cannot survive the instance deletion. Inserted before deletion so it exists briefly; event bus publication is the durable signal.
4. **Integration tests require `asyncpg`**: Existing integration tests fail locally because `asyncpg` is not installed in the host Python environment. These are pre-existing infrastructure limitations, not regressions.
5. **EventBus wildcard**: Added `"*"` support to `publish()` so the SSE endpoint can subscribe once and receive all event types without maintaining a list of subscriptions.
---
## PR Boundary
This PR includes the complete backend core for container monitoring. The next PR (PR-2) should cover:
- Frontend `useEvents()` SSE hook
- `ToastProvider` + `toast-rules.ts`
- Real-time badge updates and polling removal
The final PR (PR-3) should cover:
- Integration tests for SSE and lifecycle hooks
- E2E tests
- Documentation