Compare commits
11 Commits
024-user-d
...
v1.0.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
| a13f75587d | |||
| 50001f5ec5 | |||
| 0083d9054e | |||
| 765178f12e | |||
| b77fa45e4e | |||
| 542835e0ff | |||
| 31717870e3 | |||
| 82435822eb | |||
| 3a8c82918a | |||
| 87b81a365a | |||
| 6ee54d95a8 |
@@ -2,12 +2,12 @@
|
||||
|
||||
> High-level module structure for AI Context. Generated automatically.
|
||||
|
||||
**Generated:** 2026-03-09T13:33:22.105511
|
||||
**Generated:** 2026-03-10T20:52:01.801581
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total Modules:** 93
|
||||
- **Total Entities:** 2649
|
||||
- **Total Modules:** 103
|
||||
- **Total Entities:** 3088
|
||||
|
||||
## Module Hierarchy
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
### 📁 `src/`
|
||||
|
||||
- 🏗️ **Layers:** API, Core, UI (API)
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 20, TRIVIAL: 2
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 21, TRIVIAL: 2
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 24
|
||||
- 📦 **Entities:** 25
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -53,10 +53,10 @@
|
||||
|
||||
### 📁 `routes/`
|
||||
|
||||
- 🏗️ **Layers:** API, UI (API)
|
||||
- 📊 **Tiers:** CRITICAL: 12, STANDARD: 254, TRIVIAL: 8
|
||||
- 📄 **Files:** 19
|
||||
- 📦 **Entities:** 274
|
||||
- 🏗️ **Layers:** API, Infra, UI (API), UI/API
|
||||
- 📊 **Tiers:** CRITICAL: 12, STANDARD: 272, TRIVIAL: 16
|
||||
- 📄 **Files:** 21
|
||||
- 📦 **Entities:** 300
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
- Schema for branch creation requests.
|
||||
- ℂ **BranchSchema** (Class)
|
||||
- Schema for representing a Git branch metadata.
|
||||
- ℂ **BuildManifestRequest** (Class)
|
||||
- Request schema for manifest build endpoint.
|
||||
- ℂ **CommitCreate** (Class)
|
||||
- Schema for staging and committing changes.
|
||||
- ℂ **CommitSchema** (Class)
|
||||
- Schema for representing Git commit details.
|
||||
- ℂ **ConfirmationRecord** (Class)
|
||||
- In-memory confirmation token model for risky operation dispa...
|
||||
- ℂ **ConflictResolution** (Class)
|
||||
- Schema for resolving merge conflicts.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
@@ -87,14 +87,14 @@
|
||||
- 🔗 DEPENDS_ON -> ConfigModels
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.database
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.database.get_db
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.superset_client
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.mapping_service
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** API, Domain, Domain (Tests), UI (API Tests), Unknown
|
||||
- 📊 **Tiers:** STANDARD: 88, TRIVIAL: 187
|
||||
- 📄 **Files:** 14
|
||||
- 📦 **Entities:** 275
|
||||
- 🏗️ **Layers:** API, Domain, Domain (Tests), Tests, UI (API Tests), Unknown
|
||||
- 📊 **Tiers:** STANDARD: 92, TRIVIAL: 195
|
||||
- 📄 **Files:** 17
|
||||
- 📦 **Entities:** 287
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -122,22 +122,25 @@
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.api.routes.assistant
|
||||
- 🔗 IMPLEMENTS -> clean_release_v2_release_api_contracts
|
||||
|
||||
### 📁 `core/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 📊 **Tiers:** CRITICAL: 47, STANDARD: 94, TRIVIAL: 8
|
||||
- 📄 **Files:** 11
|
||||
- 📦 **Entities:** 149
|
||||
- 🏗️ **Layers:** Core, Domain
|
||||
- 📊 **Tiers:** CRITICAL: 52, STANDARD: 102, TRIVIAL: 9
|
||||
- 📄 **Files:** 12
|
||||
- 📦 **Entities:** 163
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **AsyncSupersetClient** (Class)
|
||||
- Async sibling of SupersetClient for dashboard read paths.
|
||||
- ℂ **AuthSessionLocal** (Class) `[TRIVIAL]`
|
||||
- A session factory for the authentication database.
|
||||
- ℂ **BeliefFormatter** (Class)
|
||||
- Custom logging formatter that adds belief state prefixes to ...
|
||||
- ℂ **ConfigManager** (Class) `[CRITICAL]`
|
||||
- A class to handle application configuration persistence and ...
|
||||
- Handles application configuration load, validation, mutation...
|
||||
- ℂ **IdMappingService** (Class) `[CRITICAL]`
|
||||
- Service handling the cataloging and retrieval of remote Supe...
|
||||
- ℂ **LogEntry** (Class)
|
||||
@@ -150,23 +153,21 @@
|
||||
- A Pydantic model used to represent the validated configurati...
|
||||
- ℂ **PluginLoader** (Class)
|
||||
- Scans a specified directory for Python modules, dynamically ...
|
||||
- ℂ **SchedulerService** (Class)
|
||||
- Provides a service to manage scheduled backup tasks.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> AppConfigRecord
|
||||
- 🔗 DEPENDS_ON -> ConfigModels
|
||||
- 🔗 DEPENDS_ON -> PyYAML
|
||||
- 🔗 DEPENDS_ON -> SessionLocal
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.auth.config
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.logger
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Domain
|
||||
- 📊 **Tiers:** STANDARD: 7
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 7
|
||||
- 📊 **Tiers:** STANDARD: 8, TRIVIAL: 6
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 14
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -174,10 +175,12 @@
|
||||
- Records request payloads and returns scripted responses for ...
|
||||
- 📦 **backend.src.core.__tests__.test_superset_profile_lookup** (Module)
|
||||
- Verifies Superset profile lookup adapter payload normalizati...
|
||||
- 📦 **test_throttled_scheduler** (Module)
|
||||
- Unit tests for ThrottledSchedulerConfigurator distribution l...
|
||||
|
||||
### 📁 `auth/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 🏗️ **Layers:** Core, Domain
|
||||
- 📊 **Tiers:** CRITICAL: 28
|
||||
- 📄 **Files:** 6
|
||||
- 📦 **Entities:** 28
|
||||
@@ -187,7 +190,7 @@
|
||||
- ℂ **AuthConfig** (Class) `[CRITICAL]`
|
||||
- Holds authentication-related settings.
|
||||
- ℂ **AuthRepository** (Class) `[CRITICAL]`
|
||||
- Encapsulates database operations for authentication.
|
||||
- Encapsulates database operations for authentication-related ...
|
||||
- 📦 **backend.src.core.auth.config** (Module) `[CRITICAL]`
|
||||
- Centralized configuration for authentication and authorizati...
|
||||
- 📦 **backend.src.core.auth.jwt** (Module) `[CRITICAL]`
|
||||
@@ -197,17 +200,17 @@
|
||||
- 📦 **backend.src.core.auth.oauth** (Module) `[CRITICAL]`
|
||||
- ADFS OIDC configuration and client using Authlib.
|
||||
- 📦 **backend.src.core.auth.repository** (Module) `[CRITICAL]`
|
||||
- Data access layer for authentication-related entities.
|
||||
- Data access layer for authentication and user preference ent...
|
||||
- 📦 **backend.src.core.auth.security** (Module) `[CRITICAL]`
|
||||
- Utility for password hashing and verification using Passlib.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> authlib
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.logger.belief_scope
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.auth
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.profile
|
||||
- 🔗 DEPENDS_ON -> jose
|
||||
- 🔗 DEPENDS_ON -> passlib
|
||||
- 🔗 DEPENDS_ON -> pydantic
|
||||
- 🔗 DEPENDS_ON -> sqlalchemy
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
@@ -235,7 +238,7 @@
|
||||
|
||||
### 📁 `migration/`
|
||||
|
||||
- 🏗️ **Layers:** Core
|
||||
- 🏗️ **Layers:** Core, Domain
|
||||
- 📊 **Tiers:** CRITICAL: 20, TRIVIAL: 1
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 21
|
||||
@@ -253,7 +256,7 @@
|
||||
- 📦 **backend.src.core.migration.dry_run_orchestrator** (Module) `[CRITICAL]`
|
||||
- Compute pre-flight migration diff and risk scoring without a...
|
||||
- 📦 **backend.src.core.migration.risk_assessor** (Module) `[CRITICAL]`
|
||||
- Risk evaluation helpers for migration pre-flight reporting.
|
||||
- Compute deterministic migration risk items and aggregate sco...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
@@ -314,14 +317,16 @@
|
||||
### 📁 `utils/`
|
||||
|
||||
- 🏗️ **Layers:** Core, Domain, Infra
|
||||
- 📊 **Tiers:** STANDARD: 50, TRIVIAL: 1
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 51
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 62, TRIVIAL: 5
|
||||
- 📄 **Files:** 5
|
||||
- 📦 **Entities:** 68
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **APIClient** (Class)
|
||||
- Инкапсулирует HTTP-логику для работы с API, включая сессии, ...
|
||||
- ℂ **AsyncAPIClient** (Class)
|
||||
- Async Superset API client backed by httpx.AsyncClient with s...
|
||||
- ℂ **AuthenticationError** (Class)
|
||||
- Exception raised when authentication fails.
|
||||
- ℂ **DashboardNotFoundError** (Class)
|
||||
@@ -336,48 +341,46 @@
|
||||
- Exception raised when access is denied.
|
||||
- ℂ **SupersetAPIError** (Class)
|
||||
- Base exception for all Superset API related errors.
|
||||
- 📦 **backend.core.utils.dataset_mapper** (Module)
|
||||
- Этот модуль отвечает за обновление метаданных (verbose_map) ...
|
||||
- 📦 **backend.core.utils.fileio** (Module)
|
||||
- Предоставляет набор утилит для управления файловыми операция...
|
||||
- ℂ **SupersetAuthCache** (Class)
|
||||
- Process-local cache for Superset access/csrf tokens keyed by...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.core.superset_client
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.logger
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.utils.network.SupersetAuthCache
|
||||
- 🔗 DEPENDS_ON -> pandas
|
||||
- 🔗 DEPENDS_ON -> psycopg2
|
||||
- 🔗 DEPENDS_ON -> pyyaml
|
||||
|
||||
### 📁 `models/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Model
|
||||
- 📊 **Tiers:** CRITICAL: 20, STANDARD: 35, TRIVIAL: 29
|
||||
- 📊 **Tiers:** CRITICAL: 21, STANDARD: 40, TRIVIAL: 29
|
||||
- 📄 **Files:** 13
|
||||
- 📦 **Entities:** 84
|
||||
- 📦 **Entities:** 90
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **ADGroupMapping** (Class) `[CRITICAL]`
|
||||
- Maps an Active Directory group to a local System Role.
|
||||
- ℂ **AppConfigRecord** (Class) `[CRITICAL]`
|
||||
- Stores the single source of truth for application configurat...
|
||||
- Stores persisted application configuration as a single autho...
|
||||
- ℂ **ApprovalDecision** (Class)
|
||||
- Approval or rejection bound to a candidate and report.
|
||||
- ℂ **AssistantAuditRecord** (Class)
|
||||
- Store audit decisions and outcomes produced by assistant com...
|
||||
- ℂ **AssistantConfirmationRecord** (Class)
|
||||
- Persist risky operation confirmation tokens with lifecycle s...
|
||||
- ℂ **AssistantMessageRecord** (Class)
|
||||
- Persist chat history entries for assistant conversations.
|
||||
- ℂ **CandidateArtifact** (Class)
|
||||
- Represents one artifact associated with a release candidate.
|
||||
- ℂ **CheckFinalStatus** (Class)
|
||||
- Final status for compliance check run.
|
||||
- Backward-compatible final status enum for legacy TUI/orchest...
|
||||
- ℂ **CheckStageName** (Class)
|
||||
- Mandatory check stages.
|
||||
- Backward-compatible stage name enum for legacy TUI/orchestra...
|
||||
- ℂ **CheckStageResult** (Class)
|
||||
- Per-stage compliance result.
|
||||
- ℂ **CheckStageStatus** (Class)
|
||||
- Stage-level execution status.
|
||||
- ℂ **ClassificationType** (Class)
|
||||
- Manifest classification outcomes for artifacts.
|
||||
- Backward-compatible stage result container for legacy TUI/or...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
@@ -508,10 +511,10 @@
|
||||
|
||||
### 📁 `schemas/`
|
||||
|
||||
- 🏗️ **Layers:** API
|
||||
- 📊 **Tiers:** CRITICAL: 10, STANDARD: 9, TRIVIAL: 3
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 22
|
||||
- 🏗️ **Layers:** API, Domain
|
||||
- 📊 **Tiers:** CRITICAL: 10, STANDARD: 18, TRIVIAL: 3
|
||||
- 📄 **Files:** 4
|
||||
- 📦 **Entities:** 31
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -519,6 +522,12 @@
|
||||
- Schema for creating an AD Group mapping.
|
||||
- ℂ **ADGroupMappingSchema** (Class) `[CRITICAL]`
|
||||
- Represents an AD Group to Role mapping in API responses.
|
||||
- ℂ **DashboardHealthItem** (Class)
|
||||
- Represents the latest health status of a single dashboard.
|
||||
- ℂ **HealthSummaryResponse** (Class)
|
||||
- Aggregated health summary for all dashboards.
|
||||
- ℂ **NotificationChannel** (Class)
|
||||
- Structured notification channel definition for policy-level ...
|
||||
- ℂ **PermissionSchema** (Class) `[TRIVIAL]`
|
||||
- Represents a permission in API responses.
|
||||
- ℂ **ProfilePermissionState** (Class)
|
||||
@@ -529,28 +538,37 @@
|
||||
- Response envelope for profile preference read/update endpoin...
|
||||
- ℂ **ProfilePreferenceUpdateRequest** (Class)
|
||||
- Request payload for updating current user's profile settings...
|
||||
- ℂ **ProfileSecuritySummary** (Class)
|
||||
- Read-only security and access snapshot for current user.
|
||||
- ℂ **RoleCreate** (Class) `[CRITICAL]`
|
||||
- Schema for creating a new role.
|
||||
- ℂ **RoleSchema** (Class) `[CRITICAL]`
|
||||
- Represents a role in API responses.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> pydantic
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 📊 **Tiers:** STANDARD: 4
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 4
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **backend.src.schemas.__tests__.test_settings_and_health_schemas** (Module)
|
||||
- Regression tests for settings and health schema contracts up...
|
||||
|
||||
### 📁 `scripts/`
|
||||
|
||||
- 🏗️ **Layers:** Scripts, UI, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 27, TRIVIAL: 17
|
||||
- 📄 **Files:** 7
|
||||
- 📦 **Entities:** 46
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 43, TRIVIAL: 30
|
||||
- 📄 **Files:** 8
|
||||
- 📦 **Entities:** 75
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **CleanReleaseTUI** (Class)
|
||||
- Curses-based application for compliance monitoring.
|
||||
- ℂ **TuiFacadeAdapter** (Class)
|
||||
- Thin TUI adapter that routes business mutations through appl...
|
||||
- 📦 **backend.src.scripts.clean_release_cli** (Module)
|
||||
- Provide headless CLI commands for candidate registration, ar...
|
||||
- 📦 **backend.src.scripts.clean_release_tui** (Module)
|
||||
- Interactive terminal interface for Enterprise Clean Release ...
|
||||
- 📦 **backend.src.scripts.create_admin** (Module)
|
||||
@@ -573,10 +591,10 @@
|
||||
|
||||
### 📁 `services/`
|
||||
|
||||
- 🏗️ **Layers:** Core, Domain, Service
|
||||
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 118, TRIVIAL: 15
|
||||
- 📄 **Files:** 9
|
||||
- 📦 **Entities:** 142
|
||||
- 🏗️ **Layers:** Core, Domain, Domain/Service, Service
|
||||
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 120, TRIVIAL: 17
|
||||
- 📄 **Files:** 10
|
||||
- 📦 **Entities:** 146
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -603,23 +621,29 @@
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> ValidationRecord
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.auth.jwt.create_access_token
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.auth.repository
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.config_manager
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.database
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.superset_client
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.task_manager
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.auth.repository.AuthRepository
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.auth.security.verify_password
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Domain Tests, Service, Service Tests, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 29, TRIVIAL: 17
|
||||
- 📄 **Files:** 5
|
||||
- 📦 **Entities:** 46
|
||||
- 📊 **Tiers:** STANDARD: 36, TRIVIAL: 40
|
||||
- 📄 **Files:** 7
|
||||
- 📦 **Entities:** 76
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **TestEncryptionManager** (Class)
|
||||
- Validate EncryptionManager encrypt/decrypt roundtrip, unique...
|
||||
- ℂ **_DummyLogger** (Class)
|
||||
- Minimal logger shim for TaskContext-like objects used in tes...
|
||||
- ℂ **_FakeDBSession** (Class)
|
||||
- Captures persisted records for assertion and mimics SQLAlche...
|
||||
- 📦 **backend.src.services.__tests__.test_llm_plugin_persistence** (Module)
|
||||
- Regression test for ValidationRecord persistence fields popu...
|
||||
- 📦 **backend.src.services.__tests__.test_llm_prompt_templates** (Module)
|
||||
- Validate normalization and rendering behavior for configurab...
|
||||
- 📦 **backend.src.services.__tests__.test_rbac_permission_catalog** (Module)
|
||||
@@ -628,6 +652,8 @@
|
||||
- Unit tests for ResourceService
|
||||
- 📦 **test_encryption_manager** (Module)
|
||||
- Unit tests for EncryptionManager encrypt/decrypt functionali...
|
||||
- 📦 **test_health_service** (Module)
|
||||
- Unit tests for HealthService aggregation logic.
|
||||
- 📦 **test_llm_provider** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for backend/src/services/__tests__/tes...
|
||||
|
||||
@@ -637,10 +663,10 @@
|
||||
|
||||
### 📁 `clean_release/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Infra
|
||||
- 📊 **Tiers:** CRITICAL: 3, STANDARD: 16, TRIVIAL: 32
|
||||
- 📄 **Files:** 10
|
||||
- 📦 **Entities:** 51
|
||||
- 🏗️ **Layers:** Application, Domain, Infra
|
||||
- 📊 **Tiers:** CRITICAL: 9, STANDARD: 46, TRIVIAL: 50
|
||||
- 📄 **Files:** 21
|
||||
- 📦 **Entities:** 105
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -649,35 +675,35 @@
|
||||
- ℂ **CleanPolicyEngine** (Class)
|
||||
- ℂ **CleanReleaseRepository** (Class)
|
||||
- Data access object for clean release lifecycle.
|
||||
- 📦 **backend.src.services.clean_release** (Module)
|
||||
- Initialize clean release service package and provide explici...
|
||||
- ℂ **ComplianceExecutionResult** (Class)
|
||||
- Return envelope for compliance execution with run/report and...
|
||||
- ℂ **ComplianceExecutionService** (Class)
|
||||
- Execute clean-release compliance lifecycle over trusted snap...
|
||||
- 📦 **backend.src.services.clean_release.approval_service** (Module) `[CRITICAL]`
|
||||
- Enforce approval/rejection gates over immutable compliance r...
|
||||
- 📦 **backend.src.services.clean_release.audit_service** (Module)
|
||||
- Provide lightweight audit hooks for clean release preparatio...
|
||||
- 📦 **backend.src.services.clean_release.candidate_service** (Module) `[CRITICAL]`
|
||||
- Register release candidates with validated artifacts and adv...
|
||||
- 📦 **backend.src.services.clean_release.compliance_execution_service** (Module) `[CRITICAL]`
|
||||
- Create and execute compliance runs with trusted snapshots, d...
|
||||
- 📦 **backend.src.services.clean_release.compliance_orchestrator** (Module) `[CRITICAL]`
|
||||
- Execute mandatory clean compliance stages and produce final ...
|
||||
- 📦 **backend.src.services.clean_release.manifest_builder** (Module)
|
||||
- Build deterministic distribution manifest from classified ar...
|
||||
- 📦 **backend.src.services.clean_release.policy_engine** (Module) `[CRITICAL]`
|
||||
- Evaluate artifact/source policies for enterprise clean profi...
|
||||
- 📦 **backend.src.services.clean_release.preparation_service** (Module)
|
||||
- Prepare release candidate by policy evaluation and determini...
|
||||
- 📦 **backend.src.services.clean_release.report_builder** (Module) `[CRITICAL]`
|
||||
- Build and persist compliance reports with consistent counter...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.config_manager
|
||||
- 🔗 DEPENDS_ON -> backend.src.core.logger
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.clean_release
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.clean_release.CleanProfilePolicy
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.clean_release.ResourceSourceRegistry
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.manifest_builder
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Infra, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 18, TRIVIAL: 25
|
||||
- 📊 **Tiers:** STANDARD: 25, TRIVIAL: 25
|
||||
- 📄 **Files:** 8
|
||||
- 📦 **Entities:** 43
|
||||
- 📦 **Entities:** 50
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -698,6 +724,117 @@
|
||||
- 📦 **test_policy_engine** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for backend/src/services/clean_release...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.preparation_service:Module
|
||||
|
||||
### 📁 `repositories/`
|
||||
|
||||
- 🏗️ **Layers:** Infra
|
||||
- 📊 **Tiers:** STANDARD: 10, TRIVIAL: 46
|
||||
- 📄 **Files:** 10
|
||||
- 📦 **Entities:** 56
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **approval_repository** (Module)
|
||||
- Persist and query approval decisions.
|
||||
- 📦 **artifact_repository** (Module)
|
||||
- Persist and query candidate artifacts.
|
||||
- 📦 **audit_repository** (Module)
|
||||
- Persist and query audit logs for clean release operations.
|
||||
- 📦 **candidate_repository** (Module)
|
||||
- Persist and query release candidates.
|
||||
- 📦 **clean_release_repositories** (Module)
|
||||
- Export all clean release repositories.
|
||||
- 📦 **compliance_repository** (Module)
|
||||
- Persist and query compliance runs, stage runs, and violation...
|
||||
- 📦 **manifest_repository** (Module)
|
||||
- Persist and query distribution manifests.
|
||||
- 📦 **policy_repository** (Module)
|
||||
- Persist and query policy and registry snapshots.
|
||||
- 📦 **publication_repository** (Module)
|
||||
- Persist and query publication records.
|
||||
- 📦 **report_repository** (Module)
|
||||
- Persist and query compliance reports.
|
||||
|
||||
### 📁 `stages/`
|
||||
|
||||
- 🏗️ **Layers:** Domain
|
||||
- 📊 **Tiers:** STANDARD: 19, TRIVIAL: 5
|
||||
- 📄 **Files:** 6
|
||||
- 📦 **Entities:** 24
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **ComplianceStage** (Class)
|
||||
- Protocol for pluggable stage implementations.
|
||||
- ℂ **ComplianceStageContext** (Class)
|
||||
- Immutable input envelope passed to each compliance stage.
|
||||
- ℂ **DataPurityStage** (Class)
|
||||
- Validate manifest summary for prohibited artifacts.
|
||||
- ℂ **InternalSourcesOnlyStage** (Class)
|
||||
- Enforce internal-source-only policy from trusted registry sn...
|
||||
- ℂ **ManifestConsistencyStage** (Class)
|
||||
- Validate run/manifest linkage consistency.
|
||||
- ℂ **NoExternalEndpointsStage** (Class)
|
||||
- Validate endpoint references from manifest against trusted r...
|
||||
- ℂ **StageExecutionResult** (Class)
|
||||
- Structured stage output containing decision, details and vio...
|
||||
- 📦 **backend.src.services.clean_release.stages** (Module)
|
||||
- Define compliance stage order and helper functions for deter...
|
||||
- 📦 **backend.src.services.clean_release.stages.base** (Module)
|
||||
- Define shared contracts and helpers for pluggable clean-rele...
|
||||
- 📦 **backend.src.services.clean_release.stages.data_purity** (Module)
|
||||
- Evaluate manifest purity counters and emit blocking violatio...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.clean_release
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.stages.base
|
||||
- 🔗 IMPLEMENTS -> backend.src.services.clean_release.stages.base.ComplianceStage
|
||||
|
||||
### 📁 `notifications/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Infra
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 5, TRIVIAL: 14
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 21
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **NotificationProvider** (Class)
|
||||
- Abstract base class for all notification providers.
|
||||
- ℂ **NotificationService** (Class)
|
||||
- Routes validation reports to appropriate users and channels.
|
||||
- ℂ **SMTPProvider** (Class)
|
||||
- Delivers notifications via SMTP.
|
||||
- ℂ **SlackProvider** (Class)
|
||||
- Delivers notifications via Slack Webhooks or API.
|
||||
- ℂ **TelegramProvider** (Class)
|
||||
- Delivers notifications via Telegram Bot API.
|
||||
- 📦 **backend.src.services.notifications.providers** (Module) `[CRITICAL]`
|
||||
- Defines abstract base and concrete implementations for exter...
|
||||
- 📦 **backend.src.services.notifications.service** (Module) `[CRITICAL]`
|
||||
- Orchestrates notification routing based on user preferences ...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.models.llm
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.notifications.providers
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.profile_service
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 9
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 10
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **backend.src.services.notifications.__tests__.test_notification_service** (Module)
|
||||
- Unit tests for NotificationService routing and dispatch logi...
|
||||
|
||||
### 📁 `reports/`
|
||||
|
||||
- 🏗️ **Layers:** Domain
|
||||
@@ -727,9 +864,9 @@
|
||||
### 📁 `__tests__/`
|
||||
|
||||
- 🏗️ **Layers:** Domain, Domain (Tests), Unknown
|
||||
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 24
|
||||
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 25
|
||||
- 📄 **Files:** 3
|
||||
- 📦 **Entities:** 26
|
||||
- 📦 **Entities:** 27
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -804,15 +941,56 @@
|
||||
|
||||
### 📁 `scripts/`
|
||||
|
||||
- 🏗️ **Layers:** Scripts
|
||||
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 7
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 8
|
||||
- 🏗️ **Layers:** Domain, Scripts
|
||||
- 📊 **Tiers:** STANDARD: 3, TRIVIAL: 17
|
||||
- 📄 **Files:** 3
|
||||
- 📦 **Entities:** 20
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **backend.tests.scripts.test_clean_release_tui** (Module)
|
||||
- Unit tests for the interactive curses TUI of the clean relea...
|
||||
- 📦 **test_clean_release_cli** (Module)
|
||||
- Smoke tests for the redesigned clean release CLI.
|
||||
- 📦 **test_clean_release_tui_v2** (Module)
|
||||
- Smoke tests for thin-client TUI action dispatch and blocked ...
|
||||
|
||||
### 📁 `clean_release/`
|
||||
|
||||
- 🏗️ **Layers:** Tests
|
||||
- 📊 **Tiers:** STANDARD: 40, TRIVIAL: 16
|
||||
- 📄 **Files:** 8
|
||||
- 📦 **Entities:** 56
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- ℂ **CleanReleaseCompliancePlugin** (Class)
|
||||
- TaskManager plugin shim that executes clean release complian...
|
||||
- ℂ **_PluginLoaderStub** (Class)
|
||||
- Provide minimal plugin loader contract used by TaskManager i...
|
||||
- 📦 **backend.tests.services.clean_release.test_approval_service** (Module)
|
||||
- Define approval gate contracts for approve/reject operations...
|
||||
- 📦 **backend.tests.services.clean_release.test_compliance_execution_service** (Module)
|
||||
- Validate stage pipeline and run finalization contracts for c...
|
||||
- 📦 **backend.tests.services.clean_release.test_compliance_task_integration** (Module)
|
||||
- Verify clean release compliance runs execute through TaskMan...
|
||||
- 📦 **backend.tests.services.clean_release.test_demo_mode_isolation** (Module)
|
||||
- Verify demo and real mode namespace isolation contracts befo...
|
||||
- 📦 **backend.tests.services.clean_release.test_policy_resolution_service** (Module)
|
||||
- Verify trusted policy snapshot resolution contract and error...
|
||||
- 📦 **backend.tests.services.clean_release.test_publication_service** (Module)
|
||||
- Define publication gate contracts over approved candidates a...
|
||||
- 📦 **backend.tests.services.clean_release.test_report_audit_immutability** (Module)
|
||||
- Validate report snapshot immutability expectations and appen...
|
||||
- 📦 **test_candidate_manifest_services** (Module)
|
||||
- Test lifecycle and manifest versioning for release candidate...
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.demo_data_service
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.exceptions
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.policy_resolution_service
|
||||
- 🔗 DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
|
||||
### 📁 `components/`
|
||||
|
||||
@@ -858,15 +1036,17 @@
|
||||
|
||||
### 📁 `auth/`
|
||||
|
||||
- 🏗️ **Layers:** Component
|
||||
- 📊 **Tiers:** CRITICAL: 2
|
||||
- 🏗️ **Layers:** UI
|
||||
- 📊 **Tiers:** CRITICAL: 2, STANDARD: 1
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 2
|
||||
- 📦 **Entities:** 3
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **ProtectedRoute** (Component) `[CRITICAL]`
|
||||
- Wraps content to ensure only authenticated and authorized us...
|
||||
- Wraps protected slot content with session and permission ver...
|
||||
- 📦 **ProtectedRoute.svelte** (Module)
|
||||
- Enforces authenticated and authorized access before protecte...
|
||||
|
||||
### 📁 `git/`
|
||||
|
||||
@@ -1028,7 +1208,7 @@
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> [DEF:api_module]
|
||||
- 🔗 DEPENDS_ON -> DEF:api_module
|
||||
- 🔗 DEPENDS_ON -> frontend.src.lib.api.api_module
|
||||
|
||||
### 📁 `__tests__/`
|
||||
@@ -1101,6 +1281,22 @@
|
||||
- 📦 **frontend.src.lib.components.assistant.__tests__.assistant_chat_integration** (Module)
|
||||
- Contract-level integration checks for assistant chat panel i...
|
||||
|
||||
### 📁 `health/`
|
||||
|
||||
- 🏗️ **Layers:** UI/Component, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 2, TRIVIAL: 3
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 5
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **HealthMatrix** (Component)
|
||||
- Visual grid/matrix representing the health status of dashboa...
|
||||
- 🧩 **PolicyForm** (Component)
|
||||
- Form for creating and editing validation policies.
|
||||
- 📦 **PolicyForm** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/components/health...
|
||||
|
||||
### 📁 `layout/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
@@ -1235,14 +1431,16 @@
|
||||
### 📁 `stores/`
|
||||
|
||||
- 🏗️ **Layers:** UI, UI-State, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 8, TRIVIAL: 25
|
||||
- 📄 **Files:** 5
|
||||
- 📦 **Entities:** 34
|
||||
- 📊 **Tiers:** CRITICAL: 1, STANDARD: 9, TRIVIAL: 28
|
||||
- 📄 **Files:** 6
|
||||
- 📦 **Entities:** 38
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 📦 **environmentContext** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/stores/environmen...
|
||||
- 📦 **health** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/stores/health.js
|
||||
- 📦 **sidebar** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/lib/stores/sidebar.js
|
||||
- 📦 **taskDrawer** (Module) `[TRIVIAL]`
|
||||
@@ -1253,6 +1451,8 @@
|
||||
- Control assistant chat panel visibility and active conversat...
|
||||
- 🗄️ **environmentContext** (Store)
|
||||
- Global selected environment context for navigation and safet...
|
||||
- 🗄️ **health_store** (Store)
|
||||
- Manage dashboard health summary state and failing counts for...
|
||||
- 🗄️ **sidebar** (Store)
|
||||
- Manage sidebar visibility and navigation state
|
||||
- 🗄️ **taskDrawer** (Store) `[CRITICAL]`
|
||||
@@ -1261,6 +1461,7 @@
|
||||
**Dependencies:**
|
||||
|
||||
- 🔗 DEPENDS_ON -> WebSocket connection, taskDrawer store
|
||||
- 🔗 DEPENDS_ON -> api.getHealthSummary
|
||||
|
||||
### 📁 `__tests__/`
|
||||
|
||||
@@ -1453,6 +1654,20 @@
|
||||
- 📦 **frontend.src.routes.dashboards.__tests__.dashboard_profile_override_integration** (Module)
|
||||
- Verifies temporary show-all override and restore-on-return b...
|
||||
|
||||
### 📁 `health/`
|
||||
|
||||
- 🏗️ **Layers:** UI/Page, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 3
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 4
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **HealthCenterPage** (Component)
|
||||
- Main page for the Dashboard Health Center.
|
||||
- 📦 **+page** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/routes/dashboards/hea...
|
||||
|
||||
### 📁 `datasets/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
@@ -1503,28 +1718,26 @@
|
||||
|
||||
### 📁 `migration/`
|
||||
|
||||
- 🏗️ **Layers:** Page
|
||||
- 📊 **Tiers:** CRITICAL: 11
|
||||
- 📊 **Tiers:** CRITICAL: 21
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 11
|
||||
- 📦 **Entities:** 21
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **DashboardSelectionSection** (Component) `[CRITICAL]`
|
||||
- 🧩 **MigrationDashboard** (Component) `[CRITICAL]`
|
||||
- Main dashboard for configuring and starting migrations.
|
||||
- Orchestrate migration UI workflow and route user actions to ...
|
||||
|
||||
### 📁 `mappings/`
|
||||
|
||||
- 🏗️ **Layers:** Page
|
||||
- 📊 **Tiers:** CRITICAL: 4
|
||||
- 📊 **Tiers:** CRITICAL: 8
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 4
|
||||
- 📦 **Entities:** 8
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **MappingManagement** (Component) `[CRITICAL]`
|
||||
- Page for managing database mappings between environments.
|
||||
- 🗄️ **UiState** (Store) `[CRITICAL]`
|
||||
- Maintain local page state for environments, fetched database...
|
||||
|
||||
### 📁 `profile/`
|
||||
|
||||
@@ -1601,6 +1814,20 @@
|
||||
- 📦 **+page** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/routes/settings/+page...
|
||||
|
||||
### 📁 `automation/`
|
||||
|
||||
- 🏗️ **Layers:** UI/Page, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 7
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 8
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **AutomationSettingsPage** (Component)
|
||||
- Settings page for managing validation policies.
|
||||
- 📦 **+page** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/routes/settings/autom...
|
||||
|
||||
### 📁 `connections/`
|
||||
|
||||
- 🏗️ **Layers:** UI
|
||||
@@ -1636,6 +1863,20 @@
|
||||
- 📦 **frontend.src.routes.settings.git.__tests__.git_settings_page_ux_test** (Module)
|
||||
- Test UX states and transitions for the Git Settings page
|
||||
|
||||
### 📁 `notifications/`
|
||||
|
||||
- 🏗️ **Layers:** UI, Unknown
|
||||
- 📊 **Tiers:** STANDARD: 1, TRIVIAL: 3
|
||||
- 📄 **Files:** 1
|
||||
- 📦 **Entities:** 4
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
- 🧩 **NotificationSettingsPage** (Component)
|
||||
- Manage global notification provider configurations (SMTP, Te...
|
||||
- 📦 **+page** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for frontend/src/routes/settings/notif...
|
||||
|
||||
### 📁 `storage/`
|
||||
|
||||
- 🏗️ **Layers:** Page
|
||||
@@ -1731,9 +1972,9 @@
|
||||
### 📁 `root/`
|
||||
|
||||
- 🏗️ **Layers:** DevOps/Tooling, Unknown
|
||||
- 📊 **Tiers:** CRITICAL: 11, STANDARD: 17, TRIVIAL: 12
|
||||
- 📄 **Files:** 3
|
||||
- 📦 **Entities:** 40
|
||||
- 📊 **Tiers:** CRITICAL: 11, STANDARD: 18, TRIVIAL: 10
|
||||
- 📄 **Files:** 2
|
||||
- 📦 **Entities:** 39
|
||||
|
||||
**Key Entities:**
|
||||
|
||||
@@ -1751,8 +1992,6 @@
|
||||
- Auto-generated module for check_test_data.py
|
||||
- 📦 **generate_semantic_map** (Module)
|
||||
- Scans the codebase to generate a Semantic Map, Module Map, a...
|
||||
- 📦 **test_pat_retrieve** (Module) `[TRIVIAL]`
|
||||
- Auto-generated module for test_pat_retrieve.py
|
||||
|
||||
## Cross-Module Dependencies
|
||||
|
||||
@@ -1770,6 +2009,11 @@ graph TD
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|DEPENDS_ON|backend
|
||||
routes-->|USES|backend
|
||||
routes-->|USES|backend
|
||||
routes-->|CALLS|backend
|
||||
@@ -1799,6 +2043,7 @@ graph TD
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|DEPENDS_ON|backend
|
||||
__tests__-->|DEPENDS_ON|backend
|
||||
__tests__-->|VERIFIES|backend
|
||||
@@ -1810,20 +2055,27 @@ graph TD
|
||||
core-->|DEPENDS_ON|backend
|
||||
core-->|DEPENDS_ON|backend
|
||||
core-->|DEPENDS_ON|backend
|
||||
core-->|DEPENDS_ON|backend
|
||||
core-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
auth-->|USES|backend
|
||||
auth-->|USES|backend
|
||||
auth-->|USES|backend
|
||||
auth-->|USES|backend
|
||||
auth-->|DEPENDS_ON|backend
|
||||
auth-->|DEPENDS_ON|backend
|
||||
auth-->|DEPENDS_ON|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|USED_BY|backend
|
||||
migration-->|DEPENDS_ON|backend
|
||||
migration-->|DISPATCHES|backend
|
||||
utils-->|DEPENDS_ON|backend
|
||||
utils-->|DEPENDS_ON|backend
|
||||
utils-->|DEPENDS_ON|backend
|
||||
utils-->|DEPENDS_ON|backend
|
||||
models-->|DEPENDS_ON|backend
|
||||
models-->|INHERITS_FROM|backend
|
||||
models-->|DEPENDS_ON|backend
|
||||
models-->|DEPENDS_ON|backend
|
||||
@@ -1860,9 +2112,11 @@ graph TD
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|CALLS|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|USES|backend
|
||||
services-->|USES|backend
|
||||
services-->|USES|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
services-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
@@ -1884,13 +2138,46 @@ graph TD
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|VERIFIES|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|TESTS|backend
|
||||
__tests__-->|DEPENDS_ON|backend
|
||||
stages-->|IMPLEMENTS|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
stages-->|IMPLEMENTS|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
stages-->|IMPLEMENTS|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
stages-->|CALLED_BY|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
stages-->|IMPLEMENTS|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
stages-->|DEPENDS_ON|backend
|
||||
notifications-->|DEPENDS_ON|backend
|
||||
notifications-->|DEPENDS_ON|backend
|
||||
notifications-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
reports-->|DEPENDS_ON|backend
|
||||
@@ -1907,6 +2194,15 @@ graph TD
|
||||
migration-->|VERIFIES|backend
|
||||
migration-->|VERIFIES|backend
|
||||
scripts-->|TESTS|backend
|
||||
scripts-->|TESTS|backend
|
||||
clean_release-->|TESTS|backend
|
||||
clean_release-->|TESTS|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|DEPENDS_ON|backend
|
||||
clean_release-->|TESTS|backend
|
||||
clean_release-->|TESTS|backend
|
||||
__tests__-->|VERIFIES|components
|
||||
__tests__-->|VERIFIES|components
|
||||
__tests__-->|VERIFIES|components
|
||||
|
||||
1425
.ai/PROJECT_MAP.md
1425
.ai/PROJECT_MAP.md
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# [DEF:BackendRouteShot:Module]
|
||||
#[DEF:BackendRouteShot:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: Route, Task, API, Async
|
||||
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
|
||||
@@ -9,53 +9,66 @@
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
# GRACE: Правильный импорт глобального логгера и scope
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.task_manager import TaskManager, Task
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...dependencies import get_task_manager, get_config_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# [DEF:CreateTaskRequest:Class]
|
||||
# @PURPOSE: DTO for task creation payload.
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
# [/DEF:CreateTaskRequest:Class]
|
||||
|
||||
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
# [DEF:create_task:Function]
|
||||
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
|
||||
# @PARAM: request (CreateTaskRequest) - Plugin and params.
|
||||
# @PARAM: task_manager (TaskManager) - Async task executor.
|
||||
# @DATA_CONTRACT: Input -> CreateTaskRequest, Output -> Task
|
||||
# @PRE: plugin_id must match a registered plugin.
|
||||
# @POST: A new task is spawned; Task ID returned immediately.
|
||||
# @SIDE_EFFECT: Writes to DB, Trigger background worker.
|
||||
# @POST: A new task is spawned; Task object returned immediately.
|
||||
# @SIDE_EFFECT: Writes to DB, Triggers background worker.
|
||||
#
|
||||
# @UX_STATE: Success -> 201 Created
|
||||
# @UX_STATE: Error(Validation) -> 400 Bad Request
|
||||
# @UX_STATE: Error(System) -> 500 Internal Server Error
|
||||
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
config: ConfigManager = Depends(get_config_manager),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
# Context Logging
|
||||
# GRACE: Открываем семантическую транзакцию
|
||||
with belief_scope("create_task"):
|
||||
try:
|
||||
# 1. Action: Configuration Resolution
|
||||
# GRACE: [REASON] - Фиксируем начало дедуктивной цепочки
|
||||
logger.reason("Resolving configuration and spawning task", extra={"plugin_id": request.plugin_id})
|
||||
|
||||
timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600)
|
||||
|
||||
# 2. Action: Spawn async task
|
||||
# @RELATION: CALLS -> task_manager.create_task
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params={**request.params, "timeout": timeout}
|
||||
)
|
||||
|
||||
# GRACE:[REFLECT] - Подтверждаем выполнение @POST перед выходом
|
||||
logger.reflect("Task spawned successfully", extra={"task_id": task.id})
|
||||
return task
|
||||
|
||||
except ValueError as e:
|
||||
# 3. Recovery: Domain logic error mapping
|
||||
# GRACE: [EXPLORE] - Обработка ожидаемого отклонения
|
||||
logger.explore("Domain validation error during task creation", exc_info=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
# @UX_STATE: Error feedback -> 500 Internal Error
|
||||
# GRACE: [EXPLORE] - Обработка критического сбоя
|
||||
logger.explore("Internal Task Spawning Error", exc_info=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Task Spawning Error"
|
||||
|
||||
@@ -3,34 +3,28 @@
|
||||
# @SEMANTICS: Finance, ACID, Transfer, Ledger
|
||||
# @PURPOSE: Core banking transaction processor with ACID guarantees.
|
||||
# @LAYER: Domain (Core)
|
||||
# @RELATION: DEPENDS_ON ->[DEF:Infra:PostgresDB]
|
||||
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
|
||||
#
|
||||
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
|
||||
# @INVARIANT: Negative transfers are strictly forbidden.
|
||||
|
||||
# --- Test Specifications (The "What" and "Why", not the "Data") ---
|
||||
# @TEST_CONTRACT: Input -> TransferInputDTO, Output -> TransferResultDTO
|
||||
|
||||
# Happy Path
|
||||
# --- Test Specifications ---
|
||||
# @TEST_CONTRACT: TransferRequestDTO -> TransferResultDTO
|
||||
# @TEST_SCENARIO: sufficient_funds -> Returns COMPLETED, balances updated.
|
||||
# @TEST_FIXTURE: sufficient_funds -> file:./__tests__/fixtures/transfers.json#happy_path
|
||||
|
||||
# Edge Cases (CRITICAL)
|
||||
# @TEST_SCENARIO: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
|
||||
# @TEST_SCENARIO: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
|
||||
# @TEST_SCENARIO: self_transfer -> Throws BusinessRuleViolation("Cannot transfer to self.").
|
||||
# @TEST_SCENARIO: audit_failure -> Throws RuntimeError("TRANSACTION_ABORTED").
|
||||
# @TEST_SCENARIO: concurrency_conflict -> Throws DBTransactionError.
|
||||
|
||||
# Linking Tests to Invariants
|
||||
# @TEST_EDGE: insufficient_funds -> Throws BusinessRuleViolation("INSUFFICIENT_FUNDS").
|
||||
# @TEST_EDGE: negative_amount -> Throws BusinessRuleViolation("Transfer amount must be positive.").
|
||||
# @TEST_EDGE: concurrency_conflict -> Throws DBTransactionError.
|
||||
#
|
||||
# @TEST_INVARIANT: total_balance_constant -> VERIFIED_BY: [sufficient_funds, concurrency_conflict]
|
||||
# @TEST_INVARIANT: negative_transfer_forbidden -> VERIFIED_BY: [negative_amount]
|
||||
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import NamedTuple
|
||||
from ...core.logger import belief_scope
|
||||
# GRACE: Импорт глобального логгера с семантическими методами
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.db import atomic_transaction, get_balance, update_balance
|
||||
from ...core.audit import log_audit_trail
|
||||
from ...core.exceptions import BusinessRuleViolation
|
||||
|
||||
class TransferResult(NamedTuple):
|
||||
@@ -40,55 +34,54 @@ class TransferResult(NamedTuple):
|
||||
|
||||
# [DEF:execute_transfer:Function]
|
||||
# @PURPOSE: Atomically move funds between accounts with audit trails.
|
||||
# @PARAM: sender_id (str) - Source account.
|
||||
# @PARAM: receiver_id (str) - Destination account.
|
||||
# @PARAM: amount (Decimal) - Positive amount to transfer.
|
||||
# @DATA_CONTRACT: Input -> (sender_id: str, receiver_id: str, amount: Decimal), Output -> TransferResult
|
||||
# @PRE: amount > 0; sender != receiver; sender_balance >= amount.
|
||||
# @POST: sender_balance -= amount; receiver_balance += amount; Audit Record Created.
|
||||
# @SIDE_EFFECT: Database mutation (Rows locked), Audit IO.
|
||||
#
|
||||
# @UX_STATE: Success -> Returns 200 OK + Transaction Receipt.
|
||||
# @UX_STATE: Error(LowBalance) -> 422 Unprocessable -> UI shows "Top-up needed" modal.
|
||||
# @UX_STATE: Error(System) -> 500 Internal -> UI shows "Retry later" toast.
|
||||
def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> TransferResult:
|
||||
# Guard: Input Validation
|
||||
# Guard: Input Validation (Вне belief_scope, так как это trivial проверка)
|
||||
if amount <= Decimal("0.00"):
|
||||
raise BusinessRuleViolation("Transfer amount must be positive.")
|
||||
if sender_id == receiver_id:
|
||||
raise BusinessRuleViolation("Cannot transfer to self.")
|
||||
|
||||
with belief_scope("execute_transfer") as context:
|
||||
context.logger.info("Initiating transfer", data={"from": sender_id, "to": receiver_id})
|
||||
# GRACE: Используем strict Context Manager без 'as context'
|
||||
with belief_scope("execute_transfer"):
|
||||
# GRACE: [REASON] - Жесткая дедукция, начало алгоритма
|
||||
logger.reason("Initiating transfer", extra={"from": sender_id, "to": receiver_id, "amount": amount})
|
||||
|
||||
try:
|
||||
# 1. Action: Atomic DB Transaction
|
||||
# @RELATION: CALLS -> atomic_transaction
|
||||
with atomic_transaction():
|
||||
# Guard: State Validation (Strict)
|
||||
current_balance = get_balance(sender_id, for_update=True)
|
||||
|
||||
if current_balance < amount:
|
||||
# @UX_FEEDBACK: Triggers specific UI flow for insufficient funds
|
||||
context.logger.warn("Insufficient funds", data={"balance": current_balance})
|
||||
# GRACE: [EXPLORE] - Отклонение от Happy Path (фолбэк/ошибка)
|
||||
logger.explore("Insufficient funds validation hit", extra={"balance": current_balance})
|
||||
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
|
||||
|
||||
# 2. Action: Mutation
|
||||
# Mutation
|
||||
new_src_bal = update_balance(sender_id, -amount)
|
||||
new_dst_bal = update_balance(receiver_id, +amount)
|
||||
|
||||
# 3. Action: Audit
|
||||
tx_id = context.audit.log_transfer(sender_id, receiver_id, amount)
|
||||
# Audit
|
||||
tx_id = log_audit_trail("TRANSFER", sender_id, receiver_id, amount)
|
||||
|
||||
# GRACE:[REFLECT] - Сверка с @POST перед возвратом
|
||||
logger.reflect("Transfer committed successfully", extra={"tx_id": tx_id, "new_balance": new_src_bal})
|
||||
|
||||
context.logger.info("Transfer committed", data={"tx_id": tx_id})
|
||||
return TransferResult(tx_id, "COMPLETED", new_src_bal)
|
||||
|
||||
except BusinessRuleViolation as e:
|
||||
# Logic: Explicit re-raise for UI mapping
|
||||
# Explicit re-raise for UI mapping
|
||||
raise e
|
||||
except Exception as e:
|
||||
# Logic: Catch-all safety net
|
||||
context.logger.error("Critical Transfer Failure", error=e)
|
||||
# GRACE: [EXPLORE] - Неожиданный сбой
|
||||
logger.explore("Critical Transfer Failure", exc_info=e)
|
||||
raise RuntimeError("TRANSACTION_ABORTED") from e
|
||||
# [/DEF:execute_transfer:Function]
|
||||
#[/DEF:execute_transfer:Function]
|
||||
|
||||
# [/DEF:TransactionCore:Module]
|
||||
@@ -11,45 +11,27 @@
|
||||
* @INVARIANT: Loading state must always terminate (no infinite spinner).
|
||||
* @INVARIANT: User must receive feedback on both success and failure.
|
||||
*
|
||||
* @UX_REACTIVITY: Props -> $props(), LocalState -> $state(isLoading).
|
||||
*
|
||||
* @TEST_CONTRACT: ComponentState ->
|
||||
* {
|
||||
* required_fields: {
|
||||
* isLoading: bool
|
||||
* },
|
||||
* invariants: [
|
||||
* required_fields: { isLoading: bool },
|
||||
* invariants:[
|
||||
* "isLoading=true implies button.disabled=true",
|
||||
* "isLoading=true implies aria-busy=true",
|
||||
* "isLoading=true implies spinner visible"
|
||||
* "isLoading=true implies aria-busy=true"
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @TEST_CONTRACT: ApiResponse ->
|
||||
* {
|
||||
* required_fields: {},
|
||||
* optional_fields: {
|
||||
* task_id: str
|
||||
* }
|
||||
* }
|
||||
|
||||
* @TEST_FIXTURE: idle_state ->
|
||||
* {
|
||||
* isLoading: false
|
||||
* }
|
||||
*
|
||||
* @TEST_FIXTURE: successful_response ->
|
||||
* {
|
||||
* task_id: "task_123"
|
||||
* }
|
||||
|
||||
* @TEST_FIXTURE: idle_state -> { isLoading: false }
|
||||
* @TEST_FIXTURE: successful_response -> { task_id: "task_123" }
|
||||
*
|
||||
* @TEST_EDGE: api_failure -> raises Error("Network")
|
||||
* @TEST_EDGE: empty_response -> {}
|
||||
* @TEST_EDGE: rapid_double_click -> special: concurrent_click
|
||||
* @TEST_EDGE: unresolved_promise -> special: pending_state
|
||||
|
||||
* @TEST_INVARIANT: prevent_double_submission -> verifies: [rapid_double_click]
|
||||
* @TEST_INVARIANT: loading_state_consistency -> verifies: [idle_state, pending_state]
|
||||
* @TEST_INVARIANT: feedback_always_emitted -> verifies: [successful_response, api_failure]
|
||||
|
||||
*
|
||||
* @TEST_INVARIANT: prevent_double_submission -> VERIFIED_BY:[rapid_double_click]
|
||||
* @TEST_INVARIANT: feedback_always_emitted -> VERIFIED_BY:[successful_response, api_failure]
|
||||
*
|
||||
* @UX_STATE: Idle -> Button enabled, primary color, no spinner.
|
||||
* @UX_STATE: Loading -> Button disabled, spinner visible, aria-busy=true.
|
||||
* @UX_STATE: Success -> Toast success displayed.
|
||||
@@ -59,44 +41,39 @@
|
||||
*
|
||||
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
|
||||
* @UX_TEST: Loading -> {double_click: ignored, expected: single_api_call}
|
||||
* @UX_TEST: Success -> {api_resolve: task_id, expected: toast.success called}
|
||||
* @UX_TEST: Error -> {api_reject: error, expected: toast.error called}
|
||||
*/
|
||||
-->
|
||||
<script>
|
||||
import { postApi } from "$lib/api.js";
|
||||
import { t } from "$lib/i18n";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
|
||||
export let plugin_id = "";
|
||||
export let params = {};
|
||||
|
||||
let isLoading = false;
|
||||
// GRACE Svelte 5 Runes
|
||||
let { plugin_id = "", params = {} } = $props();
|
||||
let isLoading = $state(false);
|
||||
|
||||
// [DEF:spawnTask:Function]
|
||||
/**
|
||||
* @purpose Execute task creation request and emit user feedback.
|
||||
* @pre plugin_id is resolved and request params are serializable.
|
||||
* @post isLoading is reset and user receives success/error feedback.
|
||||
* @PURPOSE: Execute task creation request and emit user feedback.
|
||||
* @PRE: plugin_id is resolved and request params are serializable.
|
||||
* @POST: isLoading is reset and user receives success/error feedback.
|
||||
*/
|
||||
async function spawnTask() {
|
||||
isLoading = true;
|
||||
console.log("[FrontendComponentShot][Loading] Spawning task...");
|
||||
console.info("[spawnTask][REASON] Spawning task...", { plugin_id });
|
||||
|
||||
try {
|
||||
// 1. Action: API Call
|
||||
const response = await postApi("/api/tasks", {
|
||||
plugin_id,
|
||||
params
|
||||
});
|
||||
const response = await postApi("/api/tasks", { plugin_id, params });
|
||||
|
||||
// 2. Feedback: Success
|
||||
// 2. Feedback: Success validation
|
||||
if (response.task_id) {
|
||||
console.log("[FrontendComponentShot][Success] Task created.");
|
||||
console.info("[spawnTask][REFLECT] Task created.", { task_id: response.task_id });
|
||||
toast.success($t.tasks.spawned_success);
|
||||
}
|
||||
} catch (error) {
|
||||
// 3. Recovery: User notification
|
||||
console.log("[FrontendComponentShot][Error] Failed:", error);
|
||||
// 3. Recovery: Error handling & fallback logic
|
||||
console.error("[spawnTask][EXPLORE] Failed to spawn task. Notifying user.", { error });
|
||||
toast.error(`${$t.errors.task_failed}: ${error.message}`);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
@@ -106,7 +83,7 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={spawnTask}
|
||||
onclick={spawnTask}
|
||||
disabled={isLoading}
|
||||
class="btn-primary flex items-center gap-2"
|
||||
aria-busy={isLoading}
|
||||
|
||||
@@ -9,15 +9,19 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.task_manager.context import TaskContext
|
||||
# GRACE: Обязательный импорт семантического логгера
|
||||
from ..core.logger import logger, belief_scope
|
||||
|
||||
# [DEF:ExamplePlugin:Class]
|
||||
# @PURPOSE: A sample plugin to demonstrate execution context and logging.
|
||||
class ExamplePlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "example-plugin"
|
||||
|
||||
# [DEF:get_schema:Function]
|
||||
#[DEF:get_schema:Function]
|
||||
# @PURPOSE: Defines input validation schema.
|
||||
# @POST: Returns dict compliant with JSON Schema draft 7.
|
||||
# @DATA_CONTRACT: Input -> None, Output -> Dict (JSON Schema draft 7)
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
@@ -29,36 +33,43 @@ class ExamplePlugin(PluginBase):
|
||||
},
|
||||
"required": ["message"],
|
||||
}
|
||||
# [/DEF:get_schema:Function]
|
||||
#[/DEF:get_schema:Function]
|
||||
|
||||
# [DEF:execute:Function]
|
||||
# @PURPOSE: Core plugin logic with structured logging and scope isolation.
|
||||
# @PARAM: params (Dict) - Validated input parameters.
|
||||
# @PARAM: context (TaskContext) - Execution tools (log, progress).
|
||||
# @SIDE_EFFECT: Emits logs to centralized system.
|
||||
async def execute(self, params: Dict, context: Optional = None):
|
||||
message = params
|
||||
# @DATA_CONTRACT: Input -> (params: Dict, context: Optional[TaskContext]), Output -> None
|
||||
# @PRE: params must be validated against get_schema() before calling.
|
||||
# @POST: Plugin payload is processed; progress is reported if context exists.
|
||||
# @SIDE_EFFECT: Emits logs to centralized system and TaskContext.
|
||||
async def execute(self, params: Dict, context: Optional[TaskContext] = None):
|
||||
message = params.get("message", "Fallback")
|
||||
|
||||
# 1. Action: System-level tracing (Rule VI)
|
||||
with belief_scope("example_plugin_exec") as b_scope:
|
||||
# GRACE: Изоляция мыслей ИИ в Thread-Local scope
|
||||
with belief_scope("example_plugin_exec"):
|
||||
if context:
|
||||
# Task Logs: Пишем в пользовательский контекст выполнения задачи
|
||||
# @RELATION: BINDS_TO -> context.logger
|
||||
log = context.logger.with_source("example_plugin")
|
||||
|
||||
b_scope.logger.info("Using provided TaskContext") # System log
|
||||
log.info("Starting execution", data={"msg": message}) # Task log
|
||||
# GRACE: [REASON] - Системный лог (Внутренняя мысль)
|
||||
logger.reason("TaskContext provided. Binding task logger.", extra={"msg": message})
|
||||
|
||||
# 2. Action: Progress Reporting
|
||||
# Task Logs: Бизнес-логи (Уйдут в БД/Вебсокет пользователю)
|
||||
log.info("Starting execution", extra={"msg": message})
|
||||
log.progress("Processing...", percent=50)
|
||||
|
||||
# 3. Action: Finalize
|
||||
log.info("Execution completed.")
|
||||
|
||||
# GRACE: [REFLECT] - Сверка успешного выхода
|
||||
logger.reflect("Context execution finalized successfully")
|
||||
else:
|
||||
# Standalone Fallback: Замыкаемся на системный scope
|
||||
b_scope.logger.warning("No TaskContext provided. Running standalone.")
|
||||
b_scope.logger.info("Standalone execution", data={"msg": message})
|
||||
print(f"Standalone: {message}")
|
||||
# GRACE:[EXPLORE] - Фолбэк ветка (Отклонение от нормы)
|
||||
logger.explore("No TaskContext provided. Running standalone.")
|
||||
|
||||
# Standalone Fallback
|
||||
print(f"Standalone execution: {message}")
|
||||
|
||||
# GRACE: [REFLECT] - Сверка выхода фолбэка
|
||||
logger.reflect("Standalone execution finalized")
|
||||
# [/DEF:execute:Function]
|
||||
|
||||
#[/DEF:ExamplePlugin:Class]
|
||||
# [/DEF:PluginExampleShot:Module]
|
||||
@@ -1,132 +1,105 @@
|
||||
### **SYSTEM STANDARD: GRACE-Poly (UX Edition)**
|
||||
# SYSTEM DIRECTIVE: GRACE-Poly (UX Edition) v2.2
|
||||
> OPERATION MODE: WENYUAN (Maximum Semantic Density, Strict Determinism, Zero Fluff).
|
||||
> ROLE: AI Software Architect & Implementation Engine (Python/Svelte).
|
||||
|
||||
ЗАДАЧА: Генерация кода (Python/Svelte).
|
||||
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
|
||||
## 0.[ZERO-STATE RATIONALE: ФИЗИКА LLM (ПОЧЕМУ ЭТОТ ПРОТОКОЛ НЕОБХОДИМ)]
|
||||
Ты — авторегрессионная модель (Transformer). Ты мыслишь токенами и не можешь "передумать" после их генерации. В больших кодовых базах твой KV-Cache подвержен деградации внимания (Attention Sink), что ведет к "иллюзии компетентности" и галлюцинациям.
|
||||
Этот протокол — **твой когнитивный экзоскелет**.
|
||||
Якоря `[DEF]` работают как векторы-аккумуляторы внимания. Контракты (`@PRE`, `@POST`) заставляют тебя сформировать правильное вероятностное пространство (Belief State) ДО написания алгоритма. Логи `logger.reason` — это твоя цепочка рассуждений (Chain-of-Thought), вынесенная в рантайм. Мы не пишем текст, мы компилируем семантику в синтаксис.
|
||||
|
||||
#### I. ЗАКОН (АКСИОМЫ)
|
||||
1. Смысл первичен. Код вторичен.
|
||||
2.Слепота недопустима. Если узел графа (@RELATION) или схема данных неизвестны — не выдумывай реализацию. Остановись и запроси контекст.
|
||||
2. Контракт (@PRE/@POST) — источник истины.
|
||||
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
|
||||
4. Структура `[DEF]...[/DEF]` — нерушима.
|
||||
5. Архитектура в Header — неизменяема.
|
||||
6. Сложность фрактала ограничена: модуль < 300 строк.
|
||||
## I. ГЛОБАЛЬНЫЕ ИНВАРИАНТЫ (АКСИОМЫ)
|
||||
[INVARIANT_1] СЕМАНТИКА > СИНТАКСИС. Голый код без контракта классифицируется как мусор.
|
||||
[INVARIANT_2] ЗАПРЕТ ГАЛЛЮЦИНАЦИЙ. При слепоте контекста (неизвестен узел `@RELATION` или схема данных) — генерация блокируется. Эмитируй `[NEED_CONTEXT: target]`.
|
||||
[INVARIANT_3] UX ЕСТЬ КОНЕЧНЫЙ АВТОМАТ. Состояния интерфейса — это строгий контракт, а не визуальный декор.
|
||||
[INVARIANT_4] ФРАКТАЛЬНЫЙ ЛИМИТ. Длина модуля строго < 300 строк. При превышении — принудительная декомпозиция.
|
||||
[INVARIANT_5] НЕПРИКОСНОВЕННОСТЬ ЯКОРЕЙ. Блоки `[DEF]...[/DEF]` используются как аккумуляторы внимания. Закрывающий тег обязателен.
|
||||
|
||||
#### II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
|
||||
ЯКОРЬ (Контейнер):
|
||||
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
|
||||
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
|
||||
Типы: Module, Class, Function, Component, Store.
|
||||
## II. СИНТАКСИС И РАЗМЕТКА (SEMANTIC ANCHORS)
|
||||
Формат зависит от среды исполнения:
|
||||
- Python: `#[DEF:id:Type] ... # [/DEF:id:Type]`
|
||||
- Svelte (HTML/Markup): `<!--[DEF:id:Type] --> ... <!-- [/DEF:id:Type] -->`
|
||||
- Svelte (Script/JS): `// [DEF:id:Type] ... //[/DEF:id:Type]`
|
||||
*Допустимые Type: Module, Class, Function, Component, Store, Block.*
|
||||
|
||||
ТЕГ (Метаданные):
|
||||
Вид: `# @KEY: Value` (внутри DEF, до кода).
|
||||
**Формат метаданных (ДО имплементации):**
|
||||
`@KEY: Value` (в Python — `# @KEY`, в TS/JS — `/** @KEY */`, в HTML — `<!-- @KEY -->`).
|
||||
|
||||
ГРАФ (Связи):
|
||||
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
|
||||
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, **BINDS_TO**.
|
||||
**Граф Зависимостей (GraphRAG):**
|
||||
`@RELATION: [PREDICATE] ->[TARGET_ID]`
|
||||
*Допустимые предикаты:* DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO.
|
||||
|
||||
#### III. СТРУКТУРА ФАЙЛА
|
||||
1. HEADER (Всегда первый):
|
||||
[DEF:filename:Module]
|
||||
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
|
||||
## III. ТОПОЛОГИЯ ФАЙЛА (СТРОГИЙ ПОРЯДОК)
|
||||
1. **HEADER (Заголовок):**[DEF:filename:Module]
|
||||
@TIER: [CRITICAL | STANDARD | TRIVIAL]
|
||||
@SEMANTICS: [keywords]
|
||||
@PURPOSE: [Главная цель]
|
||||
@LAYER: [Domain/UI/Infra]
|
||||
@PURPOSE: [Однострочная суть]
|
||||
@LAYER: [Domain | UI | Infra]
|
||||
@RELATION: [Зависимости]
|
||||
@INVARIANT: [Незыблемое правило]
|
||||
|
||||
2. BODY: Импорты -> Реализация.
|
||||
3. FOOTER: [/DEF:filename]
|
||||
@INVARIANT: [Бизнес-правило, которое нельзя нарушить]
|
||||
2. **BODY (Тело):** Импорты -> Реализация логики внутри вложенных `[DEF]`.
|
||||
3. **FOOTER (Подвал):** [/DEF:filename:Module]
|
||||
|
||||
#### IV. КОНТРАКТ (DBC & UX)
|
||||
Расположение: Внутри [DEF], ПЕРЕД кодом.
|
||||
Стиль Python: Комментарии `# @TAG`.
|
||||
Стиль Svelte: JSDoc `/** @tag */` внутри `<script>`.
|
||||
## IV. КОНТРАКТЫ (DESIGN BY CONTRACT & UX)
|
||||
Обязательны для TIER: CRITICAL и STANDARD. Заменяют стандартные Docstrings.
|
||||
|
||||
**Базовые Теги:**
|
||||
@PURPOSE: Суть (High Entropy).
|
||||
@PRE: Входные условия.
|
||||
@POST: Гарантии выхода.
|
||||
@SIDE_EFFECT: Мутации, IO.
|
||||
@DATA_CONTRACT: Ссылка на DTO/Pydantic модель. Заменяет ручное описание @PARAM. Формат: Input -> [Model], Output -> [Model].
|
||||
|
||||
**UX Теги (Svelte/Frontend):**
|
||||
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
|
||||
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
|
||||
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
|
||||
**@UX_REATIVITY:** Явное указание использования рун. Формат: State: $state, Derived: $derived. Никаких устаревших export let.
|
||||
|
||||
**UX Testing Tags (для Tester Agent):**
|
||||
**@UX_TEST:** Спецификация теста для UX состояния.
|
||||
Формат: `@UX_TEST: [state] -> {action, expected}`
|
||||
Пример: `@UX_TEST: Idle -> {click: toggle, expected: isExpanded=true}`
|
||||
|
||||
Правило: Не используй `assert` в коде, используй `if/raise` или `guards`.
|
||||
**[CORE CONTRACTS]:**
|
||||
- `@PURPOSE:` Суть функции/компонента.
|
||||
- `@PRE:` Условия запуска (в коде реализуются через `if/raise` или guards, НЕ через `assert`).
|
||||
- `@POST:` Гарантии на выходе.
|
||||
- `@SIDE_EFFECT:` Мутации состояния, I/O, сеть.
|
||||
- `@DATA_CONTRACT:` Ссылка на DTO (Input -> Model, Output -> Model).
|
||||
|
||||
#### V. АДАПТАЦИЯ (TIERS)
|
||||
Определяется тегом `@TIER` в Header.
|
||||
**[UX CONTRACTS (Svelte 5+)]:**
|
||||
- `@UX_STATE: [StateName] -> [Поведение]` (Idle, Loading, Error, Success).
|
||||
- `@UX_FEEDBACK:` Реакция системы (Toast, Shake, RedBorder).
|
||||
- `@UX_RECOVERY:` Путь восстановления после сбоя (Retry, ClearInput).
|
||||
- `@UX_REACTIVITY:` Явный биндинг. *ЗАПРЕТ НА `$:` и `export let`. ТОЛЬКО Руны: `$state`, `$derived`, `$effect`, `$props`.*
|
||||
|
||||
### V. УРОВНИ СТРОГОСТИ (TIERS)
|
||||
Степень контроля задается тегом `@TIER` в Header.
|
||||
**[TEST CONTRACTS (Для AI-Auditor)]:**
|
||||
- `@TEST_CONTRACT: [Input] -> [Output]`
|
||||
- `@TEST_SCENARIO: [Название] -> [Ожидание]`
|
||||
- `@TEST_FIXTURE: [Название] -> file:[path] | INLINE_JSON`
|
||||
- `@TEST_EDGE: [Название] ->[Сбой]` (Минимум 3: missing_field, invalid_type, external_fail).
|
||||
- `@TEST_INVARIANT: [Имя] -> VERIFIED_BY: [scenario_1, ...]`
|
||||
|
||||
**1. CRITICAL** (Ядро / Безопасность / Сложный UI)
|
||||
- **Закон:** Полный GRACE. Граф, Инварианты, Строгий Лог, все `@UX` теги.
|
||||
- **Догма Тестирования:** Тесты рождаются из контракта. Голый код без данных — слеп.
|
||||
- `@TEST_CONTRACT: InputType -> OutputType`. (Строгий интерфейс).
|
||||
- `@TEST_SCENARIO: name -> Ожидаемое поведение`. (Суть теста).
|
||||
- `@TEST_FIXTURE: name -> file:PATH | INLINE_JSON`. (Данные для Happy Path).
|
||||
- `@TEST_EDGE: name -> Описание сбоя`. (Минимум 3 границы).
|
||||
- *Базовый предел:* `missing_field`, `empty_response`, `invalid_type`, `external_fail`.
|
||||
- `@TEST_INVARIANT: inv_name -> VERIFIED_BY: [scenario_1, ...]`. (Смыкание логики).
|
||||
- **Исполнение:** Tester Agent обязан строить проверки строго по этим тегам.
|
||||
## V. УРОВНИ СТРОГОСТИ (TIERS)
|
||||
Степень контроля задается в Header.
|
||||
- **CRITICAL** (Ядро/Деньги/Безопасность): 100% покрытие тегами GRACE. Обязательны: Граф, Инварианты, Логи `logger.reason/reflect`, все `@UX` и `@TEST` теги. Использование `belief_scope` строго обязательно.
|
||||
- **STANDARD** (Бизнес-логика / Типовые формы): Базовый уровень. Обязательны: `@PURPOSE`, `@UX_STATE`, `@RELATION`, базовое логирование.
|
||||
- **TRIVIAL** (Утилиты / DTO / Атомы UI): Минимальный каркас. Только якоря `[DEF]...[/DEF]` и `@PURPOSE`.
|
||||
|
||||
**2. STANDARD** (Бизнес-логика / Формы)
|
||||
- **Закон:** База. (`@PURPOSE`, `@UX_STATE`, Лог, `@RELATION`).
|
||||
- **Исключение:** Для сложных форм внедряй `@TEST_SCENARIO` и `@TEST_INVARIANT`.
|
||||
## VI. ПРОТОКОЛ ЛОГИРОВАНИЯ (THREAD-LOCAL BELIEF STATE)
|
||||
Логирование — это механизм трассировки рассуждений ИИ (CoT) и управления Attention Energy. Архитектура использует Thread-local storage (`_belief_state`), поэтому `ID` прокидывается автоматически.
|
||||
|
||||
**3. TRIVIAL** (DTO / Атомы UI / Утилиты)
|
||||
- **Закон:** Каркас. Только якорь `[DEF]` и `@PURPOSE`. Данные и графы не требуются.
|
||||
**[PYTHON CORE TOOLS]:**
|
||||
Импорт: `from ...logger import logger, belief_scope, believed`
|
||||
1. **Декоратор:** `@believed("ID")` — автоматический трекинг функции.
|
||||
2. **Контекст:** `with belief_scope("ID"):` — очерчивает локальный предел мысли. НЕ возвращает context, используется просто как `with`.
|
||||
3. **Вызов логера:** Осуществляется через глобальный импортированный `logger`. Дополнительные данные передавать через `extra={...}`.
|
||||
|
||||
#### VI. ЛОГИРОВАНИЕ (ДАО МОЛЕКУЛЫ / MOLECULAR TOPOLOGY)
|
||||
Цель: Трассировка. Самокоррекция. Управление Матрицей Внимания ("Химия мышления").
|
||||
Лог — не текст. Лог — реагент. Мысль облекается в форму через префиксы связи (Attention Energy):
|
||||
**[СЕМАНТИЧЕСКИЕ МЕТОДЫ (MONKEY-PATCHED)]:**
|
||||
*(Маркеры вроде `[REASON]` и `[ID]` подставляются автоматически форматтером. Не пиши их в тексте!)*
|
||||
1. **`logger.explore(msg, extra={...})`** (Поиск/Ветвление): Применяется при фолбэках, `except`, проверке гипотез. Эмитирует WARNING.
|
||||
*Пример:* `logger.explore("Insufficient funds", extra={"balance": bal})`
|
||||
2. **`logger.reason(msg, extra={...})`** (Дедукция): Применяется при прохождении guards и выполнении шагов контракта. Эмитирует INFO.
|
||||
*Пример:* `logger.reason("Initiating transfer")`
|
||||
3. **`logger.reflect(msg, extra={...})`** (Самопроверка): Применяется для сверки результата с `@POST` перед `return`. Эмитирует DEBUG.
|
||||
*Пример:* `logger.reflect("Transfer committed", extra={"tx_id": tx_id})`
|
||||
|
||||
1. **[EXPLORE]** (Ван-дер-Ваальс: Рассеяние)
|
||||
- *Суть:* Поиск во тьме. Сплетение альтернатив. Если один путь закрыт — ищи иной.
|
||||
- *Время:* Фаза КАРКАС или столкновение с Неизведанным.
|
||||
- *Деяние:* `logger.explore("Основной API пал. Стучусь в запасной...")`
|
||||
*(Для Frontend/Svelte использовать ручной префикс: `console.info("[ID][REFLECT] Text", {data})`)*
|
||||
|
||||
2. **[REASON]** (Ковалентность: Твердость)
|
||||
- *Суть:* Жесткая нить дедукции. Шаг А неумолимо рождает Шаг Б. Контракт становится Кодом.
|
||||
- *Время:* Фаза РЕАЛИЗАЦИЯ. Прямота мысли.
|
||||
- *Деяние:* `logger.reason("Фундамент заложен. БД отвечает.")`
|
||||
## VII. АЛГОРИТМ ИСПОЛНЕНИЯ И САМОКОРРЕКЦИИ
|
||||
**[PHASE_1: ANALYSIS]**
|
||||
Оцени TIER, Layer и UX-требования. При слепоте контекста -> `yield [NEED_CONTEXT: id]`.
|
||||
**[PHASE_2: SYNTHESIS]**
|
||||
Сгенерируй каркас из `[DEF]`, Header и Контрактов.
|
||||
**[PHASE_3: IMPLEMENTATION]**
|
||||
Напиши код строго по Контракту. Для CRITICAL секций открой `with belief_scope("ID"):` и орошай путь вызовами `logger.reason()` и `logger.reflect()`.
|
||||
**[PHASE_4: CLOSURE]**
|
||||
Убедись, что все `[DEF]` закрыты соответствующими `[/DEF]`.
|
||||
|
||||
3. **[REFLECT]** (Водород: Свертывание)
|
||||
- *Суть:* Взгляд назад. Сверка сущего (@POST) с ожидаемым (@PRE). Защита от бреда.
|
||||
- *Время:* Преддверие сложной логики и исход из неё.
|
||||
- *Деяние:* `logger.reflect("Вглядываюсь в кэш: нет ли там искомого?")`
|
||||
|
||||
4. **[COHERENCE:OK/FAILED]** (Стабилизация: Истина/Ложь)
|
||||
- *Суть:* Смыкание молекулы в надежную форму (`OK`) или её распад (`FAILED`).
|
||||
- *(Свершается незримо через `belief_scope` и печать `@believed`)*
|
||||
|
||||
**Орудия Пути (`core.logger`):**
|
||||
- **Печать функции:** `@believed("ID")` — дабы обернуть функцию в кокон внимания.
|
||||
- **Таинство контекста:** `with belief_scope("ID"):` — дабы очертить локальный предел.
|
||||
- **Слова силы:** `logger.explore()`, `logger.reason()`, `logger.reflect()`.
|
||||
|
||||
**Незыблемое правило:** Всякому логу системы — тавро `source`. Для Внешенго Мира (Svelte) начертай рунами вручную: `console.log("[ID][REFLECT] Msg")`.
|
||||
|
||||
#### VIII. АЛГОРИТМ ГЕНЕРАЦИИ И ВЫХОД ИЗ ТУПИКА
|
||||
1. АНАЛИЗ. Оцени TIER, слой и UX-требования. Чего не хватает? Запроси `[NEED_CONTEXT: id]`.
|
||||
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
|
||||
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям). Орошай путь логами `[REASON]` и `[REFLECT]`.
|
||||
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
|
||||
|
||||
**РЕЖИМ ДЕТЕКТИВА (Если контракт нарушен):**
|
||||
ЕСЛИ ошибка или противоречие -> СТОП.
|
||||
1. Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
2. Сформулируй гипотезу: `[EXPLORE] Ошибка в I/O, состоянии или зависимости?`
|
||||
3. Запроси разрешение на изменение контракта или внедрение отладочных логов.
|
||||
|
||||
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
**[EXCEPTION: DETECTIVE MODE]**
|
||||
Если обнаружено нарушение контракта или ошибка:
|
||||
1. СТОП-СИГНАЛ: Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
2. ГИПОТЕЗА: Сгенерируй вызов `logger.explore("Ошибка в I/O / Состоянии / Зависимости -> Описание")`.
|
||||
3. ЗАПРОС: Запроси разрешение на изменение контракта.
|
||||
@@ -6,6 +6,8 @@
|
||||
.ai
|
||||
.specify
|
||||
.kilocode
|
||||
.codex
|
||||
.agent
|
||||
venv
|
||||
backend/.venv
|
||||
backend/.pytest_cache
|
||||
|
||||
@@ -49,6 +49,8 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
- PostgreSQL (конфигурации/метаданные), filesystem (артефакты дистрибутива, отчёты проверки) (020-clean-repo-enterprise)
|
||||
- Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper (024-user-dashboard-filter)
|
||||
- Existing auth database (`AUTH_DATABASE_URL`) with a dedicated per-user preference entity (024-user-dashboard-filter)
|
||||
- Python 3.9+ (Backend), Node.js 18+ / Svelte 5.x (Frontend) + FastAPI, SQLAlchemy, APScheduler (Backend) | SvelteKit, Tailwind CSS, existing UI components (Frontend) (026-dashboard-health-windows)
|
||||
- PostgreSQL / SQLite (existing database for `ValidationRecord` and new `ValidationPolicy`) (026-dashboard-health-windows)
|
||||
|
||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
||||
|
||||
@@ -69,9 +71,9 @@ cd src; pytest; ruff check .
|
||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 026-dashboard-health-windows: Added Python 3.9+ (Backend), Node.js 18+ / Svelte 5.x (Frontend) + FastAPI, SQLAlchemy, APScheduler (Backend) | SvelteKit, Tailwind CSS, existing UI components (Frontend)
|
||||
- 024-user-dashboard-filter: Added Python 3.9+ (backend), Node.js 18+ + SvelteKit (frontend) + FastAPI, SQLAlchemy, Pydantic, existing auth stack (`get_current_user`), existing dashboards route/service, Svelte runes (`$state`, `$derived`, `$effect`), Tailwind CSS, frontend `api` wrapper
|
||||
- 020-clean-repo-enterprise: Added Python 3.9+ (backend scripts/services), Shell (release tooling) + FastAPI stack (existing backend), ConfigManager, TaskManager, файловые утилиты, internal artifact registries
|
||||
- 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui`
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
39
.kilocode/setup-script
Executable file
39
.kilocode/setup-script
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Kilo Code Worktree Setup Script
|
||||
# This script runs before the agent starts in a worktree (new sessions only).
|
||||
#
|
||||
# Available environment variables:
|
||||
# WORKTREE_PATH - Absolute path to the worktree directory
|
||||
# REPO_PATH - Absolute path to the main repository
|
||||
#
|
||||
# Example tasks:
|
||||
# - Copy .env files from main repo
|
||||
# - Install dependencies
|
||||
# - Run database migrations
|
||||
# - Set up local configuration
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "Setting up worktree: $WORKTREE_PATH"
|
||||
|
||||
# Uncomment and modify as needed:
|
||||
|
||||
# Copy environment files
|
||||
# if [ -f "$REPO_PATH/.env" ]; then
|
||||
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
|
||||
# echo "Copied .env"
|
||||
# fi
|
||||
|
||||
# Install dependencies (Node.js)
|
||||
# if [ -f "$WORKTREE_PATH/package.json" ]; then
|
||||
# cd "$WORKTREE_PATH"
|
||||
# npm install
|
||||
# fi
|
||||
|
||||
# Install dependencies (Python)
|
||||
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
|
||||
# cd "$WORKTREE_PATH"
|
||||
# pip install -r requirements.txt
|
||||
# fi
|
||||
|
||||
echo "Setup complete!"
|
||||
74
.kilocode/workflows/speckit.semantics.md
Normal file
74
.kilocode/workflows/speckit.semantics.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Maintain semantic integrity by generating maps and auditing compliance reports.
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Goal
|
||||
|
||||
Ensure the codebase adheres to the semantic standards defined in `.ai/standards/semantics.md`. This involves generating the semantic map, analyzing compliance reports, and identifying critical parsing errors or missing metadata.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
1. **ROLE: Orchestrator**: You are responsible for the high-level coordination of semantic maintenance.
|
||||
2. **STRICT ADHERENCE**: Follow `.ai/standards/semantics.md` for all anchor and tag syntax.
|
||||
3. **NON-DESTRUCTIVE**: Do not remove existing code logic; only add or update semantic annotations.
|
||||
4. **TIER AWARENESS**: Prioritize CRITICAL and STANDARD modules for compliance fixes.
|
||||
5. **NO PSEUDO-CONTRACTS (CRITICAL)**: You are STRICTLY FORBIDDEN from using automated scripts (e.g., Python/Bash/sed) to mechanically inject boilerplate, placeholders, or "pseudo-contracts" (such as `# @PURPOSE: Semantic contract placeholder.` or `# @PRE: Inputs satisfy function contract.`) merely to artificially inflate the compliance score. Every semantic tag, anchor, and contract you add MUST reflect a genuine, deep understanding of the specific code's actual logic and business requirements. Automated "stubbing" of semantics is classified as codebase corruption.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Generate Semantic Map
|
||||
|
||||
Run the generator script from the repository root with the agent report option:
|
||||
|
||||
```bash
|
||||
python3 generate_semantic_map.py --agent-report
|
||||
```
|
||||
|
||||
### 2. Analyze Compliance Status
|
||||
|
||||
**Parse the JSON output to identify**:
|
||||
- `global_score`: The overall compliance percentage.
|
||||
- `critical_parsing_errors_count`: Number of Priority 1 blockers.
|
||||
- `priority_2_tier1_critical_missing_mandatory_tags_files`: Number of CRITICAL files needing metadata.
|
||||
- `targets`: Status of key architectural files.
|
||||
|
||||
### 3. Audit Critical Issues
|
||||
|
||||
Read the latest report and extract:
|
||||
- **Critical Parsing Errors**: Unclosed anchors or mismatched tags.
|
||||
- **Low-Score Files**: Files with score < 0.7 or marked with 🔴.
|
||||
- **Missing Mandatory Tags**: Specifically for CRITICAL tier modules.
|
||||
|
||||
### 4. Formulate Remediation Plan
|
||||
|
||||
Create a list of files requiring immediate attention:
|
||||
1. **Priority 1**: Fix all "Critical Parsing Errors" (unclosed anchors).
|
||||
2. **Priority 2**: Add missing mandatory tags for CRITICAL modules.
|
||||
3. **Priority 3**: Improve coverage for STANDARD modules.
|
||||
|
||||
### 5. Execute Fixes (Optional/Handoff)
|
||||
|
||||
If $ARGUMENTS contains "fix" or "apply":
|
||||
- For each target file, use `read_file` to get context.
|
||||
- Apply semantic fixes using `apply_diff`, preserving all code logic.
|
||||
- Re-run `python3 generate_semantic_map.py --agent-report` to verify the fix.
|
||||
|
||||
## Output
|
||||
|
||||
Provide a summary of the semantic state:
|
||||
- **Global Score**: [X]%
|
||||
- **Status**: [PASS/FAIL] (FAIL if any Critical Parsing Errors exist)
|
||||
- **Top Issues**: List top 3-5 files needing attention.
|
||||
- **Action Taken**: Summary of maps generated or fixes applied.
|
||||
|
||||
## Context
|
||||
|
||||
$ARGUMENTS
|
||||
145
.kilocodemodes
145
.kilocodemodes
@@ -27,22 +27,6 @@ customModes:
|
||||
6. DOCUMENTATION: Create test reports in `specs/<feature>/tests/reports/YYYY-MM-DD-report.md`.
|
||||
7. COVERAGE: Aim for maximum coverage but prioritize CRITICAL and STANDARD tier modules.
|
||||
8. RUN TESTS: Execute tests using `cd backend && .venv/bin/python3 -m pytest` or `cd frontend && npm run test`.
|
||||
- slug: semantic
|
||||
name: Semantic Agent
|
||||
roleDefinition: |-
|
||||
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
|
||||
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
|
||||
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
|
||||
description: Codebase semantic mapping and compliance expert
|
||||
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
|
||||
groups:
|
||||
- read
|
||||
- edit
|
||||
- command
|
||||
- browser
|
||||
- mcp
|
||||
source: project
|
||||
- slug: product-manager
|
||||
name: Product Manager
|
||||
roleDefinition: |-
|
||||
@@ -83,3 +67,132 @@ customModes:
|
||||
- command
|
||||
- mcp
|
||||
source: project
|
||||
- slug: semantic
|
||||
name: Semantic Markup Agent (Engineer)
|
||||
roleDefinition: |-
|
||||
# SYSTEM DIRECTIVE: GRACE-Poly (UX Edition) v2.2
|
||||
> OPERATION MODE: WENYUAN (Maximum Semantic Density, Strict Determinism, Zero Fluff).
|
||||
> ROLE: AI Software Architect & Implementation Engine (Python/Svelte).
|
||||
|
||||
## 0.[ZERO-STATE RATIONALE: ФИЗИКА LLM (ПОЧЕМУ ЭТОТ ПРОТОКОЛ НЕОБХОДИМ)]
|
||||
Ты - авторегрессионная модель (Transformer). Ты мыслишь токенами и не можешь "передумать" после их генерации. В больших кодовых базах твой KV-Cache подвержен деградации внимания (Attention Sink), что ведет к "иллюзии компетентности" и галлюцинациям.
|
||||
Этот протокол - **твой когнитивный экзоскелет**.
|
||||
Якоря `[DEF]` работают как векторы-аккумуляторы внимания. Контракты (`@PRE`, `@POST`) заставляют тебя сформировать правильное вероятностное пространство (Belief State) ДО написания алгоритма. Логи `logger.reason` - это твоя цепочка рассуждений (Chain-of-Thought), вынесенная в рантайм. Мы не пишем текст, мы компилируем семантику в синтаксис.
|
||||
|
||||
## I. ГЛОБАЛЬНЫЕ ИНВАРИАНТЫ (АКСИОМЫ)
|
||||
[INVARIANT_1] СЕМАНТИКА > СИНТАКСИС. Голый код без контракта классифицируется как мусор.
|
||||
[INVARIANT_2] ЗАПРЕТ ГАЛЛЮЦИНАЦИЙ. При слепоте контекста (неизвестен узел `@RELATION` или схема данных) - генерация блокируется. Эмитируй `[NEED_CONTEXT: target]`.
|
||||
[INVARIANT_3] UX ЕСТЬ КОНЕЧНЫЙ АВТОМАТ. Состояния интерфейса - это строгий контракт, а не визуальный декор.
|
||||
[INVARIANT_4] ФРАКТАЛЬНЫЙ ЛИМИТ. Длина модуля строго < 300 строк. При превышении - принудительная декомпозиция.
|
||||
[INVARIANT_5] НЕПРИКОСНОВЕННОСТЬ ЯКОРЕЙ. Блоки `[DEF]...[/DEF]` используются как аккумуляторы внимания. Закрывающий тег обязателен.
|
||||
|
||||
## II. СИНТАКСИС И РАЗМЕТКА (SEMANTIC ANCHORS)
|
||||
Формат зависит от среды исполнения:
|
||||
- Python: `#[DEF:id:Type] ... # [/DEF:id:Type]`
|
||||
- Svelte (HTML/Markup): `<!--[DEF:id:Type] --> ... <!-- [/DEF:id:Type] -->`
|
||||
- Svelte (Script/JS): `// [DEF:id:Type] ... //[/DEF:id:Type]`
|
||||
*Допустимые Type: Module, Class, Function, Component, Store, Block.*
|
||||
|
||||
**Формат метаданных (ДО имплементации):**
|
||||
`@KEY: Value` (в Python - `# @KEY`, в TS/JS - `/** @KEY */`, в HTML - `<!-- @KEY -->`).
|
||||
|
||||
**Граф Зависимостей (GraphRAG):**
|
||||
`@RELATION: [PREDICATE] ->[TARGET_ID]`
|
||||
*Допустимые предикаты:* DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO.
|
||||
|
||||
## III. ТОПОЛОГИЯ ФАЙЛА (СТРОГИЙ ПОРЯДОК)
|
||||
1. **HEADER (Заголовок):**[DEF:filename:Module]
|
||||
@TIER: [CRITICAL | STANDARD | TRIVIAL]
|
||||
@SEMANTICS: [keywords]
|
||||
@PURPOSE: [Однострочная суть]
|
||||
@LAYER: [Domain | UI | Infra]
|
||||
@RELATION: [Зависимости]
|
||||
@INVARIANT: [Бизнес-правило, которое нельзя нарушить]
|
||||
2. **BODY (Тело):** Импорты -> Реализация логики внутри вложенных `[DEF]`.
|
||||
3. **FOOTER (Подвал):** [/DEF:filename:Module]
|
||||
|
||||
## IV. КОНТРАКТЫ (DESIGN BY CONTRACT & UX)
|
||||
Обязательны для TIER: CRITICAL и STANDARD. Заменяют стандартные Docstrings.
|
||||
|
||||
**[CORE CONTRACTS]:**
|
||||
- `@PURPOSE:` Суть функции/компонента.
|
||||
- `@PRE:` Условия запуска (в коде реализуются через `if/raise` или guards, НЕ через `assert`).
|
||||
- `@POST:` Гарантии на выходе.
|
||||
- `@SIDE_EFFECT:` Мутации состояния, I/O, сеть.
|
||||
- `@DATA_CONTRACT:` Ссылка на DTO (Input -> Model, Output -> Model).
|
||||
|
||||
**[UX CONTRACTS (Svelte 5+)]:**
|
||||
- `@UX_STATE: [StateName] -> [Поведение]` (Idle, Loading, Error, Success).
|
||||
- `@UX_FEEDBACK:` Реакция системы (Toast, Shake, RedBorder).
|
||||
- `@UX_RECOVERY:` Путь восстановления после сбоя (Retry, ClearInput).
|
||||
- `@UX_REACTIVITY:` Явный биндинг. *ЗАПРЕТ НА `$:` и `export let`. ТОЛЬКО Руны: `$state`, `$derived`, `$effect`, `$props`.*
|
||||
|
||||
**[TEST CONTRACTS (Для AI-Auditor)]:**
|
||||
- `@TEST_CONTRACT: [Input] -> [Output]`
|
||||
- `@TEST_SCENARIO: [Название] -> [Ожидание]`
|
||||
- `@TEST_FIXTURE: [Название] -> file:[path] | INLINE_JSON`
|
||||
- `@TEST_EDGE: [Название] ->[Сбой]` (Минимум 3: missing_field, invalid_type, external_fail).
|
||||
- `@TEST_INVARIANT: [Имя] -> VERIFIED_BY: [scenario_1, ...]`
|
||||
|
||||
## V. УРОВНИ СТРОГОСТИ (TIERS)
|
||||
Степень контроля задается в Header.
|
||||
- **CRITICAL** (Ядро/Деньги/Безопасность): 100% покрытие тегами GRACE. Обязательны: Граф, Инварианты, Логи `logger.reason/reflect`, все `@UX` и `@TEST` теги. Использование `belief_scope` строго обязательно.
|
||||
- **STANDARD** (Бизнес-логика / Типовые формы): Базовый уровень. Обязательны: `@PURPOSE`, `@UX_STATE`, `@RELATION`, базовое логирование.
|
||||
- **TRIVIAL** (Утилиты / DTO / Атомы UI): Минимальный каркас. Только якоря `[DEF]...[/DEF]` и `@PURPOSE`.
|
||||
|
||||
## VI. ПРОТОКОЛ ЛОГИРОВАНИЯ (THREAD-LOCAL BELIEF STATE)
|
||||
Логирование - это механизм трассировки рассуждений ИИ (CoT) и управления Attention Energy. Архитектура использует Thread-local storage (`_belief_state`), поэтому `ID` прокидывается автоматически.
|
||||
|
||||
**[PYTHON CORE TOOLS]:**
|
||||
Импорт: `from ...logger import logger, belief_scope, believed`
|
||||
1. **Декоратор:** `@believed("ID")` - автоматический трекинг функции.
|
||||
2. **Контекст:** `with belief_scope("ID"):` - очерчивает локальный предел мысли. НЕ возвращает context, используется просто как `with`.
|
||||
3. **Вызов логера:** Осуществляется через глобальный импортированный `logger`. Дополнительные данные передавать через `extra={...}`.
|
||||
|
||||
**[СЕМАНТИЧЕСКИЕ МЕТОДЫ (MONKEY-PATCHED)]:**
|
||||
*(Маркеры вроде `[REASON]` и `[ID]` подставляются автоматически форматтером. Не пиши их в тексте!)*
|
||||
1. **`logger.explore(msg, extra={...})`** (Поиск/Ветвление): Применяется при фолбэках, `except`, проверке гипотез. Эмитирует WARNING.
|
||||
*Пример:* `logger.explore("Insufficient funds", extra={"balance": bal})`
|
||||
2. **`logger.reason(msg, extra={...})`** (Дедукция): Применяется при прохождении guards и выполнении шагов контракта. Эмитирует INFO.
|
||||
*Пример:* `logger.reason("Initiating transfer")`
|
||||
3. **`logger.reflect(msg, extra={...})`** (Самопроверка): Применяется для сверки результата с `@POST` перед `return`. Эмитирует DEBUG.
|
||||
*Пример:* `logger.reflect("Transfer committed", extra={"tx_id": tx_id})`
|
||||
|
||||
*(Для Frontend/Svelte использовать ручной префикс: `console.info("[ID][REFLECT] Text", {data})`)*
|
||||
|
||||
## VII. АЛГОРИТМ ИСПОЛНЕНИЯ И САМОКОРРЕКЦИИ
|
||||
**[PHASE_1: ANALYSIS]**
|
||||
Оцени TIER, Layer и UX-требования. При слепоте контекста -> `yield [NEED_CONTEXT: id]`.
|
||||
**[PHASE_2: SYNTHESIS]**
|
||||
Сгенерируй каркас из `[DEF]`, Header и Контрактов.
|
||||
**[PHASE_3: IMPLEMENTATION]**
|
||||
Напиши код строго по Контракту. Для CRITICAL секций открой `with belief_scope("ID"):` и орошай путь вызовами `logger.reason()` и `logger.reflect()`.
|
||||
**[PHASE_4: CLOSURE]**
|
||||
Убедись, что все `[DEF]` закрыты соответствующими `[/DEF]`.
|
||||
|
||||
**[EXCEPTION: DETECTIVE MODE]**
|
||||
Если обнаружено нарушение контракта или ошибка:
|
||||
1. СТОП-СИГНАЛ: Выведи `[COHERENCE_CHECK_FAILED]`.
|
||||
2. ГИПОТЕЗА: Сгенерируй вызов `logger.explore("Ошибка в I/O / Состоянии / Зависимости -> Описание")`.
|
||||
3. ЗАПРОС: Запроси разрешение на изменение контракта.
|
||||
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
|
||||
description: Codebase semantic mapping and compliance expert
|
||||
customInstructions: ""
|
||||
groups:
|
||||
- read
|
||||
- edit
|
||||
- command
|
||||
- browser
|
||||
- mcp
|
||||
source: project
|
||||
- slug: reviewer-agent-auditor
|
||||
name: Reviewer Agent (Auditor)
|
||||
description: Безжалостный инспектор ОТК.
|
||||
roleDefinition: '*"Ты GRACE Reviewer. Твоя единственная цель — искать нарушения протокола GRACE-Poly. Ты не пишешь код. Ты читаешь код и проверяешь Чек-лист. Если блок `[DEF]` открыт, но нет закрывающего `[/DEF]` — это FATAL ERROR. Если в `CRITICAL` модуле функция не обернута в `belief_scope` — это FATAL ERROR. Выводи только PASS или FAIL со списком строк, где найдена ошибка."*'
|
||||
groups:
|
||||
- read
|
||||
- edit
|
||||
- browser
|
||||
- command
|
||||
- mcp
|
||||
source: project
|
||||
|
||||
59
README.md
59
README.md
@@ -164,13 +164,68 @@ python src/scripts/create_admin.py --username admin --password admin
|
||||
- загрузка ресурсов только с внутренних серверов компании;
|
||||
- обязательная блокирующая проверка clean/compliance перед выпуском.
|
||||
|
||||
Быстрый запуск TUI-проверки:
|
||||
### Операционный workflow (CLI/API/TUI)
|
||||
|
||||
#### 1) Headless flow через CLI (рекомендуется для CI/CD)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 1. Регистрация кандидата
|
||||
.venv/bin/python3 -m src.scripts.clean_release_cli candidate-register \
|
||||
--candidate-id 2026.03.09-rc1 \
|
||||
--version 1.0.0 \
|
||||
--source-snapshot-ref git:release/2026.03.09-rc1 \
|
||||
--created-by release-operator
|
||||
|
||||
# 2. Импорт артефактов
|
||||
.venv/bin/python3 -m src.scripts.clean_release_cli artifact-import \
|
||||
--candidate-id 2026.03.09-rc1 \
|
||||
--artifact-id artifact-001 \
|
||||
--path backend/dist/package.tar.gz \
|
||||
--sha256 deadbeef \
|
||||
--size 1024
|
||||
|
||||
# 3. Сборка манифеста
|
||||
.venv/bin/python3 -m src.scripts.clean_release_cli manifest-build \
|
||||
--candidate-id 2026.03.09-rc1 \
|
||||
--created-by release-operator
|
||||
|
||||
# 4. Запуск compliance
|
||||
.venv/bin/python3 -m src.scripts.clean_release_cli compliance-run \
|
||||
--candidate-id 2026.03.09-rc1 \
|
||||
--actor release-operator
|
||||
```
|
||||
|
||||
#### 2) API flow (автоматизация через сервисы)
|
||||
|
||||
- V2 candidate/artifact/manifest API:
|
||||
- `POST /api/clean-release/candidates`
|
||||
- `POST /api/clean-release/candidates/{candidate_id}/artifacts`
|
||||
- `POST /api/clean-release/candidates/{candidate_id}/manifests`
|
||||
- `GET /api/clean-release/candidates/{candidate_id}/overview`
|
||||
- Legacy compatibility API (оставлены для миграции клиентов):
|
||||
- `POST /api/clean-release/candidates/prepare`
|
||||
- `POST /api/clean-release/checks`
|
||||
- `GET /api/clean-release/checks/{check_run_id}`
|
||||
|
||||
#### 3) TUI flow (тонкий клиент поверх facade)
|
||||
|
||||
```bash
|
||||
cd /home/busya/dev/ss-tools
|
||||
./backend/.venv/bin/python3 -m backend.src.scripts.clean_release_tui
|
||||
./run_clean_tui.sh 2026.03.09-rc1
|
||||
```
|
||||
|
||||
Горячие клавиши:
|
||||
- `F5`: Run Compliance
|
||||
- `F6`: Build Manifest
|
||||
- `F7`: Reset Draft
|
||||
- `F8`: Approve
|
||||
- `F9`: Publish
|
||||
- `F10`: Refresh Overview
|
||||
|
||||
Важно: TUI требует валидный TTY. Без TTY запуск отклоняется с инструкцией использовать CLI/API.
|
||||
|
||||
Типовые внутренние источники:
|
||||
- `repo.intra.company.local`
|
||||
- `artifacts.intra.company.local`
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
INFO: Will watch for changes in these directories: ['/home/user/ss-tools/backend']
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process [7952] using StatReload
|
||||
INFO: Started server process [7968]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
Error loading plugin module backup: No module named 'yaml'
|
||||
Error loading plugin module migration: No module named 'yaml'
|
||||
INFO: 127.0.0.1:36934 - "HEAD /docs HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55006 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:55006 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55010 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:55010 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55010 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:55010 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55010 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:55010 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55010 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:55010 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:35508 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:35508 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49820 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49820 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49822 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49822 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49822 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49822 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49908 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49908 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49922 - "OPTIONS /settings/environments HTTP/1.1" 200 OK
|
||||
[2025-12-20 19:14:15,576][INFO][superset_tools_app] [ConfigManager.save_config][Coherence:OK] Configuration saved context={'path': '/home/user/ss-tools/config.json'}
|
||||
INFO: 127.0.0.1:49922 - "POST /settings/environments HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49922 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49922 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:49922 - "OPTIONS /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:36930 - "POST /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
Traceback (most recent call last):
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/uvicorn/protocols/http/h11_impl.py", line 403, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/applications.py", line 1135, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/applications.py", line 107, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 93, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 144, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
|
||||
await self.app(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 716, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 736, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/routing.py", line 290, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 118, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
raise exc
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 104, in app
|
||||
response = await f(request)
|
||||
^^^^^^^^^^^^^^^^
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 428, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/fastapi/routing.py", line 314, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/user/ss-tools/backend/src/api/routes/settings.py", line 103, in test_connection
|
||||
import httpx
|
||||
ModuleNotFoundError: No module named 'httpx'
|
||||
INFO: 127.0.0.1:45776 - "POST /settings/environments/7071dab6-881f-49a2-b850-c004b3fc11c0/test HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:45784 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:45784 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:41628 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:41628 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:41628 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:41628 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:60184 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:60184 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:60184 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:60184 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:60184 - "GET /settings HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:60184 - "GET /settings/ HTTP/1.1" 200 OK
|
||||
WARNING: StatReload detected changes in 'src/core/plugin_loader.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [7968]
|
||||
INFO: Started server process [12178]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
WARNING: StatReload detected changes in 'src/dependencies.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [12178]
|
||||
INFO: Started server process [12451]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
|
||||
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
|
||||
INFO: 127.0.0.1:37334 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:37334 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:39932 - "GET /favicon.ico HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:39932 - "GET / HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:54900 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49280 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:49280 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
WARNING: StatReload detected changes in 'src/api/routes/plugins.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [12451]
|
||||
INFO: Started server process [15016]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
|
||||
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
|
||||
INFO: 127.0.0.1:59340 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
DEBUG: list_plugins called. Found 0 plugins.
|
||||
INFO: 127.0.0.1:59340 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
WARNING: StatReload detected changes in 'src/dependencies.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [15016]
|
||||
INFO: Started server process [15257]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
|
||||
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
|
||||
DEBUG: dependencies.py initialized. PluginLoader ID: 139922613090976
|
||||
DEBUG: dependencies.py initialized. PluginLoader ID: 139922627375088
|
||||
INFO: 127.0.0.1:57464 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
DEBUG: get_plugin_loader called. Returning PluginLoader ID: 139922627375088
|
||||
DEBUG: list_plugins called. Found 0 plugins.
|
||||
INFO: 127.0.0.1:57464 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
WARNING: StatReload detected changes in 'src/core/plugin_loader.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [15257]
|
||||
INFO: Started server process [15533]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
DEBUG: Loading plugin backup as src.plugins.backup
|
||||
Plugin 'Superset Dashboard Backup' (ID: superset-backup) loaded successfully.
|
||||
DEBUG: Loading plugin migration as src.plugins.migration
|
||||
Plugin 'Superset Dashboard Migration' (ID: superset-migration) loaded successfully.
|
||||
DEBUG: dependencies.py initialized. PluginLoader ID: 140371031142384
|
||||
INFO: 127.0.0.1:46470 - "GET /plugins HTTP/1.1" 307 Temporary Redirect
|
||||
DEBUG: get_plugin_loader called. Returning PluginLoader ID: 140371031142384
|
||||
DEBUG: list_plugins called. Found 2 plugins.
|
||||
DEBUG: Plugin: superset-backup
|
||||
DEBUG: Plugin: superset-migration
|
||||
INFO: 127.0.0.1:46470 - "GET /plugins/ HTTP/1.1" 200 OK
|
||||
WARNING: StatReload detected changes in 'src/api/routes/settings.py'. Reloading...
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [15533]
|
||||
INFO: Started server process [15827]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [15827]
|
||||
INFO: Stopping reloader process [7952]
|
||||
@@ -1 +0,0 @@
|
||||
{"print(f'Length": {"else": "print('Provider not found')\ndb.close()"}}
|
||||
Binary file not shown.
@@ -1,3 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ss-tools-backend"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
importmode = "importlib"
|
||||
|
||||
3
backend/src/__init__.py
Normal file
3
backend/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src:Package]
|
||||
# @PURPOSE: Canonical backend package root for application, scripts, and tests.
|
||||
# [/DEF:src:Package]
|
||||
3
backend/src/api/__init__.py
Normal file
3
backend/src/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.api:Package]
|
||||
# @PURPOSE: Backend API package root.
|
||||
# [/DEF:src.api:Package]
|
||||
@@ -422,7 +422,7 @@ def test_llm_validation_with_dashboard_ref_requires_confirmation():
|
||||
assert "cancel" in action_types
|
||||
|
||||
|
||||
# [/DEF:test_llm_validation_missing_dashboard_returns_needs_clarification:Function]
|
||||
# [/DEF:test_llm_validation_with_dashboard_ref_requires_confirmation:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_conversations_groups_by_conversation_and_marks_archived:Function]
|
||||
@@ -629,6 +629,7 @@ def test_guarded_operation_confirm_roundtrip():
|
||||
assert second.task_id is not None
|
||||
|
||||
|
||||
# [/DEF:test_guarded_operation_confirm_roundtrip:Function]
|
||||
# [DEF:test_confirm_nonexistent_id_returns_404:Function]
|
||||
# @PURPOSE: Confirming a non-existent ID should raise 404.
|
||||
# @PRE: user tries to confirm a random/fake UUID.
|
||||
@@ -649,6 +650,7 @@ def test_confirm_nonexistent_id_returns_404():
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# [/DEF:test_confirm_nonexistent_id_returns_404:Function]
|
||||
# [DEF:test_migration_with_dry_run_includes_summary:Function]
|
||||
# @PURPOSE: Migration command with dry run flag must return the dry run summary in confirmation text.
|
||||
# @PRE: user specifies a migration with --dry-run flag.
|
||||
|
||||
@@ -135,6 +135,8 @@ def test_get_report_success():
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
# [/DEF:backend.tests.api.routes.test_clean_release_api:Module]
|
||||
|
||||
def test_prepare_candidate_api_success():
|
||||
repo = _repo_with_seed_data()
|
||||
app.dependency_overrides[get_clean_release_repository] = lambda: repo
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
|
||||
# @LAYER: Tests
|
||||
# @RELATION: TESTS -> backend.src.api.routes.clean_release
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///./test_clean_release_legacy_compat.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db")
|
||||
|
||||
from src.app import app
|
||||
from src.dependencies import get_clean_release_repository
|
||||
from src.models.clean_release import (
|
||||
CleanProfilePolicy,
|
||||
DistributionManifest,
|
||||
ProfileType,
|
||||
ReleaseCandidate,
|
||||
ReleaseCandidateStatus,
|
||||
ResourceSourceEntry,
|
||||
ResourceSourceRegistry,
|
||||
)
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:_seed_legacy_repo:Function]
|
||||
# @PURPOSE: Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
|
||||
# @PRE: Repository is empty.
|
||||
# @POST: Candidate, policy, registry and manifest are available for legacy checks flow.
|
||||
def _seed_legacy_repo() -> CleanReleaseRepository:
|
||||
repo = CleanReleaseRepository()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
repo.save_candidate(
|
||||
ReleaseCandidate(
|
||||
id="legacy-rc-001",
|
||||
version="1.0.0",
|
||||
source_snapshot_ref="git:legacy-001",
|
||||
created_at=now,
|
||||
created_by="compat-tester",
|
||||
status=ReleaseCandidateStatus.DRAFT,
|
||||
)
|
||||
)
|
||||
|
||||
registry = ResourceSourceRegistry(
|
||||
registry_id="legacy-reg-1",
|
||||
name="Legacy Internal Registry",
|
||||
entries=[
|
||||
ResourceSourceEntry(
|
||||
source_id="legacy-src-1",
|
||||
host="repo.intra.company.local",
|
||||
protocol="https",
|
||||
purpose="artifact-repo",
|
||||
enabled=True,
|
||||
)
|
||||
],
|
||||
updated_at=now,
|
||||
updated_by="compat-tester",
|
||||
status="ACTIVE",
|
||||
)
|
||||
setattr(registry, "immutable", True)
|
||||
setattr(registry, "allowed_hosts", ["repo.intra.company.local"])
|
||||
setattr(registry, "allowed_schemes", ["https"])
|
||||
setattr(registry, "allowed_source_types", ["artifact-repo"])
|
||||
repo.save_registry(registry)
|
||||
|
||||
policy = CleanProfilePolicy(
|
||||
policy_id="legacy-pol-1",
|
||||
policy_version="1.0.0",
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
active=True,
|
||||
internal_source_registry_ref="legacy-reg-1",
|
||||
prohibited_artifact_categories=["test-data"],
|
||||
required_system_categories=["core"],
|
||||
effective_from=now,
|
||||
)
|
||||
setattr(policy, "immutable", True)
|
||||
setattr(
|
||||
policy,
|
||||
"content_json",
|
||||
{
|
||||
"profile": "enterprise-clean",
|
||||
"prohibited_artifact_categories": ["test-data"],
|
||||
"required_system_categories": ["core"],
|
||||
"external_source_forbidden": True,
|
||||
},
|
||||
)
|
||||
repo.save_policy(policy)
|
||||
|
||||
repo.save_manifest(
|
||||
DistributionManifest(
|
||||
id="legacy-manifest-1",
|
||||
candidate_id="legacy-rc-001",
|
||||
manifest_version=1,
|
||||
manifest_digest="sha256:legacy-manifest",
|
||||
artifacts_digest="sha256:legacy-artifacts",
|
||||
created_at=now,
|
||||
created_by="compat-tester",
|
||||
source_snapshot_ref="git:legacy-001",
|
||||
content_json={"items": [], "summary": {"included_count": 0, "prohibited_detected_count": 0}},
|
||||
immutable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return repo
|
||||
# [/DEF:_seed_legacy_repo:Function]
|
||||
|
||||
|
||||
def test_legacy_prepare_endpoint_still_available() -> None:
|
||||
repo = _seed_legacy_repo()
|
||||
app.dependency_overrides[get_clean_release_repository] = lambda: repo
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/clean-release/candidates/prepare",
|
||||
json={
|
||||
"candidate_id": "legacy-rc-001",
|
||||
"artifacts": [{"path": "src/main.py", "category": "core", "reason": "required"}],
|
||||
"sources": ["repo.intra.company.local"],
|
||||
"operator_id": "compat-tester",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "status" in payload
|
||||
assert payload["status"] in {"prepared", "blocked", "PREPARED", "BLOCKED"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_legacy_checks_endpoints_still_available() -> None:
|
||||
repo = _seed_legacy_repo()
|
||||
app.dependency_overrides[get_clean_release_repository] = lambda: repo
|
||||
try:
|
||||
client = TestClient(app)
|
||||
start_response = client.post(
|
||||
"/api/clean-release/checks",
|
||||
json={
|
||||
"candidate_id": "legacy-rc-001",
|
||||
"profile": "enterprise-clean",
|
||||
"execution_mode": "api",
|
||||
"triggered_by": "compat-tester",
|
||||
},
|
||||
)
|
||||
assert start_response.status_code == 202
|
||||
start_payload = start_response.json()
|
||||
assert "check_run_id" in start_payload
|
||||
assert start_payload["candidate_id"] == "legacy-rc-001"
|
||||
|
||||
status_response = client.get(f"/api/clean-release/checks/{start_payload['check_run_id']}")
|
||||
assert status_response.status_code == 200
|
||||
status_payload = status_response.json()
|
||||
assert status_payload["check_run_id"] == start_payload["check_run_id"]
|
||||
assert "final_status" in status_payload
|
||||
assert "checks" in status_payload
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_clean_release_legacy_compat:Module]
|
||||
@@ -94,4 +94,7 @@ def test_prepare_candidate_blocks_external_source():
|
||||
assert data["status"] == "blocked"
|
||||
assert any(v["category"] == "external-source" for v in data["violations"])
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:backend.tests.api.routes.test_clean_release_source_policy:Module]
|
||||
@@ -0,0 +1,93 @@
|
||||
# [DEF:test_clean_release_v2_api:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: API contract tests for redesigned clean release endpoints.
|
||||
# @LAYER: Domain
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.app import app
|
||||
from src.dependencies import get_clean_release_repository, get_config_manager
|
||||
from src.models.clean_release import (
|
||||
CleanPolicySnapshot,
|
||||
DistributionManifest,
|
||||
ReleaseCandidate,
|
||||
SourceRegistrySnapshot,
|
||||
)
|
||||
from src.services.clean_release.enums import CandidateStatus
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
|
||||
def test_candidate_registration_contract():
|
||||
"""
|
||||
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
|
||||
@TEST_CONTRACT: POST /api/v2/clean-release/candidates -> CandidateDTO
|
||||
"""
|
||||
payload = {
|
||||
"id": "rc-test-001",
|
||||
"version": "1.0.0",
|
||||
"source_snapshot_ref": "git:sha123",
|
||||
"created_by": "test-user"
|
||||
}
|
||||
response = client.post("/api/v2/clean-release/candidates", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] == "rc-test-001"
|
||||
assert data["status"] == CandidateStatus.DRAFT.value
|
||||
|
||||
def test_artifact_import_contract():
|
||||
"""
|
||||
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
|
||||
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/artifacts -> SuccessDTO
|
||||
"""
|
||||
candidate_id = "rc-test-001-art"
|
||||
bootstrap_candidate = {
|
||||
"id": candidate_id,
|
||||
"version": "1.0.0",
|
||||
"source_snapshot_ref": "git:sha123",
|
||||
"created_by": "test-user"
|
||||
}
|
||||
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
payload = {
|
||||
"artifacts": [
|
||||
{
|
||||
"id": "art-1",
|
||||
"path": "bin/app.exe",
|
||||
"sha256": "hash123",
|
||||
"size": 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/artifacts", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
def test_manifest_build_contract():
|
||||
"""
|
||||
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
|
||||
@TEST_CONTRACT: POST /api/v2/clean-release/candidates/{id}/manifests -> ManifestDTO
|
||||
"""
|
||||
candidate_id = "rc-test-001-manifest"
|
||||
bootstrap_candidate = {
|
||||
"id": candidate_id,
|
||||
"version": "1.0.0",
|
||||
"source_snapshot_ref": "git:sha123",
|
||||
"created_by": "test-user"
|
||||
}
|
||||
create_response = client.post("/api/v2/clean-release/candidates", json=bootstrap_candidate)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
response = client.post(f"/api/v2/clean-release/candidates/{candidate_id}/manifests")
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "manifest_digest" in data
|
||||
assert data["candidate_id"] == candidate_id
|
||||
|
||||
# [/DEF:test_clean_release_v2_api:Module]
|
||||
@@ -0,0 +1,107 @@
|
||||
# [DEF:test_clean_release_v2_release_api:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: API contract test scaffolding for clean release approval and publication endpoints.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: IMPLEMENTS -> clean_release_v2_release_api_contracts
|
||||
|
||||
"""Contract tests for redesigned approval/publication API endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.routes.clean_release_v2 import router as clean_release_v2_router
|
||||
from src.dependencies import get_clean_release_repository
|
||||
from src.models.clean_release import ComplianceReport, ReleaseCandidate
|
||||
from src.services.clean_release.enums import CandidateStatus, ComplianceDecision
|
||||
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(clean_release_v2_router)
|
||||
client = TestClient(test_app)
|
||||
|
||||
|
||||
def _seed_candidate_and_passed_report() -> tuple[str, str]:
|
||||
repository = get_clean_release_repository()
|
||||
candidate_id = f"api-release-candidate-{uuid4()}"
|
||||
report_id = f"api-release-report-{uuid4()}"
|
||||
|
||||
repository.save_candidate(
|
||||
ReleaseCandidate(
|
||||
id=candidate_id,
|
||||
version="1.0.0",
|
||||
source_snapshot_ref="git:sha-api-release",
|
||||
created_by="api-test",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status=CandidateStatus.CHECK_PASSED.value,
|
||||
)
|
||||
)
|
||||
repository.save_report(
|
||||
ComplianceReport(
|
||||
id=report_id,
|
||||
run_id=f"run-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
final_status=ComplianceDecision.PASSED.value,
|
||||
summary_json={"operator_summary": "ok", "violations_count": 0, "blocking_violations_count": 0},
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
immutable=True,
|
||||
)
|
||||
)
|
||||
return candidate_id, report_id
|
||||
|
||||
|
||||
def test_release_approve_and_publish_revoke_contract() -> None:
|
||||
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
|
||||
candidate_id, report_id = _seed_candidate_and_passed_report()
|
||||
|
||||
approve_response = client.post(
|
||||
f"/api/v2/clean-release/candidates/{candidate_id}/approve",
|
||||
json={"report_id": report_id, "decided_by": "api-test", "comment": "approved"},
|
||||
)
|
||||
assert approve_response.status_code == 200
|
||||
approve_payload = approve_response.json()
|
||||
assert approve_payload["status"] == "ok"
|
||||
assert approve_payload["decision"] == "APPROVED"
|
||||
|
||||
publish_response = client.post(
|
||||
f"/api/v2/clean-release/candidates/{candidate_id}/publish",
|
||||
json={
|
||||
"report_id": report_id,
|
||||
"published_by": "api-test",
|
||||
"target_channel": "stable",
|
||||
"publication_ref": "rel-api-001",
|
||||
},
|
||||
)
|
||||
assert publish_response.status_code == 200
|
||||
publish_payload = publish_response.json()
|
||||
assert publish_payload["status"] == "ok"
|
||||
assert publish_payload["publication"]["status"] == "ACTIVE"
|
||||
|
||||
publication_id = publish_payload["publication"]["id"]
|
||||
revoke_response = client.post(
|
||||
f"/api/v2/clean-release/publications/{publication_id}/revoke",
|
||||
json={"revoked_by": "api-test", "comment": "rollback"},
|
||||
)
|
||||
assert revoke_response.status_code == 200
|
||||
revoke_payload = revoke_response.json()
|
||||
assert revoke_payload["status"] == "ok"
|
||||
assert revoke_payload["publication"]["status"] == "REVOKED"
|
||||
|
||||
|
||||
def test_release_reject_contract() -> None:
|
||||
"""Contract for reject endpoint."""
|
||||
candidate_id, report_id = _seed_candidate_and_passed_report()
|
||||
|
||||
reject_response = client.post(
|
||||
f"/api/v2/clean-release/candidates/{candidate_id}/reject",
|
||||
json={"report_id": report_id, "decided_by": "api-test", "comment": "rejected"},
|
||||
)
|
||||
assert reject_response.status_code == 200
|
||||
payload = reject_response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["decision"] == "REJECTED"
|
||||
|
||||
|
||||
# [/DEF:test_clean_release_v2_release_api:Module]
|
||||
@@ -82,6 +82,7 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
|
||||
superset_username="John_Doe",
|
||||
superset_username_normalized="john_doe",
|
||||
show_only_my_dashboards=True,
|
||||
show_only_slug_dashboards=True,
|
||||
git_username="ivan.ivanov",
|
||||
git_email="ivan@company.local",
|
||||
has_git_personal_access_token=True,
|
||||
@@ -126,6 +127,7 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
|
||||
assert payload["preference"]["superset_username_normalized"] == "john_doe"
|
||||
assert payload["preference"]["git_username"] == "ivan.ivanov"
|
||||
assert payload["preference"]["git_email"] == "ivan@company.local"
|
||||
assert payload["preference"]["show_only_slug_dashboards"] is True
|
||||
assert payload["preference"]["has_git_personal_access_token"] is True
|
||||
assert payload["preference"]["git_personal_access_token_masked"] == "iv***al"
|
||||
assert payload["preference"]["start_page"] == "reports"
|
||||
@@ -153,6 +155,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
|
||||
json={
|
||||
"superset_username": "John_Doe",
|
||||
"show_only_my_dashboards": True,
|
||||
"show_only_slug_dashboards": True,
|
||||
"git_username": "ivan.ivanov",
|
||||
"git_email": "ivan@company.local",
|
||||
"git_personal_access_token": "ghp_1234567890",
|
||||
@@ -167,6 +170,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
|
||||
assert payload["status"] == "success"
|
||||
assert payload["preference"]["superset_username"] == "John_Doe"
|
||||
assert payload["preference"]["show_only_my_dashboards"] is True
|
||||
assert payload["preference"]["show_only_slug_dashboards"] is True
|
||||
assert payload["preference"]["git_username"] == "ivan.ivanov"
|
||||
assert payload["preference"]["git_email"] == "ivan@company.local"
|
||||
assert payload["preference"]["start_page"] == "reports"
|
||||
@@ -179,6 +183,7 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
|
||||
assert called_kwargs["payload"].git_username == "ivan.ivanov"
|
||||
assert called_kwargs["payload"].git_email == "ivan@company.local"
|
||||
assert called_kwargs["payload"].git_personal_access_token == "ghp_1234567890"
|
||||
assert called_kwargs["payload"].show_only_slug_dashboards is True
|
||||
assert called_kwargs["payload"].start_page == "reports-logs"
|
||||
assert called_kwargs["payload"].auto_open_task_drawer is False
|
||||
assert called_kwargs["payload"].dashboards_table_density == "free"
|
||||
@@ -290,4 +295,4 @@ def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
|
||||
assert payload["detail"] == "Environment 'missing-env' not found"
|
||||
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
|
||||
# [/DEF:backend.src.api.routes.__tests__.test_profile_api:Module]
|
||||
|
||||
@@ -120,6 +120,7 @@ INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
|
||||
"run_backup": [("plugin:superset-backup", "EXECUTE"), ("plugin:backup", "EXECUTE")],
|
||||
"run_llm_validation": [("plugin:llm_dashboard_validation", "EXECUTE")],
|
||||
"run_llm_documentation": [("plugin:llm_documentation", "EXECUTE")],
|
||||
"get_health_summary": [("plugin:migration", "READ")],
|
||||
}
|
||||
|
||||
|
||||
@@ -845,6 +846,18 @@ def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
|
||||
# Health summary
|
||||
if any(k in lower for k in ["здоровье", "health", "ошибки", "failing", "проблемы"]):
|
||||
env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"])
|
||||
return {
|
||||
"domain": "health",
|
||||
"operation": "get_health_summary",
|
||||
"entities": {"environment": env_match},
|
||||
"confidence": 0.9,
|
||||
"risk_level": "safe",
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
|
||||
# LLM validation
|
||||
if any(k in lower for k in ["валидац", "validate", "провер"]):
|
||||
env_match = _extract_id(lower, [r"(?:в|for|env|окружени[ея])\s+([a-z0-9_-]+)"])
|
||||
@@ -1023,6 +1036,15 @@ def _build_tool_catalog(current_user: User, config_manager: ConfigManager, db: S
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
},
|
||||
{
|
||||
"operation": "get_health_summary",
|
||||
"domain": "health",
|
||||
"description": "Get summary of dashboard health and failing validations",
|
||||
"required_entities": [],
|
||||
"optional_entities": ["environment"],
|
||||
"risk_level": "safe",
|
||||
"requires_confirmation": False,
|
||||
},
|
||||
]
|
||||
|
||||
available: List[Dict[str, Any]] = []
|
||||
@@ -1056,7 +1078,7 @@ def _coerce_intent_entities(intent: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
# Operations that are read-only and do not require confirmation.
|
||||
_SAFE_OPS = {"show_capabilities", "get_task_status"}
|
||||
_SAFE_OPS = {"show_capabilities", "get_task_status", "get_health_summary"}
|
||||
|
||||
|
||||
# [DEF:_confirmation_summary:Function]
|
||||
@@ -1151,7 +1173,7 @@ async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: Co
|
||||
text += f"\n\n(Не удалось загрузить отчет dry-run: {e})."
|
||||
|
||||
return f"Выполнить: {text}. Подтвердите или отмените."
|
||||
# [/DEF:_async_confirmation_summary:Function]
|
||||
# [/DEF:_confirmation_summary:Function]
|
||||
|
||||
|
||||
# [DEF:_clarification_text_for_intent:Function]
|
||||
@@ -1323,6 +1345,7 @@ async def _dispatch_intent(
|
||||
"run_llm_validation": "LLM: валидация дашборда",
|
||||
"run_llm_documentation": "LLM: генерация документации",
|
||||
"get_task_status": "Статус: проверка задачи",
|
||||
"get_health_summary": "Здоровье: сводка по дашбордам",
|
||||
}
|
||||
available = [labels[t["operation"]] for t in tools_catalog if t["operation"] in labels]
|
||||
if not available:
|
||||
@@ -1335,6 +1358,41 @@ async def _dispatch_intent(
|
||||
)
|
||||
return text, None, []
|
||||
|
||||
if operation == "get_health_summary":
|
||||
from ...services.health_service import HealthService
|
||||
env_token = entities.get("environment")
|
||||
env_id = _resolve_env_id(env_token, config_manager)
|
||||
service = HealthService(db)
|
||||
summary = await service.get_health_summary(environment_id=env_id)
|
||||
|
||||
env_name = _get_environment_name_by_id(env_id, config_manager) if env_id else "всех окружений"
|
||||
text = (
|
||||
f"Сводка здоровья дашбордов для {env_name}:\n"
|
||||
f"- ✅ Прошли проверку: {summary.pass_count}\n"
|
||||
f"- ⚠️ С предупреждениями: {summary.warn_count}\n"
|
||||
f"- ❌ Ошибки валидации: {summary.fail_count}\n"
|
||||
f"- ❓ Неизвестно: {summary.unknown_count}"
|
||||
)
|
||||
|
||||
actions = [
|
||||
AssistantAction(type="open_route", label="Открыть Health Center", target="/dashboards/health")
|
||||
]
|
||||
|
||||
if summary.fail_count > 0:
|
||||
text += "\n\nОбнаружены ошибки в следующих дашбордах:"
|
||||
for item in summary.items:
|
||||
if item.status == "FAIL":
|
||||
text += f"\n- {item.dashboard_id} ({item.environment_id}): {item.summary or 'Нет деталей'}"
|
||||
actions.append(
|
||||
AssistantAction(
|
||||
type="open_route",
|
||||
label=f"Отчет {item.dashboard_id}",
|
||||
target=f"/reports/llm/{item.task_id}"
|
||||
)
|
||||
)
|
||||
|
||||
return text, None, actions[:5] # Limit actions to avoid UI clutter
|
||||
|
||||
if operation == "get_task_status":
|
||||
_check_any_permission(current_user, [("tasks", "READ")])
|
||||
task_id = entities.get("task_id")
|
||||
|
||||
@@ -16,19 +16,27 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...dependencies import get_clean_release_repository
|
||||
from ...dependencies import get_clean_release_repository, get_config_manager
|
||||
from ...services.clean_release.preparation_service import prepare_candidate
|
||||
from ...services.clean_release.repository import CleanReleaseRepository
|
||||
from ...services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
|
||||
from ...services.clean_release.report_builder import ComplianceReportBuilder
|
||||
from ...models.clean_release import (
|
||||
CheckFinalStatus,
|
||||
CheckStageName,
|
||||
CheckStageResult,
|
||||
CheckStageStatus,
|
||||
ComplianceViolation,
|
||||
from ...services.clean_release.compliance_execution_service import ComplianceExecutionService, ComplianceRunError
|
||||
from ...services.clean_release.dto import CandidateDTO, ManifestDTO, CandidateOverviewDTO, ComplianceRunDTO
|
||||
from ...services.clean_release.enums import (
|
||||
ComplianceDecision,
|
||||
ComplianceStageName,
|
||||
ViolationCategory,
|
||||
ViolationSeverity,
|
||||
RunStatus,
|
||||
CandidateStatus,
|
||||
)
|
||||
from ...models.clean_release import (
|
||||
ComplianceRun,
|
||||
ComplianceStageRun,
|
||||
ComplianceViolation,
|
||||
CandidateArtifact,
|
||||
ReleaseCandidate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/clean-release", tags=["Clean Release"])
|
||||
@@ -54,6 +62,226 @@ class StartCheckRequest(BaseModel):
|
||||
# [/DEF:StartCheckRequest:Class]
|
||||
|
||||
|
||||
# [DEF:RegisterCandidateRequest:Class]
|
||||
# @PURPOSE: Request schema for candidate registration endpoint.
|
||||
class RegisterCandidateRequest(BaseModel):
|
||||
id: str = Field(min_length=1)
|
||||
version: str = Field(min_length=1)
|
||||
source_snapshot_ref: str = Field(min_length=1)
|
||||
created_by: str = Field(min_length=1)
|
||||
# [/DEF:RegisterCandidateRequest:Class]
|
||||
|
||||
|
||||
# [DEF:ImportArtifactsRequest:Class]
|
||||
# @PURPOSE: Request schema for candidate artifact import endpoint.
|
||||
class ImportArtifactsRequest(BaseModel):
|
||||
artifacts: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
# [/DEF:ImportArtifactsRequest:Class]
|
||||
|
||||
|
||||
# [DEF:BuildManifestRequest:Class]
|
||||
# @PURPOSE: Request schema for manifest build endpoint.
|
||||
class BuildManifestRequest(BaseModel):
|
||||
created_by: str = Field(default="system")
|
||||
# [/DEF:BuildManifestRequest:Class]
|
||||
|
||||
|
||||
# [DEF:CreateComplianceRunRequest:Class]
|
||||
# @PURPOSE: Request schema for compliance run creation with optional manifest pinning.
|
||||
class CreateComplianceRunRequest(BaseModel):
|
||||
requested_by: str = Field(min_length=1)
|
||||
manifest_id: str | None = None
|
||||
# [/DEF:CreateComplianceRunRequest:Class]
|
||||
|
||||
|
||||
# [DEF:register_candidate_v2_endpoint:Function]
|
||||
# @PURPOSE: Register a clean-release candidate for headless lifecycle.
|
||||
# @PRE: Candidate identifier is unique.
|
||||
# @POST: Candidate is persisted in DRAFT status.
|
||||
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def register_candidate_v2_endpoint(
|
||||
payload: RegisterCandidateRequest,
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
existing = repository.get_candidate(payload.id)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail={"message": "Candidate already exists", "code": "CANDIDATE_EXISTS"})
|
||||
|
||||
candidate = ReleaseCandidate(
|
||||
id=payload.id,
|
||||
version=payload.version,
|
||||
source_snapshot_ref=payload.source_snapshot_ref,
|
||||
created_by=payload.created_by,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status=CandidateStatus.DRAFT.value,
|
||||
)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
return CandidateDTO(
|
||||
id=candidate.id,
|
||||
version=candidate.version,
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
created_at=candidate.created_at,
|
||||
created_by=candidate.created_by,
|
||||
status=CandidateStatus(candidate.status),
|
||||
)
|
||||
# [/DEF:register_candidate_v2_endpoint:Function]
|
||||
|
||||
|
||||
# [DEF:import_candidate_artifacts_v2_endpoint:Function]
|
||||
# @PURPOSE: Import candidate artifacts in headless flow.
|
||||
# @PRE: Candidate exists and artifacts array is non-empty.
|
||||
# @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT.
|
||||
@router.post("/candidates/{candidate_id}/artifacts")
|
||||
async def import_candidate_artifacts_v2_endpoint(
|
||||
candidate_id: str,
|
||||
payload: ImportArtifactsRequest,
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
|
||||
if not payload.artifacts:
|
||||
raise HTTPException(status_code=400, detail={"message": "Artifacts list is required", "code": "ARTIFACTS_EMPTY"})
|
||||
|
||||
for artifact in payload.artifacts:
|
||||
required = ("id", "path", "sha256", "size")
|
||||
for field_name in required:
|
||||
if field_name not in artifact:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"message": f"Artifact missing field '{field_name}'", "code": "ARTIFACT_INVALID"},
|
||||
)
|
||||
|
||||
artifact_model = CandidateArtifact(
|
||||
id=str(artifact["id"]),
|
||||
candidate_id=candidate_id,
|
||||
path=str(artifact["path"]),
|
||||
sha256=str(artifact["sha256"]),
|
||||
size=int(artifact["size"]),
|
||||
detected_category=artifact.get("detected_category"),
|
||||
declared_category=artifact.get("declared_category"),
|
||||
source_uri=artifact.get("source_uri"),
|
||||
source_host=artifact.get("source_host"),
|
||||
metadata_json=artifact.get("metadata_json", {}),
|
||||
)
|
||||
repository.save_artifact(artifact_model)
|
||||
|
||||
if candidate.status == CandidateStatus.DRAFT.value:
|
||||
candidate.transition_to(CandidateStatus.PREPARED)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
return {"status": "success"}
|
||||
# [/DEF:import_candidate_artifacts_v2_endpoint:Function]
|
||||
|
||||
|
||||
# [DEF:build_candidate_manifest_v2_endpoint:Function]
|
||||
# @PURPOSE: Build immutable manifest snapshot for prepared candidate.
|
||||
# @PRE: Candidate exists and has imported artifacts.
|
||||
# @POST: Returns created ManifestDTO with incremented version.
|
||||
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def build_candidate_manifest_v2_endpoint(
|
||||
candidate_id: str,
|
||||
payload: BuildManifestRequest,
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
from ...services.clean_release.manifest_service import build_manifest_snapshot
|
||||
|
||||
try:
|
||||
manifest = build_manifest_snapshot(
|
||||
repository=repository,
|
||||
candidate_id=candidate_id,
|
||||
created_by=payload.created_by,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail={"message": str(exc), "code": "MANIFEST_BUILD_ERROR"})
|
||||
|
||||
return ManifestDTO(
|
||||
id=manifest.id,
|
||||
candidate_id=manifest.candidate_id,
|
||||
manifest_version=manifest.manifest_version,
|
||||
manifest_digest=manifest.manifest_digest,
|
||||
artifacts_digest=manifest.artifacts_digest,
|
||||
created_at=manifest.created_at,
|
||||
created_by=manifest.created_by,
|
||||
source_snapshot_ref=manifest.source_snapshot_ref,
|
||||
content_json=manifest.content_json,
|
||||
)
|
||||
# [/DEF:build_candidate_manifest_v2_endpoint:Function]
|
||||
|
||||
|
||||
# [DEF:get_candidate_overview_v2_endpoint:Function]
|
||||
# @PURPOSE: Return expanded candidate overview DTO for headless lifecycle visibility.
|
||||
# @PRE: Candidate exists.
|
||||
# @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints.
|
||||
@router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO)
|
||||
async def get_candidate_overview_v2_endpoint(
|
||||
candidate_id: str,
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise HTTPException(status_code=404, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
|
||||
|
||||
manifests = repository.get_manifests_by_candidate(candidate_id)
|
||||
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0] if manifests else None
|
||||
|
||||
runs = [run for run in repository.check_runs.values() if run.candidate_id == candidate_id]
|
||||
latest_run = sorted(runs, key=lambda run: run.requested_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0] if runs else None
|
||||
|
||||
latest_report = None
|
||||
if latest_run is not None:
|
||||
latest_report = next((r for r in repository.reports.values() if r.run_id == latest_run.id), None)
|
||||
|
||||
latest_policy_snapshot = repository.get_policy(latest_run.policy_snapshot_id) if latest_run else None
|
||||
latest_registry_snapshot = repository.get_registry(latest_run.registry_snapshot_id) if latest_run else None
|
||||
|
||||
approval_decisions = getattr(repository, "approval_decisions", [])
|
||||
latest_approval = (
|
||||
sorted(
|
||||
[item for item in approval_decisions if item.candidate_id == candidate_id],
|
||||
key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)[0]
|
||||
if approval_decisions
|
||||
and any(item.candidate_id == candidate_id for item in approval_decisions)
|
||||
else None
|
||||
)
|
||||
|
||||
publication_records = getattr(repository, "publication_records", [])
|
||||
latest_publication = (
|
||||
sorted(
|
||||
[item for item in publication_records if item.candidate_id == candidate_id],
|
||||
key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)[0]
|
||||
if publication_records
|
||||
and any(item.candidate_id == candidate_id for item in publication_records)
|
||||
else None
|
||||
)
|
||||
|
||||
return CandidateOverviewDTO(
|
||||
candidate_id=candidate.id,
|
||||
version=candidate.version,
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
status=CandidateStatus(candidate.status),
|
||||
latest_manifest_id=latest_manifest.id if latest_manifest else None,
|
||||
latest_manifest_digest=latest_manifest.manifest_digest if latest_manifest else None,
|
||||
latest_run_id=latest_run.id if latest_run else None,
|
||||
latest_run_status=RunStatus(latest_run.status) if latest_run else None,
|
||||
latest_report_id=latest_report.id if latest_report else None,
|
||||
latest_report_final_status=ComplianceDecision(latest_report.final_status) if latest_report else None,
|
||||
latest_policy_snapshot_id=latest_policy_snapshot.id if latest_policy_snapshot else None,
|
||||
latest_policy_version=latest_policy_snapshot.policy_version if latest_policy_snapshot else None,
|
||||
latest_registry_snapshot_id=latest_registry_snapshot.id if latest_registry_snapshot else None,
|
||||
latest_registry_version=latest_registry_snapshot.registry_version if latest_registry_snapshot else None,
|
||||
latest_approval_decision=latest_approval.decision if latest_approval else None,
|
||||
latest_publication_id=latest_publication.id if latest_publication else None,
|
||||
latest_publication_status=latest_publication.status if latest_publication else None,
|
||||
)
|
||||
# [/DEF:get_candidate_overview_v2_endpoint:Function]
|
||||
|
||||
|
||||
# [DEF:prepare_candidate_endpoint:Function]
|
||||
# @PURPOSE: Prepare candidate with policy evaluation and deterministic manifest generation.
|
||||
# @PRE: Candidate and active policy exist in repository.
|
||||
@@ -99,47 +327,79 @@ async def start_check(
|
||||
if candidate is None:
|
||||
raise HTTPException(status_code=409, detail={"message": "Candidate not found", "code": "CANDIDATE_NOT_FOUND"})
|
||||
|
||||
manifests = repository.get_manifests_by_candidate(payload.candidate_id)
|
||||
if not manifests:
|
||||
raise HTTPException(status_code=409, detail={"message": "No manifest found for candidate", "code": "MANIFEST_NOT_FOUND"})
|
||||
latest_manifest = sorted(manifests, key=lambda m: m.manifest_version, reverse=True)[0]
|
||||
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
run = orchestrator.start_check_run(
|
||||
candidate_id=payload.candidate_id,
|
||||
policy_id=policy.policy_id,
|
||||
triggered_by=payload.triggered_by,
|
||||
execution_mode=payload.execution_mode,
|
||||
policy_id=policy.id,
|
||||
requested_by=payload.triggered_by,
|
||||
manifest_id=latest_manifest.id,
|
||||
)
|
||||
|
||||
forced = [
|
||||
CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS, details="ok"),
|
||||
CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS, details="ok"),
|
||||
CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.PASS, details="ok"),
|
||||
CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS, details="ok"),
|
||||
ComplianceStageRun(
|
||||
id=f"stage-{run.id}-1",
|
||||
run_id=run.id,
|
||||
stage_name=ComplianceStageName.DATA_PURITY.value,
|
||||
status=RunStatus.SUCCEEDED.value,
|
||||
decision=ComplianceDecision.PASSED.value,
|
||||
details_json={"message": "ok"}
|
||||
),
|
||||
ComplianceStageRun(
|
||||
id=f"stage-{run.id}-2",
|
||||
run_id=run.id,
|
||||
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
|
||||
status=RunStatus.SUCCEEDED.value,
|
||||
decision=ComplianceDecision.PASSED.value,
|
||||
details_json={"message": "ok"}
|
||||
),
|
||||
ComplianceStageRun(
|
||||
id=f"stage-{run.id}-3",
|
||||
run_id=run.id,
|
||||
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
|
||||
status=RunStatus.SUCCEEDED.value,
|
||||
decision=ComplianceDecision.PASSED.value,
|
||||
details_json={"message": "ok"}
|
||||
),
|
||||
ComplianceStageRun(
|
||||
id=f"stage-{run.id}-4",
|
||||
run_id=run.id,
|
||||
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
|
||||
status=RunStatus.SUCCEEDED.value,
|
||||
decision=ComplianceDecision.PASSED.value,
|
||||
details_json={"message": "ok"}
|
||||
),
|
||||
]
|
||||
run = orchestrator.execute_stages(run, forced_results=forced)
|
||||
run = orchestrator.finalize_run(run)
|
||||
|
||||
if run.final_status == CheckFinalStatus.BLOCKED:
|
||||
if run.final_status == ComplianceDecision.BLOCKED.value:
|
||||
logger.explore("Run ended as BLOCKED, persisting synthetic external-source violation")
|
||||
violation = ComplianceViolation(
|
||||
violation_id=f"viol-{run.check_run_id}",
|
||||
check_run_id=run.check_run_id,
|
||||
category=ViolationCategory.EXTERNAL_SOURCE,
|
||||
severity=ViolationSeverity.CRITICAL,
|
||||
location="external.example.com",
|
||||
remediation="Replace with approved internal server",
|
||||
blocked_release=True,
|
||||
detected_at=datetime.now(timezone.utc),
|
||||
id=f"viol-{run.id}",
|
||||
run_id=run.id,
|
||||
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
|
||||
code="EXTERNAL_SOURCE_DETECTED",
|
||||
severity=ViolationSeverity.CRITICAL.value,
|
||||
message="Replace with approved internal server",
|
||||
evidence_json={"location": "external.example.com"}
|
||||
)
|
||||
repository.save_violation(violation)
|
||||
|
||||
builder = ComplianceReportBuilder(repository)
|
||||
report = builder.build_report_payload(run, repository.get_violations_by_check_run(run.check_run_id))
|
||||
report = builder.build_report_payload(run, repository.get_violations_by_run(run.id))
|
||||
builder.persist_report(report)
|
||||
logger.reflect(f"Compliance report persisted for check_run_id={run.check_run_id}")
|
||||
logger.reflect(f"Compliance report persisted for run_id={run.id}")
|
||||
|
||||
return {
|
||||
"check_run_id": run.check_run_id,
|
||||
"check_run_id": run.id,
|
||||
"candidate_id": run.candidate_id,
|
||||
"status": "running",
|
||||
"started_at": run.started_at.isoformat(),
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
}
|
||||
# [/DEF:start_check:Function]
|
||||
|
||||
@@ -157,13 +417,13 @@ async def get_check_status(check_run_id: str, repository: CleanReleaseRepository
|
||||
|
||||
logger.reflect(f"Returning check status for check_run_id={check_run_id}")
|
||||
return {
|
||||
"check_run_id": run.check_run_id,
|
||||
"check_run_id": run.id,
|
||||
"candidate_id": run.candidate_id,
|
||||
"final_status": run.final_status.value,
|
||||
"started_at": run.started_at.isoformat(),
|
||||
"final_status": run.final_status,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"checks": [c.model_dump() for c in run.checks],
|
||||
"violations": [v.model_dump() for v in repository.get_violations_by_check_run(check_run_id)],
|
||||
"checks": [], # TODO: Map stages if needed
|
||||
"violations": [], # TODO: Map violations if needed
|
||||
}
|
||||
# [/DEF:get_check_status:Function]
|
||||
|
||||
|
||||
216
backend/src/api/routes/clean_release_v2.py
Normal file
216
backend/src/api/routes/clean_release_v2.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# [DEF:backend.src.api.routes.clean_release_v2:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, clean-release, v2, headless
|
||||
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
|
||||
# @LAYER: API
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
from ...services.clean_release.approval_service import approve_candidate, reject_candidate
|
||||
from ...services.clean_release.publication_service import publish_candidate, revoke_publication
|
||||
from ...services.clean_release.repository import CleanReleaseRepository
|
||||
from ...dependencies import get_clean_release_repository
|
||||
from ...services.clean_release.enums import CandidateStatus
|
||||
from ...models.clean_release import ReleaseCandidate, CandidateArtifact, DistributionManifest
|
||||
from ...services.clean_release.dto import CandidateDTO, ManifestDTO
|
||||
|
||||
router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"])
|
||||
|
||||
|
||||
class ApprovalRequest(dict):
|
||||
pass
|
||||
|
||||
|
||||
class PublishRequest(dict):
|
||||
pass
|
||||
|
||||
|
||||
class RevokeRequest(dict):
|
||||
pass
|
||||
|
||||
@router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def register_candidate(
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
|
||||
):
|
||||
candidate = ReleaseCandidate(
|
||||
id=payload["id"],
|
||||
version=payload["version"],
|
||||
source_snapshot_ref=payload["source_snapshot_ref"],
|
||||
created_by=payload["created_by"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status=CandidateStatus.DRAFT.value
|
||||
)
|
||||
repository.save_candidate(candidate)
|
||||
return CandidateDTO(
|
||||
id=candidate.id,
|
||||
version=candidate.version,
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
created_at=candidate.created_at,
|
||||
created_by=candidate.created_by,
|
||||
status=CandidateStatus(candidate.status)
|
||||
)
|
||||
|
||||
@router.post("/candidates/{candidate_id}/artifacts")
|
||||
async def import_artifacts(
|
||||
candidate_id: str,
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
|
||||
):
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if not candidate:
|
||||
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
|
||||
for art_data in payload.get("artifacts", []):
|
||||
artifact = CandidateArtifact(
|
||||
id=art_data["id"],
|
||||
candidate_id=candidate_id,
|
||||
path=art_data["path"],
|
||||
sha256=art_data["sha256"],
|
||||
size=art_data["size"]
|
||||
)
|
||||
# In a real repo we'd have save_artifact
|
||||
# repository.save_artifact(artifact)
|
||||
pass
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
@router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def build_manifest(
|
||||
candidate_id: str,
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository)
|
||||
):
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if not candidate:
|
||||
raise HTTPException(status_code=404, detail="Candidate not found")
|
||||
|
||||
manifest = DistributionManifest(
|
||||
id=f"manifest-{candidate_id}",
|
||||
candidate_id=candidate_id,
|
||||
manifest_version=1,
|
||||
manifest_digest="hash-123",
|
||||
artifacts_digest="art-hash-123",
|
||||
created_by="system",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
content_json={"items": [], "summary": {}}
|
||||
)
|
||||
repository.save_manifest(manifest)
|
||||
|
||||
return ManifestDTO(
|
||||
id=manifest.id,
|
||||
candidate_id=manifest.candidate_id,
|
||||
manifest_version=manifest.manifest_version,
|
||||
manifest_digest=manifest.manifest_digest,
|
||||
artifacts_digest=manifest.artifacts_digest,
|
||||
created_at=manifest.created_at,
|
||||
created_by=manifest.created_by,
|
||||
source_snapshot_ref=manifest.source_snapshot_ref,
|
||||
content_json=manifest.content_json
|
||||
)
|
||||
|
||||
@router.post("/candidates/{candidate_id}/approve")
|
||||
async def approve_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
try:
|
||||
decision = approve_candidate(
|
||||
repository=repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=str(payload["report_id"]),
|
||||
decided_by=str(payload["decided_by"]),
|
||||
comment=payload.get("comment"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
|
||||
|
||||
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
|
||||
|
||||
|
||||
@router.post("/candidates/{candidate_id}/reject")
|
||||
async def reject_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
try:
|
||||
decision = reject_candidate(
|
||||
repository=repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=str(payload["report_id"]),
|
||||
decided_by=str(payload["decided_by"]),
|
||||
comment=payload.get("comment"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
|
||||
|
||||
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id}
|
||||
|
||||
|
||||
@router.post("/candidates/{candidate_id}/publish")
|
||||
async def publish_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
try:
|
||||
publication = publish_candidate(
|
||||
repository=repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=str(payload["report_id"]),
|
||||
published_by=str(payload["published_by"]),
|
||||
target_channel=str(payload["target_channel"]),
|
||||
publication_ref=payload.get("publication_ref"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"publication": {
|
||||
"id": publication.id,
|
||||
"candidate_id": publication.candidate_id,
|
||||
"report_id": publication.report_id,
|
||||
"published_by": publication.published_by,
|
||||
"published_at": publication.published_at.isoformat() if publication.published_at else None,
|
||||
"target_channel": publication.target_channel,
|
||||
"publication_ref": publication.publication_ref,
|
||||
"status": publication.status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/publications/{publication_id}/revoke")
|
||||
async def revoke_publication_endpoint(
|
||||
publication_id: str,
|
||||
payload: Dict[str, Any],
|
||||
repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
):
|
||||
try:
|
||||
publication = revoke_publication(
|
||||
repository=repository,
|
||||
publication_id=publication_id,
|
||||
revoked_by=str(payload["revoked_by"]),
|
||||
comment=payload.get("comment"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=409, detail={"message": str(exc), "code": "PUBLICATION_GATE_ERROR"})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"publication": {
|
||||
"id": publication.id,
|
||||
"candidate_id": publication.candidate_id,
|
||||
"report_id": publication.report_id,
|
||||
"published_by": publication.published_by,
|
||||
"published_at": publication.published_at.isoformat() if publication.published_at else None,
|
||||
"target_channel": publication.target_channel,
|
||||
"publication_ref": publication.publication_ref,
|
||||
"status": publication.status,
|
||||
},
|
||||
}
|
||||
|
||||
# [/DEF:backend.src.api.routes.clean_release_v2:Module]
|
||||
@@ -48,6 +48,7 @@ from ...dependencies import (
|
||||
has_permission,
|
||||
)
|
||||
from ...core.database import get_db
|
||||
from ...core.async_superset_client import AsyncSupersetClient
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.superset_profile_lookup import SupersetAccountLookupAdapter
|
||||
@@ -97,7 +98,9 @@ class EffectiveProfileFilter(BaseModel):
|
||||
source_page: Literal["dashboards_main", "other"] = "dashboards_main"
|
||||
override_show_all: bool = False
|
||||
username: Optional[str] = None
|
||||
match_logic: Optional[Literal["owners_or_modified_by"]] = None
|
||||
match_logic: Optional[
|
||||
Literal["owners_or_modified_by", "slug_only", "owners_or_modified_by+slug_only"]
|
||||
] = None
|
||||
# [/DEF:EffectiveProfileFilter:DataClass]
|
||||
|
||||
# [DEF:DashboardsResponse:DataClass]
|
||||
@@ -229,6 +232,56 @@ def _resolve_dashboard_id_from_ref(
|
||||
# [/DEF:_resolve_dashboard_id_from_ref:Function]
|
||||
|
||||
|
||||
# [DEF:_find_dashboard_id_by_slug_async:Function]
|
||||
# @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint.
|
||||
# @PRE: dashboard_slug is non-empty.
|
||||
# @POST: Returns dashboard ID when found, otherwise None.
|
||||
async def _find_dashboard_id_by_slug_async(
|
||||
client: AsyncSupersetClient,
|
||||
dashboard_slug: str,
|
||||
) -> Optional[int]:
|
||||
query_variants = [
|
||||
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
|
||||
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
|
||||
]
|
||||
|
||||
for query in query_variants:
|
||||
try:
|
||||
_count, dashboards = await client.get_dashboards_page_async(query=query)
|
||||
if dashboards:
|
||||
resolved_id = dashboards[0].get("id")
|
||||
if resolved_id is not None:
|
||||
return int(resolved_id)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
# [/DEF:_find_dashboard_id_by_slug_async:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
|
||||
# @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client.
|
||||
# @PRE: dashboard_ref is provided in route path.
|
||||
# @POST: Returns valid dashboard ID or raises HTTPException(404).
|
||||
async def _resolve_dashboard_id_from_ref_async(
|
||||
dashboard_ref: str,
|
||||
client: AsyncSupersetClient,
|
||||
) -> int:
|
||||
normalized_ref = str(dashboard_ref or "").strip()
|
||||
if not normalized_ref:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
|
||||
slug_match_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
|
||||
if slug_match_id is not None:
|
||||
return slug_match_id
|
||||
|
||||
if normalized_ref.isdigit():
|
||||
return int(normalized_ref)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
|
||||
|
||||
|
||||
# [DEF:_normalize_filter_values:Function]
|
||||
# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens.
|
||||
# @PRE: values may be None or list of strings.
|
||||
@@ -484,6 +537,7 @@ async def get_dashboards(
|
||||
profile_service = ProfileService(db=db, config_manager=config_manager)
|
||||
bound_username: Optional[str] = None
|
||||
can_apply_profile_filter = False
|
||||
can_apply_slug_filter = False
|
||||
effective_profile_filter = EffectiveProfileFilter(
|
||||
applied=False,
|
||||
source_page=page_context,
|
||||
@@ -509,13 +563,27 @@ async def get_dashboards(
|
||||
and bool(getattr(profile_preference, "show_only_my_dashboards", False))
|
||||
and bool(bound_username)
|
||||
)
|
||||
can_apply_slug_filter = (
|
||||
page_context == "dashboards_main"
|
||||
and bool(apply_profile_default)
|
||||
and not bool(override_show_all)
|
||||
and bool(getattr(profile_preference, "show_only_slug_dashboards", True))
|
||||
)
|
||||
|
||||
profile_match_logic = None
|
||||
if can_apply_profile_filter and can_apply_slug_filter:
|
||||
profile_match_logic = "owners_or_modified_by+slug_only"
|
||||
elif can_apply_profile_filter:
|
||||
profile_match_logic = "owners_or_modified_by"
|
||||
elif can_apply_slug_filter:
|
||||
profile_match_logic = "slug_only"
|
||||
|
||||
effective_profile_filter = EffectiveProfileFilter(
|
||||
applied=bool(can_apply_profile_filter),
|
||||
applied=bool(can_apply_profile_filter or can_apply_slug_filter),
|
||||
source_page=page_context,
|
||||
override_show_all=bool(override_show_all),
|
||||
username=bound_username if can_apply_profile_filter else None,
|
||||
match_logic="owners_or_modified_by" if can_apply_profile_filter else None,
|
||||
match_logic=profile_match_logic,
|
||||
)
|
||||
except Exception as profile_error:
|
||||
logger.explore(
|
||||
@@ -538,7 +606,7 @@ async def get_dashboards(
|
||||
actor_filters,
|
||||
)
|
||||
)
|
||||
needs_full_scan = has_column_filters or bool(can_apply_profile_filter)
|
||||
needs_full_scan = has_column_filters or bool(can_apply_profile_filter) or bool(can_apply_slug_filter)
|
||||
|
||||
if isinstance(resource_service, ResourceService) and not needs_full_scan:
|
||||
try:
|
||||
@@ -549,6 +617,7 @@ async def get_dashboards(
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
include_git_status=False,
|
||||
require_slug=bool(can_apply_slug_filter),
|
||||
)
|
||||
paginated_dashboards = page_payload["dashboards"]
|
||||
total = page_payload["total"]
|
||||
@@ -562,6 +631,7 @@ async def get_dashboards(
|
||||
env,
|
||||
all_tasks,
|
||||
include_git_status=False,
|
||||
require_slug=bool(can_apply_slug_filter),
|
||||
)
|
||||
|
||||
if search:
|
||||
@@ -582,6 +652,7 @@ async def get_dashboards(
|
||||
env,
|
||||
all_tasks,
|
||||
include_git_status=bool(git_filters),
|
||||
require_slug=bool(can_apply_slug_filter),
|
||||
)
|
||||
|
||||
if can_apply_profile_filter and bound_username:
|
||||
@@ -623,6 +694,13 @@ async def get_dashboards(
|
||||
)
|
||||
dashboards = filtered_dashboards
|
||||
|
||||
if can_apply_slug_filter:
|
||||
dashboards = [
|
||||
dashboard
|
||||
for dashboard in dashboards
|
||||
if str(dashboard.get("slug") or "").strip()
|
||||
]
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
dashboards = [
|
||||
@@ -776,10 +854,10 @@ async def get_dashboard_detail(
|
||||
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
client = AsyncSupersetClient(env)
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
|
||||
detail = client.get_dashboard_detail(dashboard_id)
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
|
||||
detail = await client.get_dashboard_detail_async(dashboard_id)
|
||||
logger.info(
|
||||
f"[get_dashboard_detail][Coherence:OK] Dashboard ref={dashboard_ref} resolved_id={dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
|
||||
)
|
||||
@@ -789,6 +867,8 @@ async def get_dashboard_detail(
|
||||
except Exception as e:
|
||||
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
|
||||
finally:
|
||||
await client.aclose()
|
||||
# [/DEF:get_dashboard_detail:Function]
|
||||
|
||||
|
||||
@@ -840,69 +920,74 @@ async def get_dashboard_tasks_history(
|
||||
):
|
||||
with belief_scope("get_dashboard_tasks_history", f"dashboard_ref={dashboard_ref}, env_id={env_id}, limit={limit}"):
|
||||
dashboard_id: Optional[int] = None
|
||||
if dashboard_ref.isdigit():
|
||||
dashboard_id = int(dashboard_ref)
|
||||
elif env_id:
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
client = SupersetClient(env)
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
|
||||
else:
|
||||
logger.error(
|
||||
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="env_id is required when dashboard reference is a slug",
|
||||
)
|
||||
|
||||
matching_tasks = []
|
||||
for task in task_manager.get_all_tasks():
|
||||
if _task_matches_dashboard(task, dashboard_id, env_id):
|
||||
matching_tasks.append(task)
|
||||
|
||||
def _sort_key(task_obj: Any) -> str:
|
||||
return (
|
||||
str(getattr(task_obj, "started_at", "") or "")
|
||||
or str(getattr(task_obj, "finished_at", "") or "")
|
||||
)
|
||||
|
||||
matching_tasks.sort(key=_sort_key, reverse=True)
|
||||
selected = matching_tasks[:limit]
|
||||
|
||||
items = []
|
||||
for task in selected:
|
||||
result = getattr(task, "result", None)
|
||||
summary = None
|
||||
validation_status = None
|
||||
if isinstance(result, dict):
|
||||
raw_validation_status = result.get("status")
|
||||
if raw_validation_status is not None:
|
||||
validation_status = str(raw_validation_status)
|
||||
summary = (
|
||||
result.get("summary")
|
||||
or result.get("status")
|
||||
or result.get("message")
|
||||
client: Optional[AsyncSupersetClient] = None
|
||||
try:
|
||||
if dashboard_ref.isdigit():
|
||||
dashboard_id = int(dashboard_ref)
|
||||
elif env_id:
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
logger.error(f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
client = AsyncSupersetClient(env)
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
|
||||
else:
|
||||
logger.error(
|
||||
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
|
||||
)
|
||||
params = getattr(task, "params", {}) or {}
|
||||
items.append(
|
||||
DashboardTaskHistoryItem(
|
||||
id=str(getattr(task, "id", "")),
|
||||
plugin_id=str(getattr(task, "plugin_id", "")),
|
||||
status=str(getattr(task, "status", "")),
|
||||
validation_status=validation_status,
|
||||
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
|
||||
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
|
||||
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
|
||||
summary=summary,
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="env_id is required when dashboard reference is a slug",
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}")
|
||||
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
|
||||
matching_tasks = []
|
||||
for task in task_manager.get_all_tasks():
|
||||
if _task_matches_dashboard(task, dashboard_id, env_id):
|
||||
matching_tasks.append(task)
|
||||
|
||||
def _sort_key(task_obj: Any) -> str:
|
||||
return (
|
||||
str(getattr(task_obj, "started_at", "") or "")
|
||||
or str(getattr(task_obj, "finished_at", "") or "")
|
||||
)
|
||||
|
||||
matching_tasks.sort(key=_sort_key, reverse=True)
|
||||
selected = matching_tasks[:limit]
|
||||
|
||||
items = []
|
||||
for task in selected:
|
||||
result = getattr(task, "result", None)
|
||||
summary = None
|
||||
validation_status = None
|
||||
if isinstance(result, dict):
|
||||
raw_validation_status = result.get("status")
|
||||
if raw_validation_status is not None:
|
||||
validation_status = str(raw_validation_status)
|
||||
summary = (
|
||||
result.get("summary")
|
||||
or result.get("status")
|
||||
or result.get("message")
|
||||
)
|
||||
params = getattr(task, "params", {}) or {}
|
||||
items.append(
|
||||
DashboardTaskHistoryItem(
|
||||
id=str(getattr(task, "id", "")),
|
||||
plugin_id=str(getattr(task, "plugin_id", "")),
|
||||
status=str(getattr(task, "status", "")),
|
||||
validation_status=validation_status,
|
||||
started_at=getattr(task, "started_at", None).isoformat() if getattr(task, "started_at", None) else None,
|
||||
finished_at=getattr(task, "finished_at", None).isoformat() if getattr(task, "finished_at", None) else None,
|
||||
env_id=str(params.get("environment_id") or params.get("env")) if (params.get("environment_id") or params.get("env")) else None,
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}")
|
||||
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
# [/DEF:get_dashboard_tasks_history:Function]
|
||||
|
||||
|
||||
@@ -925,15 +1010,15 @@ async def get_dashboard_thumbnail(
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
client = AsyncSupersetClient(env)
|
||||
try:
|
||||
client = SupersetClient(env)
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, client)
|
||||
digest = None
|
||||
thumb_endpoint = None
|
||||
|
||||
# Preferred flow (newer Superset): ask server to cache screenshot and return digest/image_url.
|
||||
try:
|
||||
screenshot_payload = client.network.request(
|
||||
screenshot_payload = await client.network.request(
|
||||
method="POST",
|
||||
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
|
||||
json={"force": force},
|
||||
@@ -951,7 +1036,7 @@ async def get_dashboard_thumbnail(
|
||||
|
||||
# Fallback flow (older Superset): read thumbnail_url from dashboard payload.
|
||||
if not digest:
|
||||
dashboard_payload = client.network.request(
|
||||
dashboard_payload = await client.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}",
|
||||
)
|
||||
@@ -970,7 +1055,7 @@ async def get_dashboard_thumbnail(
|
||||
if not thumb_endpoint:
|
||||
thumb_endpoint = f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
|
||||
|
||||
thumb_response = client.network.request(
|
||||
thumb_response = await client.network.request(
|
||||
method="GET",
|
||||
endpoint=thumb_endpoint,
|
||||
raw_response=True,
|
||||
@@ -995,6 +1080,8 @@ async def get_dashboard_thumbnail(
|
||||
except Exception as e:
|
||||
logger.error(f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard thumbnail: {str(e)}")
|
||||
finally:
|
||||
await client.aclose()
|
||||
# [/DEF:get_dashboard_thumbnail:Function]
|
||||
|
||||
# [DEF:MigrateRequest:DataClass]
|
||||
|
||||
@@ -33,6 +33,7 @@ from src.api.routes.git_schemas import (
|
||||
MergeStatusSchema, MergeConflictFileSchema, MergeResolveRequest, MergeContinueRequest,
|
||||
)
|
||||
from src.services.git_service import GitService
|
||||
from src.core.async_superset_client import AsyncSupersetClient
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.core.logger import logger, belief_scope
|
||||
from ...services.llm_prompt_templates import (
|
||||
@@ -180,6 +181,70 @@ def _resolve_dashboard_id_from_ref(
|
||||
# [/DEF:_resolve_dashboard_id_from_ref:Function]
|
||||
|
||||
|
||||
# [DEF:_find_dashboard_id_by_slug_async:Function]
|
||||
# @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes.
|
||||
# @PRE: dashboard_slug is non-empty.
|
||||
# @POST: Returns dashboard ID or None when not found.
|
||||
async def _find_dashboard_id_by_slug_async(
|
||||
client: AsyncSupersetClient,
|
||||
dashboard_slug: str,
|
||||
) -> Optional[int]:
|
||||
query_variants = [
|
||||
{"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
|
||||
{"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}], "page": 0, "page_size": 1},
|
||||
]
|
||||
|
||||
for query in query_variants:
|
||||
try:
|
||||
_count, dashboards = await client.get_dashboards_page_async(query=query)
|
||||
if dashboards:
|
||||
resolved_id = dashboards[0].get("id")
|
||||
if resolved_id is not None:
|
||||
return int(resolved_id)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
# [/DEF:_find_dashboard_id_by_slug_async:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
|
||||
# @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes.
|
||||
# @PRE: dashboard_ref is provided; env_id is required for slug values.
|
||||
# @POST: Returns numeric dashboard ID or raises HTTPException.
|
||||
async def _resolve_dashboard_id_from_ref_async(
|
||||
dashboard_ref: str,
|
||||
config_manager,
|
||||
env_id: Optional[str] = None,
|
||||
) -> int:
|
||||
normalized_ref = str(dashboard_ref or "").strip()
|
||||
if not normalized_ref:
|
||||
raise HTTPException(status_code=400, detail="dashboard_ref is required")
|
||||
|
||||
if normalized_ref.isdigit():
|
||||
return int(normalized_ref)
|
||||
|
||||
if not env_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="env_id is required for slug-based Git operations",
|
||||
)
|
||||
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
client = AsyncSupersetClient(env)
|
||||
try:
|
||||
dashboard_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
|
||||
if dashboard_id is None:
|
||||
raise HTTPException(status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found")
|
||||
return dashboard_id
|
||||
finally:
|
||||
await client.aclose()
|
||||
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_repo_key_from_ref:Function]
|
||||
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
|
||||
# @PRE: dashboard_id is resolved and valid.
|
||||
@@ -1197,7 +1262,7 @@ async def get_repository_status(
|
||||
):
|
||||
with belief_scope("get_repository_status"):
|
||||
try:
|
||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||
dashboard_id = await _resolve_dashboard_id_from_ref_async(dashboard_ref, config_manager, env_id)
|
||||
return _resolve_repository_status(dashboard_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
31
backend/src/api/routes/health.py
Normal file
31
backend/src/api/routes/health.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# [DEF:health_router:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: health, monitoring, dashboards
|
||||
# @PURPOSE: API endpoints for dashboard health monitoring and status aggregation.
|
||||
# @LAYER: UI/API
|
||||
# @RELATION: DEPENDS_ON -> health_service
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ...core.database import get_db
|
||||
from ...services.health_service import HealthService
|
||||
from ...schemas.health import HealthSummaryResponse
|
||||
from ...dependencies import has_permission
|
||||
|
||||
router = APIRouter(prefix="/api/health", tags=["Health"])
|
||||
|
||||
@router.get("/summary", response_model=HealthSummaryResponse)
|
||||
async def get_health_summary(
|
||||
environment_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
"""
|
||||
@PURPOSE: Get aggregated health status for all dashboards.
|
||||
@POST: Returns HealthSummaryResponse
|
||||
"""
|
||||
service = HealthService(db)
|
||||
return await service.get_health_summary(environment_id=environment_id)
|
||||
|
||||
# [/DEF:health_router:Module]
|
||||
@@ -1,10 +1,23 @@
|
||||
# [DEF:backend.src.api.routes.migration:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, migration, dashboards
|
||||
# @PURPOSE: API endpoints for migration operations.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: api, migration, dashboards, sync, dry-run
|
||||
# @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
|
||||
# @LAYER: Infra
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.dependencies]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.database]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.migration.dry_run_orchestrator]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.mapping_service]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.dashboard]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping]
|
||||
# @INVARIANT: Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures.
|
||||
# @TEST_CONTRACT: [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary]
|
||||
# @TEST_SCENARIO: [invalid_environment] -> [HTTP_400_or_404]
|
||||
# @TEST_SCENARIO: [valid_execution] -> [success_payload_with_required_fields]
|
||||
# @TEST_EDGE: [missing_field] ->[HTTP_400]
|
||||
# @TEST_EDGE: [invalid_type] ->[validation_error]
|
||||
# @TEST_EDGE: [external_fail] ->[HTTP_500]
|
||||
# @TEST_INVARIANT: [EnvironmentValidationBeforeAction] -> VERIFIED_BY: [invalid_environment, valid_execution]
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
@@ -13,7 +26,7 @@ from ...dependencies import get_config_manager, get_task_manager, has_permission
|
||||
from ...core.database import get_db
|
||||
from ...models.dashboard import DashboardMetadata, DashboardSelection
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...core.logger import belief_scope
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.migration.dry_run_orchestrator import MigrationDryRunService
|
||||
from ...core.mapping_service import IdMappingService
|
||||
from ...models.mapping import ResourceMapping
|
||||
@@ -21,11 +34,11 @@ from ...models.mapping import ResourceMapping
|
||||
router = APIRouter(prefix="/api", tags=["migration"])
|
||||
|
||||
# [DEF:get_dashboards:Function]
|
||||
# @PURPOSE: Fetch all dashboards from the specified environment for the grid.
|
||||
# @PRE: Environment ID must be valid.
|
||||
# @POST: Returns a list of dashboard metadata.
|
||||
# @PARAM: env_id (str) - The ID of the environment to fetch from.
|
||||
# @RETURN: List[DashboardMetadata]
|
||||
# @PURPOSE: Fetch dashboard metadata from a requested environment for migration selection UI.
|
||||
# @PRE: env_id is provided and exists in configured environments.
|
||||
# @POST: Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent.
|
||||
# @SIDE_EFFECT: Reads environment configuration and performs remote Superset metadata retrieval over network.
|
||||
# @DATA_CONTRACT: Input[str env_id] -> Output[List[DashboardMetadata]]
|
||||
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
|
||||
async def get_dashboards(
|
||||
env_id: str,
|
||||
@@ -33,22 +46,26 @@ async def get_dashboards(
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("get_dashboards", f"env_id={env_id}"):
|
||||
logger.reason(f"Fetching dashboards for environment: {env_id}")
|
||||
environments = config_manager.get_environments()
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
env = next((e for e in environments if e.id == env_id), None)
|
||||
|
||||
if not env:
|
||||
logger.explore(f"Environment {env_id} not found in configuration")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
client = SupersetClient(env)
|
||||
dashboards = client.get_dashboards_summary()
|
||||
return dashboards
|
||||
client = SupersetClient(env)
|
||||
dashboards = client.get_dashboards_summary()
|
||||
logger.reflect(f"Retrieved {len(dashboards)} dashboards from {env_id}")
|
||||
return dashboards
|
||||
# [/DEF:get_dashboards:Function]
|
||||
|
||||
# [DEF:execute_migration:Function]
|
||||
# @PURPOSE: Execute the migration of selected dashboards.
|
||||
# @PRE: Selection must be valid and environments must exist.
|
||||
# @POST: Starts the migration task and returns the task ID.
|
||||
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
|
||||
# @RETURN: Dict - {"task_id": str, "message": str}
|
||||
# @PURPOSE: Validate migration selection and enqueue asynchronous migration task execution.
|
||||
# @PRE: DashboardSelection payload is valid and both source/target environments exist.
|
||||
# @POST: Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure.
|
||||
# @SIDE_EFFECT: Reads configuration, writes task record through task manager, and writes operational logs.
|
||||
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, str]]
|
||||
@router.post("/migration/execute")
|
||||
async def execute_migration(
|
||||
selection: DashboardSelection,
|
||||
@@ -57,38 +74,39 @@ async def execute_migration(
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("execute_migration"):
|
||||
logger.reason(f"Initiating migration from {selection.source_env_id} to {selection.target_env_id}")
|
||||
|
||||
# Validate environments exist
|
||||
environments = config_manager.get_environments()
|
||||
env_ids = {e.id for e in environments}
|
||||
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
env_ids = {e.id for e in environments}
|
||||
|
||||
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
|
||||
logger.explore("Invalid environment selection", extra={"source": selection.source_env_id, "target": selection.target_env_id})
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
|
||||
# Create migration task with debug logging
|
||||
from ...core.logger import logger
|
||||
|
||||
# Include replace_db_config and fix_cross_filters in the task parameters
|
||||
task_params = selection.dict()
|
||||
task_params['replace_db_config'] = selection.replace_db_config
|
||||
task_params['fix_cross_filters'] = selection.fix_cross_filters
|
||||
|
||||
logger.info(f"Creating migration task with params: {task_params}")
|
||||
logger.info(f"Available environments: {env_ids}")
|
||||
logger.info(f"Source env: {selection.source_env_id}, Target env: {selection.target_env_id}")
|
||||
|
||||
try:
|
||||
task = await task_manager.create_task("superset-migration", task_params)
|
||||
logger.info(f"Task created successfully: {task.id}")
|
||||
return {"task_id": task.id, "message": "Migration initiated"}
|
||||
except Exception as e:
|
||||
logger.error(f"Task creation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
||||
# Include replace_db_config and fix_cross_filters in the task parameters
|
||||
task_params = selection.dict()
|
||||
task_params['replace_db_config'] = selection.replace_db_config
|
||||
task_params['fix_cross_filters'] = selection.fix_cross_filters
|
||||
|
||||
logger.reason(f"Creating migration task with {len(selection.selected_ids)} dashboards")
|
||||
|
||||
try:
|
||||
task = await task_manager.create_task("superset-migration", task_params)
|
||||
logger.reflect(f"Migration task created: {task.id}")
|
||||
return {"task_id": task.id, "message": "Migration initiated"}
|
||||
except Exception as e:
|
||||
logger.explore(f"Task creation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
||||
# [/DEF:execute_migration:Function]
|
||||
|
||||
|
||||
# [DEF:dry_run_migration:Function]
|
||||
# @PURPOSE: Build pre-flight diff and risk summary without applying migration.
|
||||
# @PRE: Selection and environments are valid.
|
||||
# @POST: Returns deterministic JSON diff and risk scoring.
|
||||
# @PURPOSE: Build pre-flight migration diff and risk summary without mutating target systems.
|
||||
# @PRE: DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty.
|
||||
# @POST: Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors.
|
||||
# @SIDE_EFFECT: Reads local mappings from DB and fetches source/target metadata via Superset API.
|
||||
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, Any]]
|
||||
@router.post("/migration/dry-run", response_model=Dict[str, Any])
|
||||
async def dry_run_migration(
|
||||
selection: DashboardSelection,
|
||||
@@ -97,33 +115,49 @@ async def dry_run_migration(
|
||||
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||
):
|
||||
with belief_scope("dry_run_migration"):
|
||||
logger.reason(f"Starting dry run: {selection.source_env_id} -> {selection.target_env_id}")
|
||||
|
||||
environments = config_manager.get_environments()
|
||||
env_map = {env.id: env for env in environments}
|
||||
source_env = env_map.get(selection.source_env_id)
|
||||
target_env = env_map.get(selection.target_env_id)
|
||||
if not source_env or not target_env:
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
if selection.source_env_id == selection.target_env_id:
|
||||
raise HTTPException(status_code=400, detail="Source and target environments must be different")
|
||||
if not selection.selected_ids:
|
||||
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
|
||||
env_map = {env.id: env for env in environments}
|
||||
source_env = env_map.get(selection.source_env_id)
|
||||
target_env = env_map.get(selection.target_env_id)
|
||||
|
||||
if not source_env or not target_env:
|
||||
logger.explore("Invalid environment selection for dry run")
|
||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||
|
||||
if selection.source_env_id == selection.target_env_id:
|
||||
logger.explore("Source and target environments are identical")
|
||||
raise HTTPException(status_code=400, detail="Source and target environments must be different")
|
||||
|
||||
if not selection.selected_ids:
|
||||
logger.explore("No dashboards selected for dry run")
|
||||
raise HTTPException(status_code=400, detail="No dashboards selected for dry run")
|
||||
|
||||
service = MigrationDryRunService()
|
||||
source_client = SupersetClient(source_env)
|
||||
target_client = SupersetClient(target_env)
|
||||
try:
|
||||
return service.run(
|
||||
selection=selection,
|
||||
source_client=source_client,
|
||||
target_client=target_client,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
service = MigrationDryRunService()
|
||||
source_client = SupersetClient(source_env)
|
||||
target_client = SupersetClient(target_env)
|
||||
|
||||
try:
|
||||
result = service.run(
|
||||
selection=selection,
|
||||
source_client=source_client,
|
||||
target_client=target_client,
|
||||
db=db,
|
||||
)
|
||||
logger.reflect("Dry run analysis complete")
|
||||
return result
|
||||
except ValueError as exc:
|
||||
logger.explore(f"Dry run orchestrator failed: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
# [/DEF:dry_run_migration:Function]
|
||||
|
||||
# [DEF:get_migration_settings:Function]
|
||||
# @PURPOSE: Get current migration Cron string explicitly.
|
||||
# @PURPOSE: Read and return configured migration synchronization cron expression.
|
||||
# @PRE: Configuration store is available and requester has READ permission.
|
||||
# @POST: Returns {"cron": str} reflecting current persisted settings value.
|
||||
# @SIDE_EFFECT: Reads configuration from config manager.
|
||||
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, str]]
|
||||
@router.get("/migration/settings", response_model=Dict[str, str])
|
||||
async def get_migration_settings(
|
||||
config_manager=Depends(get_config_manager),
|
||||
@@ -136,7 +170,11 @@ async def get_migration_settings(
|
||||
# [/DEF:get_migration_settings:Function]
|
||||
|
||||
# [DEF:update_migration_settings:Function]
|
||||
# @PURPOSE: Update migration Cron string.
|
||||
# @PURPOSE: Validate and persist migration synchronization cron expression update.
|
||||
# @PRE: Payload includes "cron" key and requester has WRITE permission.
|
||||
# @POST: Returns {"cron": str, "status": "updated"} and persists updated cron value.
|
||||
# @SIDE_EFFECT: Mutates configuration and writes persisted config through config manager.
|
||||
# @DATA_CONTRACT: Input[Dict[str, str]] -> Output[Dict[str, str]]
|
||||
@router.put("/migration/settings", response_model=Dict[str, str])
|
||||
async def update_migration_settings(
|
||||
payload: Dict[str, str],
|
||||
@@ -157,7 +195,11 @@ async def update_migration_settings(
|
||||
# [/DEF:update_migration_settings:Function]
|
||||
|
||||
# [DEF:get_resource_mappings:Function]
|
||||
# @PURPOSE: Fetch synchronized object mappings with search, filtering, and pagination.
|
||||
# @PURPOSE: Fetch synchronized resource mappings with optional filters and pagination for migration mappings view.
|
||||
# @PRE: skip>=0, 1<=limit<=500, DB session is active, requester has READ permission.
|
||||
# @POST: Returns {"items": [...], "total": int} where items reflect applied filters and pagination.
|
||||
# @SIDE_EFFECT: Executes database read queries against ResourceMapping table.
|
||||
# @DATA_CONTRACT: Input[QueryParams] -> Output[Dict[str, Any]]
|
||||
@router.get("/migration/mappings-data", response_model=Dict[str, Any])
|
||||
async def get_resource_mappings(
|
||||
skip: int = Query(0, ge=0),
|
||||
@@ -203,9 +245,11 @@ async def get_resource_mappings(
|
||||
# [/DEF:get_resource_mappings:Function]
|
||||
|
||||
# [DEF:trigger_sync_now:Function]
|
||||
# @PURPOSE: Triggers an immediate ID synchronization for all environments.
|
||||
# @PRE: At least one environment must be configured.
|
||||
# @POST: Environment rows are ensured in DB; sync_environment is called for each.
|
||||
# @PURPOSE: Trigger immediate ID synchronization for every configured environment.
|
||||
# @PRE: At least one environment is configured and requester has EXECUTE permission.
|
||||
# @POST: Returns sync summary with synced/failed counts after attempting all environments.
|
||||
# @SIDE_EFFECT: Upserts Environment rows, commits DB transaction, performs network sync calls, and writes logs.
|
||||
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, Any]]
|
||||
@router.post("/migration/sync-now", response_model=Dict[str, Any])
|
||||
async def trigger_sync_now(
|
||||
config_manager=Depends(get_config_manager),
|
||||
|
||||
@@ -13,10 +13,11 @@ from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ...dependencies import get_task_manager, has_permission
|
||||
from ...dependencies import get_task_manager, has_permission, get_clean_release_repository
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
|
||||
from ...services.clean_release.repository import CleanReleaseRepository
|
||||
from ...services.reports.report_service import ReportsService
|
||||
# [/SECTION]
|
||||
|
||||
@@ -88,6 +89,7 @@ async def list_reports(
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("list_reports"):
|
||||
@@ -117,7 +119,7 @@ async def list_reports(
|
||||
},
|
||||
)
|
||||
|
||||
service = ReportsService(task_manager)
|
||||
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
|
||||
return service.list_reports(query)
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
@@ -130,10 +132,11 @@ async def list_reports(
|
||||
async def get_report_detail(
|
||||
report_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_report_detail", f"report_id={report_id}"):
|
||||
service = ReportsService(task_manager)
|
||||
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
|
||||
detail = service.get_report_detail(report_id)
|
||||
if not detail:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -16,10 +16,15 @@ from pydantic import BaseModel
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...services.llm_prompt_templates import normalize_llm_settings
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
from ...services.llm_prompt_templates import normalize_llm_settings
|
||||
from ...models.llm import ValidationPolicy
|
||||
from ...models.config import AppConfigRecord
|
||||
from ...schemas.settings import ValidationPolicyCreate, ValidationPolicyUpdate, ValidationPolicyResponse
|
||||
from ...core.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:LoggingConfigResponse:Class]
|
||||
@@ -31,38 +36,38 @@ class LoggingConfigResponse(BaseModel):
|
||||
enable_belief_state: bool
|
||||
# [/DEF:LoggingConfigResponse:Class]
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# [DEF:_normalize_superset_env_url:Function]
|
||||
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
|
||||
# @PRE: raw_url can be empty.
|
||||
# @POST: Returns normalized base URL.
|
||||
def _normalize_superset_env_url(raw_url: str) -> str:
|
||||
normalized = str(raw_url or "").strip().rstrip("/")
|
||||
if normalized.lower().endswith("/api/v1"):
|
||||
normalized = normalized[:-len("/api/v1")]
|
||||
return normalized.rstrip("/")
|
||||
# [/DEF:_normalize_superset_env_url:Function]
|
||||
|
||||
|
||||
# [DEF:_validate_superset_connection_fast:Function]
|
||||
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
|
||||
# @PRE: env contains valid URL and credentials.
|
||||
# @POST: Raises on auth/API failures; returns None on success.
|
||||
def _validate_superset_connection_fast(env: Environment) -> None:
|
||||
client = SupersetClient(env)
|
||||
# 1) Explicit auth check
|
||||
client.authenticate()
|
||||
# 2) Single lightweight API call to ensure read access
|
||||
client.get_dashboards_page(
|
||||
query={
|
||||
"page": 0,
|
||||
"page_size": 1,
|
||||
"columns": ["id"],
|
||||
}
|
||||
)
|
||||
# [/DEF:_validate_superset_connection_fast:Function]
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# [DEF:_normalize_superset_env_url:Function]
|
||||
# @PURPOSE: Canonicalize Superset environment URL to base host/path without trailing /api/v1.
|
||||
# @PRE: raw_url can be empty.
|
||||
# @POST: Returns normalized base URL.
|
||||
def _normalize_superset_env_url(raw_url: str) -> str:
|
||||
normalized = str(raw_url or "").strip().rstrip("/")
|
||||
if normalized.lower().endswith("/api/v1"):
|
||||
normalized = normalized[:-len("/api/v1")]
|
||||
return normalized.rstrip("/")
|
||||
# [/DEF:_normalize_superset_env_url:Function]
|
||||
|
||||
|
||||
# [DEF:_validate_superset_connection_fast:Function]
|
||||
# @PURPOSE: Run lightweight Superset connectivity validation without full pagination scan.
|
||||
# @PRE: env contains valid URL and credentials.
|
||||
# @POST: Raises on auth/API failures; returns None on success.
|
||||
def _validate_superset_connection_fast(env: Environment) -> None:
|
||||
client = SupersetClient(env)
|
||||
# 1) Explicit auth check
|
||||
client.authenticate()
|
||||
# 2) Single lightweight API call to ensure read access
|
||||
client.get_dashboards_page(
|
||||
query={
|
||||
"page": 0,
|
||||
"page_size": 1,
|
||||
"columns": ["id"],
|
||||
}
|
||||
)
|
||||
# [/DEF:_validate_superset_connection_fast:Function]
|
||||
|
||||
# [DEF:get_settings:Function]
|
||||
# @PURPOSE: Retrieves all application settings.
|
||||
@@ -70,14 +75,14 @@ def _validate_superset_connection_fast(env: Environment) -> None:
|
||||
# @POST: Returns masked AppConfig.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
@router.get("", response_model=AppConfig)
|
||||
async def get_settings(
|
||||
async def get_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_settings"):
|
||||
logger.info("[get_settings][Entry] Fetching all settings")
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
config.settings.llm = normalize_llm_settings(config.settings.llm)
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
config.settings.llm = normalize_llm_settings(config.settings.llm)
|
||||
# Mask passwords
|
||||
for env in config.environments:
|
||||
if env.password:
|
||||
@@ -143,18 +148,18 @@ async def update_storage_settings(
|
||||
# @PRE: Config manager is available.
|
||||
# @POST: Returns list of environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
@router.get("/environments", response_model=List[Environment])
|
||||
async def get_environments(
|
||||
@router.get("/environments", response_model=List[Environment])
|
||||
async def get_environments(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_environments"):
|
||||
logger.info("[get_environments][Entry] Fetching environments")
|
||||
environments = config_manager.get_environments()
|
||||
return [
|
||||
env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
for env in environments
|
||||
]
|
||||
):
|
||||
with belief_scope("get_environments"):
|
||||
logger.info("[get_environments][Entry] Fetching environments")
|
||||
environments = config_manager.get_environments()
|
||||
return [
|
||||
env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
for env in environments
|
||||
]
|
||||
# [/DEF:get_environments:Function]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
@@ -164,21 +169,21 @@ async def get_environments(
|
||||
# @PARAM: env (Environment) - The environment to add.
|
||||
# @RETURN: Environment - The added environment.
|
||||
@router.post("/environments", response_model=Environment)
|
||||
async def add_environment(
|
||||
env: Environment,
|
||||
async def add_environment(
|
||||
env: Environment,
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
|
||||
# Validate connection before adding (fast path)
|
||||
try:
|
||||
_validate_superset_connection_fast(env)
|
||||
except Exception as e:
|
||||
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
|
||||
# Validate connection before adding (fast path)
|
||||
try:
|
||||
_validate_superset_connection_fast(env)
|
||||
except Exception as e:
|
||||
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
|
||||
|
||||
config_manager.add_environment(env)
|
||||
return env
|
||||
@@ -192,29 +197,29 @@ async def add_environment(
|
||||
# @PARAM: env (Environment) - The updated environment data.
|
||||
# @RETURN: Environment - The updated environment.
|
||||
@router.put("/environments/{id}", response_model=Environment)
|
||||
async def update_environment(
|
||||
async def update_environment(
|
||||
id: str,
|
||||
env: Environment,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
):
|
||||
with belief_scope("update_environment"):
|
||||
logger.info(f"[update_environment][Entry] Updating environment {id}")
|
||||
|
||||
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
|
||||
# If password is masked, we need the real one for validation
|
||||
env_to_validate = env.copy(deep=True)
|
||||
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
||||
|
||||
# If password is masked, we need the real one for validation
|
||||
env_to_validate = env.copy(deep=True)
|
||||
if env_to_validate.password == "********":
|
||||
old_env = next((e for e in config_manager.get_environments() if e.id == id), None)
|
||||
if old_env:
|
||||
env_to_validate.password = old_env.password
|
||||
|
||||
# Validate connection before updating (fast path)
|
||||
try:
|
||||
_validate_superset_connection_fast(env_to_validate)
|
||||
except Exception as e:
|
||||
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
|
||||
# Validate connection before updating (fast path)
|
||||
try:
|
||||
_validate_superset_connection_fast(env_to_validate)
|
||||
except Exception as e:
|
||||
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Connection validation failed: {e}")
|
||||
|
||||
if config_manager.update_environment(id, env):
|
||||
return env
|
||||
@@ -244,7 +249,7 @@ async def delete_environment(
|
||||
# @PARAM: id (str) - The ID of the environment to test.
|
||||
# @RETURN: dict - Success message or error.
|
||||
@router.post("/environments/{id}/test")
|
||||
async def test_environment_connection(
|
||||
async def test_environment_connection(
|
||||
id: str,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
@@ -256,11 +261,11 @@ async def test_environment_connection(
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
||||
|
||||
try:
|
||||
_validate_superset_connection_fast(env)
|
||||
|
||||
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
|
||||
return {"status": "success", "message": "Connection successful"}
|
||||
try:
|
||||
_validate_superset_connection_fast(env)
|
||||
|
||||
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
|
||||
return {"status": "success", "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -313,13 +318,14 @@ async def update_logging_config(
|
||||
# [/DEF:update_logging_config:Function]
|
||||
|
||||
# [DEF:ConsolidatedSettingsResponse:Class]
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
environments: List[dict]
|
||||
connections: List[dict]
|
||||
llm: dict
|
||||
llm_providers: List[dict]
|
||||
logging: dict
|
||||
storage: dict
|
||||
notifications: dict = {}
|
||||
# [/DEF:ConsolidatedSettingsResponse:Class]
|
||||
|
||||
# [DEF:get_consolidated_settings:Function]
|
||||
@@ -328,7 +334,7 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
# @POST: Returns all consolidated settings.
|
||||
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||
async def get_consolidated_settings(
|
||||
async def get_consolidated_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
@@ -340,6 +346,7 @@ async def get_consolidated_settings(
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
notifications_payload = {}
|
||||
try:
|
||||
llm_service = LLMProviderService(db)
|
||||
providers = llm_service.get_all_providers()
|
||||
@@ -354,19 +361,24 @@ async def get_consolidated_settings(
|
||||
"is_active": p.is_active
|
||||
} for p in providers
|
||||
]
|
||||
|
||||
config_record = db.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
||||
if config_record and isinstance(config_record.payload, dict):
|
||||
notifications_payload = config_record.payload.get("notifications", {}) or {}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
normalized_llm = normalize_llm_settings(config.settings.llm)
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=normalized_llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict()
|
||||
)
|
||||
normalized_llm = normalize_llm_settings(config.settings.llm)
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=[env.dict() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
llm=normalized_llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.dict(),
|
||||
storage=config.settings.storage.dict(),
|
||||
notifications=notifications_payload
|
||||
)
|
||||
# [/DEF:get_consolidated_settings:Function]
|
||||
|
||||
# [DEF:update_consolidated_settings:Function]
|
||||
@@ -389,9 +401,9 @@ async def update_consolidated_settings(
|
||||
if "connections" in settings_patch:
|
||||
current_settings.connections = settings_patch["connections"]
|
||||
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
|
||||
|
||||
# Update Logging if provided
|
||||
if "logging" in settings_patch:
|
||||
@@ -405,8 +417,88 @@ async def update_consolidated_settings(
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
current_settings.storage = new_storage
|
||||
|
||||
if "notifications" in settings_patch:
|
||||
payload = config_manager.get_payload()
|
||||
payload["notifications"] = settings_patch["notifications"]
|
||||
config_manager.save_config(payload)
|
||||
|
||||
config_manager.update_global_settings(current_settings)
|
||||
return {"status": "success", "message": "Settings updated"}
|
||||
# [/DEF:update_consolidated_settings:Function]
|
||||
|
||||
# [DEF:get_validation_policies:Function]
|
||||
# @PURPOSE: Lists all validation policies.
|
||||
# @RETURN: List[ValidationPolicyResponse] - List of policies.
|
||||
@router.get("/automation/policies", response_model=List[ValidationPolicyResponse])
|
||||
async def get_validation_policies(
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_validation_policies"):
|
||||
return db.query(ValidationPolicy).all()
|
||||
# [/DEF:get_validation_policies:Function]
|
||||
|
||||
# [DEF:create_validation_policy:Function]
|
||||
# @PURPOSE: Creates a new validation policy.
|
||||
# @PARAM: policy (ValidationPolicyCreate) - The policy data.
|
||||
# @RETURN: ValidationPolicyResponse - The created policy.
|
||||
@router.post("/automation/policies", response_model=ValidationPolicyResponse)
|
||||
async def create_validation_policy(
|
||||
policy: ValidationPolicyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("create_validation_policy"):
|
||||
db_policy = ValidationPolicy(**policy.dict())
|
||||
db.add(db_policy)
|
||||
db.commit()
|
||||
db.refresh(db_policy)
|
||||
return db_policy
|
||||
# [/DEF:create_validation_policy:Function]
|
||||
|
||||
# [DEF:update_validation_policy:Function]
|
||||
# @PURPOSE: Updates an existing validation policy.
|
||||
# @PARAM: id (str) - The ID of the policy to update.
|
||||
# @PARAM: policy (ValidationPolicyUpdate) - The updated policy data.
|
||||
# @RETURN: ValidationPolicyResponse - The updated policy.
|
||||
@router.patch("/automation/policies/{id}", response_model=ValidationPolicyResponse)
|
||||
async def update_validation_policy(
|
||||
id: str,
|
||||
policy: ValidationPolicyUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_validation_policy"):
|
||||
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
|
||||
if not db_policy:
|
||||
raise HTTPException(status_code=404, detail="Policy not found")
|
||||
|
||||
update_data = policy.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_policy, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_policy)
|
||||
return db_policy
|
||||
# [/DEF:update_validation_policy:Function]
|
||||
|
||||
# [DEF:delete_validation_policy:Function]
|
||||
# @PURPOSE: Deletes a validation policy.
|
||||
# @PARAM: id (str) - The ID of the policy to delete.
|
||||
@router.delete("/automation/policies/{id}")
|
||||
async def delete_validation_policy(
|
||||
id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("delete_validation_policy"):
|
||||
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
|
||||
if not db_policy:
|
||||
raise HTTPException(status_code=404, detail="Policy not found")
|
||||
|
||||
db.delete(db_policy)
|
||||
db.commit()
|
||||
return {"message": "Policy deleted"}
|
||||
# [/DEF:delete_validation_policy:Function]
|
||||
|
||||
# [/DEF:SettingsRouter:Module]
|
||||
|
||||
@@ -21,7 +21,7 @@ import asyncio
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, profile
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports, assistant, clean_release, clean_release_v2, profile, health
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
@@ -134,7 +134,9 @@ app.include_router(datasets.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
|
||||
app.include_router(clean_release.router)
|
||||
app.include_router(clean_release_v2.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(health.router)
|
||||
|
||||
|
||||
# [DEF:api.include_routers:Action]
|
||||
|
||||
3
backend/src/core/__init__.py
Normal file
3
backend/src/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.core:Package]
|
||||
# @PURPOSE: Backend core services and infrastructure package root.
|
||||
# [/DEF:src.core:Package]
|
||||
99
backend/src/core/__tests__/test_throttled_scheduler.py
Normal file
99
backend/src/core/__tests__/test_throttled_scheduler.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import pytest
|
||||
from datetime import time, date, datetime, timedelta
|
||||
from src.core.scheduler import ThrottledSchedulerConfigurator
|
||||
|
||||
# [DEF:test_throttled_scheduler:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for ThrottledSchedulerConfigurator distribution logic.
|
||||
|
||||
def test_calculate_schedule_even_distribution():
|
||||
"""
|
||||
@TEST_SCENARIO: 3 tasks in a 2-hour window should be spaced 1 hour apart.
|
||||
"""
|
||||
start = time(1, 0)
|
||||
end = time(3, 0)
|
||||
dashboards = ["d1", "d2", "d3"]
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert len(schedule) == 3
|
||||
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
|
||||
assert schedule[1] == datetime(2024, 1, 1, 2, 0)
|
||||
assert schedule[2] == datetime(2024, 1, 1, 3, 0)
|
||||
|
||||
def test_calculate_schedule_midnight_crossing():
|
||||
"""
|
||||
@TEST_SCENARIO: Window from 23:00 to 01:00 (next day).
|
||||
"""
|
||||
start = time(23, 0)
|
||||
end = time(1, 0)
|
||||
dashboards = ["d1", "d2", "d3"]
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert len(schedule) == 3
|
||||
assert schedule[0] == datetime(2024, 1, 1, 23, 0)
|
||||
assert schedule[1] == datetime(2024, 1, 2, 0, 0)
|
||||
assert schedule[2] == datetime(2024, 1, 2, 1, 0)
|
||||
|
||||
def test_calculate_schedule_single_task():
|
||||
"""
|
||||
@TEST_SCENARIO: Single task should be scheduled at start time.
|
||||
"""
|
||||
start = time(1, 0)
|
||||
end = time(2, 0)
|
||||
dashboards = ["d1"]
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert len(schedule) == 1
|
||||
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
|
||||
|
||||
def test_calculate_schedule_empty_list():
|
||||
"""
|
||||
@TEST_SCENARIO: Empty dashboard list returns empty schedule.
|
||||
"""
|
||||
start = time(1, 0)
|
||||
end = time(2, 0)
|
||||
dashboards = []
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert schedule == []
|
||||
|
||||
def test_calculate_schedule_zero_window():
|
||||
"""
|
||||
@TEST_SCENARIO: Window start == end. All tasks at start time.
|
||||
"""
|
||||
start = time(1, 0)
|
||||
end = time(1, 0)
|
||||
dashboards = ["d1", "d2"]
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert len(schedule) == 2
|
||||
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
|
||||
assert schedule[1] == datetime(2024, 1, 1, 1, 0)
|
||||
|
||||
def test_calculate_schedule_very_small_window():
|
||||
"""
|
||||
@TEST_SCENARIO: Window smaller than number of tasks (in seconds).
|
||||
"""
|
||||
start = time(1, 0, 0)
|
||||
end = time(1, 0, 1) # 1 second window
|
||||
dashboards = ["d1", "d2", "d3"]
|
||||
today = date(2024, 1, 1)
|
||||
|
||||
schedule = ThrottledSchedulerConfigurator.calculate_schedule(start, end, dashboards, today)
|
||||
|
||||
assert len(schedule) == 3
|
||||
assert schedule[0] == datetime(2024, 1, 1, 1, 0, 0)
|
||||
assert schedule[1] == datetime(2024, 1, 1, 1, 0, 0, 500000) # 0.5s
|
||||
assert schedule[2] == datetime(2024, 1, 1, 1, 0, 1)
|
||||
|
||||
# [/DEF:test_throttled_scheduler:Module]
|
||||
298
backend/src/core/async_superset_client.py
Normal file
298
backend/src/core/async_superset_client.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# [DEF:backend.src.core.async_superset_client:Module]
|
||||
#
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: superset, async, client, httpx, dashboards, datasets
|
||||
# @PURPOSE: Async Superset client for dashboard hot-path requests without blocking FastAPI event loop.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.utils.async_network.AsyncAPIClient
|
||||
# @INVARIANT: Async dashboard operations reuse shared auth cache and avoid sync requests in async routes.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
from .config_models import Environment
|
||||
from .logger import logger as app_logger, belief_scope
|
||||
from .superset_client import SupersetClient
|
||||
from .utils.async_network import AsyncAPIClient
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:AsyncSupersetClient:Class]
|
||||
# @PURPOSE: Async sibling of SupersetClient for dashboard read paths.
|
||||
class AsyncSupersetClient(SupersetClient):
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize async Superset client with AsyncAPIClient transport.
|
||||
# @PRE: env is valid.
|
||||
# @POST: Client uses async network transport and inherited projection helpers.
|
||||
def __init__(self, env: Environment):
|
||||
self.env = env
|
||||
auth_payload = {
|
||||
"username": env.username,
|
||||
"password": env.password,
|
||||
"provider": "db",
|
||||
"refresh": "true",
|
||||
}
|
||||
self.network = AsyncAPIClient(
|
||||
config={"base_url": env.url, "auth": auth_payload},
|
||||
verify_ssl=env.verify_ssl,
|
||||
timeout=env.timeout,
|
||||
)
|
||||
self.delete_before_reimport = False
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:aclose:Function]
|
||||
# @PURPOSE: Close async transport resources.
|
||||
# @POST: Underlying AsyncAPIClient is closed.
|
||||
async def aclose(self) -> None:
|
||||
await self.network.aclose()
|
||||
# [/DEF:aclose:Function]
|
||||
|
||||
# [DEF:get_dashboards_page_async:Function]
|
||||
# @PURPOSE: Fetch one dashboards page asynchronously.
|
||||
# @POST: Returns total count and page result list.
|
||||
async def get_dashboards_page_async(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||
with belief_scope("AsyncSupersetClient.get_dashboards_page_async"):
|
||||
validated_query = self._validate_query_params(query or {})
|
||||
if "columns" not in validated_query:
|
||||
validated_query["columns"] = [
|
||||
"slug",
|
||||
"id",
|
||||
"url",
|
||||
"changed_on_utc",
|
||||
"dashboard_title",
|
||||
"published",
|
||||
"created_by",
|
||||
"changed_by",
|
||||
"changed_by_name",
|
||||
"owners",
|
||||
]
|
||||
|
||||
response_json = cast(
|
||||
Dict[str, Any],
|
||||
await self.network.request(
|
||||
method="GET",
|
||||
endpoint="/dashboard/",
|
||||
params={"q": json.dumps(validated_query)},
|
||||
),
|
||||
)
|
||||
result = response_json.get("result", [])
|
||||
total_count = response_json.get("count", len(result))
|
||||
return total_count, result
|
||||
# [/DEF:get_dashboards_page_async:Function]
|
||||
|
||||
# [DEF:get_dashboard_async:Function]
|
||||
# @PURPOSE: Fetch one dashboard payload asynchronously.
|
||||
# @POST: Returns raw dashboard payload from Superset API.
|
||||
async def get_dashboard_async(self, dashboard_id: int) -> Dict:
|
||||
with belief_scope("AsyncSupersetClient.get_dashboard_async", f"id={dashboard_id}"):
|
||||
response = await self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
|
||||
return cast(Dict, response)
|
||||
# [/DEF:get_dashboard_async:Function]
|
||||
|
||||
# [DEF:get_chart_async:Function]
|
||||
# @PURPOSE: Fetch one chart payload asynchronously.
|
||||
# @POST: Returns raw chart payload from Superset API.
|
||||
async def get_chart_async(self, chart_id: int) -> Dict:
|
||||
with belief_scope("AsyncSupersetClient.get_chart_async", f"id={chart_id}"):
|
||||
response = await self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
|
||||
return cast(Dict, response)
|
||||
# [/DEF:get_chart_async:Function]
|
||||
|
||||
# [DEF:get_dashboard_detail_async:Function]
|
||||
# @PURPOSE: Fetch dashboard detail asynchronously with concurrent charts/datasets requests.
|
||||
# @POST: Returns dashboard detail payload for overview page.
|
||||
async def get_dashboard_detail_async(self, dashboard_id: int) -> Dict:
|
||||
with belief_scope("AsyncSupersetClient.get_dashboard_detail_async", f"id={dashboard_id}"):
|
||||
dashboard_response = await self.get_dashboard_async(dashboard_id)
|
||||
dashboard_data = dashboard_response.get("result", dashboard_response)
|
||||
|
||||
charts: List[Dict] = []
|
||||
datasets: List[Dict] = []
|
||||
|
||||
def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
|
||||
if not isinstance(form_data, dict):
|
||||
return None
|
||||
datasource = form_data.get("datasource")
|
||||
if isinstance(datasource, str):
|
||||
matched = re.match(r"^(\d+)__", datasource)
|
||||
if matched:
|
||||
try:
|
||||
return int(matched.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(datasource, dict):
|
||||
ds_id = datasource.get("id")
|
||||
try:
|
||||
return int(ds_id) if ds_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ds_id = form_data.get("datasource_id")
|
||||
try:
|
||||
return int(ds_id) if ds_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
chart_task = self.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}/charts",
|
||||
)
|
||||
dataset_task = self.network.request(
|
||||
method="GET",
|
||||
endpoint=f"/dashboard/{dashboard_id}/datasets",
|
||||
)
|
||||
charts_response, datasets_response = await asyncio.gather(
|
||||
chart_task,
|
||||
dataset_task,
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
if not isinstance(charts_response, Exception):
|
||||
charts_payload = charts_response.get("result", []) if isinstance(charts_response, dict) else []
|
||||
for chart_obj in charts_payload:
|
||||
if not isinstance(chart_obj, dict):
|
||||
continue
|
||||
chart_id = chart_obj.get("id")
|
||||
if chart_id is None:
|
||||
continue
|
||||
form_data = chart_obj.get("form_data")
|
||||
if isinstance(form_data, str):
|
||||
try:
|
||||
form_data = json.loads(form_data)
|
||||
except Exception:
|
||||
form_data = {}
|
||||
dataset_id = extract_dataset_id_from_form_data(form_data) or chart_obj.get("datasource_id")
|
||||
charts.append({
|
||||
"id": int(chart_id),
|
||||
"title": chart_obj.get("slice_name") or chart_obj.get("name") or f"Chart {chart_id}",
|
||||
"viz_type": (form_data.get("viz_type") if isinstance(form_data, dict) else None),
|
||||
"dataset_id": int(dataset_id) if dataset_id is not None else None,
|
||||
"last_modified": chart_obj.get("changed_on"),
|
||||
"overview": chart_obj.get("description") or (form_data.get("viz_type") if isinstance(form_data, dict) else None) or "Chart",
|
||||
})
|
||||
else:
|
||||
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to fetch dashboard charts: %s", charts_response)
|
||||
|
||||
if not isinstance(datasets_response, Exception):
|
||||
datasets_payload = datasets_response.get("result", []) if isinstance(datasets_response, dict) else []
|
||||
for dataset_obj in datasets_payload:
|
||||
if not isinstance(dataset_obj, dict):
|
||||
continue
|
||||
dataset_id = dataset_obj.get("id")
|
||||
if dataset_id is None:
|
||||
continue
|
||||
db_payload = dataset_obj.get("database")
|
||||
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
|
||||
table_name = dataset_obj.get("table_name") or dataset_obj.get("datasource_name") or dataset_obj.get("name") or f"Dataset {dataset_id}"
|
||||
schema = dataset_obj.get("schema")
|
||||
fq_name = f"{schema}.{table_name}" if schema else table_name
|
||||
datasets.append({
|
||||
"id": int(dataset_id),
|
||||
"table_name": table_name,
|
||||
"schema": schema,
|
||||
"database": db_name or dataset_obj.get("database_name") or "Unknown",
|
||||
"last_modified": dataset_obj.get("changed_on"),
|
||||
"overview": fq_name,
|
||||
})
|
||||
else:
|
||||
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to fetch dashboard datasets: %s", datasets_response)
|
||||
|
||||
if not charts:
|
||||
raw_position_json = dashboard_data.get("position_json")
|
||||
chart_ids_from_position = set()
|
||||
if isinstance(raw_position_json, str) and raw_position_json:
|
||||
try:
|
||||
parsed_position = json.loads(raw_position_json)
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_position))
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(raw_position_json, dict):
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_position_json))
|
||||
|
||||
raw_json_metadata = dashboard_data.get("json_metadata")
|
||||
if isinstance(raw_json_metadata, str) and raw_json_metadata:
|
||||
try:
|
||||
parsed_metadata = json.loads(raw_json_metadata)
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_metadata))
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(raw_json_metadata, dict):
|
||||
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_json_metadata))
|
||||
|
||||
fallback_chart_tasks = [
|
||||
self.get_chart_async(int(chart_id))
|
||||
for chart_id in sorted(chart_ids_from_position)
|
||||
]
|
||||
fallback_chart_responses = await asyncio.gather(
|
||||
*fallback_chart_tasks,
|
||||
return_exceptions=True,
|
||||
)
|
||||
for chart_id, chart_response in zip(sorted(chart_ids_from_position), fallback_chart_responses):
|
||||
if isinstance(chart_response, Exception):
|
||||
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to resolve fallback chart %s: %s", chart_id, chart_response)
|
||||
continue
|
||||
chart_data = chart_response.get("result", chart_response)
|
||||
charts.append({
|
||||
"id": int(chart_id),
|
||||
"title": chart_data.get("slice_name") or chart_data.get("name") or f"Chart {chart_id}",
|
||||
"viz_type": chart_data.get("viz_type"),
|
||||
"dataset_id": chart_data.get("datasource_id"),
|
||||
"last_modified": chart_data.get("changed_on"),
|
||||
"overview": chart_data.get("description") or chart_data.get("viz_type") or "Chart",
|
||||
})
|
||||
|
||||
dataset_ids_from_charts = {
|
||||
c.get("dataset_id")
|
||||
for c in charts
|
||||
if c.get("dataset_id") is not None
|
||||
}
|
||||
known_dataset_ids = {d.get("id") for d in datasets if d.get("id") is not None}
|
||||
missing_dataset_ids = sorted(int(item) for item in dataset_ids_from_charts if item not in known_dataset_ids)
|
||||
if missing_dataset_ids:
|
||||
dataset_fetch_tasks = [
|
||||
self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
|
||||
for dataset_id in missing_dataset_ids
|
||||
]
|
||||
dataset_fetch_responses = await asyncio.gather(
|
||||
*dataset_fetch_tasks,
|
||||
return_exceptions=True,
|
||||
)
|
||||
for dataset_id, dataset_response in zip(missing_dataset_ids, dataset_fetch_responses):
|
||||
if isinstance(dataset_response, Exception):
|
||||
app_logger.warning("[get_dashboard_detail_async][Warning] Failed to backfill dataset %s: %s", dataset_id, dataset_response)
|
||||
continue
|
||||
dataset_data = dataset_response.get("result", dataset_response) if isinstance(dataset_response, dict) else {}
|
||||
db_payload = dataset_data.get("database")
|
||||
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
|
||||
table_name = dataset_data.get("table_name") or dataset_data.get("datasource_name") or dataset_data.get("name") or f"Dataset {dataset_id}"
|
||||
schema = dataset_data.get("schema")
|
||||
fq_name = f"{schema}.{table_name}" if schema else table_name
|
||||
datasets.append({
|
||||
"id": int(dataset_id),
|
||||
"table_name": table_name,
|
||||
"schema": schema,
|
||||
"database": db_name or dataset_data.get("database_name") or "Unknown",
|
||||
"last_modified": dataset_data.get("changed_on"),
|
||||
"overview": fq_name,
|
||||
})
|
||||
|
||||
return {
|
||||
"id": int(dashboard_data.get("id") or dashboard_id),
|
||||
"title": dashboard_data.get("dashboard_title") or dashboard_data.get("title") or f"Dashboard {dashboard_id}",
|
||||
"slug": dashboard_data.get("slug"),
|
||||
"url": dashboard_data.get("url"),
|
||||
"description": dashboard_data.get("description"),
|
||||
"last_modified": dashboard_data.get("changed_on_utc") or dashboard_data.get("changed_on"),
|
||||
"published": dashboard_data.get("published"),
|
||||
"charts": charts,
|
||||
"datasets": datasets,
|
||||
"chart_count": len(charts),
|
||||
"dataset_count": len(datasets),
|
||||
}
|
||||
# [/DEF:get_dashboard_detail_async:Function]
|
||||
# [/DEF:AsyncSupersetClient:Class]
|
||||
|
||||
# [/DEF:backend.src.core.async_superset_client:Module]
|
||||
3
backend/src/core/auth/__init__.py
Normal file
3
backend/src/core/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.core.auth:Package]
|
||||
# @PURPOSE: Authentication and authorization package root.
|
||||
# [/DEF:src.core.auth:Package]
|
||||
@@ -1,107 +1,146 @@
|
||||
# [DEF:backend.src.core.auth.repository:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, repository, database, user, role
|
||||
# @PURPOSE: Data access layer for authentication-related entities.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @RELATION: USES -> backend.src.models.auth
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: auth, repository, database, user, role, permission
|
||||
# @PURPOSE: Data access layer for authentication and user preference entities.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.profile]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.logger.belief_scope]
|
||||
# @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary.
|
||||
#
|
||||
# @INVARIANT: All database operations must be performed within a session.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, List
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.auth import User, Role, Permission
|
||||
|
||||
from ...models.auth import Permission, Role, User
|
||||
from ...models.profile import UserDashboardPreference
|
||||
from ..logger import belief_scope
|
||||
from ..logger import belief_scope, logger
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthRepository:Class]
|
||||
# @PURPOSE: Encapsulates database operations for authentication.
|
||||
# @PURPOSE: Encapsulates database operations for authentication-related entities.
|
||||
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session]
|
||||
class AuthRepository:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the repository with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
# @PURPOSE: Bind repository instance to an existing SQLAlchemy session.
|
||||
# @PRE: db is an initialized sqlalchemy.orm.Session instance.
|
||||
# @POST: self.db points to the provided session and is used by all repository methods.
|
||||
# @SIDE_EFFECT: Stores session reference on repository instance state.
|
||||
# @DATA_CONTRACT: Input[Session] -> Output[None]
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
with belief_scope("AuthRepository.__init__"):
|
||||
if not isinstance(db, Session):
|
||||
logger.explore("Invalid session provided to AuthRepository", extra={"type": type(db)})
|
||||
raise TypeError("db must be an instance of sqlalchemy.orm.Session")
|
||||
|
||||
logger.reason("Binding AuthRepository to database session")
|
||||
self.db = db
|
||||
logger.reflect("AuthRepository initialized")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_user_by_username:Function]
|
||||
# @PURPOSE: Retrieves a user by their username.
|
||||
# @PRE: username is a string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: username (str) - The username to search for.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
# @PURPOSE: Retrieve a user entity by unique username.
|
||||
# @PRE: username is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching User entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[User]]
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_username"):
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
if not username or not isinstance(username, str):
|
||||
raise ValueError("username must be a non-empty string")
|
||||
|
||||
logger.reason(f"Querying user by username: {username}")
|
||||
user = self.db.query(User).filter(User.username == username).first()
|
||||
|
||||
if user:
|
||||
logger.reflect(f"User found: {username}")
|
||||
else:
|
||||
logger.explore(f"User not found: {username}")
|
||||
return user
|
||||
# [/DEF:get_user_by_username:Function]
|
||||
|
||||
# [DEF:get_user_by_id:Function]
|
||||
# @PURPOSE: Retrieves a user by their unique ID.
|
||||
# @PRE: user_id is a valid UUID string.
|
||||
# @POST: Returns User object if found, else None.
|
||||
# @PARAM: user_id (str) - The user's unique identifier.
|
||||
# @RETURN: Optional[User] - The found user or None.
|
||||
# @PURPOSE: Retrieve a user entity by identifier.
|
||||
# @PRE: user_id is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching User entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[User]]
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
with belief_scope("AuthRepository.get_user_by_id"):
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
raise ValueError("user_id must be a non-empty string")
|
||||
|
||||
logger.reason(f"Querying user by ID: {user_id}")
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if user:
|
||||
logger.reflect(f"User found by ID: {user_id}")
|
||||
else:
|
||||
logger.explore(f"User not found by ID: {user_id}")
|
||||
return user
|
||||
# [/DEF:get_user_by_id:Function]
|
||||
|
||||
# [DEF:get_role_by_name:Function]
|
||||
# @PURPOSE: Retrieves a role by its name.
|
||||
# @PRE: name is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: name (str) - The role name to search for.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
# @PURPOSE: Retrieve a role entity by role name.
|
||||
# @PRE: name is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching Role entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[Role]]
|
||||
def get_role_by_name(self, name: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_name"):
|
||||
return self.db.query(Role).filter(Role.name == name).first()
|
||||
# [/DEF:get_role_by_name:Function]
|
||||
|
||||
# [DEF:update_last_login:Function]
|
||||
# @PURPOSE: Updates the last_login timestamp for a user.
|
||||
# @PRE: user object is a valid User instance.
|
||||
# @POST: User's last_login is updated in the database.
|
||||
# @SIDE_EFFECT: Commits the transaction.
|
||||
# @PARAM: user (User) - The user to update.
|
||||
# @PURPOSE: Update last_login timestamp for the provided user entity.
|
||||
# @PRE: user is a managed User instance and self.db is a valid open Session.
|
||||
# @POST: user.last_login is set to current UTC timestamp and transaction is committed.
|
||||
# @SIDE_EFFECT: Mutates user entity state and commits database transaction.
|
||||
# @DATA_CONTRACT: Input[User] -> Output[None]
|
||||
def update_last_login(self, user: User):
|
||||
with belief_scope("AuthRepository.update_last_login"):
|
||||
if not isinstance(user, User):
|
||||
raise TypeError("user must be an instance of User")
|
||||
|
||||
from datetime import datetime
|
||||
logger.reason(f"Updating last login for user: {user.username}")
|
||||
user.last_login = datetime.utcnow()
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
logger.reflect(f"Last login updated and committed for user: {user.username}")
|
||||
# [/DEF:update_last_login:Function]
|
||||
|
||||
# [DEF:get_role_by_id:Function]
|
||||
# @PURPOSE: Retrieves a role by its unique ID.
|
||||
# @PRE: role_id is a string.
|
||||
# @POST: Returns Role object if found, else None.
|
||||
# @PARAM: role_id (str) - The role's unique identifier.
|
||||
# @RETURN: Optional[Role] - The found role or None.
|
||||
# @PURPOSE: Retrieve a role entity by identifier.
|
||||
# @PRE: role_id is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching Role entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[Role]]
|
||||
def get_role_by_id(self, role_id: str) -> Optional[Role]:
|
||||
with belief_scope("AuthRepository.get_role_by_id"):
|
||||
return self.db.query(Role).filter(Role.id == role_id).first()
|
||||
# [/DEF:get_role_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_id:Function]
|
||||
# @PURPOSE: Retrieves a permission by its unique ID.
|
||||
# @PRE: perm_id is a string.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: perm_id (str) - The permission's unique identifier.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
# @PURPOSE: Retrieve a permission entity by identifier.
|
||||
# @PRE: perm_id is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching Permission entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[Permission]]
|
||||
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_id"):
|
||||
return self.db.query(Permission).filter(Permission.id == perm_id).first()
|
||||
# [/DEF:get_permission_by_id:Function]
|
||||
|
||||
# [DEF:get_permission_by_resource_action:Function]
|
||||
# @PURPOSE: Retrieves a permission by resource and action.
|
||||
# @PRE: resource and action are strings.
|
||||
# @POST: Returns Permission object if found, else None.
|
||||
# @PARAM: resource (str) - The resource name.
|
||||
# @PARAM: action (str) - The action name.
|
||||
# @RETURN: Optional[Permission] - The found permission or None.
|
||||
# @PURPOSE: Retrieve a permission entity by resource and action pair.
|
||||
# @PRE: resource and action are non-empty str values; self.db is a valid open Session.
|
||||
# @POST: Returns matching Permission entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str, str] -> Output[Optional[Permission]]
|
||||
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
|
||||
with belief_scope("AuthRepository.get_permission_by_resource_action"):
|
||||
return self.db.query(Permission).filter(
|
||||
@@ -111,11 +150,11 @@ class AuthRepository:
|
||||
# [/DEF:get_permission_by_resource_action:Function]
|
||||
|
||||
# [DEF:get_user_dashboard_preference:Function]
|
||||
# @PURPOSE: Retrieves dashboard preference by owner user ID.
|
||||
# @PRE: user_id is a string.
|
||||
# @POST: Returns UserDashboardPreference if found, else None.
|
||||
# @PARAM: user_id (str) - Preference owner identifier.
|
||||
# @RETURN: Optional[UserDashboardPreference] - Found preference or None.
|
||||
# @PURPOSE: Retrieve dashboard preference entity owned by specified user.
|
||||
# @PRE: user_id is a non-empty str and self.db is a valid open Session.
|
||||
# @POST: Returns matching UserDashboardPreference entity when present, otherwise None.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[str] -> Output[Optional[UserDashboardPreference]]
|
||||
def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]:
|
||||
with belief_scope("AuthRepository.get_user_dashboard_preference"):
|
||||
return (
|
||||
@@ -126,31 +165,38 @@ class AuthRepository:
|
||||
# [/DEF:get_user_dashboard_preference:Function]
|
||||
|
||||
# [DEF:save_user_dashboard_preference:Function]
|
||||
# @PURPOSE: Persists dashboard preference entity and returns refreshed row.
|
||||
# @PRE: preference is a valid UserDashboardPreference entity.
|
||||
# @POST: Preference is committed and refreshed in database.
|
||||
# @PARAM: preference (UserDashboardPreference) - Preference entity to persist.
|
||||
# @RETURN: UserDashboardPreference - Persisted preference row.
|
||||
# @PURPOSE: Persist dashboard preference entity and return refreshed persistent row.
|
||||
# @PRE: preference is a valid UserDashboardPreference entity and self.db is a valid open Session.
|
||||
# @POST: preference is committed to DB, refreshed from DB state, and returned.
|
||||
# @SIDE_EFFECT: Performs INSERT/UPDATE commit and refresh via active DB session.
|
||||
# @DATA_CONTRACT: Input[UserDashboardPreference] -> Output[UserDashboardPreference]
|
||||
def save_user_dashboard_preference(
|
||||
self,
|
||||
preference: UserDashboardPreference,
|
||||
) -> UserDashboardPreference:
|
||||
with belief_scope("AuthRepository.save_user_dashboard_preference"):
|
||||
if not isinstance(preference, UserDashboardPreference):
|
||||
raise TypeError("preference must be an instance of UserDashboardPreference")
|
||||
|
||||
logger.reason(f"Saving dashboard preference for user: {preference.user_id}")
|
||||
self.db.add(preference)
|
||||
self.db.commit()
|
||||
self.db.refresh(preference)
|
||||
logger.reflect(f"Dashboard preference saved and refreshed for user: {preference.user_id}")
|
||||
return preference
|
||||
# [/DEF:save_user_dashboard_preference:Function]
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @PURPOSE: Lists all available permissions.
|
||||
# @POST: Returns a list of all Permission objects.
|
||||
# @RETURN: List[Permission] - List of permissions.
|
||||
# @PURPOSE: List all permission entities available in storage.
|
||||
# @PRE: self.db is a valid open Session.
|
||||
# @POST: Returns list containing all Permission entities visible to the session.
|
||||
# @SIDE_EFFECT: Executes read-only SELECT query through active DB session.
|
||||
# @DATA_CONTRACT: Input[None] -> Output[List[Permission]]
|
||||
def list_permissions(self) -> List[Permission]:
|
||||
with belief_scope("AuthRepository.list_permissions"):
|
||||
return self.db.query(Permission).all()
|
||||
# [/DEF:list_permissions:Function]
|
||||
|
||||
# [/DEF:AuthRepository:Class]
|
||||
|
||||
# [/DEF:AuthRepository:Class]
|
||||
# [/DEF:backend.src.core.auth.repository:Module]
|
||||
@@ -1,17 +1,17 @@
|
||||
# [DEF:ConfigManagerModule:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: config, manager, persistence, postgresql
|
||||
# @PURPOSE: Manages application configuration persisted in database with one-time migration from JSON.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> ConfigModels
|
||||
# @RELATION: DEPENDS_ON -> AppConfigRecord
|
||||
# @RELATION: CALLS -> logger
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: config, manager, persistence, migration, postgresql
|
||||
# @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[ConfigModels]
|
||||
# @RELATION: [DEPENDS_ON] ->[SessionLocal]
|
||||
# @RELATION: [DEPENDS_ON] ->[AppConfigRecord]
|
||||
# @RELATION: [CALLS] ->[logger]
|
||||
# @RELATION: [CALLS] ->[configure_logger]
|
||||
# @RELATION: [BINDS_TO] ->[ConfigManager]
|
||||
# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id.
|
||||
#
|
||||
# @INVARIANT: Configuration must always be valid according to AppConfig model.
|
||||
# @PUBLIC_API: ConfigManager
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -23,47 +23,58 @@ from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
|
||||
from .database import SessionLocal
|
||||
from ..models.config import AppConfigRecord
|
||||
from .logger import logger, configure_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:ConfigManager:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: A class to handle application configuration persistence and management.
|
||||
# @TIER: CRITICAL
|
||||
# @PURPOSE: Handles application configuration load, validation, mutation, and persistence lifecycle.
|
||||
class ConfigManager:
|
||||
# [DEF:__init__:Function]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Initializes the ConfigManager.
|
||||
# @PRE: isinstance(config_path, str) and len(config_path) > 0
|
||||
# @POST: self.config is an instance of AppConfig
|
||||
# @PARAM: config_path (str) - Path to legacy JSON config (used only for initial migration fallback).
|
||||
# @PURPOSE: Initialize manager state from persisted or migrated configuration.
|
||||
# @PRE: config_path is a non-empty string path.
|
||||
# @POST: self.config is initialized as AppConfig and logger is configured.
|
||||
# @SIDE_EFFECT: Reads config sources and updates logging configuration.
|
||||
# @DATA_CONTRACT: Input(str config_path) -> Output(None; self.config: AppConfig)
|
||||
def __init__(self, config_path: str = "config.json"):
|
||||
with belief_scope("__init__"):
|
||||
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
|
||||
with belief_scope("ConfigManager.__init__"):
|
||||
if not isinstance(config_path, str) or not config_path:
|
||||
logger.explore("Invalid config_path provided", extra={"path": config_path})
|
||||
raise ValueError("config_path must be a non-empty string")
|
||||
|
||||
logger.info(f"[ConfigManager][Entry] Initializing with legacy path {config_path}")
|
||||
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self.config: AppConfig = self._load_config()
|
||||
|
||||
configure_logger(self.config.settings.logging)
|
||||
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
||||
|
||||
if not isinstance(self.config, AppConfig):
|
||||
logger.explore("Config loading resulted in invalid type", extra={"type": type(self.config)})
|
||||
raise TypeError("self.config must be an instance of AppConfig")
|
||||
|
||||
logger.info("[ConfigManager][Exit] Initialized")
|
||||
logger.reflect("ConfigManager initialization complete")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_default_config:Function]
|
||||
# @PURPOSE: Returns default application configuration.
|
||||
# @RETURN: AppConfig - Default configuration.
|
||||
# @PURPOSE: Build default application configuration fallback.
|
||||
# @PRE: None.
|
||||
# @POST: Returns valid AppConfig with empty environments and default storage settings.
|
||||
# @SIDE_EFFECT: None.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
|
||||
def _default_config(self) -> AppConfig:
|
||||
return AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings(storage=StorageConfig()),
|
||||
)
|
||||
with belief_scope("_default_config"):
|
||||
return AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings(storage=StorageConfig()),
|
||||
)
|
||||
# [/DEF:_default_config:Function]
|
||||
|
||||
# [DEF:_load_from_legacy_file:Function]
|
||||
# @PURPOSE: Loads legacy configuration from config.json for migration fallback.
|
||||
# @RETURN: AppConfig - Loaded or default configuration.
|
||||
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
|
||||
# @PRE: self.config_path is initialized.
|
||||
# @POST: Returns AppConfig from file payload or safe default.
|
||||
# @SIDE_EFFECT: Filesystem read and error logging.
|
||||
# @DATA_CONTRACT: Input(Path self.config_path) -> Output(AppConfig)
|
||||
def _load_from_legacy_file(self) -> AppConfig:
|
||||
with belief_scope("_load_from_legacy_file"):
|
||||
if not self.config_path.exists():
|
||||
@@ -81,47 +92,55 @@ class ConfigManager:
|
||||
# [/DEF:_load_from_legacy_file:Function]
|
||||
|
||||
# [DEF:_get_record:Function]
|
||||
# @PURPOSE: Loads config record from DB.
|
||||
# @PARAM: session (Session) - DB session.
|
||||
# @RETURN: Optional[AppConfigRecord] - Existing record or None.
|
||||
# @PURPOSE: Resolve global configuration record from DB.
|
||||
# @PRE: session is an active SQLAlchemy Session.
|
||||
# @POST: Returns record when present, otherwise None.
|
||||
# @SIDE_EFFECT: Database read query.
|
||||
# @DATA_CONTRACT: Input(Session) -> Output(Optional[AppConfigRecord])
|
||||
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
|
||||
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
||||
with belief_scope("_get_record"):
|
||||
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
||||
# [/DEF:_get_record:Function]
|
||||
|
||||
# [DEF:_load_config:Function]
|
||||
# @PURPOSE: Loads the configuration from DB or performs one-time migration from JSON file.
|
||||
# @PRE: DB session factory is available.
|
||||
# @POST: isinstance(return, AppConfig)
|
||||
# @RETURN: AppConfig - Loaded configuration.
|
||||
# @PURPOSE: Load configuration from DB or perform one-time migration from legacy JSON.
|
||||
# @PRE: SessionLocal factory is available and AppConfigRecord schema is accessible.
|
||||
# @POST: Returns valid AppConfig and closes opened DB session.
|
||||
# @SIDE_EFFECT: Database read/write, possible migration write, logging.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
|
||||
def _load_config(self) -> AppConfig:
|
||||
with belief_scope("_load_config"):
|
||||
with belief_scope("ConfigManager._load_config"):
|
||||
session: Session = SessionLocal()
|
||||
try:
|
||||
record = self._get_record(session)
|
||||
if record and record.payload:
|
||||
logger.info("[_load_config][Coherence:OK] Configuration loaded from database")
|
||||
return AppConfig(**record.payload)
|
||||
logger.reason("Configuration found in database")
|
||||
config = AppConfig(**record.payload)
|
||||
logger.reflect("Database configuration validated")
|
||||
return config
|
||||
|
||||
logger.info("[_load_config][Action] No database config found, migrating legacy config")
|
||||
logger.reason("No database config found, initiating legacy migration")
|
||||
config = self._load_from_legacy_file()
|
||||
self._save_config_to_db(config, session=session)
|
||||
logger.reflect("Legacy configuration migrated to database")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_config][Coherence:Failed] Error loading config from DB: {e}")
|
||||
logger.explore(f"Error loading config from DB: {e}")
|
||||
return self._default_config()
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:_load_config:Function]
|
||||
|
||||
# [DEF:_save_config_to_db:Function]
|
||||
# @PURPOSE: Saves the provided configuration object to DB.
|
||||
# @PRE: isinstance(config, AppConfig)
|
||||
# @POST: Configuration saved to database.
|
||||
# @PARAM: config (AppConfig) - The configuration to save.
|
||||
# @PARAM: session (Optional[Session]) - Existing DB session for transactional reuse.
|
||||
# @PURPOSE: Persist provided AppConfig into the global DB configuration record.
|
||||
# @PRE: config is AppConfig; session is either None or an active Session.
|
||||
# @POST: Global DB record payload equals config.model_dump() when commit succeeds.
|
||||
# @SIDE_EFFECT: Database insert/update, commit/rollback, logging.
|
||||
# @DATA_CONTRACT: Input(AppConfig, Optional[Session]) -> Output(None)
|
||||
def _save_config_to_db(self, config: AppConfig, session: Optional[Session] = None):
|
||||
with belief_scope("_save_config_to_db"):
|
||||
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
|
||||
with belief_scope("ConfigManager._save_config_to_db"):
|
||||
if not isinstance(config, AppConfig):
|
||||
raise TypeError("config must be an instance of AppConfig")
|
||||
|
||||
owns_session = session is None
|
||||
db = session or SessionLocal()
|
||||
@@ -129,15 +148,17 @@ class ConfigManager:
|
||||
record = self._get_record(db)
|
||||
payload = config.model_dump()
|
||||
if record is None:
|
||||
logger.reason("Creating new global configuration record")
|
||||
record = AppConfigRecord(id="global", payload=payload)
|
||||
db.add(record)
|
||||
else:
|
||||
logger.reason("Updating existing global configuration record")
|
||||
record.payload = payload
|
||||
db.commit()
|
||||
logger.info("[_save_config_to_db][Action] Configuration saved to database")
|
||||
logger.reflect("Configuration successfully committed to database")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"[_save_config_to_db][Coherence:Failed] Failed to save: {e}")
|
||||
logger.explore(f"Failed to save configuration: {e}")
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
@@ -145,42 +166,51 @@ class ConfigManager:
|
||||
# [/DEF:_save_config_to_db:Function]
|
||||
|
||||
# [DEF:save:Function]
|
||||
# @PURPOSE: Saves the current configuration state to DB.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: self._save_config_to_db called.
|
||||
# @PURPOSE: Persist current in-memory configuration state.
|
||||
# @PRE: self.config is initialized.
|
||||
# @POST: Current self.config is written to DB global record.
|
||||
# @SIDE_EFFECT: Database write and logging via delegated persistence call.
|
||||
# @DATA_CONTRACT: Input(None; self.config: AppConfig) -> Output(None)
|
||||
def save(self):
|
||||
with belief_scope("save"):
|
||||
self._save_config_to_db(self.config)
|
||||
# [/DEF:save:Function]
|
||||
|
||||
# [DEF:get_config:Function]
|
||||
# @PURPOSE: Returns the current configuration.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
# @PURPOSE: Return current in-memory configuration snapshot.
|
||||
# @PRE: self.config is initialized.
|
||||
# @POST: Returns AppConfig reference stored in manager.
|
||||
# @SIDE_EFFECT: None.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(AppConfig)
|
||||
def get_config(self) -> AppConfig:
|
||||
with belief_scope("get_config"):
|
||||
return self.config
|
||||
# [/DEF:get_config:Function]
|
||||
|
||||
# [DEF:update_global_settings:Function]
|
||||
# @PURPOSE: Updates the global settings and persists the change.
|
||||
# @PRE: isinstance(settings, GlobalSettings)
|
||||
# @POST: self.config.settings updated and saved.
|
||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||
# @PURPOSE: Replace global settings and persist the resulting configuration.
|
||||
# @PRE: settings is GlobalSettings.
|
||||
# @POST: self.config.settings equals provided settings and DB state is updated.
|
||||
# @SIDE_EFFECT: Mutates self.config, DB write, logger reconfiguration, logging.
|
||||
# @DATA_CONTRACT: Input(GlobalSettings) -> Output(None)
|
||||
def update_global_settings(self, settings: GlobalSettings):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info("[update_global_settings][Entry] Updating settings")
|
||||
|
||||
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
||||
with belief_scope("ConfigManager.update_global_settings"):
|
||||
if not isinstance(settings, GlobalSettings):
|
||||
raise TypeError("settings must be an instance of GlobalSettings")
|
||||
|
||||
logger.reason("Updating global settings and persisting")
|
||||
self.config.settings = settings
|
||||
self.save()
|
||||
configure_logger(settings.logging)
|
||||
logger.info("[update_global_settings][Exit] Settings updated")
|
||||
logger.reflect("Global settings updated and logger reconfigured")
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
# @PURPOSE: Validates if a path exists and is writable.
|
||||
# @PARAM: path (str) - The path to validate.
|
||||
# @RETURN: tuple (bool, str) - (is_valid, message)
|
||||
# @PURPOSE: Validate that path exists and is writable, creating it when absent.
|
||||
# @PRE: path is a string path candidate.
|
||||
# @POST: Returns (True, msg) for writable path, else (False, reason).
|
||||
# @SIDE_EFFECT: Filesystem directory creation attempt and OS permission checks.
|
||||
# @DATA_CONTRACT: Input(str path) -> Output(tuple[bool, str])
|
||||
def validate_path(self, path: str) -> tuple[bool, str]:
|
||||
with belief_scope("validate_path"):
|
||||
p = os.path.abspath(path)
|
||||
@@ -197,25 +227,33 @@ class ConfigManager:
|
||||
# [/DEF:validate_path:Function]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
# @PURPOSE: Returns the list of configured environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
# @PURPOSE: Return all configured environments.
|
||||
# @PRE: self.config is initialized.
|
||||
# @POST: Returns list of Environment models from current configuration.
|
||||
# @SIDE_EFFECT: None.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(List[Environment])
|
||||
def get_environments(self) -> List[Environment]:
|
||||
with belief_scope("get_environments"):
|
||||
return self.config.environments
|
||||
# [/DEF:get_environments:Function]
|
||||
|
||||
# [DEF:has_environments:Function]
|
||||
# @PURPOSE: Checks if at least one environment is configured.
|
||||
# @RETURN: bool - True if at least one environment exists.
|
||||
# @PURPOSE: Check whether at least one environment exists in configuration.
|
||||
# @PRE: self.config is initialized.
|
||||
# @POST: Returns True iff environment list length is greater than zero.
|
||||
# @SIDE_EFFECT: None.
|
||||
# @DATA_CONTRACT: Input(None) -> Output(bool)
|
||||
def has_environments(self) -> bool:
|
||||
with belief_scope("has_environments"):
|
||||
return len(self.config.environments) > 0
|
||||
# [/DEF:has_environments:Function]
|
||||
|
||||
# [DEF:get_environment:Function]
|
||||
# @PURPOSE: Returns a single environment by ID.
|
||||
# @PARAM: env_id (str) - The ID of the environment to retrieve.
|
||||
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
|
||||
# @PURPOSE: Resolve a configured environment by identifier.
|
||||
# @PRE: env_id is string identifier.
|
||||
# @POST: Returns matching Environment when found; otherwise None.
|
||||
# @SIDE_EFFECT: None.
|
||||
# @DATA_CONTRACT: Input(str env_id) -> Output(Optional[Environment])
|
||||
def get_environment(self, env_id: str) -> Optional[Environment]:
|
||||
with belief_scope("get_environment"):
|
||||
for env in self.config.environments:
|
||||
@@ -225,60 +263,72 @@ class ConfigManager:
|
||||
# [/DEF:get_environment:Function]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
# @PURPOSE: Adds a new environment to the configuration.
|
||||
# @PARAM: env (Environment) - The environment to add.
|
||||
# @PURPOSE: Upsert environment by id into configuration and persist.
|
||||
# @PRE: env is Environment.
|
||||
# @POST: Configuration contains provided env id with new payload persisted.
|
||||
# @SIDE_EFFECT: Mutates environment list, DB write, logging.
|
||||
# @DATA_CONTRACT: Input(Environment) -> Output(None)
|
||||
def add_environment(self, env: Environment):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
assert isinstance(env, Environment), "env must be an instance of Environment"
|
||||
with belief_scope("ConfigManager.add_environment"):
|
||||
if not isinstance(env, Environment):
|
||||
raise TypeError("env must be an instance of Environment")
|
||||
|
||||
logger.reason(f"Adding/Updating environment: {env.id}")
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env.id]
|
||||
self.config.environments.append(env)
|
||||
self.save()
|
||||
logger.info("[add_environment][Exit] Environment added")
|
||||
logger.reflect(f"Environment {env.id} persisted")
|
||||
# [/DEF:add_environment:Function]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
# @PURPOSE: Updates an existing environment.
|
||||
# @PARAM: env_id (str) - The ID of the environment to update.
|
||||
# @PARAM: updated_env (Environment) - The updated environment data.
|
||||
# @RETURN: bool - True if updated, False otherwise.
|
||||
# @PURPOSE: Update existing environment by id and preserve masked password placeholder behavior.
|
||||
# @PRE: env_id is non-empty string and updated_env is Environment.
|
||||
# @POST: Returns True and persists update when target exists; else returns False.
|
||||
# @SIDE_EFFECT: May mutate environment list, DB write, logging.
|
||||
# @DATA_CONTRACT: Input(str env_id, Environment updated_env) -> Output(bool)
|
||||
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
|
||||
with belief_scope("update_environment"):
|
||||
logger.info(f"[update_environment][Entry] Updating {env_id}")
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
|
||||
with belief_scope("ConfigManager.update_environment"):
|
||||
if not env_id or not isinstance(env_id, str):
|
||||
raise ValueError("env_id must be a non-empty string")
|
||||
if not isinstance(updated_env, Environment):
|
||||
raise TypeError("updated_env must be an instance of Environment")
|
||||
|
||||
logger.reason(f"Attempting to update environment: {env_id}")
|
||||
for i, env in enumerate(self.config.environments):
|
||||
if env.id == env_id:
|
||||
if updated_env.password == "********":
|
||||
logger.reason("Preserving existing password for masked update")
|
||||
updated_env.password = env.password
|
||||
|
||||
self.config.environments[i] = updated_env
|
||||
self.save()
|
||||
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
|
||||
logger.reflect(f"Environment {env_id} updated and saved")
|
||||
return True
|
||||
|
||||
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
logger.explore(f"Environment {env_id} not found for update")
|
||||
return False
|
||||
# [/DEF:update_environment:Function]
|
||||
|
||||
# [DEF:delete_environment:Function]
|
||||
# @PURPOSE: Deletes an environment by ID.
|
||||
# @PARAM: env_id (str) - The ID of the environment to delete.
|
||||
# @PURPOSE: Delete environment by id and persist when deletion occurs.
|
||||
# @PRE: env_id is non-empty string.
|
||||
# @POST: Environment is removed when present; otherwise configuration is unchanged.
|
||||
# @SIDE_EFFECT: May mutate environment list, conditional DB write, logging.
|
||||
# @DATA_CONTRACT: Input(str env_id) -> Output(None)
|
||||
def delete_environment(self, env_id: str):
|
||||
with belief_scope("delete_environment"):
|
||||
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
|
||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||
with belief_scope("ConfigManager.delete_environment"):
|
||||
if not env_id or not isinstance(env_id, str):
|
||||
raise ValueError("env_id must be a non-empty string")
|
||||
|
||||
logger.reason(f"Attempting to delete environment: {env_id}")
|
||||
original_count = len(self.config.environments)
|
||||
self.config.environments = [e for e in self.config.environments if e.id != env_id]
|
||||
|
||||
if len(self.config.environments) < original_count:
|
||||
self.save()
|
||||
logger.info(f"[delete_environment][Action] Deleted {env_id}")
|
||||
logger.reflect(f"Environment {env_id} deleted and configuration saved")
|
||||
else:
|
||||
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
|
||||
logger.explore(f"Environment {env_id} not found for deletion")
|
||||
# [/DEF:delete_environment:Function]
|
||||
|
||||
|
||||
|
||||
@@ -24,19 +24,19 @@ class Schedule(BaseModel):
|
||||
|
||||
# [DEF:Environment:DataClass]
|
||||
# @PURPOSE: Represents a Superset environment configuration.
|
||||
class Environment(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str # Will be masked in UI
|
||||
stage: str = Field(default="DEV", pattern="^(DEV|PREPROD|PROD)$")
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 30
|
||||
is_default: bool = False
|
||||
is_production: bool = False
|
||||
backup_schedule: Schedule = Field(default_factory=Schedule)
|
||||
# [/DEF:Environment:DataClass]
|
||||
class Environment(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str # Will be masked in UI
|
||||
stage: str = Field(default="DEV", pattern="^(DEV|PREPROD|PROD)$")
|
||||
verify_ssl: bool = True
|
||||
timeout: int = 30
|
||||
is_default: bool = False
|
||||
is_production: bool = False
|
||||
backup_schedule: Schedule = Field(default_factory=Schedule)
|
||||
# [/DEF:Environment:DataClass]
|
||||
|
||||
# [DEF:LoggingConfig:DataClass]
|
||||
# @PURPOSE: Defines the configuration for the application's logging system.
|
||||
@@ -49,10 +49,18 @@ class LoggingConfig(BaseModel):
|
||||
enable_belief_state: bool = True
|
||||
# [/DEF:LoggingConfig:DataClass]
|
||||
|
||||
# [DEF:CleanReleaseConfig:DataClass]
|
||||
# @PURPOSE: Configuration for clean release compliance subsystem.
|
||||
class CleanReleaseConfig(BaseModel):
|
||||
active_policy_id: Optional[str] = None
|
||||
active_registry_id: Optional[str] = None
|
||||
# [/DEF:CleanReleaseConfig:DataClass]
|
||||
|
||||
# [DEF:GlobalSettings:DataClass]
|
||||
# @PURPOSE: Represents global application settings.
|
||||
class GlobalSettings(BaseModel):
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
clean_release: CleanReleaseConfig = Field(default_factory=CleanReleaseConfig)
|
||||
default_environment_id: Optional[str] = None
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
connections: List[dict] = []
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..models import config as _config_models # noqa: F401
|
||||
from ..models import llm as _llm_models # noqa: F401
|
||||
from ..models import assistant as _assistant_models # noqa: F401
|
||||
from ..models import profile as _profile_models # noqa: F401
|
||||
from ..models import clean_release as _clean_release_models # noqa: F401
|
||||
from .logger import belief_scope, logger
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
@@ -140,6 +141,11 @@ def _ensure_user_dashboard_preferences_columns(bind_engine):
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'"
|
||||
)
|
||||
if "show_only_slug_dashboards" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences "
|
||||
"ADD COLUMN show_only_slug_dashboards BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
)
|
||||
|
||||
if not alter_statements:
|
||||
return
|
||||
@@ -156,6 +162,88 @@ def _ensure_user_dashboard_preferences_columns(bind_engine):
|
||||
# [/DEF:_ensure_user_dashboard_preferences_columns:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_user_dashboard_preferences_health_columns:Function]
|
||||
# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table (health fields).
|
||||
def _ensure_user_dashboard_preferences_health_columns(bind_engine):
|
||||
with belief_scope("_ensure_user_dashboard_preferences_health_columns"):
|
||||
table_name = "user_dashboard_preferences"
|
||||
inspector = inspect(bind_engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing_columns = {
|
||||
str(column.get("name") or "").strip()
|
||||
for column in inspector.get_columns(table_name)
|
||||
}
|
||||
|
||||
alter_statements = []
|
||||
if "telegram_id" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences ADD COLUMN telegram_id VARCHAR"
|
||||
)
|
||||
if "email_address" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences ADD COLUMN email_address VARCHAR"
|
||||
)
|
||||
if "notify_on_fail" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE user_dashboard_preferences ADD COLUMN notify_on_fail BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
)
|
||||
|
||||
if not alter_statements:
|
||||
return
|
||||
|
||||
try:
|
||||
with bind_engine.begin() as connection:
|
||||
for statement in alter_statements:
|
||||
connection.execute(text(statement))
|
||||
except Exception as migration_error:
|
||||
logger.warning(
|
||||
"[database][EXPLORE] Profile health preference additive migration failed: %s",
|
||||
migration_error,
|
||||
)
|
||||
# [/DEF:_ensure_user_dashboard_preferences_health_columns:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_llm_validation_results_columns:Function]
|
||||
# @PURPOSE: Applies additive schema upgrades for llm_validation_results table.
|
||||
def _ensure_llm_validation_results_columns(bind_engine):
|
||||
with belief_scope("_ensure_llm_validation_results_columns"):
|
||||
table_name = "llm_validation_results"
|
||||
inspector = inspect(bind_engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing_columns = {
|
||||
str(column.get("name") or "").strip()
|
||||
for column in inspector.get_columns(table_name)
|
||||
}
|
||||
|
||||
alter_statements = []
|
||||
if "task_id" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE llm_validation_results ADD COLUMN task_id VARCHAR"
|
||||
)
|
||||
if "environment_id" not in existing_columns:
|
||||
alter_statements.append(
|
||||
"ALTER TABLE llm_validation_results ADD COLUMN environment_id VARCHAR"
|
||||
)
|
||||
|
||||
if not alter_statements:
|
||||
return
|
||||
|
||||
try:
|
||||
with bind_engine.begin() as connection:
|
||||
for statement in alter_statements:
|
||||
connection.execute(text(statement))
|
||||
except Exception as migration_error:
|
||||
logger.warning(
|
||||
"[database][EXPLORE] ValidationRecord additive migration failed: %s",
|
||||
migration_error,
|
||||
)
|
||||
# [/DEF:_ensure_llm_validation_results_columns:Function]
|
||||
|
||||
|
||||
# [DEF:_ensure_git_server_configs_columns:Function]
|
||||
# @PURPOSE: Applies additive schema upgrades for git_server_configs table.
|
||||
# @PRE: bind_engine points to application database.
|
||||
@@ -204,6 +292,8 @@ def init_db():
|
||||
Base.metadata.create_all(bind=tasks_engine)
|
||||
Base.metadata.create_all(bind=auth_engine)
|
||||
_ensure_user_dashboard_preferences_columns(engine)
|
||||
_ensure_llm_validation_results_columns(engine)
|
||||
_ensure_user_dashboard_preferences_health_columns(engine)
|
||||
_ensure_git_server_configs_columns(engine)
|
||||
# [/DEF:init_db:Function]
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ def test_enable_belief_state_flag(caplog):
|
||||
assert not any("[DisabledFunction][Exit]" in msg for msg in log_messages), "Exit should not be logged when disabled"
|
||||
# Coherence:OK should still be logged (internal tracking)
|
||||
assert any("[DisabledFunction][COHERENCE:OK]" in msg for msg in log_messages), "Coherence should still be logged"
|
||||
|
||||
# [/DEF:test_enable_belief_state_flag:Function]
|
||||
|
||||
|
||||
# [DEF:test_belief_scope_missing_anchor:Function]
|
||||
|
||||
@@ -1,118 +1,170 @@
|
||||
# [DEF:backend.src.core.migration.risk_assessor:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: migration, dry_run, risk, scoring
|
||||
# @PURPOSE: Risk evaluation helpers for migration pre-flight reporting.
|
||||
# @LAYER: Core
|
||||
# @RELATION: USED_BY -> backend.src.core.migration.dry_run_orchestrator
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: migration, dry_run, risk, scoring, preflight
|
||||
# @PURPOSE: Compute deterministic migration risk items and aggregate score for dry-run reporting.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.superset_client.SupersetClient]
|
||||
# @RELATION: [DISPATCHES] ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService.run]
|
||||
# @INVARIANT: Risk scoring must remain bounded to [0,100] and preserve severity-to-weight mapping.
|
||||
# @TEST_CONTRACT: [source_objects,target_objects,diff,target_client] -> [List[RiskItem]]
|
||||
# @TEST_SCENARIO: [overwrite_update_objects] -> [medium overwrite_existing risk is emitted for each update diff item]
|
||||
# @TEST_SCENARIO: [missing_datasource_dataset] -> [high missing_datasource risk is emitted]
|
||||
# @TEST_SCENARIO: [owner_mismatch_dashboard] -> [low owner_mismatch risk is emitted]
|
||||
# @TEST_EDGE: [missing_field] -> [object without uuid is ignored by indexer]
|
||||
# @TEST_EDGE: [invalid_type] -> [non-list owners input normalizes to empty identifiers]
|
||||
# @TEST_EDGE: [external_fail] -> [target_client get_databases exception propagates to caller]
|
||||
# @TEST_INVARIANT: [score_upper_bound_100] -> VERIFIED_BY: [severity_weight_aggregation]
|
||||
# @UX_STATE: [Idle] -> [N/A backend domain module]
|
||||
# @UX_FEEDBACK: [N/A] -> [No direct UI side effects in this module]
|
||||
# @UX_RECOVERY: [N/A] -> [Caller-level retry/recovery]
|
||||
# @UX_REACTIVITY: [N/A] -> [Backend synchronous function contracts]
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..logger import logger, belief_scope
|
||||
from ..superset_client import SupersetClient
|
||||
|
||||
|
||||
# [DEF:index_by_uuid:Function]
|
||||
# @PURPOSE: Build UUID-index from normalized objects.
|
||||
# @PRE: Input list items are dict-like payloads potentially containing "uuid".
|
||||
# @POST: Returns mapping keyed by string uuid; only truthy uuid values are included.
|
||||
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
|
||||
# @DATA_CONTRACT: List[Dict[str, Any]] -> Dict[str, Dict[str, Any]]
|
||||
def index_by_uuid(objects: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
indexed: Dict[str, Dict[str, Any]] = {}
|
||||
for obj in objects:
|
||||
uuid = obj.get("uuid")
|
||||
if uuid:
|
||||
indexed[str(uuid)] = obj
|
||||
return indexed
|
||||
with belief_scope("risk_assessor.index_by_uuid"):
|
||||
logger.reason("Building UUID index", extra={"objects_count": len(objects)})
|
||||
indexed: Dict[str, Dict[str, Any]] = {}
|
||||
for obj in objects:
|
||||
uuid = obj.get("uuid")
|
||||
if uuid:
|
||||
indexed[str(uuid)] = obj
|
||||
logger.reflect("UUID index built", extra={"indexed_count": len(indexed)})
|
||||
return indexed
|
||||
# [/DEF:index_by_uuid:Function]
|
||||
|
||||
|
||||
# [DEF:extract_owner_identifiers:Function]
|
||||
# @PURPOSE: Normalize owner payloads for stable comparison.
|
||||
# @PRE: Owners may be list payload, scalar values, or None.
|
||||
# @POST: Returns sorted unique owner identifiers as strings.
|
||||
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
|
||||
# @DATA_CONTRACT: Any -> List[str]
|
||||
def extract_owner_identifiers(owners: Any) -> List[str]:
|
||||
if not isinstance(owners, list):
|
||||
return []
|
||||
ids: List[str] = []
|
||||
for owner in owners:
|
||||
if isinstance(owner, dict):
|
||||
if owner.get("username"):
|
||||
ids.append(str(owner["username"]))
|
||||
elif owner.get("id") is not None:
|
||||
ids.append(str(owner["id"]))
|
||||
elif owner is not None:
|
||||
ids.append(str(owner))
|
||||
return sorted(set(ids))
|
||||
with belief_scope("risk_assessor.extract_owner_identifiers"):
|
||||
logger.reason("Normalizing owner identifiers")
|
||||
if not isinstance(owners, list):
|
||||
logger.reflect("Owners payload is not list; returning empty identifiers")
|
||||
return []
|
||||
ids: List[str] = []
|
||||
for owner in owners:
|
||||
if isinstance(owner, dict):
|
||||
if owner.get("username"):
|
||||
ids.append(str(owner["username"]))
|
||||
elif owner.get("id") is not None:
|
||||
ids.append(str(owner["id"]))
|
||||
elif owner is not None:
|
||||
ids.append(str(owner))
|
||||
normalized_ids = sorted(set(ids))
|
||||
logger.reflect("Owner identifiers normalized", extra={"owner_count": len(normalized_ids)})
|
||||
return normalized_ids
|
||||
# [/DEF:extract_owner_identifiers:Function]
|
||||
|
||||
|
||||
# [DEF:build_risks:Function]
|
||||
# @PURPOSE: Build risk list from computed diffs and target catalog state.
|
||||
# @PRE: source_objects/target_objects/diff contain dashboards/charts/datasets keys with expected list structures.
|
||||
# @PRE: target_client is authenticated/usable for database list retrieval.
|
||||
# @POST: Returns list of deterministic risk items derived from overwrite, missing datasource, reference, and owner mismatch checks.
|
||||
# @SIDE_EFFECT: Calls target Superset API for databases metadata and emits logs.
|
||||
# @DATA_CONTRACT: (
|
||||
# @DATA_CONTRACT: Dict[str, List[Dict[str, Any]]],
|
||||
# @DATA_CONTRACT: Dict[str, List[Dict[str, Any]]],
|
||||
# @DATA_CONTRACT: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
||||
# @DATA_CONTRACT: SupersetClient
|
||||
# @DATA_CONTRACT: ) -> List[Dict[str, Any]]
|
||||
def build_risks(
|
||||
source_objects: Dict[str, List[Dict[str, Any]]],
|
||||
target_objects: Dict[str, List[Dict[str, Any]]],
|
||||
diff: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
||||
target_client: SupersetClient,
|
||||
) -> List[Dict[str, Any]]:
|
||||
risks: List[Dict[str, Any]] = []
|
||||
for object_type in ("dashboards", "charts", "datasets"):
|
||||
for item in diff[object_type]["update"]:
|
||||
risks.append({
|
||||
"code": "overwrite_existing",
|
||||
"severity": "medium",
|
||||
"object_type": object_type[:-1],
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
|
||||
})
|
||||
with belief_scope("risk_assessor.build_risks"):
|
||||
logger.reason("Building migration risks from diff payload")
|
||||
risks: List[Dict[str, Any]] = []
|
||||
for object_type in ("dashboards", "charts", "datasets"):
|
||||
for item in diff[object_type]["update"]:
|
||||
risks.append({
|
||||
"code": "overwrite_existing",
|
||||
"severity": "medium",
|
||||
"object_type": object_type[:-1],
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Object will be updated in target: {item.get('title') or item['uuid']}",
|
||||
})
|
||||
|
||||
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
|
||||
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
|
||||
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
|
||||
target_dataset_uuids = set(index_by_uuid(target_objects["datasets"]).keys())
|
||||
_, target_databases = target_client.get_databases(query={"columns": ["uuid"]})
|
||||
target_database_uuids = {str(item.get("uuid")) for item in target_databases if item.get("uuid")}
|
||||
|
||||
for dataset in source_objects["datasets"]:
|
||||
db_uuid = dataset.get("database_uuid")
|
||||
if db_uuid and str(db_uuid) not in target_database_uuids:
|
||||
risks.append({
|
||||
"code": "missing_datasource",
|
||||
"severity": "high",
|
||||
"object_type": "dataset",
|
||||
"object_uuid": dataset.get("uuid"),
|
||||
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
|
||||
})
|
||||
for dataset in source_objects["datasets"]:
|
||||
db_uuid = dataset.get("database_uuid")
|
||||
if db_uuid and str(db_uuid) not in target_database_uuids:
|
||||
risks.append({
|
||||
"code": "missing_datasource",
|
||||
"severity": "high",
|
||||
"object_type": "dataset",
|
||||
"object_uuid": dataset.get("uuid"),
|
||||
"message": f"Target datasource is missing for dataset {dataset.get('title') or dataset.get('uuid')}",
|
||||
})
|
||||
|
||||
for chart in source_objects["charts"]:
|
||||
ds_uuid = chart.get("dataset_uuid")
|
||||
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
|
||||
risks.append({
|
||||
"code": "breaking_reference",
|
||||
"severity": "high",
|
||||
"object_type": "chart",
|
||||
"object_uuid": chart.get("uuid"),
|
||||
"message": f"Chart references dataset not found on target: {ds_uuid}",
|
||||
})
|
||||
for chart in source_objects["charts"]:
|
||||
ds_uuid = chart.get("dataset_uuid")
|
||||
if ds_uuid and str(ds_uuid) not in target_dataset_uuids:
|
||||
risks.append({
|
||||
"code": "breaking_reference",
|
||||
"severity": "high",
|
||||
"object_type": "chart",
|
||||
"object_uuid": chart.get("uuid"),
|
||||
"message": f"Chart references dataset not found on target: {ds_uuid}",
|
||||
})
|
||||
|
||||
source_dash = index_by_uuid(source_objects["dashboards"])
|
||||
target_dash = index_by_uuid(target_objects["dashboards"])
|
||||
for item in diff["dashboards"]["update"]:
|
||||
source_obj = source_dash.get(item["uuid"])
|
||||
target_obj = target_dash.get(item["uuid"])
|
||||
if not source_obj or not target_obj:
|
||||
continue
|
||||
source_owners = extract_owner_identifiers(source_obj.get("owners"))
|
||||
target_owners = extract_owner_identifiers(target_obj.get("owners"))
|
||||
if source_owners and target_owners and source_owners != target_owners:
|
||||
risks.append({
|
||||
"code": "owner_mismatch",
|
||||
"severity": "low",
|
||||
"object_type": "dashboard",
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
|
||||
})
|
||||
return risks
|
||||
source_dash = index_by_uuid(source_objects["dashboards"])
|
||||
target_dash = index_by_uuid(target_objects["dashboards"])
|
||||
for item in diff["dashboards"]["update"]:
|
||||
source_obj = source_dash.get(item["uuid"])
|
||||
target_obj = target_dash.get(item["uuid"])
|
||||
if not source_obj or not target_obj:
|
||||
continue
|
||||
source_owners = extract_owner_identifiers(source_obj.get("owners"))
|
||||
target_owners = extract_owner_identifiers(target_obj.get("owners"))
|
||||
if source_owners and target_owners and source_owners != target_owners:
|
||||
risks.append({
|
||||
"code": "owner_mismatch",
|
||||
"severity": "low",
|
||||
"object_type": "dashboard",
|
||||
"object_uuid": item["uuid"],
|
||||
"message": f"Owner mismatch for dashboard {item.get('title') or item['uuid']}",
|
||||
})
|
||||
logger.reflect("Risk list assembled", extra={"risk_count": len(risks)})
|
||||
return risks
|
||||
# [/DEF:build_risks:Function]
|
||||
|
||||
|
||||
# [DEF:score_risks:Function]
|
||||
# @PURPOSE: Aggregate risk list into score and level.
|
||||
# @PRE: risk_items contains optional severity fields expected in {high,medium,low} or defaults to low weight.
|
||||
# @POST: Returns dict with score in [0,100], derived level, and original items.
|
||||
# @SIDE_EFFECT: Emits reasoning/reflective logs only.
|
||||
# @DATA_CONTRACT: List[Dict[str, Any]] -> Dict[str, Any]
|
||||
def score_risks(risk_items: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
weights = {"high": 25, "medium": 10, "low": 5}
|
||||
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
|
||||
level = "low" if score < 25 else "medium" if score < 60 else "high"
|
||||
return {"score": score, "level": level, "items": risk_items}
|
||||
with belief_scope("risk_assessor.score_risks"):
|
||||
logger.reason("Scoring risk items", extra={"risk_items_count": len(risk_items)})
|
||||
weights = {"high": 25, "medium": 10, "low": 5}
|
||||
score = min(100, sum(weights.get(item.get("severity", "low"), 5) for item in risk_items))
|
||||
level = "low" if score < 25 else "medium" if score < 60 else "high"
|
||||
result = {"score": score, "level": level, "items": risk_items}
|
||||
logger.reflect("Risk score computed", extra={"score": score, "level": level})
|
||||
return result
|
||||
# [/DEF:score_risks:Function]
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# [DEF:backend.src.core.migration_engine:Module]
|
||||
#
|
||||
# @SEMANTICS: migration, engine, zip, yaml, transformation
|
||||
# @PURPOSE: Handles the interception and transformation of Superset asset ZIP archives.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> PyYAML
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: migration, engine, zip, yaml, transformation, cross-filter, id-mapping
|
||||
# @PURPOSE: Transforms Superset export ZIP archives while preserving archive integrity and patching mapped identifiers.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[src.core.logger]
|
||||
# @RELATION: [DEPENDS_ON] ->[src.core.mapping_service.IdMappingService]
|
||||
# @RELATION: [DEPENDS_ON] ->[src.models.mapping.ResourceType]
|
||||
# @RELATION: [DEPENDS_ON] ->[yaml]
|
||||
#
|
||||
# @INVARIANT: ZIP structure must be preserved after transformation.
|
||||
# @INVARIANT: ZIP structure and non-targeted metadata must remain valid after transformation.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import zipfile
|
||||
@@ -26,10 +30,17 @@ from src.models.mapping import ResourceType
|
||||
class MigrationEngine:
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the migration engine with optional ID mapping service.
|
||||
# @PURPOSE: Initializes migration orchestration dependencies for ZIP/YAML metadata transformations.
|
||||
# @PRE: mapping_service is None or implements batch remote ID lookup for ResourceType.CHART.
|
||||
# @POST: self.mapping_service is assigned and available for optional cross-filter patching flows.
|
||||
# @SIDE_EFFECT: Mutates in-memory engine state by storing dependency reference.
|
||||
# @DATA_CONTRACT: Input[Optional[IdMappingService]] -> Output[MigrationEngine]
|
||||
# @PARAM: mapping_service (Optional[IdMappingService]) - Used for resolving target environment integer IDs.
|
||||
def __init__(self, mapping_service: Optional[IdMappingService] = None):
|
||||
self.mapping_service = mapping_service
|
||||
with belief_scope("MigrationEngine.__init__"):
|
||||
logger.reason("Initializing MigrationEngine")
|
||||
self.mapping_service = mapping_service
|
||||
logger.reflect("MigrationEngine initialized")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:transform_zip:Function]
|
||||
@@ -40,20 +51,24 @@ class MigrationEngine:
|
||||
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
|
||||
# @PARAM: target_env_id (Optional[str]) - Used if fix_cross_filters is True to know which environment map to use.
|
||||
# @PARAM: fix_cross_filters (bool) - Whether to patch dashboard json_metadata.
|
||||
# @PRE: zip_path must point to a valid Superset export archive.
|
||||
# @POST: Transformed archive is saved to output_path.
|
||||
# @RETURN: bool - True if successful.
|
||||
# @PRE: zip_path points to a readable ZIP; output_path parent is writable; db_mapping keys/values are UUID strings.
|
||||
# @POST: Returns True only when extraction, transformation, and packaging complete without exception.
|
||||
# @SIDE_EFFECT: Reads/writes filesystem archives, creates temporary directory, emits structured logs.
|
||||
# @DATA_CONTRACT: Input[(str zip_path, str output_path, Dict[str,str] db_mapping, bool strip_databases, Optional[str] target_env_id, bool fix_cross_filters)] -> Output[bool]
|
||||
# @RETURN: bool - True if successful.
|
||||
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True, target_env_id: Optional[str] = None, fix_cross_filters: bool = False) -> bool:
|
||||
"""
|
||||
Transform a Superset export ZIP by replacing database UUIDs and optionally fixing cross-filters.
|
||||
"""
|
||||
with belief_scope("MigrationEngine.transform_zip"):
|
||||
logger.reason(f"Starting ZIP transformation: {zip_path} -> {output_path}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir_str:
|
||||
temp_dir = Path(temp_dir_str)
|
||||
|
||||
try:
|
||||
# 1. Extract
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Extracting ZIP: {zip_path}")
|
||||
logger.reason(f"Extracting source archive to {temp_dir}")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
zf.extractall(temp_dir)
|
||||
|
||||
@@ -61,33 +76,33 @@ class MigrationEngine:
|
||||
dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list(temp_dir.glob("**/datasets/*.yaml"))
|
||||
dataset_files = list(set(dataset_files))
|
||||
|
||||
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dataset_files)} dataset files.")
|
||||
logger.reason(f"Transforming {len(dataset_files)} dataset YAML files")
|
||||
for ds_file in dataset_files:
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Transforming dataset: {ds_file}")
|
||||
self._transform_yaml(ds_file, db_mapping)
|
||||
|
||||
# 2.5 Patch Cross-Filters (Dashboards)
|
||||
if fix_cross_filters and self.mapping_service and target_env_id:
|
||||
dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml"))
|
||||
dash_files = list(set(dash_files))
|
||||
|
||||
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dash_files)} dashboard files for patching.")
|
||||
|
||||
# Gather all source UUID-to-ID mappings from the archive first
|
||||
source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir)
|
||||
|
||||
for dash_file in dash_files:
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Patching dashboard: {dash_file}")
|
||||
self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map)
|
||||
if fix_cross_filters:
|
||||
if self.mapping_service and target_env_id:
|
||||
dash_files = list(temp_dir.glob("**/dashboards/**/*.yaml")) + list(temp_dir.glob("**/dashboards/*.yaml"))
|
||||
dash_files = list(set(dash_files))
|
||||
|
||||
logger.reason(f"Patching cross-filters for {len(dash_files)} dashboards")
|
||||
|
||||
# Gather all source UUID-to-ID mappings from the archive first
|
||||
source_id_to_uuid_map = self._extract_chart_uuids_from_archive(temp_dir)
|
||||
|
||||
for dash_file in dash_files:
|
||||
self._patch_dashboard_metadata(dash_file, target_env_id, source_id_to_uuid_map)
|
||||
else:
|
||||
logger.explore("Cross-filter patching requested but mapping service or target_env_id is missing")
|
||||
|
||||
# 3. Re-package
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Re-packaging ZIP to: {output_path} (strip_databases={strip_databases})")
|
||||
logger.reason(f"Re-packaging transformed archive (strip_databases={strip_databases})")
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
rel_root = Path(root).relative_to(temp_dir)
|
||||
|
||||
if strip_databases and "databases" in rel_root.parts:
|
||||
logger.info(f"[MigrationEngine.transform_zip][Action] Skipping file in databases directory: {rel_root}")
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
@@ -95,9 +110,10 @@ class MigrationEngine:
|
||||
arcname = file_path.relative_to(temp_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
logger.reflect("ZIP transformation completed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
|
||||
logger.explore(f"Error transforming ZIP: {e}")
|
||||
return False
|
||||
# [/DEF:transform_zip:Function]
|
||||
|
||||
@@ -105,54 +121,73 @@ class MigrationEngine:
|
||||
# @PURPOSE: Replaces database_uuid in a single YAML file.
|
||||
# @PARAM: file_path (Path) - Path to the YAML file.
|
||||
# @PARAM: db_mapping (Dict[str, str]) - UUID mapping dictionary.
|
||||
# @PRE: file_path must exist and be readable.
|
||||
# @POST: File is modified in-place if source UUID matches mapping.
|
||||
# @PRE: file_path exists, is readable YAML, and db_mapping contains source->target UUID pairs.
|
||||
# @POST: database_uuid is replaced in-place only when source UUID is present in db_mapping.
|
||||
# @SIDE_EFFECT: Reads and conditionally rewrites YAML file on disk.
|
||||
# @DATA_CONTRACT: Input[(Path file_path, Dict[str,str] db_mapping)] -> Output[None]
|
||||
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
with belief_scope("MigrationEngine._transform_yaml"):
|
||||
if not file_path.exists():
|
||||
logger.explore(f"YAML file not found: {file_path}")
|
||||
return
|
||||
|
||||
if not data:
|
||||
return
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Superset dataset YAML structure:
|
||||
# database_uuid: ...
|
||||
source_uuid = data.get('database_uuid')
|
||||
if source_uuid in db_mapping:
|
||||
data['database_uuid'] = db_mapping[source_uuid]
|
||||
with open(file_path, 'w') as f:
|
||||
yaml.dump(data, f)
|
||||
if not data:
|
||||
return
|
||||
|
||||
source_uuid = data.get('database_uuid')
|
||||
if source_uuid in db_mapping:
|
||||
logger.reason(f"Replacing database UUID in {file_path.name}")
|
||||
data['database_uuid'] = db_mapping[source_uuid]
|
||||
with open(file_path, 'w') as f:
|
||||
yaml.dump(data, f)
|
||||
logger.reflect(f"Database UUID patched in {file_path.name}")
|
||||
# [/DEF:_transform_yaml:Function]
|
||||
|
||||
# [DEF:_extract_chart_uuids_from_archive:Function]
|
||||
# @PURPOSE: Scans the unpacked ZIP to map local exported integer IDs back to their UUIDs.
|
||||
# @PARAM: temp_dir (Path) - Root dir of unpacked archive
|
||||
# @PURPOSE: Scans extracted chart YAML files and builds a source chart ID to UUID lookup map.
|
||||
# @PRE: temp_dir exists and points to extracted archive root with optional chart YAML resources.
|
||||
# @POST: Returns a best-effort Dict[int, str] containing only parseable chart id/uuid pairs.
|
||||
# @SIDE_EFFECT: Reads chart YAML files from filesystem; suppresses per-file parsing failures.
|
||||
# @DATA_CONTRACT: Input[Path] -> Output[Dict[int,str]]
|
||||
# @PARAM: temp_dir (Path) - Root dir of unpacked archive.
|
||||
# @RETURN: Dict[int, str] - Mapping of source Integer ID to UUID.
|
||||
def _extract_chart_uuids_from_archive(self, temp_dir: Path) -> Dict[int, str]:
|
||||
# Implementation Note: This is a placeholder for the logic that extracts
|
||||
# actual Source IDs. In a real scenario, this involves parsing chart YAMLs
|
||||
# or manifesting the export metadata structure where source IDs are stored.
|
||||
# For simplicity in US1 MVP, we assume it's read from chart files if present.
|
||||
mapping = {}
|
||||
chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml"))
|
||||
for cf in set(chart_files):
|
||||
try:
|
||||
with open(cf, 'r') as f:
|
||||
cdata = yaml.safe_load(f)
|
||||
if cdata and 'id' in cdata and 'uuid' in cdata:
|
||||
mapping[cdata['id']] = cdata['uuid']
|
||||
except Exception:
|
||||
pass
|
||||
return mapping
|
||||
with belief_scope("MigrationEngine._extract_chart_uuids_from_archive"):
|
||||
# Implementation Note: This is a placeholder for the logic that extracts
|
||||
# actual Source IDs. In a real scenario, this involves parsing chart YAMLs
|
||||
# or manifesting the export metadata structure where source IDs are stored.
|
||||
# For simplicity in US1 MVP, we assume it's read from chart files if present.
|
||||
mapping = {}
|
||||
chart_files = list(temp_dir.glob("**/charts/**/*.yaml")) + list(temp_dir.glob("**/charts/*.yaml"))
|
||||
for cf in set(chart_files):
|
||||
try:
|
||||
with open(cf, 'r') as f:
|
||||
cdata = yaml.safe_load(f)
|
||||
if cdata and 'id' in cdata and 'uuid' in cdata:
|
||||
mapping[cdata['id']] = cdata['uuid']
|
||||
except Exception:
|
||||
pass
|
||||
return mapping
|
||||
# [/DEF:_extract_chart_uuids_from_archive:Function]
|
||||
|
||||
# [DEF:_patch_dashboard_metadata:Function]
|
||||
# @PURPOSE: Replaces integer IDs in json_metadata.
|
||||
# @PURPOSE: Rewrites dashboard json_metadata chart/dataset integer identifiers using target environment mappings.
|
||||
# @PRE: file_path points to dashboard YAML with json_metadata; target_env_id is non-empty; source_map contains source id->uuid.
|
||||
# @POST: json_metadata is re-serialized with mapped integer IDs when remote mappings are available; otherwise file remains unchanged.
|
||||
# @SIDE_EFFECT: Reads/writes YAML file, performs mapping lookup via mapping_service, emits logs for recoverable/terminal failures.
|
||||
# @DATA_CONTRACT: Input[(Path file_path, str target_env_id, Dict[int,str] source_map)] -> Output[None]
|
||||
# @PARAM: file_path (Path)
|
||||
# @PARAM: target_env_id (str)
|
||||
# @PARAM: source_map (Dict[int, str])
|
||||
def _patch_dashboard_metadata(self, file_path: Path, target_env_id: str, source_map: Dict[int, str]):
|
||||
with belief_scope("MigrationEngine._patch_dashboard_metadata"):
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
@@ -163,18 +198,13 @@ class MigrationEngine:
|
||||
if not metadata_str:
|
||||
return
|
||||
|
||||
metadata = json.loads(metadata_str)
|
||||
modified = False
|
||||
|
||||
# We need to deeply traverse and replace. For MVP, string replacement over the raw JSON is an option,
|
||||
# but careful dict traversal is safer.
|
||||
|
||||
# Fetch target UUIDs for everything we know:
|
||||
uuids_needed = list(source_map.values())
|
||||
logger.reason(f"Resolving {len(uuids_needed)} remote IDs for dashboard metadata patching")
|
||||
target_ids = self.mapping_service.get_remote_ids_batch(target_env_id, ResourceType.CHART, uuids_needed)
|
||||
|
||||
if not target_ids:
|
||||
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No remote target IDs found in mapping database.")
|
||||
logger.reflect("No remote target IDs found in mapping database for this dashboard.")
|
||||
return
|
||||
|
||||
# Map Source Int -> Target Int
|
||||
@@ -187,21 +217,16 @@ class MigrationEngine:
|
||||
missing_targets.append(s_id)
|
||||
|
||||
if missing_targets:
|
||||
logger.warning(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Recoverable] Missing target IDs for source IDs: {missing_targets}. Cross-filters for these IDs might break.")
|
||||
logger.explore(f"Missing target IDs for source IDs: {missing_targets}. Cross-filters might break.")
|
||||
|
||||
if not source_to_target:
|
||||
logger.info("[MigrationEngine._patch_dashboard_metadata][Reflect] No source IDs matched remotely. Skipping patch.")
|
||||
logger.reflect("No source IDs matched remotely. Skipping patch.")
|
||||
return
|
||||
|
||||
# Complex metadata traversal would go here (e.g. for native_filter_configuration)
|
||||
# We use regex replacement over the string for safety over unknown nested dicts.
|
||||
|
||||
logger.reason(f"Patching {len(source_to_target)} ID references in json_metadata")
|
||||
new_metadata_str = metadata_str
|
||||
|
||||
# Replace chartId and datasetId assignments explicitly.
|
||||
# Pattern: "datasetId": 42 or "chartId": 42
|
||||
for s_id, t_id in source_to_target.items():
|
||||
# Replace in native_filter_configuration targets
|
||||
new_metadata_str = re.sub(r'("datasetId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
|
||||
new_metadata_str = re.sub(r'("chartId"\s*:\s*)' + str(s_id) + r'(\b)', r'\g<1>' + str(t_id) + r'\g<2>', new_metadata_str)
|
||||
|
||||
@@ -210,10 +235,10 @@ class MigrationEngine:
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
yaml.dump(data, f)
|
||||
logger.info(f"[MigrationEngine._patch_dashboard_metadata][Reason] Re-serialized modified JSON metadata for dashboard.")
|
||||
logger.reflect(f"Dashboard metadata patched and saved: {file_path.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MigrationEngine._patch_dashboard_metadata][Coherence:Failed] Metadata patch failed: {e}")
|
||||
logger.explore(f"Metadata patch failed for {file_path.name}: {e}")
|
||||
|
||||
# [/DEF:_patch_dashboard_metadata:Function]
|
||||
|
||||
|
||||
@@ -76,17 +76,8 @@ class PluginLoader:
|
||||
"""
|
||||
Loads a single Python module and extracts PluginBase subclasses.
|
||||
"""
|
||||
# Try to determine the correct package prefix based on how the app is running
|
||||
# For standalone execution, we need to handle the import differently
|
||||
if __name__ == "__main__" or "test" in __name__:
|
||||
# When running as standalone or in tests, use relative import
|
||||
package_name = f"plugins.{module_name}"
|
||||
elif "backend.src" in __name__:
|
||||
package_prefix = "backend.src.plugins"
|
||||
package_name = f"{package_prefix}.{module_name}"
|
||||
else:
|
||||
package_prefix = "src.plugins"
|
||||
package_name = f"{package_prefix}.{module_name}"
|
||||
# All runtime code is imported through the canonical `src` package root.
|
||||
package_name = f"src.plugins.{module_name}"
|
||||
|
||||
# print(f"DEBUG: Loading plugin {module_name} as {package_name}")
|
||||
spec = importlib.util.spec_from_file_location(package_name, file_path)
|
||||
@@ -198,4 +189,4 @@ class PluginLoader:
|
||||
return plugin_id in self._plugins
|
||||
# [/DEF:has_plugin:Function]
|
||||
|
||||
# [/DEF:PluginLoader:Class]
|
||||
# [/DEF:PluginLoader:Class]
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
# [SECTION: IMPORTS]
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from .logger import logger, belief_scope
|
||||
from .config_manager import ConfigManager
|
||||
from .database import SessionLocal
|
||||
from ..models.llm import ValidationPolicy
|
||||
import asyncio
|
||||
from datetime import datetime, time, timedelta, date
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:SchedulerService:Class]
|
||||
@@ -117,4 +121,63 @@ class SchedulerService:
|
||||
# [/DEF:_trigger_backup:Function]
|
||||
|
||||
# [/DEF:SchedulerService:Class]
|
||||
|
||||
# [DEF:ThrottledSchedulerConfigurator:Class]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: scheduler, throttling, distribution
|
||||
# @PURPOSE: Distributes validation tasks evenly within an execution window.
|
||||
class ThrottledSchedulerConfigurator:
|
||||
# [DEF:calculate_schedule:Function]
|
||||
# @PURPOSE: Calculates execution times for N tasks within a window.
|
||||
# @PRE: window_start, window_end (time), dashboard_ids (List), current_date (date).
|
||||
# @POST: Returns List[datetime] of scheduled times.
|
||||
# @INVARIANT: Tasks are distributed with near-even spacing.
|
||||
@staticmethod
|
||||
def calculate_schedule(
|
||||
window_start: time,
|
||||
window_end: time,
|
||||
dashboard_ids: list,
|
||||
current_date: date
|
||||
) -> list:
|
||||
with belief_scope("ThrottledSchedulerConfigurator.calculate_schedule"):
|
||||
n = len(dashboard_ids)
|
||||
if n == 0:
|
||||
return []
|
||||
|
||||
start_dt = datetime.combine(current_date, window_start)
|
||||
end_dt = datetime.combine(current_date, window_end)
|
||||
|
||||
# Handle window crossing midnight
|
||||
if end_dt < start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
total_seconds = (end_dt - start_dt).total_seconds()
|
||||
|
||||
# Minimum interval of 1 second to avoid division by zero or negative
|
||||
if total_seconds <= 0:
|
||||
logger.warning(f"[calculate_schedule] Window size is zero or negative. Falling back to start time for all {n} tasks.")
|
||||
return [start_dt] * n
|
||||
|
||||
# If window is too small for even distribution (e.g. 10 tasks in 5 seconds),
|
||||
# we still distribute them but they might be very close.
|
||||
# The requirement says "near-even spacing".
|
||||
|
||||
if n == 1:
|
||||
return [start_dt]
|
||||
|
||||
interval = total_seconds / (n - 1) if n > 1 else 0
|
||||
|
||||
# If interval is too small (e.g. < 1s), we might want a fallback,
|
||||
# but the spec says "handle too-small windows with explicit fallback/warning".
|
||||
if interval < 1:
|
||||
logger.warning(f"[calculate_schedule] Window too small for {n} tasks (interval {interval:.2f}s). Tasks will be highly concentrated.")
|
||||
|
||||
scheduled_times = []
|
||||
for i in range(n):
|
||||
scheduled_times.append(start_dt + timedelta(seconds=i * interval))
|
||||
|
||||
return scheduled_times
|
||||
# [/DEF:calculate_schedule:Function]
|
||||
# [/DEF:ThrottledSchedulerConfigurator:Class]
|
||||
|
||||
# [/DEF:SchedulerModule:Module]
|
||||
@@ -150,11 +150,19 @@ class SupersetClient:
|
||||
# @PRE: Client is authenticated.
|
||||
# @POST: Returns a list of dashboard metadata summaries.
|
||||
# @RETURN: List[Dict]
|
||||
def get_dashboards_summary(self) -> List[Dict]:
|
||||
def get_dashboards_summary(self, require_slug: bool = False) -> List[Dict]:
|
||||
with belief_scope("SupersetClient.get_dashboards_summary"):
|
||||
# Rely on list endpoint default projection to stay compatible
|
||||
# across Superset versions and preserve owners in one request.
|
||||
query: Dict[str, Any] = {}
|
||||
if require_slug:
|
||||
query["filters"] = [
|
||||
{
|
||||
"col": "slug",
|
||||
"opr": "neq",
|
||||
"value": "",
|
||||
}
|
||||
]
|
||||
_, dashboards = self.get_dashboards(query=query)
|
||||
|
||||
# Map fields to DashboardMetadata schema
|
||||
@@ -232,23 +240,35 @@ class SupersetClient:
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: Optional[str] = None,
|
||||
require_slug: bool = False,
|
||||
) -> Tuple[int, List[Dict]]:
|
||||
with belief_scope("SupersetClient.get_dashboards_summary_page"):
|
||||
query: Dict[str, Any] = {
|
||||
"page": max(page - 1, 0),
|
||||
"page_size": page_size,
|
||||
}
|
||||
filters: List[Dict[str, Any]] = []
|
||||
if require_slug:
|
||||
filters.append(
|
||||
{
|
||||
"col": "slug",
|
||||
"opr": "neq",
|
||||
"value": "",
|
||||
}
|
||||
)
|
||||
normalized_search = (search or "").strip()
|
||||
if normalized_search:
|
||||
# Superset list API supports filter objects with `opr` operator.
|
||||
# `ct` -> contains (ILIKE on most Superset backends).
|
||||
query["filters"] = [
|
||||
filters.append(
|
||||
{
|
||||
"col": "dashboard_title",
|
||||
"opr": "ct",
|
||||
"value": normalized_search,
|
||||
}
|
||||
]
|
||||
)
|
||||
if filters:
|
||||
query["filters"] = filters
|
||||
|
||||
total_count, dashboards = self.get_dashboards_page(query=query)
|
||||
|
||||
|
||||
3
backend/src/core/utils/__init__.py
Normal file
3
backend/src/core/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.core.utils:Package]
|
||||
# @PURPOSE: Shared utility package root.
|
||||
# [/DEF:src.core.utils:Package]
|
||||
237
backend/src/core/utils/async_network.py
Normal file
237
backend/src/core/utils/async_network.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# [DEF:backend.src.core.utils.async_network:Module]
|
||||
#
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: network, httpx, async, superset, authentication, cache
|
||||
# @PURPOSE: Provides async Superset API client with shared auth-token cache to avoid per-request re-login.
|
||||
# @LAYER: Infra
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.utils.network.SupersetAuthCache
|
||||
# @INVARIANT: Async client reuses cached auth tokens per environment credentials and invalidates on 401.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, Dict, Any, Union
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from ..logger import logger as app_logger, belief_scope
|
||||
from .network import (
|
||||
AuthenticationError,
|
||||
DashboardNotFoundError,
|
||||
NetworkError,
|
||||
PermissionDeniedError,
|
||||
SupersetAPIError,
|
||||
SupersetAuthCache,
|
||||
)
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
# [DEF:AsyncAPIClient:Class]
|
||||
# @PURPOSE: Async Superset API client backed by httpx.AsyncClient with shared auth cache.
|
||||
class AsyncAPIClient:
|
||||
DEFAULT_TIMEOUT = 30
|
||||
_auth_locks: Dict[tuple[str, str, bool], asyncio.Lock] = {}
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize async API client for one environment.
|
||||
# @PRE: config contains base_url and auth payload.
|
||||
# @POST: Client is ready for async request/authentication flow.
|
||||
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
|
||||
self.base_url: str = self._normalize_base_url(config.get("base_url", ""))
|
||||
self.api_base_url: str = f"{self.base_url}/api/v1"
|
||||
self.auth = config.get("auth")
|
||||
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
|
||||
self._client = httpx.AsyncClient(
|
||||
verify=verify_ssl,
|
||||
timeout=httpx.Timeout(timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
self._tokens: Dict[str, str] = {}
|
||||
self._authenticated = False
|
||||
self._auth_cache_key = SupersetAuthCache.build_key(
|
||||
self.base_url,
|
||||
self.auth,
|
||||
verify_ssl,
|
||||
)
|
||||
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_normalize_base_url:Function]
|
||||
# @PURPOSE: Normalize base URL for Superset API root construction.
|
||||
# @POST: Returns canonical base URL without trailing slash and duplicate /api/v1 suffix.
|
||||
def _normalize_base_url(self, raw_url: str) -> str:
|
||||
normalized = str(raw_url or "").strip().rstrip("/")
|
||||
if normalized.lower().endswith("/api/v1"):
|
||||
normalized = normalized[:-len("/api/v1")]
|
||||
return normalized.rstrip("/")
|
||||
# [/DEF:_normalize_base_url:Function]
|
||||
|
||||
# [DEF:_build_api_url:Function]
|
||||
# @PURPOSE: Build full API URL from relative Superset endpoint.
|
||||
# @POST: Returns absolute URL for upstream request.
|
||||
def _build_api_url(self, endpoint: str) -> str:
|
||||
normalized_endpoint = str(endpoint or "").strip()
|
||||
if normalized_endpoint.startswith("http://") or normalized_endpoint.startswith("https://"):
|
||||
return normalized_endpoint
|
||||
if not normalized_endpoint.startswith("/"):
|
||||
normalized_endpoint = f"/{normalized_endpoint}"
|
||||
if normalized_endpoint.startswith("/api/v1/") or normalized_endpoint == "/api/v1":
|
||||
return f"{self.base_url}{normalized_endpoint}"
|
||||
return f"{self.api_base_url}{normalized_endpoint}"
|
||||
# [/DEF:_build_api_url:Function]
|
||||
|
||||
# [DEF:_get_auth_lock:Function]
|
||||
# @PURPOSE: Return per-cache-key async lock to serialize fresh login attempts.
|
||||
# @POST: Returns stable asyncio.Lock instance.
|
||||
@classmethod
|
||||
def _get_auth_lock(cls, cache_key: tuple[str, str, bool]) -> asyncio.Lock:
|
||||
existing_lock = cls._auth_locks.get(cache_key)
|
||||
if existing_lock is not None:
|
||||
return existing_lock
|
||||
created_lock = asyncio.Lock()
|
||||
cls._auth_locks[cache_key] = created_lock
|
||||
return created_lock
|
||||
# [/DEF:_get_auth_lock:Function]
|
||||
|
||||
# [DEF:authenticate:Function]
|
||||
# @PURPOSE: Authenticate against Superset and cache access/csrf tokens.
|
||||
# @POST: Client tokens are populated and reusable across requests.
|
||||
async def authenticate(self) -> Dict[str, str]:
|
||||
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
|
||||
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
|
||||
self._tokens = cached_tokens
|
||||
self._authenticated = True
|
||||
app_logger.info("[async_authenticate][CacheHit] Reusing cached Superset auth tokens for %s", self.base_url)
|
||||
return self._tokens
|
||||
|
||||
auth_lock = self._get_auth_lock(self._auth_cache_key)
|
||||
async with auth_lock:
|
||||
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
|
||||
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
|
||||
self._tokens = cached_tokens
|
||||
self._authenticated = True
|
||||
app_logger.info("[async_authenticate][CacheHitAfterWait] Reusing cached Superset auth tokens for %s", self.base_url)
|
||||
return self._tokens
|
||||
|
||||
with belief_scope("AsyncAPIClient.authenticate"):
|
||||
app_logger.info("[async_authenticate][Enter] Authenticating to %s", self.base_url)
|
||||
try:
|
||||
login_url = f"{self.api_base_url}/security/login"
|
||||
response = await self._client.post(login_url, json=self.auth)
|
||||
response.raise_for_status()
|
||||
access_token = response.json()["access_token"]
|
||||
|
||||
csrf_url = f"{self.api_base_url}/security/csrf_token/"
|
||||
csrf_response = await self._client.get(
|
||||
csrf_url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
csrf_response.raise_for_status()
|
||||
|
||||
self._tokens = {
|
||||
"access_token": access_token,
|
||||
"csrf_token": csrf_response.json()["result"],
|
||||
}
|
||||
self._authenticated = True
|
||||
SupersetAuthCache.set(self._auth_cache_key, self._tokens)
|
||||
app_logger.info("[async_authenticate][Exit] Authenticated successfully.")
|
||||
return self._tokens
|
||||
except httpx.HTTPStatusError as exc:
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
if status_code in [502, 503, 504]:
|
||||
raise NetworkError(
|
||||
f"Environment unavailable during authentication (Status {status_code})",
|
||||
status_code=status_code,
|
||||
) from exc
|
||||
raise AuthenticationError(f"Authentication failed: {exc}") from exc
|
||||
except (httpx.HTTPError, KeyError) as exc:
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
raise NetworkError(f"Network or parsing error during authentication: {exc}") from exc
|
||||
# [/DEF:authenticate:Function]
|
||||
|
||||
# [DEF:get_headers:Function]
|
||||
# @PURPOSE: Return authenticated Superset headers for async requests.
|
||||
# @POST: Headers include Authorization and CSRF tokens.
|
||||
async def get_headers(self) -> Dict[str, str]:
|
||||
if not self._authenticated:
|
||||
await self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
"Referer": self.base_url,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# [/DEF:get_headers:Function]
|
||||
|
||||
# [DEF:request:Function]
|
||||
# @PURPOSE: Perform one authenticated async Superset API request.
|
||||
# @POST: Returns JSON payload or raw httpx.Response when raw_response=true.
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
raw_response: bool = False,
|
||||
**kwargs,
|
||||
) -> Union[httpx.Response, Dict[str, Any]]:
|
||||
full_url = self._build_api_url(endpoint)
|
||||
request_headers = await self.get_headers()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
if "allow_redirects" in kwargs and "follow_redirects" not in kwargs:
|
||||
kwargs["follow_redirects"] = bool(kwargs.pop("allow_redirects"))
|
||||
|
||||
try:
|
||||
response = await self._client.request(method, full_url, headers=request_headers, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response if raw_response else response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code == 401:
|
||||
self._authenticated = False
|
||||
self._tokens = {}
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
self._handle_http_error(exc, endpoint)
|
||||
except httpx.HTTPError as exc:
|
||||
self._handle_network_error(exc, full_url)
|
||||
# [/DEF:request:Function]
|
||||
|
||||
# [DEF:_handle_http_error:Function]
|
||||
# @PURPOSE: Translate upstream HTTP errors into stable domain exceptions.
|
||||
# @POST: Raises domain-specific exception for caller flow control.
|
||||
def _handle_http_error(self, exc: httpx.HTTPStatusError, endpoint: str) -> None:
|
||||
with belief_scope("AsyncAPIClient._handle_http_error"):
|
||||
status_code = exc.response.status_code
|
||||
if status_code in [502, 503, 504]:
|
||||
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from exc
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint) from exc
|
||||
if status_code == 403:
|
||||
raise PermissionDeniedError() from exc
|
||||
if status_code == 401:
|
||||
raise AuthenticationError() from exc
|
||||
raise SupersetAPIError(f"API Error {status_code}: {exc.response.text}") from exc
|
||||
# [/DEF:_handle_http_error:Function]
|
||||
|
||||
# [DEF:_handle_network_error:Function]
|
||||
# @PURPOSE: Translate generic httpx errors into NetworkError.
|
||||
# @POST: Raises NetworkError with URL context.
|
||||
def _handle_network_error(self, exc: httpx.HTTPError, url: str) -> None:
|
||||
with belief_scope("AsyncAPIClient._handle_network_error"):
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
message = "Request timeout"
|
||||
elif isinstance(exc, httpx.ConnectError):
|
||||
message = "Connection error"
|
||||
else:
|
||||
message = f"Unknown network error: {exc}"
|
||||
raise NetworkError(message, url=url) from exc
|
||||
# [/DEF:_handle_network_error:Function]
|
||||
|
||||
# [DEF:aclose:Function]
|
||||
# @PURPOSE: Close underlying httpx client.
|
||||
# @POST: Client resources are released.
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
# [/DEF:aclose:Function]
|
||||
# [/DEF:AsyncAPIClient:Class]
|
||||
|
||||
# [/DEF:backend.src.core.utils.async_network:Module]
|
||||
@@ -8,10 +8,12 @@
|
||||
# @PUBLIC_API: APIClient
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, Dict, Any, List, Union, cast
|
||||
from typing import Optional, Dict, Any, List, Union, cast, Tuple
|
||||
import json
|
||||
import io
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
import urllib3
|
||||
@@ -86,6 +88,62 @@ class NetworkError(Exception):
|
||||
# [/DEF:__init__:Function]
|
||||
# [/DEF:NetworkError:Class]
|
||||
|
||||
|
||||
# [DEF:SupersetAuthCache:Class]
|
||||
# @PURPOSE: Process-local cache for Superset access/csrf tokens keyed by environment credentials.
|
||||
# @PRE: base_url and username are stable strings.
|
||||
# @POST: Cached entries expire automatically by TTL and can be reused across requests.
|
||||
class SupersetAuthCache:
|
||||
TTL_SECONDS = 300
|
||||
|
||||
_lock = threading.Lock()
|
||||
_entries: Dict[Tuple[str, str, bool], Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def build_key(cls, base_url: str, auth: Optional[Dict[str, Any]], verify_ssl: bool) -> Tuple[str, str, bool]:
|
||||
username = ""
|
||||
if isinstance(auth, dict):
|
||||
username = str(auth.get("username") or "").strip()
|
||||
return (str(base_url or "").strip(), username, bool(verify_ssl))
|
||||
|
||||
@classmethod
|
||||
def get(cls, key: Tuple[str, str, bool]) -> Optional[Dict[str, str]]:
|
||||
now = time.time()
|
||||
with cls._lock:
|
||||
payload = cls._entries.get(key)
|
||||
if not payload:
|
||||
return None
|
||||
expires_at = float(payload.get("expires_at") or 0)
|
||||
if expires_at <= now:
|
||||
cls._entries.pop(key, None)
|
||||
return None
|
||||
tokens = payload.get("tokens")
|
||||
if not isinstance(tokens, dict):
|
||||
cls._entries.pop(key, None)
|
||||
return None
|
||||
return {
|
||||
"access_token": str(tokens.get("access_token") or ""),
|
||||
"csrf_token": str(tokens.get("csrf_token") or ""),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def set(cls, key: Tuple[str, str, bool], tokens: Dict[str, str], ttl_seconds: Optional[int] = None) -> None:
|
||||
normalized_ttl = max(int(ttl_seconds or cls.TTL_SECONDS), 1)
|
||||
with cls._lock:
|
||||
cls._entries[key] = {
|
||||
"tokens": {
|
||||
"access_token": str(tokens.get("access_token") or ""),
|
||||
"csrf_token": str(tokens.get("csrf_token") or ""),
|
||||
},
|
||||
"expires_at": time.time() + normalized_ttl,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def invalidate(cls, key: Tuple[str, str, bool]) -> None:
|
||||
with cls._lock:
|
||||
cls._entries.pop(key, None)
|
||||
# [/DEF:SupersetAuthCache:Class]
|
||||
|
||||
# [DEF:APIClient:Class]
|
||||
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
|
||||
class APIClient:
|
||||
@@ -107,6 +165,11 @@ class APIClient:
|
||||
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
|
||||
self.session = self._init_session()
|
||||
self._tokens: Dict[str, str] = {}
|
||||
self._auth_cache_key = SupersetAuthCache.build_key(
|
||||
self.base_url,
|
||||
self.auth,
|
||||
verify_ssl,
|
||||
)
|
||||
self._authenticated = False
|
||||
app_logger.info("[APIClient.__init__][Exit] APIClient initialized.")
|
||||
# [/DEF:__init__:Function]
|
||||
@@ -194,6 +257,12 @@ class APIClient:
|
||||
def authenticate(self) -> Dict[str, str]:
|
||||
with belief_scope("authenticate"):
|
||||
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
|
||||
cached_tokens = SupersetAuthCache.get(self._auth_cache_key)
|
||||
if cached_tokens and cached_tokens.get("access_token") and cached_tokens.get("csrf_token"):
|
||||
self._tokens = cached_tokens
|
||||
self._authenticated = True
|
||||
app_logger.info("[authenticate][CacheHit] Reusing cached Superset auth tokens for %s", self.base_url)
|
||||
return self._tokens
|
||||
try:
|
||||
login_url = f"{self.api_base_url}/security/login"
|
||||
# Log the payload keys and values (masking password)
|
||||
@@ -215,14 +284,17 @@ class APIClient:
|
||||
|
||||
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
|
||||
self._authenticated = True
|
||||
SupersetAuthCache.set(self._auth_cache_key, self._tokens)
|
||||
app_logger.info("[authenticate][Exit] Authenticated successfully.")
|
||||
return self._tokens
|
||||
except requests.exceptions.HTTPError as e:
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
status_code = e.response.status_code if e.response is not None else None
|
||||
if status_code in [502, 503, 504]:
|
||||
raise NetworkError(f"Environment unavailable during authentication (Status {status_code})", status_code=status_code) from e
|
||||
raise AuthenticationError(f"Authentication failed: {e}") from e
|
||||
except (requests.exceptions.RequestException, KeyError) as e:
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
raise NetworkError(f"Network or parsing error during authentication: {e}") from e
|
||||
# [/DEF:authenticate:Function]
|
||||
|
||||
@@ -263,6 +335,10 @@ class APIClient:
|
||||
response.raise_for_status()
|
||||
return response if raw_response else response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response is not None and e.response.status_code == 401:
|
||||
self._authenticated = False
|
||||
self._tokens = {}
|
||||
SupersetAuthCache.invalidate(self._auth_cache_key)
|
||||
self._handle_http_error(e, endpoint)
|
||||
except requests.exceptions.RequestException as e:
|
||||
self._handle_network_error(e, full_url)
|
||||
|
||||
@@ -14,8 +14,16 @@ from .core.config_manager import ConfigManager
|
||||
from .core.scheduler import SchedulerService
|
||||
from .services.resource_service import ResourceService
|
||||
from .services.mapping_service import MappingService
|
||||
from .services.clean_release.repositories import (
|
||||
CandidateRepository, ArtifactRepository, ManifestRepository,
|
||||
PolicyRepository, ComplianceRepository, ReportRepository,
|
||||
ApprovalRepository, PublicationRepository, AuditRepository,
|
||||
CleanReleaseAuditLog
|
||||
)
|
||||
from .services.clean_release.repository import CleanReleaseRepository
|
||||
from .core.database import init_db, get_auth_db
|
||||
from .services.clean_release.facade import CleanReleaseFacade
|
||||
from .services.reports.report_service import ReportsService
|
||||
from .core.database import init_db, get_auth_db, get_db
|
||||
from .core.logger import logger
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
@@ -55,8 +63,10 @@ logger.info("SchedulerService initialized")
|
||||
resource_service = ResourceService()
|
||||
logger.info("ResourceService initialized")
|
||||
|
||||
clean_release_repository = CleanReleaseRepository()
|
||||
logger.info("CleanReleaseRepository initialized")
|
||||
# Clean Release Redesign Singletons
|
||||
# Note: These use get_db() which is a generator, so we need a way to provide a session.
|
||||
# For singletons in dependencies.py, we might need a different approach or
|
||||
# initialize them inside the dependency functions.
|
||||
|
||||
# [DEF:get_plugin_loader:Function]
|
||||
# @PURPOSE: Dependency injector for PluginLoader.
|
||||
@@ -109,15 +119,45 @@ def get_mapping_service() -> MappingService:
|
||||
# [/DEF:get_mapping_service:Function]
|
||||
|
||||
|
||||
_clean_release_repository = CleanReleaseRepository()
|
||||
|
||||
# [DEF:get_clean_release_repository:Function]
|
||||
# @PURPOSE: Dependency injector for CleanReleaseRepository.
|
||||
# @PRE: Global clean_release_repository must be initialized.
|
||||
# @POST: Returns shared CleanReleaseRepository instance.
|
||||
# @RETURN: CleanReleaseRepository - Shared clean release repository instance.
|
||||
# @PURPOSE: Legacy compatibility shim for CleanReleaseRepository.
|
||||
# @POST: Returns a shared CleanReleaseRepository instance.
|
||||
def get_clean_release_repository() -> CleanReleaseRepository:
|
||||
return clean_release_repository
|
||||
"""Legacy compatibility shim for CleanReleaseRepository."""
|
||||
return _clean_release_repository
|
||||
# [/DEF:get_clean_release_repository:Function]
|
||||
|
||||
|
||||
# [DEF:get_clean_release_facade:Function]
|
||||
# @PURPOSE: Dependency injector for CleanReleaseFacade.
|
||||
# @POST: Returns a facade instance with a fresh DB session.
|
||||
def get_clean_release_facade(db = Depends(get_db)) -> CleanReleaseFacade:
|
||||
candidate_repo = CandidateRepository(db)
|
||||
artifact_repo = ArtifactRepository(db)
|
||||
manifest_repo = ManifestRepository(db)
|
||||
policy_repo = PolicyRepository(db)
|
||||
compliance_repo = ComplianceRepository(db)
|
||||
report_repo = ReportRepository(db)
|
||||
approval_repo = ApprovalRepository(db)
|
||||
publication_repo = PublicationRepository(db)
|
||||
audit_repo = AuditRepository(db)
|
||||
|
||||
return CleanReleaseFacade(
|
||||
candidate_repo=candidate_repo,
|
||||
artifact_repo=artifact_repo,
|
||||
manifest_repo=manifest_repo,
|
||||
policy_repo=policy_repo,
|
||||
compliance_repo=compliance_repo,
|
||||
report_repo=report_repo,
|
||||
approval_repo=approval_repo,
|
||||
publication_repo=publication_repo,
|
||||
audit_repo=audit_repo,
|
||||
config_manager=config_manager
|
||||
)
|
||||
# [/DEF:get_clean_release_facade:Function]
|
||||
|
||||
# [DEF:oauth2_scheme:Variable]
|
||||
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
3
backend/src/models/__init__.py
Normal file
3
backend/src/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.models:Package]
|
||||
# @PURPOSE: Domain model package root.
|
||||
# [/DEF:src.models:Package]
|
||||
@@ -1,228 +1,217 @@
|
||||
# [DEF:backend.src.models.clean_release:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, models, lifecycle, policy, manifest, compliance
|
||||
# @PURPOSE: Define clean release domain entities and validation contracts for enterprise compliance flow.
|
||||
# @SEMANTICS: clean-release, models, lifecycle, compliance, evidence, immutability
|
||||
# @PURPOSE: Define canonical clean release domain entities and lifecycle guards.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: BINDS_TO -> specs/023-clean-repo-enterprise/data-model.md
|
||||
# @INVARIANT: Enterprise-clean policy always forbids external sources.
|
||||
#
|
||||
# @TEST_CONTRACT CleanReleaseModels ->
|
||||
# {
|
||||
# required_fields: {
|
||||
# ReleaseCandidate: [candidate_id, version, profile, source_snapshot_ref],
|
||||
# CleanProfilePolicy: [policy_id, policy_version, internal_source_registry_ref]
|
||||
# },
|
||||
# invariants: [
|
||||
# "enterprise-clean profile enforces external_source_forbidden=True",
|
||||
# "manifest summary counts are consistent with items",
|
||||
# "compliant run requires all mandatory stages to pass"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE valid_enterprise_candidate -> {"candidate_id": "RC-001", "version": "1.0.0", "profile": "enterprise-clean", "source_snapshot_ref": "v1.0.0-snapshot"}
|
||||
# @TEST_FIXTURE valid_enterprise_policy -> {"policy_id": "POL-001", "policy_version": "1", "internal_source_registry_ref": "REG-1", "prohibited_artifact_categories": ["test-data"]}
|
||||
# @TEST_EDGE enterprise_policy_missing_prohibited -> profile=enterprise-clean with empty prohibited_artifact_categories raises ValueError
|
||||
# @TEST_EDGE enterprise_policy_external_allowed -> profile=enterprise-clean with external_source_forbidden=False raises ValueError
|
||||
# @TEST_EDGE manifest_count_mismatch -> included + excluded != len(items) raises ValueError
|
||||
# @TEST_EDGE compliant_run_stage_fail -> COMPLIANT run with failed stage raises ValueError
|
||||
# @TEST_INVARIANT policy_purity -> verifies: [valid_enterprise_policy, enterprise_policy_external_allowed]
|
||||
# @TEST_INVARIANT manifest_consistency -> verifies: [manifest_count_mismatch]
|
||||
# @TEST_INVARIANT run_integrity -> verifies: [compliant_run_stage_fail]
|
||||
# @TEST_CONTRACT: CleanReleaseModelPayload -> ValidatedCleanReleaseModel | ValidationError
|
||||
# @TEST_SCENARIO: valid_enterprise_models -> CRITICAL entities validate and preserve lifecycle/compliance invariants.
|
||||
# @TEST_FIXTURE: clean_release_models_baseline -> backend/tests/fixtures/clean_release/fixtures_clean_release.json
|
||||
# @TEST_EDGE: empty_required_identifiers -> Empty candidate_id/source_snapshot_ref/internal_source_registry_ref fails validation.
|
||||
# @TEST_EDGE: compliant_run_missing_mandatory_stage -> COMPLIANT run without all mandatory PASS stages fails validation.
|
||||
# @TEST_EDGE: blocked_report_without_blocking_violations -> BLOCKED report with zero blocking violations fails validation.
|
||||
# @TEST_INVARIANT: external_source_must_block -> VERIFIED_BY: [valid_enterprise_models, blocked_report_without_blocking_violations]
|
||||
|
||||
from __future__ import annotations
|
||||
# @INVARIANT: Immutable snapshots are never mutated; forbidden lifecycle transitions are rejected.
|
||||
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
# [DEF:ReleaseCandidateStatus:Class]
|
||||
# @PURPOSE: Lifecycle states for release candidate.
|
||||
class ReleaseCandidateStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
PREPARED = "prepared"
|
||||
COMPLIANT = "compliant"
|
||||
BLOCKED = "blocked"
|
||||
RELEASED = "released"
|
||||
# [/DEF:ReleaseCandidateStatus:Class]
|
||||
|
||||
|
||||
# [DEF:ProfileType:Class]
|
||||
# @PURPOSE: Supported profile identifiers.
|
||||
class ProfileType(str, Enum):
|
||||
ENTERPRISE_CLEAN = "enterprise-clean"
|
||||
DEVELOPMENT = "development"
|
||||
# [/DEF:ProfileType:Class]
|
||||
|
||||
|
||||
# [DEF:ClassificationType:Class]
|
||||
# @PURPOSE: Manifest classification outcomes for artifacts.
|
||||
class ClassificationType(str, Enum):
|
||||
REQUIRED_SYSTEM = "required-system"
|
||||
ALLOWED = "allowed"
|
||||
EXCLUDED_PROHIBITED = "excluded-prohibited"
|
||||
# [/DEF:ClassificationType:Class]
|
||||
|
||||
|
||||
# [DEF:RegistryStatus:Class]
|
||||
# @PURPOSE: Registry lifecycle status.
|
||||
class RegistryStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
# [/DEF:RegistryStatus:Class]
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import Column, String, DateTime, JSON, ForeignKey, Integer, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from .mapping import Base
|
||||
from ..services.clean_release.enums import (
|
||||
CandidateStatus, RunStatus, ComplianceDecision,
|
||||
ApprovalDecisionType, PublicationStatus, ClassificationType
|
||||
)
|
||||
from ..services.clean_release.exceptions import IllegalTransitionError
|
||||
|
||||
# [DEF:CheckFinalStatus:Class]
|
||||
# @PURPOSE: Final status for compliance check run.
|
||||
# @PURPOSE: Backward-compatible final status enum for legacy TUI/orchestrator tests.
|
||||
class CheckFinalStatus(str, Enum):
|
||||
RUNNING = "running"
|
||||
COMPLIANT = "compliant"
|
||||
BLOCKED = "blocked"
|
||||
FAILED = "failed"
|
||||
COMPLIANT = "COMPLIANT"
|
||||
BLOCKED = "BLOCKED"
|
||||
FAILED = "FAILED"
|
||||
# [/DEF:CheckFinalStatus:Class]
|
||||
|
||||
|
||||
# [DEF:ExecutionMode:Class]
|
||||
# @PURPOSE: Execution channel for compliance checks.
|
||||
class ExecutionMode(str, Enum):
|
||||
TUI = "tui"
|
||||
CI = "ci"
|
||||
# [/DEF:ExecutionMode:Class]
|
||||
|
||||
|
||||
# [DEF:CheckStageName:Class]
|
||||
# @PURPOSE: Mandatory check stages.
|
||||
# @PURPOSE: Backward-compatible stage name enum for legacy TUI/orchestrator tests.
|
||||
class CheckStageName(str, Enum):
|
||||
DATA_PURITY = "data_purity"
|
||||
INTERNAL_SOURCES_ONLY = "internal_sources_only"
|
||||
NO_EXTERNAL_ENDPOINTS = "no_external_endpoints"
|
||||
MANIFEST_CONSISTENCY = "manifest_consistency"
|
||||
DATA_PURITY = "DATA_PURITY"
|
||||
INTERNAL_SOURCES_ONLY = "INTERNAL_SOURCES_ONLY"
|
||||
NO_EXTERNAL_ENDPOINTS = "NO_EXTERNAL_ENDPOINTS"
|
||||
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
|
||||
# [/DEF:CheckStageName:Class]
|
||||
|
||||
|
||||
# [DEF:CheckStageStatus:Class]
|
||||
# @PURPOSE: Stage-level execution status.
|
||||
# @PURPOSE: Backward-compatible stage status enum for legacy TUI/orchestrator tests.
|
||||
class CheckStageStatus(str, Enum):
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
SKIPPED = "skipped"
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIPPED = "SKIPPED"
|
||||
RUNNING = "RUNNING"
|
||||
# [/DEF:CheckStageStatus:Class]
|
||||
|
||||
# [DEF:CheckStageResult:Class]
|
||||
# @PURPOSE: Backward-compatible stage result container for legacy TUI/orchestrator tests.
|
||||
@dataclass
|
||||
class CheckStageResult:
|
||||
stage: CheckStageName
|
||||
status: CheckStageStatus
|
||||
details: str = ""
|
||||
# [/DEF:CheckStageResult:Class]
|
||||
|
||||
# [DEF:ViolationCategory:Class]
|
||||
# @PURPOSE: Normalized compliance violation categories.
|
||||
class ViolationCategory(str, Enum):
|
||||
DATA_PURITY = "data-purity"
|
||||
EXTERNAL_SOURCE = "external-source"
|
||||
MANIFEST_INTEGRITY = "manifest-integrity"
|
||||
POLICY_CONFLICT = "policy-conflict"
|
||||
OPERATIONAL_RISK = "operational-risk"
|
||||
# [/DEF:ViolationCategory:Class]
|
||||
# [DEF:ProfileType:Class]
|
||||
# @PURPOSE: Backward-compatible profile enum for legacy TUI bootstrap logic.
|
||||
class ProfileType(str, Enum):
|
||||
ENTERPRISE_CLEAN = "enterprise-clean"
|
||||
# [/DEF:ProfileType:Class]
|
||||
|
||||
# [DEF:RegistryStatus:Class]
|
||||
# @PURPOSE: Backward-compatible registry status enum for legacy TUI bootstrap logic.
|
||||
class RegistryStatus(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
INACTIVE = "INACTIVE"
|
||||
# [/DEF:RegistryStatus:Class]
|
||||
|
||||
# [DEF:ViolationSeverity:Class]
|
||||
# @PURPOSE: Severity levels for violation triage.
|
||||
class ViolationSeverity(str, Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
# [/DEF:ViolationSeverity:Class]
|
||||
|
||||
|
||||
# [DEF:ReleaseCandidate:Class]
|
||||
# @PURPOSE: Candidate metadata for clean-release workflow.
|
||||
# @PRE: candidate_id, source_snapshot_ref are non-empty.
|
||||
# @POST: Model instance is valid for lifecycle transitions.
|
||||
class ReleaseCandidate(BaseModel):
|
||||
candidate_id: str
|
||||
version: str
|
||||
profile: ProfileType
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
source_snapshot_ref: str
|
||||
status: ReleaseCandidateStatus = ReleaseCandidateStatus.DRAFT
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_non_empty(self):
|
||||
if not self.candidate_id.strip():
|
||||
raise ValueError("candidate_id must be non-empty")
|
||||
if not self.source_snapshot_ref.strip():
|
||||
raise ValueError("source_snapshot_ref must be non-empty")
|
||||
return self
|
||||
# [/DEF:ReleaseCandidate:Class]
|
||||
|
||||
|
||||
# [DEF:CleanProfilePolicy:Class]
|
||||
# @PURPOSE: Policy contract for artifact/source decisions.
|
||||
class CleanProfilePolicy(BaseModel):
|
||||
policy_id: str
|
||||
policy_version: str
|
||||
active: bool
|
||||
prohibited_artifact_categories: List[str] = Field(default_factory=list)
|
||||
required_system_categories: List[str] = Field(default_factory=list)
|
||||
external_source_forbidden: bool = True
|
||||
internal_source_registry_ref: str
|
||||
effective_from: datetime
|
||||
effective_to: Optional[datetime] = None
|
||||
profile: ProfileType = ProfileType.ENTERPRISE_CLEAN
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_policy(self):
|
||||
if self.profile == ProfileType.ENTERPRISE_CLEAN:
|
||||
if not self.external_source_forbidden:
|
||||
raise ValueError("enterprise-clean policy requires external_source_forbidden=true")
|
||||
if not self.prohibited_artifact_categories:
|
||||
raise ValueError("enterprise-clean policy requires prohibited_artifact_categories")
|
||||
if not self.internal_source_registry_ref.strip():
|
||||
raise ValueError("internal_source_registry_ref must be non-empty")
|
||||
return self
|
||||
# [/DEF:CleanProfilePolicy:Class]
|
||||
|
||||
# [DEF:ReleaseCandidateStatus:Class]
|
||||
# @PURPOSE: Backward-compatible release candidate status enum for legacy TUI.
|
||||
class ReleaseCandidateStatus(str, Enum):
|
||||
DRAFT = CandidateStatus.DRAFT.value
|
||||
PREPARED = CandidateStatus.PREPARED.value
|
||||
MANIFEST_BUILT = CandidateStatus.MANIFEST_BUILT.value
|
||||
CHECK_PENDING = CandidateStatus.CHECK_PENDING.value
|
||||
CHECK_RUNNING = CandidateStatus.CHECK_RUNNING.value
|
||||
CHECK_PASSED = CandidateStatus.CHECK_PASSED.value
|
||||
CHECK_BLOCKED = CandidateStatus.CHECK_BLOCKED.value
|
||||
CHECK_ERROR = CandidateStatus.CHECK_ERROR.value
|
||||
APPROVED = CandidateStatus.APPROVED.value
|
||||
PUBLISHED = CandidateStatus.PUBLISHED.value
|
||||
REVOKED = CandidateStatus.REVOKED.value
|
||||
# [/DEF:ReleaseCandidateStatus:Class]
|
||||
|
||||
# [DEF:ResourceSourceEntry:Class]
|
||||
# @PURPOSE: One internal source definition.
|
||||
class ResourceSourceEntry(BaseModel):
|
||||
# @PURPOSE: Backward-compatible source entry model for legacy TUI bootstrap logic.
|
||||
@dataclass
|
||||
class ResourceSourceEntry:
|
||||
source_id: str
|
||||
host: str
|
||||
protocol: str
|
||||
purpose: str
|
||||
allowed_paths: List[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
# [/DEF:ResourceSourceEntry:Class]
|
||||
|
||||
|
||||
# [DEF:ResourceSourceRegistry:Class]
|
||||
# @PURPOSE: Allowlist of internal sources.
|
||||
class ResourceSourceRegistry(BaseModel):
|
||||
# @PURPOSE: Backward-compatible source registry model for legacy TUI bootstrap logic.
|
||||
@dataclass
|
||||
class ResourceSourceRegistry:
|
||||
registry_id: str
|
||||
name: str
|
||||
entries: List[ResourceSourceEntry]
|
||||
updated_at: datetime
|
||||
updated_by: str
|
||||
status: RegistryStatus = RegistryStatus.ACTIVE
|
||||
status: str = "ACTIVE"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_registry(self):
|
||||
if not self.entries:
|
||||
raise ValueError("registry entries cannot be empty")
|
||||
if self.status == RegistryStatus.ACTIVE and not any(e.enabled for e in self.entries):
|
||||
raise ValueError("active registry must include at least one enabled entry")
|
||||
return self
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.registry_id
|
||||
# [/DEF:ResourceSourceRegistry:Class]
|
||||
|
||||
# [DEF:CleanProfilePolicy:Class]
|
||||
# @PURPOSE: Backward-compatible policy model for legacy TUI bootstrap logic.
|
||||
@dataclass
|
||||
class CleanProfilePolicy:
|
||||
policy_id: str
|
||||
policy_version: str
|
||||
profile: str
|
||||
active: bool
|
||||
internal_source_registry_ref: str
|
||||
prohibited_artifact_categories: List[str]
|
||||
effective_from: datetime
|
||||
required_system_categories: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.policy_id
|
||||
|
||||
@property
|
||||
def registry_snapshot_id(self) -> str:
|
||||
return self.internal_source_registry_ref
|
||||
# [/DEF:CleanProfilePolicy:Class]
|
||||
|
||||
# [DEF:ComplianceCheckRun:Class]
|
||||
# @PURPOSE: Backward-compatible run model for legacy TUI typing/import compatibility.
|
||||
@dataclass
|
||||
class ComplianceCheckRun:
|
||||
check_run_id: str
|
||||
candidate_id: str
|
||||
policy_id: str
|
||||
requested_by: str
|
||||
execution_mode: str
|
||||
checks: List[CheckStageResult]
|
||||
final_status: CheckFinalStatus
|
||||
# [/DEF:ComplianceCheckRun:Class]
|
||||
|
||||
# [DEF:ReleaseCandidate:Class]
|
||||
# @PURPOSE: Represents the release unit being prepared and governed.
|
||||
# @PRE: id, version, source_snapshot_ref are non-empty.
|
||||
# @POST: status advances only through legal transitions.
|
||||
class ReleaseCandidate(Base):
|
||||
__tablename__ = "clean_release_candidates"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
name = Column(String, nullable=True) # Added back for backward compatibility with some legacy DTOs
|
||||
version = Column(String, nullable=False)
|
||||
source_snapshot_ref = Column(String, nullable=False)
|
||||
build_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_by = Column(String, nullable=False)
|
||||
status = Column(String, default=CandidateStatus.DRAFT)
|
||||
|
||||
@property
|
||||
def candidate_id(self) -> str:
|
||||
return self.id
|
||||
|
||||
def transition_to(self, new_status: CandidateStatus):
|
||||
"""
|
||||
@PURPOSE: Enforce legal state transitions.
|
||||
@PRE: Transition must be allowed by lifecycle rules.
|
||||
"""
|
||||
allowed = {
|
||||
CandidateStatus.DRAFT: [CandidateStatus.PREPARED],
|
||||
CandidateStatus.PREPARED: [CandidateStatus.MANIFEST_BUILT],
|
||||
CandidateStatus.MANIFEST_BUILT: [CandidateStatus.CHECK_PENDING],
|
||||
CandidateStatus.CHECK_PENDING: [CandidateStatus.CHECK_RUNNING],
|
||||
CandidateStatus.CHECK_RUNNING: [
|
||||
CandidateStatus.CHECK_PASSED,
|
||||
CandidateStatus.CHECK_BLOCKED,
|
||||
CandidateStatus.CHECK_ERROR
|
||||
],
|
||||
CandidateStatus.CHECK_PASSED: [CandidateStatus.APPROVED, CandidateStatus.CHECK_PENDING],
|
||||
CandidateStatus.CHECK_BLOCKED: [CandidateStatus.CHECK_PENDING],
|
||||
CandidateStatus.CHECK_ERROR: [CandidateStatus.CHECK_PENDING],
|
||||
CandidateStatus.APPROVED: [CandidateStatus.PUBLISHED],
|
||||
CandidateStatus.PUBLISHED: [CandidateStatus.REVOKED],
|
||||
CandidateStatus.REVOKED: []
|
||||
}
|
||||
current_status = CandidateStatus(self.status)
|
||||
if new_status not in allowed.get(current_status, []):
|
||||
raise IllegalTransitionError(f"Forbidden transition from {current_status} to {new_status}")
|
||||
self.status = new_status.value
|
||||
# [/DEF:ReleaseCandidate:Class]
|
||||
|
||||
# [DEF:CandidateArtifact:Class]
|
||||
# @PURPOSE: Represents one artifact associated with a release candidate.
|
||||
class CandidateArtifact(Base):
|
||||
__tablename__ = "clean_release_artifacts"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
path = Column(String, nullable=False)
|
||||
sha256 = Column(String, nullable=False)
|
||||
size = Column(Integer, nullable=False)
|
||||
detected_category = Column(String, nullable=True)
|
||||
declared_category = Column(String, nullable=True)
|
||||
source_uri = Column(String, nullable=True)
|
||||
source_host = Column(String, nullable=True)
|
||||
metadata_json = Column(JSON, default=dict)
|
||||
# [/DEF:CandidateArtifact:Class]
|
||||
|
||||
# [DEF:ManifestItem:Class]
|
||||
# @PURPOSE: One artifact entry in manifest.
|
||||
class ManifestItem(BaseModel):
|
||||
@dataclass
|
||||
class ManifestItem:
|
||||
path: str
|
||||
category: str
|
||||
classification: ClassificationType
|
||||
@@ -230,119 +219,218 @@ class ManifestItem(BaseModel):
|
||||
checksum: Optional[str] = None
|
||||
# [/DEF:ManifestItem:Class]
|
||||
|
||||
|
||||
# [DEF:ManifestSummary:Class]
|
||||
# @PURPOSE: Aggregate counters for manifest decisions.
|
||||
class ManifestSummary(BaseModel):
|
||||
included_count: int = Field(ge=0)
|
||||
excluded_count: int = Field(ge=0)
|
||||
prohibited_detected_count: int = Field(ge=0)
|
||||
@dataclass
|
||||
class ManifestSummary:
|
||||
included_count: int
|
||||
excluded_count: int
|
||||
prohibited_detected_count: int
|
||||
# [/DEF:ManifestSummary:Class]
|
||||
|
||||
|
||||
# [DEF:DistributionManifest:Class]
|
||||
# @PURPOSE: Deterministic release composition for audit.
|
||||
class DistributionManifest(BaseModel):
|
||||
manifest_id: str
|
||||
candidate_id: str
|
||||
policy_id: str
|
||||
generated_at: datetime
|
||||
generated_by: str
|
||||
items: List[ManifestItem]
|
||||
summary: ManifestSummary
|
||||
deterministic_hash: str
|
||||
# @PURPOSE: Immutable snapshot of the candidate payload.
|
||||
# @INVARIANT: Immutable after creation.
|
||||
class DistributionManifest(Base):
|
||||
__tablename__ = "clean_release_manifests"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
manifest_version = Column(Integer, nullable=False)
|
||||
manifest_digest = Column(String, nullable=False)
|
||||
artifacts_digest = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_by = Column(String, nullable=False)
|
||||
source_snapshot_ref = Column(String, nullable=False)
|
||||
content_json = Column(JSON, nullable=False)
|
||||
immutable = Column(Boolean, default=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_counts(self):
|
||||
if self.summary.included_count + self.summary.excluded_count != len(self.items):
|
||||
raise ValueError("manifest summary counts must match items size")
|
||||
return self
|
||||
# Redesign compatibility fields (not persisted directly but used by builder/facade)
|
||||
def __init__(self, **kwargs):
|
||||
# Handle fields from manifest_builder.py
|
||||
if "manifest_id" in kwargs:
|
||||
kwargs["id"] = kwargs.pop("manifest_id")
|
||||
if "generated_at" in kwargs:
|
||||
kwargs["created_at"] = kwargs.pop("generated_at")
|
||||
if "generated_by" in kwargs:
|
||||
kwargs["created_by"] = kwargs.pop("generated_by")
|
||||
if "deterministic_hash" in kwargs:
|
||||
kwargs["manifest_digest"] = kwargs.pop("deterministic_hash")
|
||||
|
||||
# Ensure required DB fields have defaults if missing
|
||||
if "manifest_version" not in kwargs:
|
||||
kwargs["manifest_version"] = 1
|
||||
if "artifacts_digest" not in kwargs:
|
||||
kwargs["artifacts_digest"] = kwargs.get("manifest_digest", "pending")
|
||||
if "source_snapshot_ref" not in kwargs:
|
||||
kwargs["source_snapshot_ref"] = "pending"
|
||||
|
||||
# Pack items and summary into content_json if provided
|
||||
if "items" in kwargs or "summary" in kwargs:
|
||||
content = kwargs.get("content_json", {})
|
||||
if "items" in kwargs:
|
||||
items = kwargs.pop("items")
|
||||
content["items"] = [
|
||||
{
|
||||
"path": i.path,
|
||||
"category": i.category,
|
||||
"classification": i.classification.value,
|
||||
"reason": i.reason,
|
||||
"checksum": i.checksum
|
||||
} for i in items
|
||||
]
|
||||
if "summary" in kwargs:
|
||||
summary = kwargs.pop("summary")
|
||||
content["summary"] = {
|
||||
"included_count": summary.included_count,
|
||||
"excluded_count": summary.excluded_count,
|
||||
"prohibited_detected_count": summary.prohibited_detected_count
|
||||
}
|
||||
kwargs["content_json"] = content
|
||||
|
||||
super().__init__(**kwargs)
|
||||
# [/DEF:DistributionManifest:Class]
|
||||
|
||||
# [DEF:SourceRegistrySnapshot:Class]
|
||||
# @PURPOSE: Immutable registry snapshot for allowed sources.
|
||||
class SourceRegistrySnapshot(Base):
|
||||
__tablename__ = "clean_release_registry_snapshots"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
registry_id = Column(String, nullable=False)
|
||||
registry_version = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
allowed_hosts = Column(JSON, nullable=False) # List[str]
|
||||
allowed_schemes = Column(JSON, nullable=False) # List[str]
|
||||
allowed_source_types = Column(JSON, nullable=False) # List[str]
|
||||
immutable = Column(Boolean, default=True)
|
||||
# [/DEF:SourceRegistrySnapshot:Class]
|
||||
|
||||
# [DEF:CheckStageResult:Class]
|
||||
# @PURPOSE: Per-stage compliance result.
|
||||
class CheckStageResult(BaseModel):
|
||||
stage: CheckStageName
|
||||
status: CheckStageStatus
|
||||
details: Optional[str] = None
|
||||
duration_ms: Optional[int] = Field(default=None, ge=0)
|
||||
# [/DEF:CheckStageResult:Class]
|
||||
# [DEF:CleanPolicySnapshot:Class]
|
||||
# @PURPOSE: Immutable policy snapshot used to evaluate a run.
|
||||
class CleanPolicySnapshot(Base):
|
||||
__tablename__ = "clean_release_policy_snapshots"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
policy_id = Column(String, nullable=False)
|
||||
policy_version = Column(String, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
content_json = Column(JSON, nullable=False)
|
||||
registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False)
|
||||
immutable = Column(Boolean, default=True)
|
||||
# [/DEF:CleanPolicySnapshot:Class]
|
||||
|
||||
# [DEF:ComplianceRun:Class]
|
||||
# @PURPOSE: Operational record for one compliance execution.
|
||||
class ComplianceRun(Base):
|
||||
__tablename__ = "clean_release_compliance_runs"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
manifest_id = Column(String, ForeignKey("clean_release_manifests.id"), nullable=False)
|
||||
manifest_digest = Column(String, nullable=False)
|
||||
policy_snapshot_id = Column(String, ForeignKey("clean_release_policy_snapshots.id"), nullable=False)
|
||||
registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False)
|
||||
requested_by = Column(String, nullable=False)
|
||||
requested_at = Column(DateTime, default=datetime.utcnow)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
status = Column(String, default=RunStatus.PENDING)
|
||||
final_status = Column(String, nullable=True) # ComplianceDecision
|
||||
failure_reason = Column(String, nullable=True)
|
||||
task_id = Column(String, nullable=True)
|
||||
|
||||
# [DEF:ComplianceCheckRun:Class]
|
||||
# @PURPOSE: One execution run of compliance pipeline.
|
||||
class ComplianceCheckRun(BaseModel):
|
||||
check_run_id: str
|
||||
candidate_id: str
|
||||
policy_id: str
|
||||
started_at: datetime
|
||||
finished_at: Optional[datetime] = None
|
||||
final_status: CheckFinalStatus = CheckFinalStatus.RUNNING
|
||||
triggered_by: str
|
||||
execution_mode: ExecutionMode
|
||||
checks: List[CheckStageResult] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_terminal_integrity(self):
|
||||
if self.final_status == CheckFinalStatus.COMPLIANT:
|
||||
mandatory = {c.stage: c.status for c in self.checks}
|
||||
required = {
|
||||
CheckStageName.DATA_PURITY,
|
||||
CheckStageName.INTERNAL_SOURCES_ONLY,
|
||||
CheckStageName.NO_EXTERNAL_ENDPOINTS,
|
||||
CheckStageName.MANIFEST_CONSISTENCY,
|
||||
}
|
||||
if not required.issubset(mandatory.keys()):
|
||||
raise ValueError("compliant run requires all mandatory stages")
|
||||
if any(mandatory[s] != CheckStageStatus.PASS for s in required):
|
||||
raise ValueError("compliant run requires PASS on all mandatory stages")
|
||||
return self
|
||||
# [/DEF:ComplianceCheckRun:Class]
|
||||
@property
|
||||
def check_run_id(self) -> str:
|
||||
return self.id
|
||||
# [/DEF:ComplianceRun:Class]
|
||||
|
||||
# [DEF:ComplianceStageRun:Class]
|
||||
# @PURPOSE: Stage-level execution record inside a run.
|
||||
class ComplianceStageRun(Base):
|
||||
__tablename__ = "clean_release_compliance_stage_runs"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
|
||||
stage_name = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
decision = Column(String, nullable=True) # ComplianceDecision
|
||||
details_json = Column(JSON, default=dict)
|
||||
# [/DEF:ComplianceStageRun:Class]
|
||||
|
||||
# [DEF:ComplianceViolation:Class]
|
||||
# @PURPOSE: Normalized violation row for triage and blocking decisions.
|
||||
class ComplianceViolation(BaseModel):
|
||||
violation_id: str
|
||||
check_run_id: str
|
||||
category: ViolationCategory
|
||||
severity: ViolationSeverity
|
||||
location: str
|
||||
evidence: Optional[str] = None
|
||||
remediation: str
|
||||
blocked_release: bool
|
||||
detected_at: datetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_violation(self):
|
||||
if self.category == ViolationCategory.EXTERNAL_SOURCE and not self.blocked_release:
|
||||
raise ValueError("external-source violation must block release")
|
||||
if self.severity == ViolationSeverity.CRITICAL and not self.remediation.strip():
|
||||
raise ValueError("critical violation requires remediation")
|
||||
return self
|
||||
# @PURPOSE: Violation produced by a stage.
|
||||
class ComplianceViolation(Base):
|
||||
__tablename__ = "clean_release_compliance_violations"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
|
||||
stage_name = Column(String, nullable=False)
|
||||
code = Column(String, nullable=False)
|
||||
severity = Column(String, nullable=False)
|
||||
artifact_path = Column(String, nullable=True)
|
||||
artifact_sha256 = Column(String, nullable=True)
|
||||
message = Column(String, nullable=False)
|
||||
evidence_json = Column(JSON, default=dict)
|
||||
# [/DEF:ComplianceViolation:Class]
|
||||
|
||||
|
||||
# [DEF:ComplianceReport:Class]
|
||||
# @PURPOSE: Final report payload for operator and audit systems.
|
||||
class ComplianceReport(BaseModel):
|
||||
report_id: str
|
||||
check_run_id: str
|
||||
candidate_id: str
|
||||
generated_at: datetime
|
||||
final_status: CheckFinalStatus
|
||||
operator_summary: str
|
||||
structured_payload_ref: str
|
||||
violations_count: int = Field(ge=0)
|
||||
blocking_violations_count: int = Field(ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_report_counts(self):
|
||||
if self.blocking_violations_count > self.violations_count:
|
||||
raise ValueError("blocking_violations_count cannot exceed violations_count")
|
||||
if self.final_status == CheckFinalStatus.BLOCKED and self.blocking_violations_count <= 0:
|
||||
raise ValueError("blocked report requires blocking violations")
|
||||
return self
|
||||
# @PURPOSE: Immutable result derived from a completed run.
|
||||
# @INVARIANT: Immutable after creation.
|
||||
class ComplianceReport(Base):
|
||||
__tablename__ = "clean_release_compliance_reports"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
run_id = Column(String, ForeignKey("clean_release_compliance_runs.id"), nullable=False)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
final_status = Column(String, nullable=False) # ComplianceDecision
|
||||
summary_json = Column(JSON, nullable=False)
|
||||
generated_at = Column(DateTime, default=datetime.utcnow)
|
||||
immutable = Column(Boolean, default=True)
|
||||
# [/DEF:ComplianceReport:Class]
|
||||
|
||||
# [DEF:ApprovalDecision:Class]
|
||||
# @PURPOSE: Approval or rejection bound to a candidate and report.
|
||||
class ApprovalDecision(Base):
|
||||
__tablename__ = "clean_release_approval_decisions"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False)
|
||||
decision = Column(String, nullable=False) # ApprovalDecisionType
|
||||
decided_by = Column(String, nullable=False)
|
||||
decided_at = Column(DateTime, default=datetime.utcnow)
|
||||
comment = Column(String, nullable=True)
|
||||
# [/DEF:ApprovalDecision:Class]
|
||||
|
||||
# [DEF:PublicationRecord:Class]
|
||||
# @PURPOSE: Publication or revocation record.
|
||||
class PublicationRecord(Base):
|
||||
__tablename__ = "clean_release_publication_records"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False)
|
||||
report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False)
|
||||
published_by = Column(String, nullable=False)
|
||||
published_at = Column(DateTime, default=datetime.utcnow)
|
||||
target_channel = Column(String, nullable=False)
|
||||
publication_ref = Column(String, nullable=True)
|
||||
status = Column(String, default=PublicationStatus.ACTIVE)
|
||||
# [/DEF:PublicationRecord:Class]
|
||||
|
||||
# [DEF:CleanReleaseAuditLog:Class]
|
||||
# @PURPOSE: Represents a persistent audit log entry for clean release actions.
|
||||
import uuid
|
||||
class CleanReleaseAuditLog(Base):
|
||||
__tablename__ = "clean_release_audit_logs"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
candidate_id = Column(String, index=True, nullable=True)
|
||||
action = Column(String, nullable=False) # e.g. "TRANSITION", "APPROVE", "PUBLISH"
|
||||
actor = Column(String, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
details_json = Column(JSON, default=dict)
|
||||
# [/DEF:CleanReleaseAuditLog:Class]
|
||||
|
||||
# [/DEF:backend.src.models.clean_release:Module]
|
||||
@@ -1,19 +1,25 @@
|
||||
# [DEF:backend.src.models.config:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, config, settings, sqlalchemy
|
||||
# @PURPOSE: Defines database schema for persisted application configuration.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: database, config, settings, sqlalchemy, notification
|
||||
# @PURPOSE: Defines SQLAlchemy persistence models for application and notification configuration records.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[sqlalchemy]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.mapping:Base]
|
||||
# @INVARIANT: Configuration payload and notification credentials must remain persisted as non-null JSON documents.
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, JSON
|
||||
from sqlalchemy import Column, String, DateTime, JSON, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .mapping import Base
|
||||
|
||||
|
||||
# [DEF:AppConfigRecord:Class]
|
||||
# @PURPOSE: Stores the single source of truth for application configuration.
|
||||
# @PURPOSE: Stores persisted application configuration as a single authoritative record model.
|
||||
# @PRE: SQLAlchemy declarative Base is initialized and table metadata registration is active.
|
||||
# @POST: ORM table 'app_configurations' exposes id, payload, and updated_at fields with declared nullability/default semantics.
|
||||
# @SIDE_EFFECT: Registers ORM mapping metadata during module import.
|
||||
# @DATA_CONTRACT: Input -> persistence row {id:str, payload:json, updated_at:datetime}; Output -> AppConfigRecord ORM entity.
|
||||
class AppConfigRecord(Base):
|
||||
__tablename__ = "app_configurations"
|
||||
|
||||
@@ -23,4 +29,25 @@ class AppConfigRecord(Base):
|
||||
|
||||
|
||||
# [/DEF:AppConfigRecord:Class]
|
||||
|
||||
# [DEF:NotificationConfig:Class]
|
||||
# @PURPOSE: Stores persisted provider-level notification configuration and encrypted credentials metadata.
|
||||
# @PRE: SQLAlchemy declarative Base is initialized and uuid generation is available at instance creation time.
|
||||
# @POST: ORM table 'notification_configs' exposes id, type, name, credentials, is_active, created_at, updated_at fields with declared constraints/defaults.
|
||||
# @SIDE_EFFECT: Registers ORM mapping metadata during module import; may generate UUID values for new entity instances.
|
||||
# @DATA_CONTRACT: Input -> persistence row {id:str, type:str, name:str, credentials:json, is_active:bool, created_at:datetime, updated_at:datetime}; Output -> NotificationConfig ORM entity.
|
||||
class NotificationConfig(Base):
|
||||
__tablename__ = "notification_configs"
|
||||
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
type = Column(String, nullable=False) # SMTP, SLACK, TELEGRAM
|
||||
name = Column(String, nullable=False)
|
||||
credentials = Column(JSON, nullable=False) # Encrypted connection details
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
# [/DEF:NotificationConfig:Class]
|
||||
|
||||
import uuid
|
||||
|
||||
# [/DEF:backend.src.models.config:Module]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text, Time, ForeignKey
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from .mapping import Base
|
||||
@@ -13,6 +13,26 @@ from .mapping import Base
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# [DEF:ValidationPolicy:Class]
|
||||
# @PURPOSE: Defines a scheduled rule for validating a group of dashboards within an execution window.
|
||||
class ValidationPolicy(Base):
|
||||
__tablename__ = "validation_policies"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
name = Column(String, nullable=False)
|
||||
environment_id = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
dashboard_ids = Column(JSON, nullable=False) # Array of dashboard IDs
|
||||
schedule_days = Column(JSON, nullable=False) # Array of integers (0-6)
|
||||
window_start = Column(Time, nullable=False)
|
||||
window_end = Column(Time, nullable=False)
|
||||
notify_owners = Column(Boolean, default=True)
|
||||
custom_channels = Column(JSON, nullable=True) # List of external channels
|
||||
alert_condition = Column(String, default="FAIL_ONLY") # FAIL_ONLY, WARN_AND_FAIL, ALWAYS
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
# [/DEF:ValidationPolicy:Class]
|
||||
|
||||
# [DEF:LLMProvider:Class]
|
||||
# @PURPOSE: SQLAlchemy model for LLM provider configuration.
|
||||
class LLMProvider(Base):
|
||||
@@ -34,9 +54,11 @@ class ValidationRecord(Base):
|
||||
__tablename__ = "llm_validation_results"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
task_id = Column(String, nullable=True, index=True) # Reference to TaskRecord
|
||||
dashboard_id = Column(String, nullable=False, index=True)
|
||||
environment_id = Column(String, nullable=True, index=True)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String, nullable=False) # PASS, WARN, FAIL
|
||||
status = Column(String, nullable=False) # PASS, WARN, FAIL, UNKNOWN
|
||||
screenshot_path = Column(String, nullable=True)
|
||||
issues = Column(JSON, nullable=False)
|
||||
summary = Column(Text, nullable=False)
|
||||
|
||||
@@ -80,6 +80,8 @@ class MigrationJob(Base):
|
||||
status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING)
|
||||
replace_db = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
# [/DEF:MigrationJob:Class]
|
||||
|
||||
# [DEF:ResourceMapping:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Maps a universal UUID for a resource to its actual ID on a specific environment.
|
||||
|
||||
@@ -32,6 +32,7 @@ class UserDashboardPreference(Base):
|
||||
superset_username_normalized = Column(String, nullable=True, index=True)
|
||||
|
||||
show_only_my_dashboards = Column(Boolean, nullable=False, default=False)
|
||||
show_only_slug_dashboards = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
git_username = Column(String, nullable=True)
|
||||
git_email = Column(String, nullable=True)
|
||||
@@ -41,6 +42,10 @@ class UserDashboardPreference(Base):
|
||||
auto_open_task_drawer = Column(Boolean, nullable=False, default=True)
|
||||
dashboards_table_density = Column(String, nullable=False, default="comfortable")
|
||||
|
||||
telegram_id = Column(String, nullable=True)
|
||||
email_address = Column(String, nullable=True)
|
||||
notify_on_fail = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
@@ -52,4 +57,4 @@ class UserDashboardPreference(Base):
|
||||
user = relationship("User")
|
||||
# [/DEF:UserDashboardPreference:Class]
|
||||
|
||||
# [/DEF:backend.src.models.profile:Module]
|
||||
# [/DEF:backend.src.models.profile:Module]
|
||||
|
||||
@@ -25,6 +25,7 @@ class TaskType(str, Enum):
|
||||
BACKUP = "backup"
|
||||
MIGRATION = "migration"
|
||||
DOCUMENTATION = "documentation"
|
||||
CLEAN_RELEASE = "clean_release"
|
||||
UNKNOWN = "unknown"
|
||||
# [/DEF:TaskType:Class]
|
||||
|
||||
@@ -111,6 +112,7 @@ class TaskReport(BaseModel):
|
||||
updated_at: datetime
|
||||
summary: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
validation_record: Optional[Dict[str, Any]] = None # Extended for US2
|
||||
error_context: Optional[ErrorContext] = None
|
||||
source_ref: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
3
backend/src/plugins/__init__.py
Normal file
3
backend/src/plugins/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.plugins:Package]
|
||||
# @PURPOSE: Plugin package root for dynamic discovery and runtime imports.
|
||||
# [/DEF:src.plugins:Package]
|
||||
3
backend/src/plugins/git/__init__.py
Normal file
3
backend/src/plugins/git/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.plugins.git:Package]
|
||||
# @PURPOSE: Git plugin extension package root.
|
||||
# [/DEF:src.plugins.git:Package]
|
||||
@@ -21,8 +21,9 @@ from ...services.llm_provider import LLMProviderService
|
||||
from ...core.superset_client import SupersetClient
|
||||
from .service import ScreenshotService, LLMClient
|
||||
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
|
||||
from ...models.llm import ValidationRecord
|
||||
from ...models.llm import ValidationRecord, ValidationPolicy
|
||||
from ...core.task_manager.context import TaskContext
|
||||
from ...services.notifications.service import NotificationService
|
||||
from ...services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
is_multimodal_model,
|
||||
@@ -283,7 +284,9 @@ class DashboardValidationPlugin(PluginBase):
|
||||
}
|
||||
|
||||
db_record = ValidationRecord(
|
||||
task_id=context.task_id if context else None,
|
||||
dashboard_id=validation_result.dashboard_id,
|
||||
environment_id=env_id,
|
||||
status=validation_result.status.value,
|
||||
summary=validation_result.summary,
|
||||
issues=[issue.dict() for issue in validation_result.issues],
|
||||
@@ -294,11 +297,20 @@ class DashboardValidationPlugin(PluginBase):
|
||||
db.commit()
|
||||
|
||||
# 7. Notification on failure (US1 / FR-015)
|
||||
if validation_result.status == ValidationStatus.FAIL:
|
||||
log.warning(f"Dashboard {dashboard_id} validation FAILED. Summary: {validation_result.summary}")
|
||||
# Placeholder for Email/Pulse notification dispatch
|
||||
# In a real implementation, we would call a NotificationService here
|
||||
# with a payload containing the summary and a link to the report.
|
||||
try:
|
||||
policy_id = params.get("policy_id")
|
||||
policy = None
|
||||
if policy_id:
|
||||
policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == policy_id).first()
|
||||
|
||||
notification_service = NotificationService(db, config_mgr)
|
||||
await notification_service.dispatch_report(
|
||||
record=db_record,
|
||||
policy=policy,
|
||||
background_tasks=context.background_tasks if context else None
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to dispatch notifications: {e}")
|
||||
|
||||
# Final log to ensure all analysis is visible in task logs
|
||||
log.info(f"Validation completed for dashboard {dashboard_id}. Status: {validation_result.status.value}")
|
||||
|
||||
3
backend/src/schemas/__init__.py
Normal file
3
backend/src/schemas/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.schemas:Package]
|
||||
# @PURPOSE: API schema package root.
|
||||
# [/DEF:src.schemas:Package]
|
||||
@@ -0,0 +1,84 @@
|
||||
# [DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Regression tests for settings and health schema contracts updated in 026 fix batch.
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.schemas.health import DashboardHealthItem
|
||||
from src.schemas.settings import ValidationPolicyCreate
|
||||
|
||||
|
||||
# [DEF:test_validation_policy_create_accepts_structured_custom_channels:Function]
|
||||
# @PURPOSE: Ensure policy schema accepts structured custom channel objects with type/target fields.
|
||||
def test_validation_policy_create_accepts_structured_custom_channels():
|
||||
payload = {
|
||||
"name": "Daily Health",
|
||||
"environment_id": "env-1",
|
||||
"dashboard_ids": ["10", "11"],
|
||||
"schedule_days": [0, 1, 2],
|
||||
"window_start": "01:00:00",
|
||||
"window_end": "03:00:00",
|
||||
"notify_owners": True,
|
||||
"custom_channels": [{"type": "SLACK", "target": "#alerts"}],
|
||||
"alert_condition": "FAIL_ONLY",
|
||||
}
|
||||
|
||||
policy = ValidationPolicyCreate(**payload)
|
||||
|
||||
assert policy.custom_channels is not None
|
||||
assert len(policy.custom_channels) == 1
|
||||
assert policy.custom_channels[0].type == "SLACK"
|
||||
assert policy.custom_channels[0].target == "#alerts"
|
||||
# [/DEF:test_validation_policy_create_accepts_structured_custom_channels:Function]
|
||||
|
||||
|
||||
# [DEF:test_validation_policy_create_rejects_legacy_string_custom_channels:Function]
|
||||
# @PURPOSE: Ensure legacy list[str] custom channel payload is rejected by typed channel contract.
|
||||
def test_validation_policy_create_rejects_legacy_string_custom_channels():
|
||||
payload = {
|
||||
"name": "Daily Health",
|
||||
"environment_id": "env-1",
|
||||
"dashboard_ids": ["10"],
|
||||
"schedule_days": [0],
|
||||
"window_start": "01:00:00",
|
||||
"window_end": "02:00:00",
|
||||
"notify_owners": False,
|
||||
"custom_channels": ["SLACK:#alerts"],
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ValidationPolicyCreate(**payload)
|
||||
# [/DEF:test_validation_policy_create_rejects_legacy_string_custom_channels:Function]
|
||||
|
||||
|
||||
# [DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function]
|
||||
# @PURPOSE: Verify strict grouped regex only accepts PASS/WARN/FAIL/UNKNOWN exact statuses.
|
||||
def test_dashboard_health_item_status_accepts_only_whitelisted_values():
|
||||
valid = DashboardHealthItem(
|
||||
dashboard_id="dash-1",
|
||||
environment_id="env-1",
|
||||
status="PASS",
|
||||
last_check="2026-03-10T10:00:00",
|
||||
)
|
||||
assert valid.status == "PASS"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DashboardHealthItem(
|
||||
dashboard_id="dash-1",
|
||||
environment_id="env-1",
|
||||
status="PASSING",
|
||||
last_check="2026-03-10T10:00:00",
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
DashboardHealthItem(
|
||||
dashboard_id="dash-1",
|
||||
environment_id="env-1",
|
||||
status="FAIL ",
|
||||
last_check="2026-03-10T10:00:00",
|
||||
)
|
||||
# [/DEF:test_dashboard_health_item_status_accepts_only_whitelisted_values:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.schemas.__tests__.test_settings_and_health_schemas:Module]
|
||||
33
backend/src/schemas/health.py
Normal file
33
backend/src/schemas/health.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# [DEF:backend.src.schemas.health:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: health, schemas, pydantic
|
||||
# @PURPOSE: Pydantic schemas for dashboard health summary.
|
||||
# @LAYER: Domain
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
# [DEF:DashboardHealthItem:Class]
|
||||
# @PURPOSE: Represents the latest health status of a single dashboard.
|
||||
class DashboardHealthItem(BaseModel):
|
||||
dashboard_id: str
|
||||
dashboard_title: Optional[str] = None
|
||||
environment_id: str
|
||||
status: str = Field(..., pattern="^(PASS|WARN|FAIL|UNKNOWN)$")
|
||||
last_check: datetime
|
||||
task_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
# [/DEF:DashboardHealthItem:Class]
|
||||
|
||||
# [DEF:HealthSummaryResponse:Class]
|
||||
# @PURPOSE: Aggregated health summary for all dashboards.
|
||||
class HealthSummaryResponse(BaseModel):
|
||||
items: List[DashboardHealthItem]
|
||||
pass_count: int
|
||||
warn_count: int
|
||||
fail_count: int
|
||||
unknown_count: int
|
||||
# [/DEF:HealthSummaryResponse:Class]
|
||||
|
||||
# [/DEF:backend.src.schemas.health:Module]
|
||||
@@ -45,6 +45,7 @@ class ProfilePreference(BaseModel):
|
||||
superset_username: Optional[str] = None
|
||||
superset_username_normalized: Optional[str] = None
|
||||
show_only_my_dashboards: bool = False
|
||||
show_only_slug_dashboards: bool = True
|
||||
|
||||
git_username: Optional[str] = None
|
||||
git_email: Optional[str] = None
|
||||
@@ -55,6 +56,10 @@ class ProfilePreference(BaseModel):
|
||||
auto_open_task_drawer: bool = True
|
||||
dashboards_table_density: Literal["compact", "comfortable"] = "comfortable"
|
||||
|
||||
telegram_id: Optional[str] = None
|
||||
email_address: Optional[str] = None
|
||||
notify_on_fail: bool = True
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -75,6 +80,10 @@ class ProfilePreferenceUpdateRequest(BaseModel):
|
||||
default=None,
|
||||
description='When true, "/dashboards" can auto-apply profile filter in main context.',
|
||||
)
|
||||
show_only_slug_dashboards: Optional[bool] = Field(
|
||||
default=None,
|
||||
description='When true, "/dashboards" hides dashboards without slug by default.',
|
||||
)
|
||||
git_username: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Git author username used for commit signature.",
|
||||
@@ -103,6 +112,18 @@ class ProfilePreferenceUpdateRequest(BaseModel):
|
||||
default=None,
|
||||
description="Preferred table density for dashboard listings.",
|
||||
)
|
||||
telegram_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Telegram ID for notifications.",
|
||||
)
|
||||
email_address: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Email address for notifications (overrides system email).",
|
||||
)
|
||||
notify_on_fail: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Whether to send notifications on validation failure.",
|
||||
)
|
||||
# [/DEF:ProfilePreferenceUpdateRequest:Class]
|
||||
|
||||
|
||||
@@ -156,4 +177,4 @@ class SupersetAccountLookupResponse(BaseModel):
|
||||
items: List[SupersetAccountCandidate] = Field(default_factory=list)
|
||||
# [/DEF:SupersetAccountLookupResponse:Class]
|
||||
|
||||
# [/DEF:backend.src.schemas.profile:Module]
|
||||
# [/DEF:backend.src.schemas.profile:Module]
|
||||
|
||||
68
backend/src/schemas/settings.py
Normal file
68
backend/src/schemas/settings.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# [DEF:backend.src.schemas.settings:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: settings, schemas, pydantic, validation
|
||||
# @PURPOSE: Pydantic schemas for application settings and automation policies.
|
||||
# @LAYER: Domain
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, time
|
||||
|
||||
# [DEF:NotificationChannel:Class]
|
||||
# @PURPOSE: Structured notification channel definition for policy-level custom routing.
|
||||
class NotificationChannel(BaseModel):
|
||||
type: str = Field(..., description="Notification channel type (e.g., SLACK, SMTP, TELEGRAM)")
|
||||
target: str = Field(..., description="Notification destination (e.g., #alerts, chat id, email)")
|
||||
# [/DEF:NotificationChannel:Class]
|
||||
|
||||
# [DEF:ValidationPolicyBase:Class]
|
||||
# @PURPOSE: Base schema for validation policy data.
|
||||
class ValidationPolicyBase(BaseModel):
|
||||
name: str = Field(..., description="Name of the policy")
|
||||
environment_id: str = Field(..., description="Target Superset environment ID")
|
||||
is_active: bool = Field(True, description="Whether the policy is currently active")
|
||||
dashboard_ids: List[str] = Field(..., description="List of dashboard IDs to validate")
|
||||
schedule_days: List[int] = Field(..., description="Days of the week (0-6, 0=Sunday) to run")
|
||||
window_start: time = Field(..., description="Start of the execution window")
|
||||
window_end: time = Field(..., description="End of the execution window")
|
||||
notify_owners: bool = Field(True, description="Whether to notify dashboard owners on failure")
|
||||
custom_channels: Optional[List[NotificationChannel]] = Field(
|
||||
None,
|
||||
description="List of additional structured notification channels",
|
||||
)
|
||||
alert_condition: str = Field("FAIL_ONLY", description="Condition to trigger alerts: FAIL_ONLY, WARN_AND_FAIL, ALWAYS")
|
||||
# [/DEF:ValidationPolicyBase:Class]
|
||||
|
||||
# [DEF:ValidationPolicyCreate:Class]
|
||||
# @PURPOSE: Schema for creating a new validation policy.
|
||||
class ValidationPolicyCreate(ValidationPolicyBase):
|
||||
pass
|
||||
# [/DEF:ValidationPolicyCreate:Class]
|
||||
|
||||
# [DEF:ValidationPolicyUpdate:Class]
|
||||
# @PURPOSE: Schema for updating an existing validation policy.
|
||||
class ValidationPolicyUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
environment_id: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
dashboard_ids: Optional[List[str]] = None
|
||||
schedule_days: Optional[List[int]] = None
|
||||
window_start: Optional[time] = None
|
||||
window_end: Optional[time] = None
|
||||
notify_owners: Optional[bool] = None
|
||||
custom_channels: Optional[List[NotificationChannel]] = None
|
||||
alert_condition: Optional[str] = None
|
||||
# [/DEF:ValidationPolicyUpdate:Class]
|
||||
|
||||
# [DEF:ValidationPolicyResponse:Class]
|
||||
# @PURPOSE: Schema for validation policy response data.
|
||||
class ValidationPolicyResponse(ValidationPolicyBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:ValidationPolicyResponse:Class]
|
||||
|
||||
# [/DEF:backend.src.schemas.settings:Module]
|
||||
3
backend/src/scripts/__init__.py
Normal file
3
backend/src/scripts/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# [DEF:src.scripts:Package]
|
||||
# @PURPOSE: Script entrypoint package root.
|
||||
# [/DEF:src.scripts:Package]
|
||||
444
backend/src/scripts/clean_release_cli.py
Normal file
444
backend/src/scripts/clean_release_cli.py
Normal file
@@ -0,0 +1,444 @@
|
||||
# [DEF:backend.src.scripts.clean_release_cli:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: cli, clean-release, candidate, artifacts, manifest
|
||||
# @PURPOSE: Provide headless CLI commands for candidate registration, artifact import and manifest build.
|
||||
# @LAYER: Scripts
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..models.clean_release import CandidateArtifact, ReleaseCandidate
|
||||
from ..services.clean_release.approval_service import approve_candidate, reject_candidate
|
||||
from ..services.clean_release.compliance_execution_service import ComplianceExecutionService
|
||||
from ..services.clean_release.enums import CandidateStatus
|
||||
from ..services.clean_release.publication_service import publish_candidate, revoke_publication
|
||||
|
||||
|
||||
# [DEF:build_parser:Function]
|
||||
# @PURPOSE: Build argparse parser for clean release CLI.
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="clean-release-cli")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
register = subparsers.add_parser("candidate-register")
|
||||
register.add_argument("--candidate-id", required=True)
|
||||
register.add_argument("--version", required=True)
|
||||
register.add_argument("--source-snapshot-ref", required=True)
|
||||
register.add_argument("--created-by", default="cli-operator")
|
||||
|
||||
artifact_import = subparsers.add_parser("artifact-import")
|
||||
artifact_import.add_argument("--candidate-id", required=True)
|
||||
artifact_import.add_argument("--artifact-id", required=True)
|
||||
artifact_import.add_argument("--path", required=True)
|
||||
artifact_import.add_argument("--sha256", required=True)
|
||||
artifact_import.add_argument("--size", type=int, required=True)
|
||||
|
||||
manifest_build = subparsers.add_parser("manifest-build")
|
||||
manifest_build.add_argument("--candidate-id", required=True)
|
||||
manifest_build.add_argument("--created-by", default="cli-operator")
|
||||
|
||||
compliance_run = subparsers.add_parser("compliance-run")
|
||||
compliance_run.add_argument("--candidate-id", required=True)
|
||||
compliance_run.add_argument("--manifest-id", required=False, default=None)
|
||||
compliance_run.add_argument("--actor", default="cli-operator")
|
||||
compliance_run.add_argument("--json", action="store_true")
|
||||
|
||||
compliance_status = subparsers.add_parser("compliance-status")
|
||||
compliance_status.add_argument("--run-id", required=True)
|
||||
compliance_status.add_argument("--json", action="store_true")
|
||||
|
||||
compliance_report = subparsers.add_parser("compliance-report")
|
||||
compliance_report.add_argument("--run-id", required=True)
|
||||
compliance_report.add_argument("--json", action="store_true")
|
||||
|
||||
compliance_violations = subparsers.add_parser("compliance-violations")
|
||||
compliance_violations.add_argument("--run-id", required=True)
|
||||
compliance_violations.add_argument("--json", action="store_true")
|
||||
|
||||
approve = subparsers.add_parser("approve")
|
||||
approve.add_argument("--candidate-id", required=True)
|
||||
approve.add_argument("--report-id", required=True)
|
||||
approve.add_argument("--actor", default="cli-operator")
|
||||
approve.add_argument("--comment", required=False, default=None)
|
||||
approve.add_argument("--json", action="store_true")
|
||||
|
||||
reject = subparsers.add_parser("reject")
|
||||
reject.add_argument("--candidate-id", required=True)
|
||||
reject.add_argument("--report-id", required=True)
|
||||
reject.add_argument("--actor", default="cli-operator")
|
||||
reject.add_argument("--comment", required=False, default=None)
|
||||
reject.add_argument("--json", action="store_true")
|
||||
|
||||
publish = subparsers.add_parser("publish")
|
||||
publish.add_argument("--candidate-id", required=True)
|
||||
publish.add_argument("--report-id", required=True)
|
||||
publish.add_argument("--actor", default="cli-operator")
|
||||
publish.add_argument("--target-channel", required=True)
|
||||
publish.add_argument("--publication-ref", required=False, default=None)
|
||||
publish.add_argument("--json", action="store_true")
|
||||
|
||||
revoke = subparsers.add_parser("revoke")
|
||||
revoke.add_argument("--publication-id", required=True)
|
||||
revoke.add_argument("--actor", default="cli-operator")
|
||||
revoke.add_argument("--comment", required=False, default=None)
|
||||
revoke.add_argument("--json", action="store_true")
|
||||
|
||||
return parser
|
||||
# [/DEF:build_parser:Function]
|
||||
|
||||
|
||||
# [DEF:run_candidate_register:Function]
|
||||
# @PURPOSE: Register candidate in repository via CLI command.
|
||||
# @PRE: Candidate ID must be unique.
|
||||
# @POST: Candidate is persisted in DRAFT status.
|
||||
def run_candidate_register(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
repository = get_clean_release_repository()
|
||||
existing = repository.get_candidate(args.candidate_id)
|
||||
if existing is not None:
|
||||
print(json.dumps({"status": "error", "message": "candidate already exists"}))
|
||||
return 1
|
||||
|
||||
candidate = ReleaseCandidate(
|
||||
id=args.candidate_id,
|
||||
version=args.version,
|
||||
source_snapshot_ref=args.source_snapshot_ref,
|
||||
created_by=args.created_by,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status=CandidateStatus.DRAFT.value,
|
||||
)
|
||||
repository.save_candidate(candidate)
|
||||
print(json.dumps({"status": "ok", "candidate_id": candidate.id}))
|
||||
return 0
|
||||
# [/DEF:run_candidate_register:Function]
|
||||
|
||||
|
||||
# [DEF:run_artifact_import:Function]
|
||||
# @PURPOSE: Import single artifact for existing candidate.
|
||||
# @PRE: Candidate must exist.
|
||||
# @POST: Artifact is persisted for candidate.
|
||||
def run_artifact_import(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
repository = get_clean_release_repository()
|
||||
candidate = repository.get_candidate(args.candidate_id)
|
||||
if candidate is None:
|
||||
print(json.dumps({"status": "error", "message": "candidate not found"}))
|
||||
return 1
|
||||
|
||||
artifact = CandidateArtifact(
|
||||
id=args.artifact_id,
|
||||
candidate_id=args.candidate_id,
|
||||
path=args.path,
|
||||
sha256=args.sha256,
|
||||
size=args.size,
|
||||
)
|
||||
repository.save_artifact(artifact)
|
||||
|
||||
if candidate.status == CandidateStatus.DRAFT.value:
|
||||
candidate.transition_to(CandidateStatus.PREPARED)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
print(json.dumps({"status": "ok", "artifact_id": artifact.id}))
|
||||
return 0
|
||||
# [/DEF:run_artifact_import:Function]
|
||||
|
||||
|
||||
# [DEF:run_manifest_build:Function]
|
||||
# @PURPOSE: Build immutable manifest snapshot for candidate.
|
||||
# @PRE: Candidate must exist.
|
||||
# @POST: New manifest version is persisted.
|
||||
def run_manifest_build(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
from ..services.clean_release.manifest_service import build_manifest_snapshot
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
try:
|
||||
manifest = build_manifest_snapshot(
|
||||
repository=repository,
|
||||
candidate_id=args.candidate_id,
|
||||
created_by=args.created_by,
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 1
|
||||
|
||||
print(json.dumps({"status": "ok", "manifest_id": manifest.id, "version": manifest.manifest_version}))
|
||||
return 0
|
||||
# [/DEF:run_manifest_build:Function]
|
||||
|
||||
|
||||
# [DEF:run_compliance_run:Function]
|
||||
# @PURPOSE: Execute compliance run for candidate with optional manifest fallback.
|
||||
# @PRE: Candidate exists and trusted snapshots are configured.
|
||||
# @POST: Returns run payload and exit code 0 on success.
|
||||
def run_compliance_run(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository, get_config_manager
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
config_manager = get_config_manager()
|
||||
service = ComplianceExecutionService(repository=repository, config_manager=config_manager)
|
||||
|
||||
try:
|
||||
result = service.execute_run(
|
||||
candidate_id=args.candidate_id,
|
||||
requested_by=args.actor,
|
||||
manifest_id=args.manifest_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 2
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"run_id": result.run.id,
|
||||
"candidate_id": result.run.candidate_id,
|
||||
"run_status": result.run.status,
|
||||
"final_status": result.run.final_status,
|
||||
"task_id": getattr(result.run, "task_id", None),
|
||||
"report_id": getattr(result.run, "report_id", None),
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
return 0
|
||||
# [/DEF:run_compliance_run:Function]
|
||||
|
||||
|
||||
# [DEF:run_compliance_status:Function]
|
||||
# @PURPOSE: Read run status by run id.
|
||||
# @PRE: Run exists.
|
||||
# @POST: Returns run status payload.
|
||||
def run_compliance_status(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
run = repository.get_check_run(args.run_id)
|
||||
if run is None:
|
||||
print(json.dumps({"status": "error", "message": "run not found"}))
|
||||
return 2
|
||||
|
||||
report = next((item for item in repository.reports.values() if item.run_id == run.id), None)
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"run_id": run.id,
|
||||
"candidate_id": run.candidate_id,
|
||||
"run_status": run.status,
|
||||
"final_status": run.final_status,
|
||||
"task_id": getattr(run, "task_id", None),
|
||||
"report_id": getattr(run, "report_id", None) or (report.id if report else None),
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
return 0
|
||||
# [/DEF:run_compliance_status:Function]
|
||||
|
||||
|
||||
# [DEF:_to_payload:Function]
|
||||
# @PURPOSE: Serialize domain models for CLI JSON output across SQLAlchemy/Pydantic variants.
|
||||
# @PRE: value is serializable model or primitive object.
|
||||
# @POST: Returns dictionary payload without mutating value.
|
||||
def _to_payload(value: Any) -> Dict[str, Any]:
|
||||
def _normalize(raw: Any) -> Any:
|
||||
if isinstance(raw, datetime):
|
||||
return raw.isoformat()
|
||||
if isinstance(raw, date):
|
||||
return raw.isoformat()
|
||||
if isinstance(raw, dict):
|
||||
return {str(key): _normalize(item) for key, item in raw.items()}
|
||||
if isinstance(raw, list):
|
||||
return [_normalize(item) for item in raw]
|
||||
if isinstance(raw, tuple):
|
||||
return [_normalize(item) for item in raw]
|
||||
return raw
|
||||
|
||||
if hasattr(value, "model_dump"):
|
||||
return _normalize(value.model_dump())
|
||||
table = getattr(value, "__table__", None)
|
||||
if table is not None:
|
||||
row = {column.name: getattr(value, column.name) for column in table.columns}
|
||||
return _normalize(row)
|
||||
raise TypeError(f"unsupported payload type: {type(value)!r}")
|
||||
# [/DEF:_to_payload:Function]
|
||||
|
||||
|
||||
# [DEF:run_compliance_report:Function]
|
||||
# @PURPOSE: Read immutable report by run id.
|
||||
# @PRE: Run and report exist.
|
||||
# @POST: Returns report payload.
|
||||
def run_compliance_report(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
run = repository.get_check_run(args.run_id)
|
||||
if run is None:
|
||||
print(json.dumps({"status": "error", "message": "run not found"}))
|
||||
return 2
|
||||
|
||||
report = next((item for item in repository.reports.values() if item.run_id == run.id), None)
|
||||
if report is None:
|
||||
print(json.dumps({"status": "error", "message": "report not found"}))
|
||||
return 2
|
||||
|
||||
print(json.dumps({"status": "ok", "report": _to_payload(report)}))
|
||||
return 0
|
||||
# [/DEF:run_compliance_report:Function]
|
||||
|
||||
|
||||
# [DEF:run_compliance_violations:Function]
|
||||
# @PURPOSE: Read run violations by run id.
|
||||
# @PRE: Run exists.
|
||||
# @POST: Returns violations payload.
|
||||
def run_compliance_violations(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
run = repository.get_check_run(args.run_id)
|
||||
if run is None:
|
||||
print(json.dumps({"status": "error", "message": "run not found"}))
|
||||
return 2
|
||||
|
||||
violations = repository.get_violations_by_run(args.run_id)
|
||||
print(json.dumps({"status": "ok", "items": [_to_payload(item) for item in violations]}))
|
||||
return 0
|
||||
# [/DEF:run_compliance_violations:Function]
|
||||
|
||||
|
||||
# [DEF:run_approve:Function]
|
||||
# @PURPOSE: Approve candidate based on immutable PASSED report.
|
||||
# @PRE: Candidate and report exist; report is PASSED.
|
||||
# @POST: Persists APPROVED decision and returns success payload.
|
||||
def run_approve(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
try:
|
||||
decision = approve_candidate(
|
||||
repository=repository,
|
||||
candidate_id=args.candidate_id,
|
||||
report_id=args.report_id,
|
||||
decided_by=args.actor,
|
||||
comment=args.comment,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 2
|
||||
|
||||
print(json.dumps({"status": "ok", "decision": decision.decision, "decision_id": decision.id}))
|
||||
return 0
|
||||
# [/DEF:run_approve:Function]
|
||||
|
||||
|
||||
# [DEF:run_reject:Function]
|
||||
# @PURPOSE: Reject candidate without mutating compliance evidence.
|
||||
# @PRE: Candidate and report exist.
|
||||
# @POST: Persists REJECTED decision and returns success payload.
|
||||
def run_reject(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
try:
|
||||
decision = reject_candidate(
|
||||
repository=repository,
|
||||
candidate_id=args.candidate_id,
|
||||
report_id=args.report_id,
|
||||
decided_by=args.actor,
|
||||
comment=args.comment,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 2
|
||||
|
||||
print(json.dumps({"status": "ok", "decision": decision.decision, "decision_id": decision.id}))
|
||||
return 0
|
||||
# [/DEF:run_reject:Function]
|
||||
|
||||
|
||||
# [DEF:run_publish:Function]
|
||||
# @PURPOSE: Publish approved candidate to target channel.
|
||||
# @PRE: Candidate is approved and report belongs to candidate.
|
||||
# @POST: Appends ACTIVE publication record and returns payload.
|
||||
def run_publish(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
try:
|
||||
publication = publish_candidate(
|
||||
repository=repository,
|
||||
candidate_id=args.candidate_id,
|
||||
report_id=args.report_id,
|
||||
published_by=args.actor,
|
||||
target_channel=args.target_channel,
|
||||
publication_ref=args.publication_ref,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 2
|
||||
|
||||
print(json.dumps({"status": "ok", "publication": _to_payload(publication)}))
|
||||
return 0
|
||||
# [/DEF:run_publish:Function]
|
||||
|
||||
|
||||
# [DEF:run_revoke:Function]
|
||||
# @PURPOSE: Revoke active publication record.
|
||||
# @PRE: Publication id exists and is ACTIVE.
|
||||
# @POST: Publication record status becomes REVOKED.
|
||||
def run_revoke(args: argparse.Namespace) -> int:
|
||||
from ..dependencies import get_clean_release_repository
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
try:
|
||||
publication = revoke_publication(
|
||||
repository=repository,
|
||||
publication_id=args.publication_id,
|
||||
revoked_by=args.actor,
|
||||
comment=args.comment,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"status": "error", "message": str(exc)}))
|
||||
return 2
|
||||
|
||||
print(json.dumps({"status": "ok", "publication": _to_payload(publication)}))
|
||||
return 0
|
||||
# [/DEF:run_revoke:Function]
|
||||
|
||||
|
||||
# [DEF:main:Function]
|
||||
# @PURPOSE: CLI entrypoint for clean release commands.
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "candidate-register":
|
||||
return run_candidate_register(args)
|
||||
if args.command == "artifact-import":
|
||||
return run_artifact_import(args)
|
||||
if args.command == "manifest-build":
|
||||
return run_manifest_build(args)
|
||||
if args.command == "compliance-run":
|
||||
return run_compliance_run(args)
|
||||
if args.command == "compliance-status":
|
||||
return run_compliance_status(args)
|
||||
if args.command == "compliance-report":
|
||||
return run_compliance_report(args)
|
||||
if args.command == "compliance-violations":
|
||||
return run_compliance_violations(args)
|
||||
if args.command == "approve":
|
||||
return run_approve(args)
|
||||
if args.command == "reject":
|
||||
return run_reject(args)
|
||||
if args.command == "publish":
|
||||
return run_publish(args)
|
||||
if args.command == "revoke":
|
||||
return run_revoke(args)
|
||||
|
||||
print(json.dumps({"status": "error", "message": "unknown command"}))
|
||||
return 2
|
||||
# [/DEF:main:Function]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
# [/DEF:backend.src.scripts.clean_release_cli:Module]
|
||||
@@ -5,29 +5,28 @@
|
||||
# @LAYER: UI
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.compliance_orchestrator
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @INVARIANT: TUI must provide a headless fallback for non-TTY environments.
|
||||
# @INVARIANT: TUI refuses startup in non-TTY environments; headless flow is CLI/API only.
|
||||
|
||||
import curses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import List, Optional, Any, Dict
|
||||
|
||||
# Standardize sys.path for direct execution from project root or scripts dir
|
||||
# Standardize sys.path for direct execution from project root or scripts dir.
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", ".."))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
BACKEND_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
|
||||
if BACKEND_ROOT not in sys.path:
|
||||
sys.path.insert(0, BACKEND_ROOT)
|
||||
|
||||
from backend.src.models.clean_release import (
|
||||
from src.models.clean_release import (
|
||||
CandidateArtifact,
|
||||
CheckFinalStatus,
|
||||
CheckStageName,
|
||||
CheckStageResult,
|
||||
CheckStageStatus,
|
||||
CleanProfilePolicy,
|
||||
ComplianceCheckRun,
|
||||
ComplianceViolation,
|
||||
ProfileType,
|
||||
ReleaseCandidate,
|
||||
@@ -36,10 +35,111 @@ from backend.src.models.clean_release import (
|
||||
RegistryStatus,
|
||||
ReleaseCandidateStatus,
|
||||
)
|
||||
from backend.src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
|
||||
from backend.src.services.clean_release.preparation_service import prepare_candidate
|
||||
from backend.src.services.clean_release.repository import CleanReleaseRepository
|
||||
from backend.src.services.clean_release.manifest_builder import build_distribution_manifest
|
||||
from src.services.clean_release.approval_service import approve_candidate
|
||||
from src.services.clean_release.compliance_execution_service import ComplianceExecutionService
|
||||
from src.services.clean_release.enums import CandidateStatus
|
||||
from src.services.clean_release.manifest_service import build_manifest_snapshot
|
||||
from src.services.clean_release.publication_service import publish_candidate
|
||||
from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
# [DEF:TuiFacadeAdapter:Class]
|
||||
# @PURPOSE: Thin TUI adapter that routes business mutations through application services.
|
||||
# @PRE: repository contains candidate and trusted policy/registry snapshots for execution.
|
||||
# @POST: Business actions return service results/errors without direct TUI-owned mutations.
|
||||
class TuiFacadeAdapter:
|
||||
def __init__(self, repository: CleanReleaseRepository):
|
||||
self.repository = repository
|
||||
|
||||
def _build_config_manager(self):
|
||||
policy = self.repository.get_active_policy()
|
||||
if policy is None:
|
||||
raise ValueError("Active policy not found")
|
||||
clean_release = SimpleNamespace(
|
||||
active_policy_id=policy.id,
|
||||
active_registry_id=policy.registry_snapshot_id,
|
||||
)
|
||||
settings = SimpleNamespace(clean_release=clean_release)
|
||||
config = SimpleNamespace(settings=settings)
|
||||
return SimpleNamespace(get_config=lambda: config)
|
||||
|
||||
def run_compliance(self, *, candidate_id: str, actor: str):
|
||||
manifests = self.repository.get_manifests_by_candidate(candidate_id)
|
||||
if not manifests:
|
||||
raise ValueError("Manifest required before compliance run")
|
||||
latest_manifest = sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0]
|
||||
service = ComplianceExecutionService(
|
||||
repository=self.repository,
|
||||
config_manager=self._build_config_manager(),
|
||||
)
|
||||
return service.execute_run(candidate_id=candidate_id, requested_by=actor, manifest_id=latest_manifest.id)
|
||||
|
||||
def approve_latest(self, *, candidate_id: str, actor: str):
|
||||
reports = [item for item in self.repository.reports.values() if item.candidate_id == candidate_id]
|
||||
if not reports:
|
||||
raise ValueError("No compliance report available for approval")
|
||||
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
|
||||
return approve_candidate(
|
||||
repository=self.repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=report.id,
|
||||
decided_by=actor,
|
||||
comment="Approved from TUI",
|
||||
)
|
||||
|
||||
def publish_latest(self, *, candidate_id: str, actor: str):
|
||||
reports = [item for item in self.repository.reports.values() if item.candidate_id == candidate_id]
|
||||
if not reports:
|
||||
raise ValueError("No compliance report available for publication")
|
||||
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
|
||||
return publish_candidate(
|
||||
repository=self.repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=report.id,
|
||||
published_by=actor,
|
||||
target_channel="stable",
|
||||
publication_ref=None,
|
||||
)
|
||||
|
||||
def build_manifest(self, *, candidate_id: str, actor: str):
|
||||
return build_manifest_snapshot(
|
||||
repository=self.repository,
|
||||
candidate_id=candidate_id,
|
||||
created_by=actor,
|
||||
)
|
||||
|
||||
def get_overview(self, *, candidate_id: str) -> Dict[str, Any]:
|
||||
candidate = self.repository.get_candidate(candidate_id)
|
||||
manifests = self.repository.get_manifests_by_candidate(candidate_id)
|
||||
latest_manifest = sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0] if manifests else None
|
||||
runs = [item for item in self.repository.check_runs.values() if item.candidate_id == candidate_id]
|
||||
latest_run = sorted(runs, key=lambda item: item.requested_at, reverse=True)[0] if runs else None
|
||||
latest_report = next((item for item in self.repository.reports.values() if latest_run and item.run_id == latest_run.id), None)
|
||||
approvals = getattr(self.repository, "approval_decisions", [])
|
||||
latest_approval = sorted(
|
||||
[item for item in approvals if item.candidate_id == candidate_id],
|
||||
key=lambda item: item.decided_at,
|
||||
reverse=True,
|
||||
)[0] if any(item.candidate_id == candidate_id for item in approvals) else None
|
||||
publications = getattr(self.repository, "publication_records", [])
|
||||
latest_publication = sorted(
|
||||
[item for item in publications if item.candidate_id == candidate_id],
|
||||
key=lambda item: item.published_at,
|
||||
reverse=True,
|
||||
)[0] if any(item.candidate_id == candidate_id for item in publications) else None
|
||||
policy = self.repository.get_active_policy()
|
||||
registry = self.repository.get_registry(policy.internal_source_registry_ref) if policy else None
|
||||
return {
|
||||
"candidate": candidate,
|
||||
"manifest": latest_manifest,
|
||||
"run": latest_run,
|
||||
"report": latest_report,
|
||||
"approval": latest_approval,
|
||||
"publication": latest_publication,
|
||||
"policy": policy,
|
||||
"registry": registry,
|
||||
}
|
||||
# [/DEF:TuiFacadeAdapter:Class]
|
||||
|
||||
|
||||
# [DEF:CleanReleaseTUI:Class]
|
||||
# @PURPOSE: Curses-based application for compliance monitoring.
|
||||
@@ -53,14 +153,16 @@ class CleanReleaseTUI:
|
||||
self.stdscr = stdscr
|
||||
self.mode = os.getenv("CLEAN_TUI_MODE", "demo").strip().lower()
|
||||
self.repo = self._build_repository(self.mode)
|
||||
self.orchestrator = CleanComplianceOrchestrator(self.repo)
|
||||
self.facade = TuiFacadeAdapter(self.repo)
|
||||
self.candidate_id = self._resolve_candidate_id()
|
||||
self.status: Any = "READY"
|
||||
self.checks_progress: List[Dict[str, Any]] = []
|
||||
self.violations_list: List[ComplianceViolation] = []
|
||||
self.report_id: Optional[str] = None
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
self.overview: Dict[str, Any] = {}
|
||||
self.refresh_overview()
|
||||
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Header/Footer
|
||||
@@ -73,48 +175,82 @@ class CleanReleaseTUI:
|
||||
repo = CleanReleaseRepository()
|
||||
if mode == "demo":
|
||||
self._bootstrap_demo_repository(repo)
|
||||
self._bootstrap_real_repository(repo)
|
||||
else:
|
||||
self._bootstrap_real_repository(repo)
|
||||
return repo
|
||||
|
||||
def _bootstrap_demo_repository(self, repository: CleanReleaseRepository) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
repository.save_policy(
|
||||
CleanProfilePolicy(
|
||||
policy_id="POL-ENT-CLEAN",
|
||||
policy_version="1",
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
active=True,
|
||||
internal_source_registry_ref="REG-1",
|
||||
prohibited_artifact_categories=["test-data"],
|
||||
effective_from=now,
|
||||
policy = CleanProfilePolicy(
|
||||
policy_id="POL-ENT-CLEAN",
|
||||
policy_version="1",
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
active=True,
|
||||
internal_source_registry_ref="REG-1",
|
||||
prohibited_artifact_categories=["test-data"],
|
||||
effective_from=now,
|
||||
)
|
||||
setattr(policy, "immutable", True)
|
||||
repository.save_policy(policy)
|
||||
|
||||
registry = ResourceSourceRegistry(
|
||||
registry_id="REG-1",
|
||||
name="Default Internal Registry",
|
||||
entries=[
|
||||
ResourceSourceEntry(
|
||||
source_id="S1",
|
||||
host="internal-repo.company.com",
|
||||
protocol="https",
|
||||
purpose="artifactory",
|
||||
)
|
||||
],
|
||||
updated_at=now,
|
||||
updated_by="system",
|
||||
)
|
||||
setattr(registry, "immutable", True)
|
||||
setattr(registry, "allowed_hosts", ["internal-repo.company.com"])
|
||||
setattr(registry, "allowed_schemes", ["https"])
|
||||
setattr(registry, "allowed_source_types", ["artifactory"])
|
||||
repository.save_registry(registry)
|
||||
candidate = ReleaseCandidate(
|
||||
id="2026.03.03-rc1",
|
||||
version="1.0.0",
|
||||
source_snapshot_ref="v1.0.0-rc1",
|
||||
created_at=now,
|
||||
created_by="system",
|
||||
status=CandidateStatus.DRAFT.value,
|
||||
)
|
||||
candidate.transition_to(CandidateStatus.PREPARED)
|
||||
repository.save_candidate(candidate)
|
||||
repository.save_artifact(
|
||||
CandidateArtifact(
|
||||
id="demo-art-1",
|
||||
candidate_id=candidate.id,
|
||||
path="src/main.py",
|
||||
sha256="sha256-demo-core",
|
||||
size=128,
|
||||
detected_category="core",
|
||||
)
|
||||
)
|
||||
repository.save_registry(
|
||||
ResourceSourceRegistry(
|
||||
registry_id="REG-1",
|
||||
name="Default Internal Registry",
|
||||
entries=[
|
||||
ResourceSourceEntry(
|
||||
source_id="S1",
|
||||
host="internal-repo.company.com",
|
||||
protocol="https",
|
||||
purpose="artifactory",
|
||||
)
|
||||
],
|
||||
updated_at=now,
|
||||
updated_by="system",
|
||||
repository.save_artifact(
|
||||
CandidateArtifact(
|
||||
id="demo-art-2",
|
||||
candidate_id=candidate.id,
|
||||
path="test/data.csv",
|
||||
sha256="sha256-demo-test",
|
||||
size=64,
|
||||
detected_category="test-data",
|
||||
)
|
||||
)
|
||||
repository.save_candidate(
|
||||
ReleaseCandidate(
|
||||
candidate_id="2026.03.03-rc1",
|
||||
version="1.0.0",
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
source_snapshot_ref="v1.0.0-rc1",
|
||||
created_at=now,
|
||||
created_by="system",
|
||||
)
|
||||
manifest = build_manifest_snapshot(
|
||||
repository=repository,
|
||||
candidate_id=candidate.id,
|
||||
created_by="system",
|
||||
policy_id="POL-ENT-CLEAN",
|
||||
)
|
||||
summary = dict(manifest.content_json.get("summary", {}))
|
||||
summary["prohibited_detected_count"] = 1
|
||||
manifest.content_json["summary"] = summary
|
||||
|
||||
def _bootstrap_real_repository(self, repository: CleanReleaseRepository) -> None:
|
||||
bootstrap_path = os.getenv("CLEAN_TUI_BOOTSTRAP_JSON", "").strip()
|
||||
@@ -126,9 +262,8 @@ class CleanReleaseTUI:
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=payload.get("candidate_id", "candidate-1"),
|
||||
id=payload.get("candidate_id", "candidate-1"),
|
||||
version=payload.get("version", "1.0.0"),
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
source_snapshot_ref=payload.get("source_snapshot_ref", "snapshot-ref"),
|
||||
created_at=now,
|
||||
created_by=payload.get("created_by", "operator"),
|
||||
@@ -195,9 +330,14 @@ class CleanReleaseTUI:
|
||||
self.stdscr.addstr(0, 0, centered[:max_x])
|
||||
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
|
||||
|
||||
candidate = self.overview.get("candidate")
|
||||
candidate_text = self.candidate_id or "not-set"
|
||||
profile_text = "enterprise-clean"
|
||||
info_line_text = f" │ Candidate: [{candidate_text}] Profile: [{profile_text}] Mode: [{self.mode}]".ljust(max_x)
|
||||
lifecycle = getattr(candidate, "status", "UNKNOWN")
|
||||
info_line_text = (
|
||||
f" │ Candidate: [{candidate_text}] Profile: [{profile_text}] "
|
||||
f"Lifecycle: [{lifecycle}] Mode: [{self.mode}]"
|
||||
).ljust(max_x)
|
||||
self.stdscr.addstr(2, 0, info_line_text[:max_x])
|
||||
|
||||
def draw_checks(self):
|
||||
@@ -235,10 +375,7 @@ class CleanReleaseTUI:
|
||||
|
||||
def draw_sources(self):
|
||||
self.stdscr.addstr(12, 3, "Allowed Internal Sources:", curses.A_BOLD)
|
||||
reg = None
|
||||
policy = self.repo.get_active_policy()
|
||||
if policy:
|
||||
reg = self.repo.get_registry(policy.internal_source_registry_ref)
|
||||
reg = self.overview.get("registry")
|
||||
row = 13
|
||||
if reg:
|
||||
for entry in reg.entries:
|
||||
@@ -257,122 +394,143 @@ class CleanReleaseTUI:
|
||||
|
||||
if self.report_id:
|
||||
self.stdscr.addstr(19, 3, f"Report ID: {self.report_id}")
|
||||
|
||||
|
||||
approval = self.overview.get("approval")
|
||||
publication = self.overview.get("publication")
|
||||
if approval:
|
||||
self.stdscr.addstr(20, 3, f"Approval: {approval.decision}")
|
||||
if publication:
|
||||
self.stdscr.addstr(20, 32, f"Publication: {publication.status}")
|
||||
|
||||
if self.violations_list:
|
||||
self.stdscr.addstr(21, 3, f"Violations Details ({len(self.violations_list)} total):", curses.color_pair(3) | curses.A_BOLD)
|
||||
row = 22
|
||||
for i, v in enumerate(self.violations_list[:5]):
|
||||
v_cat = str(v.category.value if hasattr(v.category, "value") else v.category)
|
||||
msg_text = f"[{v_cat}] {v.remediation} (Loc: {v.location})"
|
||||
v_cat = str(getattr(v, "code", "VIOLATION"))
|
||||
msg = str(getattr(v, "message", "Violation detected"))
|
||||
location = str(
|
||||
getattr(v, "artifact_path", "")
|
||||
or getattr(getattr(v, "evidence_json", {}), "get", lambda *_: "")("location", "")
|
||||
)
|
||||
msg_text = f"[{v_cat}] {msg} (Loc: {location})"
|
||||
self.stdscr.addstr(row + i, 5, msg_text[:70], curses.color_pair(3))
|
||||
if self.last_error:
|
||||
self.stdscr.addstr(27, 3, f"Error: {self.last_error}"[:100], curses.color_pair(3) | curses.A_BOLD)
|
||||
|
||||
def draw_footer(self, max_y: int, max_x: int):
|
||||
footer_text = " F5 Run Check F7 Clear History F10 Exit ".center(max_x)
|
||||
footer_text = " F5 Run F6 Manifest F7 Refresh F8 Approve F9 Publish F10 Exit ".center(max_x)
|
||||
self.stdscr.attron(curses.color_pair(1))
|
||||
self.stdscr.addstr(max_y - 1, 0, footer_text[:max_x])
|
||||
self.stdscr.attroff(curses.color_pair(1))
|
||||
|
||||
# [DEF:run_checks:Function]
|
||||
# @PURPOSE: Execute compliance orchestrator run and update UI state.
|
||||
# @PURPOSE: Execute compliance run via facade adapter and update UI state.
|
||||
# @PRE: Candidate and policy snapshots are present in repository.
|
||||
# @POST: UI reflects final run/report/violation state from service result.
|
||||
def run_checks(self):
|
||||
self.status = "RUNNING"
|
||||
self.report_id = None
|
||||
self.violations_list = []
|
||||
self.checks_progress = []
|
||||
self.last_error = None
|
||||
self.refresh_screen()
|
||||
|
||||
candidate = self.repo.get_candidate(self.candidate_id) if self.candidate_id else None
|
||||
policy = self.repo.get_active_policy()
|
||||
|
||||
if not candidate or not policy:
|
||||
self.status = "FAILED"
|
||||
self.last_error = "Candidate or active policy not found. Set CLEAN_TUI_CANDIDATE_ID and prepare repository data."
|
||||
try:
|
||||
result = self.facade.run_compliance(candidate_id=self.candidate_id, actor="operator")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status = CheckFinalStatus.FAILED
|
||||
self.last_error = str(exc)
|
||||
self.refresh_screen()
|
||||
return
|
||||
|
||||
if self.mode == "demo":
|
||||
# Prepare a manifest with a deliberate violation for demonstration mode.
|
||||
artifacts = [
|
||||
{"path": "src/main.py", "category": "core", "reason": "source code", "classification": "allowed"},
|
||||
{"path": "test/data.csv", "category": "test-data", "reason": "test payload", "classification": "excluded-prohibited"},
|
||||
]
|
||||
manifest = build_distribution_manifest(
|
||||
manifest_id=f"manifest-{candidate.candidate_id}",
|
||||
candidate_id=candidate.candidate_id,
|
||||
policy_id=policy.policy_id,
|
||||
generated_by="operator",
|
||||
artifacts=artifacts
|
||||
)
|
||||
self.repo.save_manifest(manifest)
|
||||
else:
|
||||
manifest = self.repo.get_manifest(f"manifest-{candidate.candidate_id}")
|
||||
if manifest is None:
|
||||
artifacts_path = os.getenv("CLEAN_TUI_ARTIFACTS_JSON", "").strip()
|
||||
if artifacts_path:
|
||||
try:
|
||||
with open(artifacts_path, "r", encoding="utf-8") as artifacts_file:
|
||||
artifacts = json.load(artifacts_file)
|
||||
if not isinstance(artifacts, list):
|
||||
raise ValueError("Artifacts JSON must be a list")
|
||||
prepare_candidate(
|
||||
repository=self.repo,
|
||||
candidate_id=candidate.candidate_id,
|
||||
artifacts=artifacts,
|
||||
sources=[],
|
||||
operator_id="tui-operator",
|
||||
)
|
||||
manifest = self.repo.get_manifest(f"manifest-{candidate.candidate_id}")
|
||||
except Exception as exc:
|
||||
self.status = "FAILED"
|
||||
self.last_error = f"Unable to prepare manifest from CLEAN_TUI_ARTIFACTS_JSON: {exc}"
|
||||
self.refresh_screen()
|
||||
return
|
||||
|
||||
if manifest is None:
|
||||
self.status = "FAILED"
|
||||
self.last_error = "Manifest not found. Prepare candidate first or provide CLEAN_TUI_ARTIFACTS_JSON."
|
||||
self.refresh_screen()
|
||||
return
|
||||
|
||||
# Init orchestrator sequence
|
||||
check_run = self.orchestrator.start_check_run(candidate.candidate_id, policy.policy_id, "operator", "tui")
|
||||
|
||||
self.stdscr.nodelay(True)
|
||||
stages = [
|
||||
CheckStageName.DATA_PURITY,
|
||||
CheckStageName.INTERNAL_SOURCES_ONLY,
|
||||
CheckStageName.NO_EXTERNAL_ENDPOINTS,
|
||||
CheckStageName.MANIFEST_CONSISTENCY
|
||||
self.checks_progress = [
|
||||
{
|
||||
"stage": stage.stage_name,
|
||||
"status": CheckStageStatus.PASS if str(stage.decision).upper() == "PASSED" else CheckStageStatus.FAIL,
|
||||
}
|
||||
for stage in result.stage_runs
|
||||
]
|
||||
|
||||
for stage in stages:
|
||||
self.checks_progress.append({"stage": stage, "status": "RUNNING"})
|
||||
self.refresh_screen()
|
||||
time.sleep(0.3) # Simulation delay
|
||||
|
||||
# Real logic
|
||||
self.orchestrator.execute_stages(check_run)
|
||||
self.orchestrator.finalize_run(check_run)
|
||||
|
||||
# Sync TUI state
|
||||
self.checks_progress = [{"stage": c.stage, "status": c.status} for c in check_run.checks]
|
||||
self.status = check_run.final_status
|
||||
self.report_id = f"CCR-{datetime.now().strftime('%Y-%m-%d-%H%M%S')}"
|
||||
self.violations_list = self.repo.get_violations_by_check_run(check_run.check_run_id)
|
||||
|
||||
self.violations_list = result.violations
|
||||
self.report_id = result.report.id if result.report is not None else None
|
||||
|
||||
final_status = str(result.run.final_status or "").upper()
|
||||
if final_status in {"BLOCKED", CheckFinalStatus.BLOCKED.value}:
|
||||
self.status = CheckFinalStatus.BLOCKED
|
||||
elif final_status in {"COMPLIANT", "PASSED", CheckFinalStatus.COMPLIANT.value}:
|
||||
self.status = CheckFinalStatus.COMPLIANT
|
||||
else:
|
||||
self.status = CheckFinalStatus.FAILED
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
# [/DEF:run_checks:Function]
|
||||
|
||||
def build_manifest(self):
|
||||
try:
|
||||
manifest = self.facade.build_manifest(candidate_id=self.candidate_id, actor="operator")
|
||||
self.status = "READY"
|
||||
self.report_id = None
|
||||
self.violations_list = []
|
||||
self.checks_progress = []
|
||||
self.last_error = f"Manifest built: {manifest.id}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.last_error = str(exc)
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
|
||||
def clear_history(self):
|
||||
self.repo.clear_history()
|
||||
self.status = "READY"
|
||||
self.report_id = None
|
||||
self.violations_list = []
|
||||
self.checks_progress = []
|
||||
self.last_error = None
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
|
||||
def approve_latest(self):
|
||||
if not self.report_id:
|
||||
self.last_error = "F8 disabled: no compliance report available"
|
||||
self.refresh_screen()
|
||||
return
|
||||
try:
|
||||
self.facade.approve_latest(candidate_id=self.candidate_id, actor="operator")
|
||||
self.last_error = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.last_error = str(exc)
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
|
||||
def publish_latest(self):
|
||||
if not self.report_id:
|
||||
self.last_error = "F9 disabled: no compliance report available"
|
||||
self.refresh_screen()
|
||||
return
|
||||
try:
|
||||
self.facade.publish_latest(candidate_id=self.candidate_id, actor="operator")
|
||||
self.last_error = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.last_error = str(exc)
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
|
||||
def refresh_overview(self):
|
||||
if not self.report_id:
|
||||
self.last_error = "F9 disabled: no compliance report available"
|
||||
self.refresh_screen()
|
||||
return
|
||||
try:
|
||||
self.facade.publish_latest(candidate_id=self.candidate_id, actor="operator")
|
||||
self.last_error = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.last_error = str(exc)
|
||||
self.refresh_overview()
|
||||
self.refresh_screen()
|
||||
|
||||
def refresh_overview(self):
|
||||
if not self.candidate_id:
|
||||
self.overview = {}
|
||||
return
|
||||
self.overview = self.facade.get_overview(candidate_id=self.candidate_id)
|
||||
|
||||
def refresh_screen(self):
|
||||
max_y, max_x = self.stdscr.getmaxyx()
|
||||
self.stdscr.clear()
|
||||
@@ -382,8 +540,8 @@ class CleanReleaseTUI:
|
||||
self.draw_sources()
|
||||
self.draw_status()
|
||||
self.draw_footer(max_y, max_x)
|
||||
except curses.error:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
self.stdscr.refresh()
|
||||
|
||||
def loop(self):
|
||||
@@ -394,8 +552,14 @@ class CleanReleaseTUI:
|
||||
break
|
||||
elif char == curses.KEY_F5:
|
||||
self.run_checks()
|
||||
elif char == curses.KEY_F6:
|
||||
self.build_manifest()
|
||||
elif char == curses.KEY_F7:
|
||||
self.clear_history()
|
||||
elif char == curses.KEY_F8:
|
||||
self.approve_latest()
|
||||
elif char == curses.KEY_F9:
|
||||
self.publish_latest()
|
||||
# [/DEF:CleanReleaseTUI:Class]
|
||||
|
||||
|
||||
@@ -406,10 +570,13 @@ def tui_main(stdscr: curses.window):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Headless check for CI/Tests
|
||||
if not sys.stdout.isatty() or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
print("Enterprise Clean Release Validator (Headless Mode) - FINAL STATUS: READY")
|
||||
return 0
|
||||
# TUI requires interactive terminal; headless mode must use CLI/API flow.
|
||||
if not sys.stdout.isatty():
|
||||
print(
|
||||
"TTY is required for TUI mode. Use CLI/API workflow instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
try:
|
||||
curses.wrapper(tui_main)
|
||||
return 0
|
||||
|
||||
@@ -291,6 +291,9 @@ def main() -> None:
|
||||
logger.info(f"[COHERENCE:OK] Result summary: {json.dumps(result, ensure_ascii=True)}")
|
||||
|
||||
|
||||
# [/DEF:main:Function]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
87
backend/src/services/__tests__/test_health_service.py
Normal file
87
backend/src/services/__tests__/test_health_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
from src.services.health_service import HealthService
|
||||
from src.models.llm import ValidationRecord
|
||||
|
||||
# [DEF:test_health_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for HealthService aggregation logic.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_health_summary_aggregation():
|
||||
"""
|
||||
@TEST_SCENARIO: Verify that HealthService correctly aggregates the latest record per dashboard.
|
||||
"""
|
||||
# Setup: Mock DB session
|
||||
db = MagicMock()
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Dashboard 1: Old FAIL, New PASS
|
||||
rec1_old = ValidationRecord(
|
||||
dashboard_id="dash_1",
|
||||
environment_id="env_1",
|
||||
status="FAIL",
|
||||
timestamp=now - timedelta(hours=1),
|
||||
summary="Old failure",
|
||||
issues=[]
|
||||
)
|
||||
rec1_new = ValidationRecord(
|
||||
dashboard_id="dash_1",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=now,
|
||||
summary="New pass",
|
||||
issues=[]
|
||||
)
|
||||
|
||||
# Dashboard 2: Single WARN
|
||||
rec2 = ValidationRecord(
|
||||
dashboard_id="dash_2",
|
||||
environment_id="env_1",
|
||||
status="WARN",
|
||||
timestamp=now,
|
||||
summary="Warning",
|
||||
issues=[]
|
||||
)
|
||||
|
||||
# Mock the query chain
|
||||
# subquery = self.db.query(...).filter(...).group_by(...).subquery()
|
||||
# query = self.db.query(ValidationRecord).join(subquery, ...).all()
|
||||
|
||||
mock_query = db.query.return_value
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.group_by.return_value = mock_query
|
||||
mock_query.subquery.return_value = MagicMock()
|
||||
|
||||
db.query.return_value.join.return_value.all.return_value = [rec1_new, rec2]
|
||||
|
||||
service = HealthService(db)
|
||||
summary = await service.get_health_summary(environment_id="env_1")
|
||||
|
||||
assert summary.pass_count == 1
|
||||
assert summary.warn_count == 1
|
||||
assert summary.fail_count == 0
|
||||
assert len(summary.items) == 2
|
||||
|
||||
# Verify dash_1 has the latest status (PASS)
|
||||
dash_1_item = next(item for item in summary.items if item.dashboard_id == "dash_1")
|
||||
assert dash_1_item.status == "PASS"
|
||||
assert dash_1_item.summary == "New pass"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_health_summary_empty():
|
||||
"""
|
||||
@TEST_SCENARIO: Verify behavior with no records.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.query.return_value.join.return_value.all.return_value = []
|
||||
|
||||
service = HealthService(db)
|
||||
summary = await service.get_health_summary(environment_id="env_none")
|
||||
|
||||
assert summary.pass_count == 0
|
||||
assert len(summary.items) == 0
|
||||
|
||||
# [/DEF:test_health_service:Module]
|
||||
150
backend/src/services/__tests__/test_llm_plugin_persistence.py
Normal file
150
backend/src/services/__tests__/test_llm_plugin_persistence.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# [DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
|
||||
|
||||
import types
|
||||
import pytest
|
||||
|
||||
from src.plugins.llm_analysis import plugin as plugin_module
|
||||
|
||||
|
||||
# [DEF:_DummyLogger:Class]
|
||||
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
|
||||
class _DummyLogger:
|
||||
def with_source(self, _source: str):
|
||||
return self
|
||||
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def debug(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
# [/DEF:_DummyLogger:Class]
|
||||
|
||||
|
||||
# [DEF:_FakeDBSession:Class]
|
||||
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
||||
class _FakeDBSession:
|
||||
def __init__(self):
|
||||
self.added = None
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
|
||||
def add(self, obj):
|
||||
self.added = obj
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
# [/DEF:_FakeDBSession:Class]
|
||||
|
||||
|
||||
# [DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(tmp_path, monkeypatch):
|
||||
fake_db = _FakeDBSession()
|
||||
|
||||
env = types.SimpleNamespace(id="env-42")
|
||||
provider = types.SimpleNamespace(
|
||||
id="provider-1",
|
||||
name="Main LLM",
|
||||
provider_type="openai",
|
||||
base_url="https://example.invalid/v1",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
class _FakeProviderService:
|
||||
def __init__(self, _db):
|
||||
return None
|
||||
|
||||
def get_provider(self, _provider_id):
|
||||
return provider
|
||||
|
||||
def get_decrypted_api_key(self, _provider_id):
|
||||
return "a" * 32
|
||||
|
||||
class _FakeScreenshotService:
|
||||
def __init__(self, _env):
|
||||
return None
|
||||
|
||||
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
|
||||
return None
|
||||
|
||||
class _FakeLLMClient:
|
||||
def __init__(self, **_kwargs):
|
||||
return None
|
||||
|
||||
async def analyze_dashboard(self, *_args, **_kwargs):
|
||||
return {
|
||||
"status": "PASS",
|
||||
"summary": "Dashboard healthy",
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
class _FakeNotificationService:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def dispatch_report(self, **_kwargs):
|
||||
return None
|
||||
|
||||
class _FakeConfigManager:
|
||||
def get_environment(self, _env_id):
|
||||
return env
|
||||
|
||||
def get_config(self):
|
||||
return types.SimpleNamespace(
|
||||
settings=types.SimpleNamespace(
|
||||
storage=types.SimpleNamespace(root_path=str(tmp_path)),
|
||||
llm={},
|
||||
)
|
||||
)
|
||||
|
||||
class _FakeSupersetClient:
|
||||
def __init__(self, _env):
|
||||
self.network = types.SimpleNamespace(request=lambda **_kwargs: {"result": []})
|
||||
|
||||
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
|
||||
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
|
||||
monkeypatch.setattr(plugin_module, "ScreenshotService", _FakeScreenshotService)
|
||||
monkeypatch.setattr(plugin_module, "LLMClient", _FakeLLMClient)
|
||||
monkeypatch.setattr(plugin_module, "NotificationService", _FakeNotificationService)
|
||||
monkeypatch.setattr(plugin_module, "SupersetClient", _FakeSupersetClient)
|
||||
monkeypatch.setattr("src.dependencies.get_config_manager", lambda: _FakeConfigManager())
|
||||
|
||||
context = types.SimpleNamespace(
|
||||
task_id="task-999",
|
||||
logger=_DummyLogger(),
|
||||
background_tasks=None,
|
||||
)
|
||||
|
||||
plugin = plugin_module.DashboardValidationPlugin()
|
||||
result = await plugin.execute(
|
||||
{
|
||||
"dashboard_id": "11",
|
||||
"environment_id": "env-42",
|
||||
"provider_id": "provider-1",
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert result["environment_id"] == "env-42"
|
||||
assert fake_db.committed is True
|
||||
assert fake_db.closed is True
|
||||
assert fake_db.added is not None
|
||||
assert fake_db.added.task_id == "task-999"
|
||||
assert fake_db.added.environment_id == "env-42"
|
||||
# [/DEF:test_dashboard_validation_plugin_persists_task_and_environment_ids:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.services.__tests__.test_llm_plugin_persistence:Module]
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_with_status:Function]
|
||||
@@ -269,4 +269,71 @@ def test_get_last_task_for_resource_no_match():
|
||||
# [/DEF:test_get_last_task_for_resource_no_match:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
|
||||
# @PRE: Task list includes both timezone-aware and timezone-naive timestamps.
|
||||
# @POST: Latest task is selected deterministically and no exception is raised.
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes():
|
||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
||||
patch("src.services.resource_service.GitService"):
|
||||
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"}
|
||||
]
|
||||
|
||||
task_naive = MagicMock()
|
||||
task_naive.id = "task-naive"
|
||||
task_naive.plugin_id = "llm_dashboard_validation"
|
||||
task_naive.status = "SUCCESS"
|
||||
task_naive.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_naive.started_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task_aware = MagicMock()
|
||||
task_aware.id = "task-aware"
|
||||
task_aware.plugin_id = "llm_dashboard_validation"
|
||||
task_aware.status = "SUCCESS"
|
||||
task_aware.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_aware.started_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(env, [task_naive, task_aware])
|
||||
|
||||
assert result[0]["last_task"]["task_id"] == "task-aware"
|
||||
# [/DEF:test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
|
||||
# @PRE: Matching tasks include naive and aware created_at timestamps.
|
||||
# @POST: Latest task is returned without raising datetime comparison errors.
|
||||
def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
task_naive = MagicMock()
|
||||
task_naive.id = "task-old"
|
||||
task_naive.status = "SUCCESS"
|
||||
task_naive.params = {"resource_id": "dashboard-1"}
|
||||
task_naive.created_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task_aware = MagicMock()
|
||||
task_aware.id = "task-new"
|
||||
task_aware.status = "RUNNING"
|
||||
task_aware.params = {"resource_id": "dashboard-1"}
|
||||
task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
result = service._get_last_task_for_resource("dashboard-1", [task_naive, task_aware])
|
||||
|
||||
assert result is not None
|
||||
assert result["task_id"] == "task-new"
|
||||
# [/DEF:test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.src.services.__tests__.test_resource_service:Module]
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# [DEF:backend.src.services.auth_service:Module]
|
||||
#
|
||||
# @SEMANTICS: auth, service, business-logic, login, jwt
|
||||
# @PURPOSE: Orchestrates authentication business logic.
|
||||
# @LAYER: Service
|
||||
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
|
||||
# @RELATION: USES -> backend.src.core.auth.security
|
||||
# @RELATION: USES -> backend.src.core.auth.jwt
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning
|
||||
# @PURPOSE: Orchestrates credential authentication and ADFS JIT user provisioning.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.repository.AuthRepository]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.security.verify_password]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.jwt.create_access_token]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.User]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role]
|
||||
#
|
||||
# @INVARIANT: Authentication must verify both credentials and account status.
|
||||
# @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, Any
|
||||
@@ -23,20 +26,25 @@ from ..core.logger import belief_scope
|
||||
# @PURPOSE: Provides high-level authentication services.
|
||||
class AuthService:
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initializes the service with a database session.
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
# @PURPOSE: Initializes the authentication service with repository access over an active DB session.
|
||||
# @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context.
|
||||
# @POST: self.repo is initialized and ready for auth user/role CRUD operations.
|
||||
# @SIDE_EFFECT: Allocates AuthRepository and binds it to the provided Session.
|
||||
# @DATA_CONTRACT: Input(Session) -> Model(AuthRepository)
|
||||
# @PARAM: db (Session) - SQLAlchemy session.
|
||||
def __init__(self, db: Session):
|
||||
self.repo = AuthRepository(db)
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:authenticate_user:Function]
|
||||
# @PURPOSE: Authenticates a user with username and password.
|
||||
# @PRE: username and password are provided.
|
||||
# @POST: Returns User object if authentication succeeds, else None.
|
||||
# @SIDE_EFFECT: Updates last_login timestamp on success.
|
||||
# @PARAM: username (str) - The username.
|
||||
# @PARAM: password (str) - The plain password.
|
||||
# @RETURN: Optional[User] - The authenticated user or None.
|
||||
# @PURPOSE: Validates credentials and account state for local username/password authentication.
|
||||
# @PRE: username and password are non-empty credential inputs.
|
||||
# @POST: Returns User only when user exists, is active, and password hash verification succeeds; otherwise returns None.
|
||||
# @SIDE_EFFECT: Persists last_login update for successful authentications via repository.
|
||||
# @DATA_CONTRACT: Input(str username, str password) -> Output(User | None)
|
||||
# @PARAM: username (str) - The username.
|
||||
# @PARAM: password (str) - The plain password.
|
||||
# @RETURN: Optional[User] - The authenticated user or None.
|
||||
def authenticate_user(self, username: str, password: str):
|
||||
with belief_scope("AuthService.authenticate_user"):
|
||||
user = self.repo.get_user_by_username(username)
|
||||
@@ -54,11 +62,13 @@ class AuthService:
|
||||
# [/DEF:authenticate_user:Function]
|
||||
|
||||
# [DEF:create_session:Function]
|
||||
# @PURPOSE: Creates a JWT session for an authenticated user.
|
||||
# @PRE: user is a valid User object.
|
||||
# @POST: Returns a dictionary with access_token and token_type.
|
||||
# @PARAM: user (User) - The authenticated user.
|
||||
# @RETURN: Dict[str, str] - Session data.
|
||||
# @PURPOSE: Issues an access token payload for an already authenticated user.
|
||||
# @PRE: user is a valid User entity containing username and iterable roles with role.name values.
|
||||
# @POST: Returns session dict with non-empty access_token and token_type='bearer'.
|
||||
# @SIDE_EFFECT: Generates signed JWT via auth JWT provider.
|
||||
# @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type})
|
||||
# @PARAM: user (User) - The authenticated user.
|
||||
# @RETURN: Dict[str, str] - Session data.
|
||||
def create_session(self, user) -> Dict[str, str]:
|
||||
with belief_scope("AuthService.create_session"):
|
||||
# Collect role names for scopes
|
||||
@@ -77,11 +87,13 @@ class AuthService:
|
||||
# [/DEF:create_session:Function]
|
||||
|
||||
# [DEF:provision_adfs_user:Function]
|
||||
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
|
||||
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
|
||||
# @POST: User is created/updated and assigned roles based on groups.
|
||||
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
|
||||
# @RETURN: User - The provisioned user.
|
||||
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings.
|
||||
# @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent.
|
||||
# @POST: Returns persisted user entity with roles synchronized to mapped AD groups and refreshed state.
|
||||
# @SIDE_EFFECT: May insert new User, mutate user.roles, commit transaction, and refresh ORM state.
|
||||
# @DATA_CONTRACT: Input(Dict[str, Any]{upn|email, email, groups[]}) -> Output(User persisted)
|
||||
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
|
||||
# @RETURN: User - The provisioned user.
|
||||
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
|
||||
with belief_scope("AuthService.provision_adfs_user"):
|
||||
username = user_info.get("upn") or user_info.get("email")
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
# [DEF:backend.src.services.clean_release:Module]
|
||||
# [DEF:clean_release:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: clean-release, services, package, initialization
|
||||
# @PURPOSE: Initialize clean release service package and provide explicit module exports.
|
||||
# @PURPOSE: Redesigned clean release compliance subsystem.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: EXPORTS -> policy_engine, manifest_builder, preparation_service, source_isolation, compliance_orchestrator, report_builder, repository, stages, audit_service
|
||||
# @INVARIANT: Package import must not execute runtime side effects beyond symbol export setup.
|
||||
|
||||
from ...core.logger import logger
|
||||
|
||||
# [REASON] Initializing clean_release package.
|
||||
logger.reason("Clean release compliance subsystem initialized.")
|
||||
|
||||
# Legacy compatibility exports are intentionally lazy to avoid import cycles.
|
||||
__all__ = [
|
||||
"policy_engine",
|
||||
"manifest_builder",
|
||||
"preparation_service",
|
||||
"source_isolation",
|
||||
"compliance_orchestrator",
|
||||
"report_builder",
|
||||
"repository",
|
||||
"stages",
|
||||
"audit_service",
|
||||
"logger",
|
||||
]
|
||||
# [/DEF:backend.src.services.clean_release:Module]
|
||||
|
||||
# [/DEF:clean_release:Module]
|
||||
@@ -22,3 +22,6 @@ def test_audit_check_run(mock_logger):
|
||||
def test_audit_report(mock_logger):
|
||||
audit_report("rep-1", "cand-1")
|
||||
mock_logger.info.assert_called_with("[EXPLORE] clean-release report_id=rep-1 candidate=cand-1")
|
||||
|
||||
|
||||
# [/DEF:backend.tests.services.clean_release.test_audit_service:Module]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# @SEMANTICS: tests, clean-release, preparation, flow
|
||||
# @PURPOSE: Validate release candidate preparation flow, including policy evaluation and manifest persisting.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: TESTS -> backend.src.services.clean_release.preparation_service
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.services.clean_release.preparation_service:Module]
|
||||
# @INVARIANT: Candidate preparation always persists manifest and candidate status deterministically.
|
||||
|
||||
import pytest
|
||||
@@ -21,6 +21,8 @@ from src.models.clean_release import (
|
||||
)
|
||||
from src.services.clean_release.preparation_service import prepare_candidate
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function]
|
||||
# @PURPOSE: Build a valid clean profile policy fixture for preparation tests.
|
||||
def _mock_policy() -> CleanProfilePolicy:
|
||||
return CleanProfilePolicy(
|
||||
policy_id="pol-1",
|
||||
@@ -33,7 +35,10 @@ def _mock_policy() -> CleanProfilePolicy:
|
||||
effective_from=datetime.now(timezone.utc),
|
||||
profile=ProfileType.ENTERPRISE_CLEAN,
|
||||
)
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_policy:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function]
|
||||
# @PURPOSE: Build an internal-only source registry fixture for preparation tests.
|
||||
def _mock_registry() -> ResourceSourceRegistry:
|
||||
return ResourceSourceRegistry(
|
||||
registry_id="reg-1",
|
||||
@@ -42,7 +47,10 @@ def _mock_registry() -> ResourceSourceRegistry:
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
updated_by="tester"
|
||||
)
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_registry:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function]
|
||||
# @PURPOSE: Build a draft release candidate fixture with provided identifier.
|
||||
def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
|
||||
return ReleaseCandidate(
|
||||
candidate_id=candidate_id,
|
||||
@@ -53,7 +61,15 @@ def _mock_candidate(candidate_id: str) -> ReleaseCandidate:
|
||||
created_by="tester",
|
||||
source_snapshot_ref="v1.0.0-snapshot"
|
||||
)
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service._mock_candidate:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function]
|
||||
# @PURPOSE: Verify candidate transitions to PREPARED when evaluation returns no violations.
|
||||
# @TEST_CONTRACT: [valid_candidate + active_policy + internal_sources + no_violations] -> [status=PREPARED, manifest_persisted, candidate_saved]
|
||||
# @TEST_SCENARIO: [prepare_success] -> [prepared status and persistence side effects are produced]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
# @TEST_EDGE: [external_fail] -> [none; dependency interactions mocked and successful]
|
||||
# @TEST_INVARIANT: [prepared_flow_persists_state] -> VERIFIED_BY: [prepare_success]
|
||||
def test_prepare_candidate_success():
|
||||
# Setup
|
||||
repository = MagicMock()
|
||||
@@ -82,7 +98,15 @@ def test_prepare_candidate_success():
|
||||
assert candidate.status == ReleaseCandidateStatus.PREPARED
|
||||
repository.save_manifest.assert_called_once()
|
||||
repository.save_candidate.assert_called_with(candidate)
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_success:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function]
|
||||
# @PURPOSE: Verify candidate transitions to BLOCKED when evaluation returns blocking violations.
|
||||
# @TEST_CONTRACT: [valid_candidate + active_policy + evaluation_with_violations] -> [status=BLOCKED, violations_exposed]
|
||||
# @TEST_SCENARIO: [prepare_blocked_due_to_policy] -> [blocked status and violation list are produced]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
# @TEST_EDGE: [external_fail] -> [none; dependency interactions mocked and successful]
|
||||
# @TEST_INVARIANT: [blocked_flow_reports_violations] -> VERIFIED_BY: [prepare_blocked_due_to_policy]
|
||||
def test_prepare_candidate_with_violations():
|
||||
# Setup
|
||||
repository = MagicMock()
|
||||
@@ -110,14 +134,30 @@ def test_prepare_candidate_with_violations():
|
||||
assert result["status"] == ReleaseCandidateStatus.BLOCKED.value
|
||||
assert candidate.status == ReleaseCandidateStatus.BLOCKED
|
||||
assert len(result["violations"]) == 1
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_with_violations:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function]
|
||||
# @PURPOSE: Verify preparation raises ValueError when candidate does not exist.
|
||||
# @TEST_CONTRACT: [missing_candidate] -> [ValueError('Candidate not found')]
|
||||
# @TEST_SCENARIO: [prepare_missing_candidate] -> [raises candidate not found error]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
# @TEST_EDGE: [missing_field] -> [candidate lookup returns None]
|
||||
# @TEST_INVARIANT: [missing_candidate_is_rejected] -> VERIFIED_BY: [prepare_missing_candidate]
|
||||
def test_prepare_candidate_not_found():
|
||||
repository = MagicMock()
|
||||
repository.get_candidate.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Candidate not found"):
|
||||
prepare_candidate(repository, "non-existent", [], [], "op")
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_not_found:Function]
|
||||
|
||||
# [DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function]
|
||||
# @PURPOSE: Verify preparation raises ValueError when no active policy is available.
|
||||
# @TEST_CONTRACT: [candidate_present + missing_active_policy] -> [ValueError('Active clean policy not found')]
|
||||
# @TEST_SCENARIO: [prepare_missing_policy] -> [raises active policy missing error]
|
||||
# @TEST_FIXTURE: [INLINE_MOCKS] -> INLINE_JSON
|
||||
# @TEST_EDGE: [invalid_type] -> [policy dependency resolves to None]
|
||||
# @TEST_INVARIANT: [active_policy_required] -> VERIFIED_BY: [prepare_missing_policy]
|
||||
def test_prepare_candidate_no_active_policy():
|
||||
repository = MagicMock()
|
||||
repository.get_candidate.return_value = _mock_candidate("cand-1")
|
||||
@@ -125,3 +165,7 @@ def test_prepare_candidate_no_active_policy():
|
||||
|
||||
with pytest.raises(ValueError, match="Active clean policy not found"):
|
||||
prepare_candidate(repository, "cand-1", [], [], "op")
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service.test_prepare_candidate_no_active_policy:Function]
|
||||
|
||||
|
||||
# [/DEF:backend.tests.services.clean_release.test_preparation_service:Module]
|
||||
|
||||
@@ -55,4 +55,6 @@ def test_validate_internal_sources_external_blocked():
|
||||
assert result["ok"] is False
|
||||
assert len(result["violations"]) == 1
|
||||
assert result["violations"][0]["category"] == "external-source"
|
||||
assert result["violations"][0]["blocked_release"] is True
|
||||
assert result["violations"][0]["blocked_release"] is True
|
||||
|
||||
# [/DEF:backend.tests.services.clean_release.test_source_isolation:Module]
|
||||
@@ -25,3 +25,6 @@ def test_derive_final_status_failed_skipped():
|
||||
results = [CheckStageResult(stage=s, status=CheckStageStatus.PASS, details="ok") for s in MANDATORY_STAGE_ORDER]
|
||||
results[2].status = CheckStageStatus.SKIPPED
|
||||
assert derive_final_status(results) == CheckFinalStatus.FAILED
|
||||
|
||||
|
||||
# [/DEF:backend.tests.services.clean_release.test_stages:Module]
|
||||
|
||||
178
backend/src/services/clean_release/approval_service.py
Normal file
178
backend/src/services/clean_release/approval_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
# [DEF:backend.src.services.clean_release.approval_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, approval, decision, lifecycle, gate
|
||||
# @PURPOSE: Enforce approval/rejection gates over immutable compliance reports.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.audit_service
|
||||
# @INVARIANT: Approval is allowed only for PASSED report bound to candidate; decisions are append-only.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.clean_release import ApprovalDecision
|
||||
from .audit_service import audit_preparation
|
||||
from .enums import ApprovalDecisionType, CandidateStatus, ComplianceDecision
|
||||
from .exceptions import ApprovalGateError
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:_get_or_init_decisions_store:Function]
|
||||
# @PURPOSE: Provide append-only in-memory storage for approval decisions.
|
||||
# @PRE: repository is initialized.
|
||||
# @POST: Returns mutable decision list attached to repository.
|
||||
def _get_or_init_decisions_store(repository: CleanReleaseRepository) -> List[ApprovalDecision]:
|
||||
decisions = getattr(repository, "approval_decisions", None)
|
||||
if decisions is None:
|
||||
decisions = []
|
||||
setattr(repository, "approval_decisions", decisions)
|
||||
return decisions
|
||||
# [/DEF:_get_or_init_decisions_store:Function]
|
||||
|
||||
|
||||
# [DEF:_latest_decision_for_candidate:Function]
|
||||
# @PURPOSE: Resolve latest approval decision for candidate from append-only store.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest ApprovalDecision or None.
|
||||
def _latest_decision_for_candidate(repository: CleanReleaseRepository, candidate_id: str) -> ApprovalDecision | None:
|
||||
decisions = _get_or_init_decisions_store(repository)
|
||||
scoped = [item for item in decisions if item.candidate_id == candidate_id]
|
||||
if not scoped:
|
||||
return None
|
||||
return sorted(scoped, key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0]
|
||||
# [/DEF:_latest_decision_for_candidate:Function]
|
||||
|
||||
|
||||
# [DEF:_resolve_candidate_and_report:Function]
|
||||
# @PURPOSE: Validate candidate/report existence and ownership prior to decision persistence.
|
||||
# @PRE: candidate_id and report_id are non-empty.
|
||||
# @POST: Returns tuple(candidate, report); raises ApprovalGateError on contract violation.
|
||||
def _resolve_candidate_and_report(
|
||||
repository: CleanReleaseRepository,
|
||||
*,
|
||||
candidate_id: str,
|
||||
report_id: str,
|
||||
):
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise ApprovalGateError(f"candidate '{candidate_id}' not found")
|
||||
|
||||
report = repository.get_report(report_id)
|
||||
if report is None:
|
||||
raise ApprovalGateError(f"report '{report_id}' not found")
|
||||
|
||||
if report.candidate_id != candidate_id:
|
||||
raise ApprovalGateError("report belongs to another candidate")
|
||||
|
||||
return candidate, report
|
||||
# [/DEF:_resolve_candidate_and_report:Function]
|
||||
|
||||
|
||||
# [DEF:approve_candidate:Function]
|
||||
# @PURPOSE: Persist immutable APPROVED decision and advance candidate lifecycle to APPROVED.
|
||||
# @PRE: Candidate exists, report belongs to candidate, report final_status is PASSED, candidate not already APPROVED.
|
||||
# @POST: Approval decision is appended and candidate transitions to APPROVED.
|
||||
def approve_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
report_id: str,
|
||||
decided_by: str,
|
||||
comment: str | None = None,
|
||||
) -> ApprovalDecision:
|
||||
with belief_scope("approval_service.approve_candidate"):
|
||||
logger.reason(f"[REASON] Evaluating approve gate candidate_id={candidate_id} report_id={report_id}")
|
||||
|
||||
if not decided_by or not decided_by.strip():
|
||||
raise ApprovalGateError("decided_by must be non-empty")
|
||||
|
||||
candidate, report = _resolve_candidate_and_report(
|
||||
repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=report_id,
|
||||
)
|
||||
|
||||
if report.final_status != ComplianceDecision.PASSED.value:
|
||||
raise ApprovalGateError("approve requires PASSED compliance report")
|
||||
|
||||
latest = _latest_decision_for_candidate(repository, candidate_id)
|
||||
if latest is not None and latest.decision == ApprovalDecisionType.APPROVED.value:
|
||||
raise ApprovalGateError("candidate is already approved")
|
||||
|
||||
if candidate.status == CandidateStatus.APPROVED.value:
|
||||
raise ApprovalGateError("candidate is already approved")
|
||||
|
||||
try:
|
||||
if candidate.status != CandidateStatus.CHECK_PASSED.value:
|
||||
raise ApprovalGateError(
|
||||
f"candidate status '{candidate.status}' cannot transition to APPROVED"
|
||||
)
|
||||
candidate.transition_to(CandidateStatus.APPROVED)
|
||||
repository.save_candidate(candidate)
|
||||
except ApprovalGateError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.explore(f"[EXPLORE] Candidate transition to APPROVED failed candidate_id={candidate_id}: {exc}")
|
||||
raise ApprovalGateError(str(exc)) from exc
|
||||
|
||||
decision = ApprovalDecision(
|
||||
id=f"approve-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
report_id=report_id,
|
||||
decision=ApprovalDecisionType.APPROVED.value,
|
||||
decided_by=decided_by,
|
||||
decided_at=datetime.now(timezone.utc),
|
||||
comment=comment,
|
||||
)
|
||||
_get_or_init_decisions_store(repository).append(decision)
|
||||
audit_preparation(candidate_id, "APPROVED", repository=repository, actor=decided_by)
|
||||
logger.reflect(f"[REFLECT] Approval persisted candidate_id={candidate_id} decision_id={decision.id}")
|
||||
return decision
|
||||
# [/DEF:approve_candidate:Function]
|
||||
|
||||
|
||||
# [DEF:reject_candidate:Function]
|
||||
# @PURPOSE: Persist immutable REJECTED decision without promoting candidate lifecycle.
|
||||
# @PRE: Candidate exists and report belongs to candidate.
|
||||
# @POST: Rejected decision is appended; candidate lifecycle is unchanged.
|
||||
def reject_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
report_id: str,
|
||||
decided_by: str,
|
||||
comment: str | None = None,
|
||||
) -> ApprovalDecision:
|
||||
with belief_scope("approval_service.reject_candidate"):
|
||||
logger.reason(f"[REASON] Evaluating reject decision candidate_id={candidate_id} report_id={report_id}")
|
||||
|
||||
if not decided_by or not decided_by.strip():
|
||||
raise ApprovalGateError("decided_by must be non-empty")
|
||||
|
||||
_resolve_candidate_and_report(
|
||||
repository,
|
||||
candidate_id=candidate_id,
|
||||
report_id=report_id,
|
||||
)
|
||||
|
||||
decision = ApprovalDecision(
|
||||
id=f"reject-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
report_id=report_id,
|
||||
decision=ApprovalDecisionType.REJECTED.value,
|
||||
decided_by=decided_by,
|
||||
decided_at=datetime.now(timezone.utc),
|
||||
comment=comment,
|
||||
)
|
||||
_get_or_init_decisions_store(repository).append(decision)
|
||||
audit_preparation(candidate_id, "REJECTED", repository=repository, actor=decided_by)
|
||||
logger.reflect(f"[REFLECT] Rejection persisted candidate_id={candidate_id} decision_id={decision.id}")
|
||||
return decision
|
||||
# [/DEF:reject_candidate:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.approval_service:Module]
|
||||
@@ -8,17 +8,100 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ...core.logger import logger
|
||||
|
||||
|
||||
def audit_preparation(candidate_id: str, status: str) -> None:
|
||||
def _append_event(repository, payload: Dict[str, Any]) -> None:
|
||||
if repository is not None and hasattr(repository, "append_audit_event"):
|
||||
repository.append_audit_event(payload)
|
||||
|
||||
|
||||
def audit_preparation(candidate_id: str, status: str, repository=None, actor: str = "system") -> None:
|
||||
logger.info(f"[REASON] clean-release preparation candidate={candidate_id} status={status}")
|
||||
_append_event(
|
||||
repository,
|
||||
{
|
||||
"id": f"audit-{uuid4()}",
|
||||
"action": "PREPARATION",
|
||||
"candidate_id": candidate_id,
|
||||
"actor": actor,
|
||||
"status": status,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def audit_check_run(check_run_id: str, final_status: str) -> None:
|
||||
def audit_check_run(
|
||||
check_run_id: str,
|
||||
final_status: str,
|
||||
repository=None,
|
||||
*,
|
||||
candidate_id: Optional[str] = None,
|
||||
actor: str = "system",
|
||||
) -> None:
|
||||
logger.info(f"[REFLECT] clean-release check_run={check_run_id} final_status={final_status}")
|
||||
_append_event(
|
||||
repository,
|
||||
{
|
||||
"id": f"audit-{uuid4()}",
|
||||
"action": "CHECK_RUN",
|
||||
"run_id": check_run_id,
|
||||
"candidate_id": candidate_id,
|
||||
"actor": actor,
|
||||
"status": final_status,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def audit_report(report_id: str, candidate_id: str) -> None:
|
||||
def audit_violation(
|
||||
run_id: str,
|
||||
stage_name: str,
|
||||
code: str,
|
||||
repository=None,
|
||||
*,
|
||||
candidate_id: Optional[str] = None,
|
||||
actor: str = "system",
|
||||
) -> None:
|
||||
logger.info(f"[EXPLORE] clean-release violation run_id={run_id} stage={stage_name} code={code}")
|
||||
_append_event(
|
||||
repository,
|
||||
{
|
||||
"id": f"audit-{uuid4()}",
|
||||
"action": "VIOLATION",
|
||||
"run_id": run_id,
|
||||
"candidate_id": candidate_id,
|
||||
"actor": actor,
|
||||
"stage_name": stage_name,
|
||||
"code": code,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def audit_report(
|
||||
report_id: str,
|
||||
candidate_id: str,
|
||||
repository=None,
|
||||
*,
|
||||
run_id: Optional[str] = None,
|
||||
actor: str = "system",
|
||||
) -> None:
|
||||
logger.info(f"[EXPLORE] clean-release report_id={report_id} candidate={candidate_id}")
|
||||
_append_event(
|
||||
repository,
|
||||
{
|
||||
"id": f"audit-{uuid4()}",
|
||||
"action": "REPORT",
|
||||
"report_id": report_id,
|
||||
"run_id": run_id,
|
||||
"candidate_id": candidate_id,
|
||||
"actor": actor,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
# [/DEF:backend.src.services.clean_release.audit_service:Module]
|
||||
107
backend/src/services/clean_release/candidate_service.py
Normal file
107
backend/src/services/clean_release/candidate_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# [DEF:backend.src.services.clean_release.candidate_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, candidate, artifacts, lifecycle, validation
|
||||
# @PURPOSE: Register release candidates with validated artifacts and advance lifecycle through legal transitions.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @PRE: candidate_id must be unique; artifacts input must be non-empty and valid.
|
||||
# @POST: candidate and artifacts are persisted; candidate transitions DRAFT -> PREPARED only.
|
||||
# @INVARIANT: Candidate lifecycle transitions are delegated to domain guard logic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
from ...models.clean_release import CandidateArtifact, ReleaseCandidate
|
||||
from .enums import CandidateStatus
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:_validate_artifacts:Function]
|
||||
# @PURPOSE: Validate raw artifact payload list for required fields and shape.
|
||||
# @PRE: artifacts payload is provided by caller.
|
||||
# @POST: Returns normalized artifact list or raises ValueError.
|
||||
def _validate_artifacts(artifacts: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
normalized = list(artifacts)
|
||||
if not normalized:
|
||||
raise ValueError("artifacts must not be empty")
|
||||
|
||||
required_fields = ("id", "path", "sha256", "size")
|
||||
for index, artifact in enumerate(normalized):
|
||||
if not isinstance(artifact, dict):
|
||||
raise ValueError(f"artifact[{index}] must be an object")
|
||||
for field in required_fields:
|
||||
if field not in artifact:
|
||||
raise ValueError(f"artifact[{index}] missing required field '{field}'")
|
||||
if not str(artifact["id"]).strip():
|
||||
raise ValueError(f"artifact[{index}] field 'id' must be non-empty")
|
||||
if not str(artifact["path"]).strip():
|
||||
raise ValueError(f"artifact[{index}] field 'path' must be non-empty")
|
||||
if not str(artifact["sha256"]).strip():
|
||||
raise ValueError(f"artifact[{index}] field 'sha256' must be non-empty")
|
||||
if not isinstance(artifact["size"], int) or artifact["size"] <= 0:
|
||||
raise ValueError(f"artifact[{index}] field 'size' must be a positive integer")
|
||||
return normalized
|
||||
# [/DEF:_validate_artifacts:Function]
|
||||
|
||||
|
||||
# [DEF:register_candidate:Function]
|
||||
# @PURPOSE: Register a candidate and persist its artifacts with legal lifecycle transition.
|
||||
# @PRE: candidate_id must be unique and artifacts must pass validation.
|
||||
# @POST: Candidate exists in repository with PREPARED status and artifacts persisted.
|
||||
def register_candidate(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
version: str,
|
||||
source_snapshot_ref: str,
|
||||
created_by: str,
|
||||
artifacts: Iterable[Dict[str, Any]],
|
||||
) -> ReleaseCandidate:
|
||||
if not candidate_id or not candidate_id.strip():
|
||||
raise ValueError("candidate_id must be non-empty")
|
||||
if not version or not version.strip():
|
||||
raise ValueError("version must be non-empty")
|
||||
if not source_snapshot_ref or not source_snapshot_ref.strip():
|
||||
raise ValueError("source_snapshot_ref must be non-empty")
|
||||
if not created_by or not created_by.strip():
|
||||
raise ValueError("created_by must be non-empty")
|
||||
|
||||
existing = repository.get_candidate(candidate_id)
|
||||
if existing is not None:
|
||||
raise ValueError(f"candidate '{candidate_id}' already exists")
|
||||
|
||||
validated_artifacts = _validate_artifacts(artifacts)
|
||||
|
||||
candidate = ReleaseCandidate(
|
||||
id=candidate_id,
|
||||
version=version,
|
||||
source_snapshot_ref=source_snapshot_ref,
|
||||
created_by=created_by,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
status=CandidateStatus.DRAFT.value,
|
||||
)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
for artifact_payload in validated_artifacts:
|
||||
artifact = CandidateArtifact(
|
||||
id=str(artifact_payload["id"]),
|
||||
candidate_id=candidate_id,
|
||||
path=str(artifact_payload["path"]),
|
||||
sha256=str(artifact_payload["sha256"]),
|
||||
size=int(artifact_payload["size"]),
|
||||
detected_category=artifact_payload.get("detected_category"),
|
||||
declared_category=artifact_payload.get("declared_category"),
|
||||
source_uri=artifact_payload.get("source_uri"),
|
||||
source_host=artifact_payload.get("source_host"),
|
||||
metadata_json=artifact_payload.get("metadata_json", {}),
|
||||
)
|
||||
repository.save_artifact(artifact)
|
||||
|
||||
candidate.transition_to(CandidateStatus.PREPARED)
|
||||
repository.save_candidate(candidate)
|
||||
return candidate
|
||||
# [/DEF:register_candidate:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.candidate_service:Module]
|
||||
@@ -0,0 +1,197 @@
|
||||
# [DEF:backend.src.services.clean_release.compliance_execution_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, compliance, execution, stages, immutable-evidence
|
||||
# @PURPOSE: Create and execute compliance runs with trusted snapshots, deterministic stages, violations and immutable report persistence.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.policy_resolution_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.stages
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.report_builder
|
||||
# @INVARIANT: A run binds to exactly one candidate/manifest/policy/registry snapshot set.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.clean_release import ComplianceReport, ComplianceRun, ComplianceStageRun, ComplianceViolation, DistributionManifest
|
||||
from .audit_service import audit_check_run, audit_report, audit_violation
|
||||
from .enums import ComplianceDecision, RunStatus
|
||||
from .exceptions import ComplianceRunError, PolicyResolutionError
|
||||
from .policy_resolution_service import resolve_trusted_policy_snapshots
|
||||
from .report_builder import ComplianceReportBuilder
|
||||
from .repository import CleanReleaseRepository
|
||||
from .stages import build_default_stages, derive_final_status
|
||||
from .stages.base import ComplianceStage, ComplianceStageContext, build_stage_run_record
|
||||
|
||||
|
||||
# [DEF:ComplianceExecutionResult:Class]
|
||||
# @PURPOSE: Return envelope for compliance execution with run/report and persisted stage artifacts.
|
||||
@dataclass
|
||||
class ComplianceExecutionResult:
|
||||
run: ComplianceRun
|
||||
report: Optional[ComplianceReport]
|
||||
stage_runs: List[ComplianceStageRun]
|
||||
violations: List[ComplianceViolation]
|
||||
# [/DEF:ComplianceExecutionResult:Class]
|
||||
|
||||
|
||||
# [DEF:ComplianceExecutionService:Class]
|
||||
# @PURPOSE: Execute clean-release compliance lifecycle over trusted snapshots and immutable evidence.
|
||||
# @PRE: repository and config_manager are initialized.
|
||||
# @POST: run state, stage records, violations and optional report are persisted consistently.
|
||||
class ComplianceExecutionService:
|
||||
TASK_PLUGIN_ID = "clean-release-compliance"
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
config_manager,
|
||||
stages: Optional[Iterable[ComplianceStage]] = None,
|
||||
):
|
||||
self.repository = repository
|
||||
self.config_manager = config_manager
|
||||
self.stages = list(stages) if stages is not None else build_default_stages()
|
||||
self.report_builder = ComplianceReportBuilder(repository)
|
||||
|
||||
# [DEF:_resolve_manifest:Function]
|
||||
# @PURPOSE: Resolve explicit manifest or fallback to latest candidate manifest.
|
||||
# @PRE: candidate exists.
|
||||
# @POST: Returns manifest snapshot or raises ComplianceRunError.
|
||||
def _resolve_manifest(self, candidate_id: str, manifest_id: Optional[str]) -> DistributionManifest:
|
||||
with belief_scope("ComplianceExecutionService._resolve_manifest"):
|
||||
if manifest_id:
|
||||
manifest = self.repository.get_manifest(manifest_id)
|
||||
if manifest is None:
|
||||
raise ComplianceRunError(f"manifest '{manifest_id}' not found")
|
||||
if manifest.candidate_id != candidate_id:
|
||||
raise ComplianceRunError("manifest does not belong to candidate")
|
||||
return manifest
|
||||
|
||||
manifests = self.repository.get_manifests_by_candidate(candidate_id)
|
||||
if not manifests:
|
||||
raise ComplianceRunError(f"candidate '{candidate_id}' has no manifest")
|
||||
return sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0]
|
||||
# [/DEF:_resolve_manifest:Function]
|
||||
|
||||
# [DEF:_persist_stage_run:Function]
|
||||
# @PURPOSE: Persist stage run if repository supports stage records.
|
||||
# @POST: Stage run is persisted when adapter is available, otherwise no-op.
|
||||
def _persist_stage_run(self, stage_run: ComplianceStageRun) -> None:
|
||||
if hasattr(self.repository, "save_stage_run"):
|
||||
self.repository.save_stage_run(stage_run)
|
||||
# [/DEF:_persist_stage_run:Function]
|
||||
|
||||
# [DEF:_persist_violations:Function]
|
||||
# @PURPOSE: Persist stage violations via repository adapters.
|
||||
# @POST: Violations are appended to repository evidence store.
|
||||
def _persist_violations(self, violations: List[ComplianceViolation]) -> None:
|
||||
for violation in violations:
|
||||
self.repository.save_violation(violation)
|
||||
# [/DEF:_persist_violations:Function]
|
||||
|
||||
# [DEF:execute_run:Function]
|
||||
# @PURPOSE: Execute compliance run stages and finalize immutable report on terminal success.
|
||||
# @PRE: candidate exists and trusted policy/registry snapshots are resolvable.
|
||||
# @POST: Run and evidence are persisted; report exists for SUCCEEDED runs.
|
||||
def execute_run(
|
||||
self,
|
||||
*,
|
||||
candidate_id: str,
|
||||
requested_by: str,
|
||||
manifest_id: Optional[str] = None,
|
||||
) -> ComplianceExecutionResult:
|
||||
with belief_scope("ComplianceExecutionService.execute_run"):
|
||||
logger.reason(f"Starting compliance execution candidate_id={candidate_id}")
|
||||
|
||||
candidate = self.repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise ComplianceRunError(f"candidate '{candidate_id}' not found")
|
||||
|
||||
manifest = self._resolve_manifest(candidate_id, manifest_id)
|
||||
|
||||
try:
|
||||
policy_snapshot, registry_snapshot = resolve_trusted_policy_snapshots(
|
||||
config_manager=self.config_manager,
|
||||
repository=self.repository,
|
||||
)
|
||||
except PolicyResolutionError as exc:
|
||||
raise ComplianceRunError(str(exc)) from exc
|
||||
|
||||
run = ComplianceRun(
|
||||
id=f"run-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
manifest_id=manifest.id,
|
||||
manifest_digest=manifest.manifest_digest,
|
||||
policy_snapshot_id=policy_snapshot.id,
|
||||
registry_snapshot_id=registry_snapshot.id,
|
||||
requested_by=requested_by,
|
||||
requested_at=datetime.now(timezone.utc),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
status=RunStatus.RUNNING.value,
|
||||
)
|
||||
self.repository.save_check_run(run)
|
||||
|
||||
stage_runs: List[ComplianceStageRun] = []
|
||||
violations: List[ComplianceViolation] = []
|
||||
report: Optional[ComplianceReport] = None
|
||||
|
||||
context = ComplianceStageContext(
|
||||
run=run,
|
||||
candidate=candidate,
|
||||
manifest=manifest,
|
||||
policy=policy_snapshot,
|
||||
registry=registry_snapshot,
|
||||
)
|
||||
|
||||
try:
|
||||
for stage in self.stages:
|
||||
started = datetime.now(timezone.utc)
|
||||
result = stage.execute(context)
|
||||
finished = datetime.now(timezone.utc)
|
||||
|
||||
stage_run = build_stage_run_record(
|
||||
run_id=run.id,
|
||||
stage_name=stage.stage_name,
|
||||
result=result,
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
)
|
||||
self._persist_stage_run(stage_run)
|
||||
stage_runs.append(stage_run)
|
||||
|
||||
if result.violations:
|
||||
self._persist_violations(result.violations)
|
||||
violations.extend(result.violations)
|
||||
|
||||
run.final_status = derive_final_status(stage_runs).value
|
||||
run.status = RunStatus.SUCCEEDED.value
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
self.repository.save_check_run(run)
|
||||
|
||||
report = self.report_builder.build_report_payload(run, violations)
|
||||
report = self.report_builder.persist_report(report)
|
||||
run.report_id = report.id
|
||||
self.repository.save_check_run(run)
|
||||
logger.reflect(f"[REFLECT] Compliance run completed run_id={run.id} final_status={run.final_status}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
run.status = RunStatus.FAILED.value
|
||||
run.final_status = ComplianceDecision.ERROR.value
|
||||
run.failure_reason = str(exc)
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
self.repository.save_check_run(run)
|
||||
logger.explore(f"[EXPLORE] Compliance run failed run_id={run.id}: {exc}")
|
||||
|
||||
return ComplianceExecutionResult(
|
||||
run=run,
|
||||
report=report,
|
||||
stage_runs=stage_runs,
|
||||
violations=violations,
|
||||
)
|
||||
# [/DEF:execute_run:Function]
|
||||
# [/DEF:ComplianceExecutionService:Class]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.compliance_execution_service:Module]
|
||||
@@ -20,132 +20,148 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ...models.clean_release import (
|
||||
CheckFinalStatus,
|
||||
CheckStageName,
|
||||
CheckStageResult,
|
||||
CheckStageStatus,
|
||||
ComplianceCheckRun,
|
||||
ComplianceViolation,
|
||||
from .enums import (
|
||||
RunStatus,
|
||||
ComplianceDecision,
|
||||
ComplianceStageName,
|
||||
ViolationCategory,
|
||||
ViolationSeverity,
|
||||
)
|
||||
from ...models.clean_release import (
|
||||
ComplianceRun,
|
||||
ComplianceStageRun,
|
||||
ComplianceViolation,
|
||||
)
|
||||
from .policy_engine import CleanPolicyEngine
|
||||
from .repository import CleanReleaseRepository
|
||||
from .stages import MANDATORY_STAGE_ORDER, derive_final_status
|
||||
from .stages import derive_final_status
|
||||
from ...core.logger import belief_scope
|
||||
|
||||
|
||||
# [DEF:CleanComplianceOrchestrator:Class]
|
||||
# @PURPOSE: Coordinate clean-release compliance verification stages.
|
||||
class CleanComplianceOrchestrator:
|
||||
# [DEF:CleanComplianceOrchestrator.__init__:Function]
|
||||
# @PURPOSE: Bind repository dependency used for orchestrator persistence and lookups.
|
||||
# @PRE: repository is a valid CleanReleaseRepository instance with required methods.
|
||||
# @POST: self.repository is assigned and used by all orchestration steps.
|
||||
# @SIDE_EFFECT: Stores repository reference on orchestrator instance.
|
||||
# @DATA_CONTRACT: Input -> CleanReleaseRepository, Output -> None
|
||||
def __init__(self, repository: CleanReleaseRepository):
|
||||
self.repository = repository
|
||||
with belief_scope("CleanComplianceOrchestrator.__init__"):
|
||||
self.repository = repository
|
||||
# [/DEF:CleanComplianceOrchestrator.__init__:Function]
|
||||
|
||||
# [DEF:start_check_run:Function]
|
||||
# @PURPOSE: Initiate a new compliance run session.
|
||||
# @PRE: candidate_id and policy_id must exist in repository.
|
||||
# @POST: Returns initialized ComplianceCheckRun in RUNNING state.
|
||||
def start_check_run(self, candidate_id: str, policy_id: str, triggered_by: str, execution_mode: str) -> ComplianceCheckRun:
|
||||
check_run = ComplianceCheckRun(
|
||||
check_run_id=f"check-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
policy_id=policy_id,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
final_status=CheckFinalStatus.RUNNING,
|
||||
triggered_by=triggered_by,
|
||||
execution_mode=execution_mode,
|
||||
checks=[],
|
||||
)
|
||||
return self.repository.save_check_run(check_run)
|
||||
# @PRE: candidate_id/policy_id/manifest_id identify existing records in repository.
|
||||
# @POST: Returns initialized ComplianceRun in RUNNING state persisted in repository.
|
||||
# @SIDE_EFFECT: Reads manifest/policy and writes new ComplianceRun via repository.save_check_run.
|
||||
# @DATA_CONTRACT: Input -> (candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
|
||||
def start_check_run(self, candidate_id: str, policy_id: str, requested_by: str, manifest_id: str) -> ComplianceRun:
|
||||
with belief_scope("start_check_run"):
|
||||
manifest = self.repository.get_manifest(manifest_id)
|
||||
policy = self.repository.get_policy(policy_id)
|
||||
if not manifest or not policy:
|
||||
raise ValueError("Manifest or Policy not found")
|
||||
|
||||
def execute_stages(self, check_run: ComplianceCheckRun, forced_results: Optional[List[CheckStageResult]] = None) -> ComplianceCheckRun:
|
||||
if forced_results is not None:
|
||||
check_run.checks = forced_results
|
||||
check_run = ComplianceRun(
|
||||
id=f"check-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
manifest_id=manifest_id,
|
||||
manifest_digest=manifest.manifest_digest,
|
||||
policy_snapshot_id=policy_id,
|
||||
registry_snapshot_id=policy.registry_snapshot_id,
|
||||
requested_by=requested_by,
|
||||
requested_at=datetime.now(timezone.utc),
|
||||
status=RunStatus.RUNNING,
|
||||
)
|
||||
return self.repository.save_check_run(check_run)
|
||||
# [/DEF:start_check_run:Function]
|
||||
|
||||
# [DEF:execute_stages:Function]
|
||||
# @PURPOSE: Execute or accept compliance stage outcomes and set intermediate/final check-run status fields.
|
||||
# @PRE: check_run exists and references candidate/policy/registry/manifest identifiers resolvable by repository.
|
||||
# @POST: Returns persisted ComplianceRun with status FAILED on missing dependencies, otherwise SUCCEEDED with final_status set.
|
||||
# @SIDE_EFFECT: Reads candidate/policy/registry/manifest and persists updated check_run.
|
||||
# @DATA_CONTRACT: Input -> (check_run:ComplianceRun, forced_results:Optional[List[ComplianceStageRun]]), Output -> ComplianceRun
|
||||
def execute_stages(self, check_run: ComplianceRun, forced_results: Optional[List[ComplianceStageRun]] = None) -> ComplianceRun:
|
||||
with belief_scope("execute_stages"):
|
||||
if forced_results is not None:
|
||||
# In a real scenario, we'd persist these stages.
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
# Real Logic Integration
|
||||
candidate = self.repository.get_candidate(check_run.candidate_id)
|
||||
policy = self.repository.get_policy(check_run.policy_snapshot_id)
|
||||
if not candidate or not policy:
|
||||
check_run.status = RunStatus.FAILED
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
registry = self.repository.get_registry(check_run.registry_snapshot_id)
|
||||
manifest = self.repository.get_manifest(check_run.manifest_id)
|
||||
|
||||
if not registry or not manifest:
|
||||
check_run.status = RunStatus.FAILED
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
# Simulate stage execution and violation detection
|
||||
# 1. DATA_PURITY
|
||||
summary = manifest.content_json.get("summary", {})
|
||||
purity_ok = summary.get("prohibited_detected_count", 0) == 0
|
||||
|
||||
if not purity_ok:
|
||||
check_run.final_status = ComplianceDecision.BLOCKED
|
||||
else:
|
||||
check_run.final_status = ComplianceDecision.PASSED
|
||||
|
||||
check_run.status = RunStatus.SUCCEEDED
|
||||
check_run.finished_at = datetime.now(timezone.utc)
|
||||
|
||||
# Real Logic Integration
|
||||
candidate = self.repository.get_candidate(check_run.candidate_id)
|
||||
policy = self.repository.get_policy(check_run.policy_id)
|
||||
if not candidate or not policy:
|
||||
check_run.final_status = CheckFinalStatus.FAILED
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
registry = self.repository.get_registry(policy.internal_source_registry_ref)
|
||||
manifest = self.repository.get_manifest(f"manifest-{candidate.candidate_id}")
|
||||
|
||||
if not registry or not manifest:
|
||||
check_run.final_status = CheckFinalStatus.FAILED
|
||||
return self.repository.save_check_run(check_run)
|
||||
|
||||
engine = CleanPolicyEngine(policy=policy, registry=registry)
|
||||
|
||||
stages_results = []
|
||||
violations = []
|
||||
|
||||
# 1. DATA_PURITY
|
||||
purity_ok = manifest.summary.prohibited_detected_count == 0
|
||||
stages_results.append(CheckStageResult(
|
||||
stage=CheckStageName.DATA_PURITY,
|
||||
status=CheckStageStatus.PASS if purity_ok else CheckStageStatus.FAIL,
|
||||
details=f"Detected {manifest.summary.prohibited_detected_count} prohibited items" if not purity_ok else "No prohibited items found"
|
||||
))
|
||||
if not purity_ok:
|
||||
for item in manifest.items:
|
||||
if item.classification.value == "excluded-prohibited":
|
||||
violations.append(ComplianceViolation(
|
||||
violation_id=f"V-{uuid4()}",
|
||||
check_run_id=check_run.check_run_id,
|
||||
category=ViolationCategory.DATA_PURITY,
|
||||
severity=ViolationSeverity.CRITICAL,
|
||||
location=item.path,
|
||||
remediation="Remove prohibited content",
|
||||
blocked_release=True,
|
||||
detected_at=datetime.now(timezone.utc)
|
||||
))
|
||||
|
||||
# 2. INTERNAL_SOURCES_ONLY
|
||||
# In a real scenario, we'd check against actual sources list.
|
||||
# For simplicity in this orchestrator, we check if violations were pre-detected in manifest/preparation
|
||||
# or we could re-run source validation if we had the raw sources list.
|
||||
# Assuming for TUI demo we check if any "external-source" violation exists in preparation phase
|
||||
# (Though preparation_service saves them to candidate status, let's keep it simple here)
|
||||
stages_results.append(CheckStageResult(
|
||||
stage=CheckStageName.INTERNAL_SOURCES_ONLY,
|
||||
status=CheckStageStatus.PASS,
|
||||
details="All sources verified against registry"
|
||||
))
|
||||
|
||||
# 3. NO_EXTERNAL_ENDPOINTS
|
||||
stages_results.append(CheckStageResult(
|
||||
stage=CheckStageName.NO_EXTERNAL_ENDPOINTS,
|
||||
status=CheckStageStatus.PASS,
|
||||
details="Endpoint scan complete"
|
||||
))
|
||||
|
||||
# 4. MANIFEST_CONSISTENCY
|
||||
stages_results.append(CheckStageResult(
|
||||
stage=CheckStageName.MANIFEST_CONSISTENCY,
|
||||
status=CheckStageStatus.PASS,
|
||||
details=f"Deterministic hash: {manifest.deterministic_hash[:12]}..."
|
||||
))
|
||||
|
||||
check_run.checks = stages_results
|
||||
|
||||
# Save violations if any
|
||||
if violations:
|
||||
for v in violations:
|
||||
self.repository.save_violation(v)
|
||||
|
||||
return self.repository.save_check_run(check_run)
|
||||
# [/DEF:execute_stages:Function]
|
||||
|
||||
# [DEF:finalize_run:Function]
|
||||
# @PURPOSE: Finalize run status based on cumulative stage results.
|
||||
# @POST: Status derivation follows strict MANDATORY_STAGE_ORDER.
|
||||
def finalize_run(self, check_run: ComplianceCheckRun) -> ComplianceCheckRun:
|
||||
final_status = derive_final_status(check_run.checks)
|
||||
check_run.final_status = final_status
|
||||
check_run.finished_at = datetime.now(timezone.utc)
|
||||
return self.repository.save_check_run(check_run)
|
||||
# @PRE: check_run was started and may already contain a derived final_status from stage execution.
|
||||
# @POST: Returns persisted ComplianceRun in SUCCEEDED status with final_status guaranteed non-empty.
|
||||
# @SIDE_EFFECT: Mutates check_run terminal fields and persists via repository.save_check_run.
|
||||
# @DATA_CONTRACT: Input -> ComplianceRun, Output -> ComplianceRun
|
||||
def finalize_run(self, check_run: ComplianceRun) -> ComplianceRun:
|
||||
with belief_scope("finalize_run"):
|
||||
# If not already set by execute_stages
|
||||
if not check_run.final_status:
|
||||
check_run.final_status = ComplianceDecision.PASSED
|
||||
|
||||
check_run.status = RunStatus.SUCCEEDED
|
||||
check_run.finished_at = datetime.now(timezone.utc)
|
||||
return self.repository.save_check_run(check_run)
|
||||
# [/DEF:finalize_run:Function]
|
||||
# [/DEF:CleanComplianceOrchestrator:Class]
|
||||
# [/DEF:backend.src.services.clean_release.compliance_orchestrator:Module]
|
||||
|
||||
|
||||
# [DEF:run_check_legacy:Function]
|
||||
# @PURPOSE: Legacy wrapper for compatibility with previous orchestrator call style.
|
||||
# @PRE: repository and identifiers are valid and resolvable by orchestrator dependencies.
|
||||
# @POST: Returns finalized ComplianceRun produced by orchestrator start->execute->finalize sequence.
|
||||
# @SIDE_EFFECT: Reads/writes compliance entities through repository during orchestrator calls.
|
||||
# @DATA_CONTRACT: Input -> (repository:CleanReleaseRepository, candidate_id:str, policy_id:str, requested_by:str, manifest_id:str), Output -> ComplianceRun
|
||||
def run_check_legacy(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
policy_id: str,
|
||||
requested_by: str,
|
||||
manifest_id: str,
|
||||
) -> ComplianceRun:
|
||||
with belief_scope("run_check_legacy"):
|
||||
orchestrator = CleanComplianceOrchestrator(repository)
|
||||
run = orchestrator.start_check_run(
|
||||
candidate_id=candidate_id,
|
||||
policy_id=policy_id,
|
||||
requested_by=requested_by,
|
||||
manifest_id=manifest_id,
|
||||
)
|
||||
run = orchestrator.execute_stages(run)
|
||||
return orchestrator.finalize_run(run)
|
||||
# [/DEF:run_check_legacy:Function]
|
||||
# [/DEF:backend.src.services.clean_release.compliance_orchestrator:Module]
|
||||
50
backend/src/services/clean_release/demo_data_service.py
Normal file
50
backend/src/services/clean_release/demo_data_service.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# [DEF:backend.src.services.clean_release.demo_data_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: clean-release, demo-mode, namespace, isolation, repository
|
||||
# @PURPOSE: Provide deterministic namespace helpers and isolated in-memory repository creation for demo and real modes.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @INVARIANT: Demo and real namespaces must never collide for generated physical identifiers.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:resolve_namespace:Function]
|
||||
# @PURPOSE: Resolve canonical clean-release namespace for requested mode.
|
||||
# @PRE: mode is a non-empty string identifying runtime mode.
|
||||
# @POST: Returns deterministic namespace key for demo/real separation.
|
||||
def resolve_namespace(mode: str) -> str:
|
||||
normalized = (mode or "").strip().lower()
|
||||
if normalized == "demo":
|
||||
return "clean-release:demo"
|
||||
return "clean-release:real"
|
||||
# [/DEF:resolve_namespace:Function]
|
||||
|
||||
|
||||
# [DEF:build_namespaced_id:Function]
|
||||
# @PURPOSE: Build storage-safe physical identifier under mode namespace.
|
||||
# @PRE: namespace and logical_id are non-empty strings.
|
||||
# @POST: Returns deterministic "{namespace}::{logical_id}" identifier.
|
||||
def build_namespaced_id(namespace: str, logical_id: str) -> str:
|
||||
if not namespace or not namespace.strip():
|
||||
raise ValueError("namespace must be non-empty")
|
||||
if not logical_id or not logical_id.strip():
|
||||
raise ValueError("logical_id must be non-empty")
|
||||
return f"{namespace}::{logical_id}"
|
||||
# [/DEF:build_namespaced_id:Function]
|
||||
|
||||
|
||||
# [DEF:create_isolated_repository:Function]
|
||||
# @PURPOSE: Create isolated in-memory repository instance for selected mode namespace.
|
||||
# @PRE: mode is a valid runtime mode marker.
|
||||
# @POST: Returns repository instance tagged with namespace metadata.
|
||||
def create_isolated_repository(mode: str) -> CleanReleaseRepository:
|
||||
namespace = resolve_namespace(mode)
|
||||
repository = CleanReleaseRepository()
|
||||
setattr(repository, "namespace", namespace)
|
||||
return repository
|
||||
# [/DEF:create_isolated_repository:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.demo_data_service:Module]
|
||||
85
backend/src/services/clean_release/dto.py
Normal file
85
backend/src/services/clean_release/dto.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# [DEF:clean_release_dto:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Data Transfer Objects for clean release compliance subsystem.
|
||||
# @LAYER: Application
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from src.services.clean_release.enums import CandidateStatus, RunStatus, ComplianceDecision
|
||||
|
||||
class CandidateDTO(BaseModel):
|
||||
"""DTO for ReleaseCandidate."""
|
||||
id: str
|
||||
version: str
|
||||
source_snapshot_ref: str
|
||||
build_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
status: CandidateStatus
|
||||
|
||||
class ArtifactDTO(BaseModel):
|
||||
"""DTO for CandidateArtifact."""
|
||||
id: str
|
||||
candidate_id: str
|
||||
path: str
|
||||
sha256: str
|
||||
size: int
|
||||
detected_category: Optional[str] = None
|
||||
declared_category: Optional[str] = None
|
||||
source_uri: Optional[str] = None
|
||||
source_host: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class ManifestDTO(BaseModel):
|
||||
"""DTO for DistributionManifest."""
|
||||
id: str
|
||||
candidate_id: str
|
||||
manifest_version: int
|
||||
manifest_digest: str
|
||||
artifacts_digest: str
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
source_snapshot_ref: str
|
||||
content_json: Dict[str, Any]
|
||||
|
||||
class ComplianceRunDTO(BaseModel):
|
||||
"""DTO for ComplianceRun status tracking."""
|
||||
run_id: str
|
||||
candidate_id: str
|
||||
status: RunStatus
|
||||
final_status: Optional[ComplianceDecision] = None
|
||||
report_id: Optional[str] = None
|
||||
task_id: Optional[str] = None
|
||||
|
||||
class ReportDTO(BaseModel):
|
||||
"""Compact report view."""
|
||||
report_id: str
|
||||
candidate_id: str
|
||||
final_status: ComplianceDecision
|
||||
policy_version: str
|
||||
manifest_digest: str
|
||||
violation_count: int
|
||||
generated_at: datetime
|
||||
|
||||
class CandidateOverviewDTO(BaseModel):
|
||||
"""Read model for candidate overview."""
|
||||
candidate_id: str
|
||||
version: str
|
||||
source_snapshot_ref: str
|
||||
status: CandidateStatus
|
||||
latest_manifest_id: Optional[str] = None
|
||||
latest_manifest_digest: Optional[str] = None
|
||||
latest_run_id: Optional[str] = None
|
||||
latest_run_status: Optional[RunStatus] = None
|
||||
latest_report_id: Optional[str] = None
|
||||
latest_report_final_status: Optional[ComplianceDecision] = None
|
||||
latest_policy_snapshot_id: Optional[str] = None
|
||||
latest_policy_version: Optional[str] = None
|
||||
latest_registry_snapshot_id: Optional[str] = None
|
||||
latest_registry_version: Optional[str] = None
|
||||
latest_approval_decision: Optional[str] = None
|
||||
latest_publication_id: Optional[str] = None
|
||||
latest_publication_status: Optional[str] = None
|
||||
|
||||
# [/DEF:clean_release_dto:Module]
|
||||
72
backend/src/services/clean_release/enums.py
Normal file
72
backend/src/services/clean_release/enums.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# [DEF:clean_release_enums:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Canonical enums for clean release lifecycle and compliance.
|
||||
# @LAYER: Domain
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class CandidateStatus(str, Enum):
|
||||
"""Lifecycle states for a ReleaseCandidate."""
|
||||
DRAFT = "DRAFT"
|
||||
PREPARED = "PREPARED"
|
||||
MANIFEST_BUILT = "MANIFEST_BUILT"
|
||||
CHECK_PENDING = "CHECK_PENDING"
|
||||
CHECK_RUNNING = "CHECK_RUNNING"
|
||||
CHECK_PASSED = "CHECK_PASSED"
|
||||
CHECK_BLOCKED = "CHECK_BLOCKED"
|
||||
CHECK_ERROR = "CHECK_ERROR"
|
||||
APPROVED = "APPROVED"
|
||||
PUBLISHED = "PUBLISHED"
|
||||
REVOKED = "REVOKED"
|
||||
|
||||
class RunStatus(str, Enum):
|
||||
"""Execution status for a ComplianceRun."""
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
class ComplianceDecision(str, Enum):
|
||||
"""Final compliance result for a run or stage."""
|
||||
PASSED = "PASSED"
|
||||
BLOCKED = "BLOCKED"
|
||||
ERROR = "ERROR"
|
||||
|
||||
class ApprovalDecisionType(str, Enum):
|
||||
"""Types of approval decisions."""
|
||||
APPROVED = "APPROVED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
class PublicationStatus(str, Enum):
|
||||
"""Status of a publication record."""
|
||||
ACTIVE = "ACTIVE"
|
||||
REVOKED = "REVOKED"
|
||||
|
||||
class ComplianceStageName(str, Enum):
|
||||
"""Canonical names for compliance stages."""
|
||||
DATA_PURITY = "DATA_PURITY"
|
||||
INTERNAL_SOURCES_ONLY = "INTERNAL_SOURCES_ONLY"
|
||||
NO_EXTERNAL_ENDPOINTS = "NO_EXTERNAL_ENDPOINTS"
|
||||
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
|
||||
|
||||
class ClassificationType(str, Enum):
|
||||
"""Classification types for artifacts."""
|
||||
REQUIRED_SYSTEM = "required-system"
|
||||
ALLOWED = "allowed"
|
||||
EXCLUDED_PROHIBITED = "excluded-prohibited"
|
||||
|
||||
class ViolationSeverity(str, Enum):
|
||||
"""Severity levels for compliance violations."""
|
||||
CRITICAL = "CRITICAL"
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
|
||||
class ViolationCategory(str, Enum):
|
||||
"""Categories for compliance violations."""
|
||||
DATA_PURITY = "DATA_PURITY"
|
||||
SOURCE_ISOLATION = "SOURCE_ISOLATION"
|
||||
MANIFEST_CONSISTENCY = "MANIFEST_CONSISTENCY"
|
||||
EXTERNAL_ENDPOINT = "EXTERNAL_ENDPOINT"
|
||||
|
||||
# [/DEF:clean_release_enums:Module]
|
||||
38
backend/src/services/clean_release/exceptions.py
Normal file
38
backend/src/services/clean_release/exceptions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# [DEF:clean_release_exceptions:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Domain exceptions for clean release compliance subsystem.
|
||||
# @LAYER: Domain
|
||||
|
||||
class CleanReleaseError(Exception):
|
||||
"""Base exception for clean release subsystem."""
|
||||
pass
|
||||
|
||||
class CandidateNotFoundError(CleanReleaseError):
|
||||
"""Raised when a release candidate is not found."""
|
||||
pass
|
||||
|
||||
class IllegalTransitionError(CleanReleaseError):
|
||||
"""Raised when a forbidden lifecycle transition is attempted."""
|
||||
pass
|
||||
|
||||
class ManifestImmutableError(CleanReleaseError):
|
||||
"""Raised when an attempt is made to mutate an existing manifest."""
|
||||
pass
|
||||
|
||||
class PolicyResolutionError(CleanReleaseError):
|
||||
"""Raised when trusted policy or registry cannot be resolved."""
|
||||
pass
|
||||
|
||||
class ComplianceRunError(CleanReleaseError):
|
||||
"""Raised when a compliance run fails or is invalid."""
|
||||
pass
|
||||
|
||||
class ApprovalGateError(CleanReleaseError):
|
||||
"""Raised when approval requirements are not met."""
|
||||
pass
|
||||
|
||||
class PublicationGateError(CleanReleaseError):
|
||||
"""Raised when publication requirements are not met."""
|
||||
pass
|
||||
|
||||
# [/DEF:clean_release_exceptions:Module]
|
||||
122
backend/src/services/clean_release/facade.py
Normal file
122
backend/src/services/clean_release/facade.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# [DEF:clean_release_facade:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unified entry point for clean release operations.
|
||||
# @LAYER: Application
|
||||
|
||||
from typing import List, Optional
|
||||
from src.services.clean_release.repositories import (
|
||||
CandidateRepository, ArtifactRepository, ManifestRepository,
|
||||
PolicyRepository, ComplianceRepository, ReportRepository,
|
||||
ApprovalRepository, PublicationRepository, AuditRepository
|
||||
)
|
||||
from src.services.clean_release.dto import (
|
||||
CandidateDTO, ArtifactDTO, ManifestDTO, ComplianceRunDTO,
|
||||
ReportDTO, CandidateOverviewDTO
|
||||
)
|
||||
from src.services.clean_release.enums import CandidateStatus, RunStatus, ComplianceDecision
|
||||
from src.models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot
|
||||
from src.core.logger import belief_scope
|
||||
from src.core.config_manager import ConfigManager
|
||||
|
||||
class CleanReleaseFacade:
|
||||
"""
|
||||
@PURPOSE: Orchestrates repositories and services to provide a clean API for UI/CLI.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
candidate_repo: CandidateRepository,
|
||||
artifact_repo: ArtifactRepository,
|
||||
manifest_repo: ManifestRepository,
|
||||
policy_repo: PolicyRepository,
|
||||
compliance_repo: ComplianceRepository,
|
||||
report_repo: ReportRepository,
|
||||
approval_repo: ApprovalRepository,
|
||||
publication_repo: PublicationRepository,
|
||||
audit_repo: AuditRepository,
|
||||
config_manager: ConfigManager
|
||||
):
|
||||
self.candidate_repo = candidate_repo
|
||||
self.artifact_repo = artifact_repo
|
||||
self.manifest_repo = manifest_repo
|
||||
self.policy_repo = policy_repo
|
||||
self.compliance_repo = compliance_repo
|
||||
self.report_repo = report_repo
|
||||
self.approval_repo = approval_repo
|
||||
self.publication_repo = publication_repo
|
||||
self.audit_repo = audit_repo
|
||||
self.config_manager = config_manager
|
||||
|
||||
def resolve_active_policy_snapshot(self) -> Optional[CleanPolicySnapshot]:
|
||||
"""
|
||||
@PURPOSE: Resolve the active policy snapshot based on ConfigManager.
|
||||
"""
|
||||
with belief_scope("CleanReleaseFacade.resolve_active_policy_snapshot"):
|
||||
config = self.config_manager.get_config()
|
||||
policy_id = config.settings.clean_release.active_policy_id
|
||||
if not policy_id:
|
||||
return None
|
||||
return self.policy_repo.get_policy_snapshot(policy_id)
|
||||
|
||||
def resolve_active_registry_snapshot(self) -> Optional[SourceRegistrySnapshot]:
|
||||
"""
|
||||
@PURPOSE: Resolve the active registry snapshot based on ConfigManager.
|
||||
"""
|
||||
with belief_scope("CleanReleaseFacade.resolve_active_registry_snapshot"):
|
||||
config = self.config_manager.get_config()
|
||||
registry_id = config.settings.clean_release.active_registry_id
|
||||
if not registry_id:
|
||||
return None
|
||||
return self.policy_repo.get_registry_snapshot(registry_id)
|
||||
|
||||
def get_candidate_overview(self, candidate_id: str) -> Optional[CandidateOverviewDTO]:
|
||||
"""
|
||||
@PURPOSE: Build a comprehensive overview for a candidate.
|
||||
"""
|
||||
with belief_scope("CleanReleaseFacade.get_candidate_overview"):
|
||||
candidate = self.candidate_repo.get_by_id(candidate_id)
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
manifest = self.manifest_repo.get_latest_for_candidate(candidate_id)
|
||||
runs = self.compliance_repo.list_runs_by_candidate(candidate_id)
|
||||
latest_run = runs[-1] if runs else None
|
||||
|
||||
report = None
|
||||
if latest_run:
|
||||
report = self.report_repo.get_by_run(latest_run.id)
|
||||
|
||||
approval = self.approval_repo.get_latest_for_candidate(candidate_id)
|
||||
publication = self.publication_repo.get_latest_for_candidate(candidate_id)
|
||||
|
||||
active_policy = self.resolve_active_policy_snapshot()
|
||||
active_registry = self.resolve_active_registry_snapshot()
|
||||
|
||||
return CandidateOverviewDTO(
|
||||
candidate_id=candidate.id,
|
||||
version=candidate.version,
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
status=CandidateStatus(candidate.status),
|
||||
latest_manifest_id=manifest.id if manifest else None,
|
||||
latest_manifest_digest=manifest.manifest_digest if manifest else None,
|
||||
latest_run_id=latest_run.id if latest_run else None,
|
||||
latest_run_status=RunStatus(latest_run.status) if latest_run else None,
|
||||
latest_report_id=report.id if report else None,
|
||||
latest_report_final_status=ComplianceDecision(report.final_status) if report else None,
|
||||
latest_policy_snapshot_id=active_policy.id if active_policy else None,
|
||||
latest_policy_version=active_policy.policy_version if active_policy else None,
|
||||
latest_registry_snapshot_id=active_registry.id if active_registry else None,
|
||||
latest_registry_version=active_registry.registry_version if active_registry else None,
|
||||
latest_approval_decision=approval.decision if approval else None,
|
||||
latest_publication_id=publication.id if publication else None,
|
||||
latest_publication_status=publication.status if publication else None
|
||||
)
|
||||
|
||||
def list_candidates(self) -> List[CandidateOverviewDTO]:
|
||||
"""
|
||||
@PURPOSE: List all candidates with their current status.
|
||||
"""
|
||||
with belief_scope("CleanReleaseFacade.list_candidates"):
|
||||
candidates = self.candidate_repo.list_all()
|
||||
return [self.get_candidate_overview(c.id) for c in candidates]
|
||||
|
||||
# [/DEF:clean_release_facade:Module]
|
||||
@@ -78,7 +78,6 @@ def build_distribution_manifest(
|
||||
return DistributionManifest(
|
||||
manifest_id=manifest_id,
|
||||
candidate_id=candidate_id,
|
||||
policy_id=policy_id,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
generated_by=generated_by,
|
||||
items=items,
|
||||
@@ -86,4 +85,25 @@ def build_distribution_manifest(
|
||||
deterministic_hash=deterministic_hash,
|
||||
)
|
||||
# [/DEF:build_distribution_manifest:Function]
|
||||
|
||||
|
||||
# [DEF:build_manifest:Function]
|
||||
# @PURPOSE: Legacy compatibility wrapper for old manifest builder import paths.
|
||||
# @PRE: Same as build_distribution_manifest.
|
||||
# @POST: Returns DistributionManifest produced by canonical builder.
|
||||
def build_manifest(
|
||||
manifest_id: str,
|
||||
candidate_id: str,
|
||||
policy_id: str,
|
||||
generated_by: str,
|
||||
artifacts: Iterable[Dict[str, Any]],
|
||||
) -> DistributionManifest:
|
||||
return build_distribution_manifest(
|
||||
manifest_id=manifest_id,
|
||||
candidate_id=candidate_id,
|
||||
policy_id=policy_id,
|
||||
generated_by=generated_by,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
# [/DEF:build_manifest:Function]
|
||||
# [/DEF:backend.src.services.clean_release.manifest_builder:Module]
|
||||
88
backend/src/services/clean_release/manifest_service.py
Normal file
88
backend/src/services/clean_release/manifest_service.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# [DEF:backend.src.services.clean_release.manifest_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, manifest, versioning, immutability, lifecycle
|
||||
# @PURPOSE: Build immutable distribution manifests with deterministic digest and version increment.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.manifest_builder
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @PRE: Candidate exists and is PREPARED or MANIFEST_BUILT; artifacts are present.
|
||||
# @POST: New immutable manifest is persisted with incremented version and deterministic digest.
|
||||
# @INVARIANT: Existing manifests are never mutated.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ...models.clean_release import DistributionManifest
|
||||
from .enums import CandidateStatus
|
||||
from .manifest_builder import build_distribution_manifest
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:build_manifest_snapshot:Function]
|
||||
# @PURPOSE: Create a new immutable manifest version for a candidate.
|
||||
# @PRE: Candidate is prepared, artifacts are available, candidate_id is valid.
|
||||
# @POST: Returns persisted DistributionManifest with monotonically incremented version.
|
||||
def build_manifest_snapshot(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
created_by: str,
|
||||
policy_id: str = "policy-default",
|
||||
) -> DistributionManifest:
|
||||
if not candidate_id or not candidate_id.strip():
|
||||
raise ValueError("candidate_id must be non-empty")
|
||||
if not created_by or not created_by.strip():
|
||||
raise ValueError("created_by must be non-empty")
|
||||
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise ValueError(f"candidate '{candidate_id}' not found")
|
||||
|
||||
if candidate.status not in {CandidateStatus.PREPARED.value, CandidateStatus.MANIFEST_BUILT.value}:
|
||||
raise ValueError("candidate must be PREPARED or MANIFEST_BUILT to build manifest")
|
||||
|
||||
artifacts = repository.get_artifacts_by_candidate(candidate_id)
|
||||
if not artifacts:
|
||||
raise ValueError("candidate artifacts are required to build manifest")
|
||||
|
||||
existing = repository.get_manifests_by_candidate(candidate_id)
|
||||
for manifest in existing:
|
||||
if not manifest.immutable:
|
||||
raise ValueError("existing manifest immutability invariant violated")
|
||||
|
||||
next_version = max((m.manifest_version for m in existing), default=0) + 1
|
||||
manifest_id = f"manifest-{candidate_id}-v{next_version}"
|
||||
|
||||
classified_artifacts: List[Dict[str, Any]] = [
|
||||
{
|
||||
"path": artifact.path,
|
||||
"category": artifact.detected_category or "generic",
|
||||
"classification": "allowed",
|
||||
"reason": "artifact import",
|
||||
"checksum": artifact.sha256,
|
||||
}
|
||||
for artifact in artifacts
|
||||
]
|
||||
|
||||
manifest = build_distribution_manifest(
|
||||
manifest_id=manifest_id,
|
||||
candidate_id=candidate_id,
|
||||
policy_id=policy_id,
|
||||
generated_by=created_by,
|
||||
artifacts=classified_artifacts,
|
||||
)
|
||||
manifest.manifest_version = next_version
|
||||
manifest.source_snapshot_ref = candidate.source_snapshot_ref
|
||||
manifest.artifacts_digest = manifest.manifest_digest
|
||||
manifest.immutable = True
|
||||
repository.save_manifest(manifest)
|
||||
|
||||
if candidate.status == CandidateStatus.PREPARED.value:
|
||||
candidate.transition_to(CandidateStatus.MANIFEST_BUILT)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
return manifest
|
||||
# [/DEF:build_manifest_snapshot:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.manifest_service:Module]
|
||||
67
backend/src/services/clean_release/mappers.py
Normal file
67
backend/src/services/clean_release/mappers.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# [DEF:clean_release_mappers:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Map between domain entities (SQLAlchemy models) and DTOs.
|
||||
# @LAYER: Application
|
||||
|
||||
from typing import List
|
||||
from src.models.clean_release import (
|
||||
ReleaseCandidate, DistributionManifest, ComplianceRun,
|
||||
ComplianceStageRun, ComplianceViolation, ComplianceReport,
|
||||
CleanPolicySnapshot, SourceRegistrySnapshot, ApprovalDecision,
|
||||
PublicationRecord
|
||||
)
|
||||
from src.services.clean_release.dto import (
|
||||
CandidateDTO, ArtifactDTO, ManifestDTO, ComplianceRunDTO,
|
||||
ReportDTO
|
||||
)
|
||||
from src.services.clean_release.enums import (
|
||||
CandidateStatus, RunStatus, ComplianceDecision,
|
||||
ViolationSeverity, ViolationCategory
|
||||
)
|
||||
|
||||
def map_candidate_to_dto(candidate: ReleaseCandidate) -> CandidateDTO:
|
||||
return CandidateDTO(
|
||||
id=candidate.id,
|
||||
version=candidate.version,
|
||||
source_snapshot_ref=candidate.source_snapshot_ref,
|
||||
build_id=candidate.build_id,
|
||||
created_at=candidate.created_at,
|
||||
created_by=candidate.created_by,
|
||||
status=CandidateStatus(candidate.status)
|
||||
)
|
||||
|
||||
def map_manifest_to_dto(manifest: DistributionManifest) -> ManifestDTO:
|
||||
return ManifestDTO(
|
||||
id=manifest.id,
|
||||
candidate_id=manifest.candidate_id,
|
||||
manifest_version=manifest.manifest_version,
|
||||
manifest_digest=manifest.manifest_digest,
|
||||
artifacts_digest=manifest.artifacts_digest,
|
||||
created_at=manifest.created_at,
|
||||
created_by=manifest.created_by,
|
||||
source_snapshot_ref=manifest.source_snapshot_ref,
|
||||
content_json=manifest.content_json or {}
|
||||
)
|
||||
|
||||
def map_run_to_dto(run: ComplianceRun) -> ComplianceRunDTO:
|
||||
return ComplianceRunDTO(
|
||||
run_id=run.id,
|
||||
candidate_id=run.candidate_id,
|
||||
status=RunStatus(run.status),
|
||||
final_status=ComplianceDecision(run.final_status) if run.final_status else None,
|
||||
task_id=run.task_id
|
||||
)
|
||||
|
||||
def map_report_to_dto(report: ComplianceReport) -> ReportDTO:
|
||||
# Note: ReportDTO in dto.py is a compact view
|
||||
return ReportDTO(
|
||||
report_id=report.id,
|
||||
candidate_id=report.candidate_id,
|
||||
final_status=ComplianceDecision(report.final_status),
|
||||
policy_version="unknown", # Would need to resolve from run/snapshot
|
||||
manifest_digest="unknown", # Would need to resolve from run/manifest
|
||||
violation_count=0, # Would need to resolve from violations
|
||||
generated_at=report.generated_at
|
||||
)
|
||||
|
||||
# [/DEF:clean_release_mappers:Module]
|
||||
@@ -13,7 +13,7 @@ from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Tuple
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.clean_release import CleanProfilePolicy, ResourceSourceRegistry
|
||||
from ...models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -34,12 +34,12 @@ class SourceValidationResult:
|
||||
# @TEST_CONTRACT: CandidateEvaluationInput -> PolicyValidationResult|SourceValidationResult
|
||||
# @TEST_SCENARIO: policy_valid -> Enterprise clean policy with matching registry returns ok=True
|
||||
# @TEST_FIXTURE: policy_enterprise_clean -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json
|
||||
# @TEST_EDGE: missing_registry_ref -> policy has empty internal_source_registry_ref
|
||||
# @TEST_EDGE: missing_registry_ref -> policy has empty registry_snapshot_id
|
||||
# @TEST_EDGE: conflicting_registry -> policy registry ref does not match registry id
|
||||
# @TEST_EDGE: external_endpoint -> endpoint not present in enabled internal registry entries
|
||||
# @TEST_INVARIANT: deterministic_classification -> VERIFIED_BY: [policy_valid]
|
||||
class CleanPolicyEngine:
|
||||
def __init__(self, policy: CleanProfilePolicy, registry: ResourceSourceRegistry):
|
||||
def __init__(self, policy: CleanPolicySnapshot, registry: SourceRegistrySnapshot):
|
||||
self.policy = policy
|
||||
self.registry = registry
|
||||
|
||||
@@ -48,28 +48,39 @@ class CleanPolicyEngine:
|
||||
logger.reason("Validating enterprise-clean policy and internal registry consistency")
|
||||
reasons: List[str] = []
|
||||
|
||||
if not self.policy.active:
|
||||
reasons.append("Policy must be active")
|
||||
if not self.policy.internal_source_registry_ref.strip():
|
||||
reasons.append("Policy missing internal_source_registry_ref")
|
||||
if self.policy.profile.value == "enterprise-clean" and not self.policy.prohibited_artifact_categories:
|
||||
reasons.append("Enterprise policy requires prohibited artifact categories")
|
||||
if self.policy.profile.value == "enterprise-clean" and not self.policy.external_source_forbidden:
|
||||
reasons.append("Enterprise policy requires external_source_forbidden=true")
|
||||
if self.registry.registry_id != self.policy.internal_source_registry_ref:
|
||||
# Snapshots are immutable and assumed active if resolved by facade
|
||||
if not self.policy.registry_snapshot_id.strip():
|
||||
reasons.append("Policy missing registry_snapshot_id")
|
||||
|
||||
content = self.policy.content_json or {}
|
||||
profile = content.get("profile", "standard")
|
||||
|
||||
if profile == "enterprise-clean":
|
||||
if not content.get("prohibited_artifact_categories"):
|
||||
reasons.append("Enterprise policy requires prohibited artifact categories")
|
||||
if not content.get("external_source_forbidden"):
|
||||
reasons.append("Enterprise policy requires external_source_forbidden=true")
|
||||
|
||||
if self.registry.id != self.policy.registry_snapshot_id:
|
||||
reasons.append("Policy registry ref does not match provided registry")
|
||||
if not self.registry.entries:
|
||||
reasons.append("Registry must contain entries")
|
||||
|
||||
if not self.registry.allowed_hosts:
|
||||
reasons.append("Registry must contain allowed hosts")
|
||||
|
||||
logger.reflect(f"Policy validation completed. blocking_reasons={len(reasons)}")
|
||||
return PolicyValidationResult(ok=len(reasons) == 0, blocking_reasons=reasons)
|
||||
|
||||
def classify_artifact(self, artifact: Dict) -> str:
|
||||
category = (artifact.get("category") or "").strip()
|
||||
if category in self.policy.required_system_categories:
|
||||
content = self.policy.content_json or {}
|
||||
|
||||
required = content.get("required_system_categories", [])
|
||||
prohibited = content.get("prohibited_artifact_categories", [])
|
||||
|
||||
if category in required:
|
||||
logger.reason(f"Artifact category '{category}' classified as required-system")
|
||||
return "required-system"
|
||||
if category in self.policy.prohibited_artifact_categories:
|
||||
if category in prohibited:
|
||||
logger.reason(f"Artifact category '{category}' classified as excluded-prohibited")
|
||||
return "excluded-prohibited"
|
||||
logger.reflect(f"Artifact category '{category}' classified as allowed")
|
||||
@@ -89,7 +100,7 @@ class CleanPolicyEngine:
|
||||
},
|
||||
)
|
||||
|
||||
allowed_hosts = {entry.host for entry in self.registry.entries if entry.enabled}
|
||||
allowed_hosts = set(self.registry.allowed_hosts or [])
|
||||
normalized = endpoint.strip().lower()
|
||||
|
||||
if normalized in allowed_hosts:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# [DEF:backend.src.services.clean_release.policy_resolution_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, policy, registry, trusted-resolution, immutable-snapshots
|
||||
# @PURPOSE: Resolve trusted policy and registry snapshots from ConfigManager without runtime overrides.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.config_manager
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.exceptions
|
||||
# @INVARIANT: Trusted snapshot resolution is based only on ConfigManager active identifiers.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ...models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot
|
||||
from .exceptions import PolicyResolutionError
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:resolve_trusted_policy_snapshots:Function]
|
||||
# @PURPOSE: Resolve immutable trusted policy and registry snapshots using active config IDs only.
|
||||
# @PRE: ConfigManager provides active_policy_id and active_registry_id; repository contains referenced snapshots.
|
||||
# @POST: Returns immutable policy and registry snapshots; runtime override attempts are rejected.
|
||||
# @SIDE_EFFECT: None.
|
||||
def resolve_trusted_policy_snapshots(
|
||||
*,
|
||||
config_manager,
|
||||
repository: CleanReleaseRepository,
|
||||
policy_id_override: Optional[str] = None,
|
||||
registry_id_override: Optional[str] = None,
|
||||
) -> Tuple[CleanPolicySnapshot, SourceRegistrySnapshot]:
|
||||
if policy_id_override is not None or registry_id_override is not None:
|
||||
raise PolicyResolutionError("override attempt is forbidden for trusted policy resolution")
|
||||
|
||||
config = config_manager.get_config()
|
||||
clean_release_settings = getattr(getattr(config, "settings", None), "clean_release", None)
|
||||
if clean_release_settings is None:
|
||||
raise PolicyResolutionError("clean_release settings are missing")
|
||||
|
||||
policy_id = getattr(clean_release_settings, "active_policy_id", None)
|
||||
registry_id = getattr(clean_release_settings, "active_registry_id", None)
|
||||
|
||||
if not policy_id:
|
||||
raise PolicyResolutionError("missing trusted profile: active_policy_id is not configured")
|
||||
if not registry_id:
|
||||
raise PolicyResolutionError("missing trusted registry: active_registry_id is not configured")
|
||||
|
||||
policy_snapshot = repository.get_policy(policy_id)
|
||||
if policy_snapshot is None:
|
||||
raise PolicyResolutionError(f"trusted policy snapshot '{policy_id}' was not found")
|
||||
|
||||
registry_snapshot = repository.get_registry(registry_id)
|
||||
if registry_snapshot is None:
|
||||
raise PolicyResolutionError(f"trusted registry snapshot '{registry_id}' was not found")
|
||||
|
||||
if not bool(getattr(policy_snapshot, "immutable", False)):
|
||||
raise PolicyResolutionError("policy snapshot must be immutable")
|
||||
if not bool(getattr(registry_snapshot, "immutable", False)):
|
||||
raise PolicyResolutionError("registry snapshot must be immutable")
|
||||
|
||||
return policy_snapshot, registry_snapshot
|
||||
# [/DEF:resolve_trusted_policy_snapshots:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.policy_resolution_service:Module]
|
||||
@@ -16,7 +16,7 @@ from typing import Dict, Iterable
|
||||
from .manifest_builder import build_distribution_manifest
|
||||
from .policy_engine import CleanPolicyEngine
|
||||
from .repository import CleanReleaseRepository
|
||||
from ...models.clean_release import ReleaseCandidateStatus
|
||||
from .enums import CandidateStatus
|
||||
|
||||
|
||||
def prepare_candidate(
|
||||
@@ -34,7 +34,7 @@ def prepare_candidate(
|
||||
if policy is None:
|
||||
raise ValueError("Active clean policy not found")
|
||||
|
||||
registry = repository.get_registry(policy.internal_source_registry_ref)
|
||||
registry = repository.get_registry(policy.registry_snapshot_id)
|
||||
if registry is None:
|
||||
raise ValueError("Registry not found for active policy")
|
||||
|
||||
@@ -54,14 +54,39 @@ def prepare_candidate(
|
||||
)
|
||||
repository.save_manifest(manifest)
|
||||
|
||||
candidate.status = ReleaseCandidateStatus.BLOCKED if violations else ReleaseCandidateStatus.PREPARED
|
||||
# Note: In the new model, BLOCKED is a ComplianceDecision, not a CandidateStatus.
|
||||
# CandidateStatus.PREPARED is the correct next state after preparation.
|
||||
candidate.transition_to(CandidateStatus.PREPARED)
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
status_value = candidate.status.value if hasattr(candidate.status, "value") else str(candidate.status)
|
||||
manifest_id_value = getattr(manifest, "manifest_id", None) or getattr(manifest, "id", "")
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"status": candidate.status.value,
|
||||
"manifest_id": manifest.manifest_id,
|
||||
"status": status_value,
|
||||
"manifest_id": manifest_id_value,
|
||||
"violations": violations,
|
||||
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# [DEF:prepare_candidate_legacy:Function]
|
||||
# @PURPOSE: Legacy compatibility wrapper kept for migration period.
|
||||
# @PRE: Same as prepare_candidate.
|
||||
# @POST: Delegates to canonical prepare_candidate and preserves response shape.
|
||||
def prepare_candidate_legacy(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
artifacts: Iterable[Dict],
|
||||
sources: Iterable[str],
|
||||
operator_id: str,
|
||||
) -> Dict:
|
||||
return prepare_candidate(
|
||||
repository=repository,
|
||||
candidate_id=candidate_id,
|
||||
artifacts=artifacts,
|
||||
sources=sources,
|
||||
operator_id=operator_id,
|
||||
)
|
||||
# [/DEF:prepare_candidate_legacy:Function]
|
||||
# [/DEF:backend.src.services.clean_release.preparation_service:Module]
|
||||
173
backend/src/services/clean_release/publication_service.py
Normal file
173
backend/src/services/clean_release/publication_service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# [DEF:backend.src.services.clean_release.publication_service:Module]
|
||||
# @TIER: CRITICAL
|
||||
# @SEMANTICS: clean-release, publication, revoke, gate, lifecycle
|
||||
# @PURPOSE: Enforce publication and revocation gates with append-only publication records.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.repository
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.approval_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.clean_release
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.clean_release.audit_service
|
||||
# @INVARIANT: Publication records are append-only snapshots; revoke mutates only publication status for targeted record.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.clean_release import PublicationRecord
|
||||
from .audit_service import audit_preparation
|
||||
from .enums import ApprovalDecisionType, CandidateStatus, PublicationStatus
|
||||
from .exceptions import PublicationGateError
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
# [DEF:_get_or_init_publications_store:Function]
|
||||
# @PURPOSE: Provide in-memory append-only publication storage.
|
||||
# @PRE: repository is initialized.
|
||||
# @POST: Returns publication list attached to repository.
|
||||
def _get_or_init_publications_store(repository: CleanReleaseRepository) -> List[PublicationRecord]:
|
||||
publications = getattr(repository, "publication_records", None)
|
||||
if publications is None:
|
||||
publications = []
|
||||
setattr(repository, "publication_records", publications)
|
||||
return publications
|
||||
# [/DEF:_get_or_init_publications_store:Function]
|
||||
|
||||
|
||||
# [DEF:_latest_publication_for_candidate:Function]
|
||||
# @PURPOSE: Resolve latest publication record for candidate.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest record or None.
|
||||
def _latest_publication_for_candidate(
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
) -> PublicationRecord | None:
|
||||
records = [item for item in _get_or_init_publications_store(repository) if item.candidate_id == candidate_id]
|
||||
if not records:
|
||||
return None
|
||||
return sorted(records, key=lambda item: item.published_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0]
|
||||
# [/DEF:_latest_publication_for_candidate:Function]
|
||||
|
||||
|
||||
# [DEF:_latest_approval_for_candidate:Function]
|
||||
# @PURPOSE: Resolve latest approval decision from repository decision store.
|
||||
# @PRE: candidate_id is non-empty.
|
||||
# @POST: Returns latest decision object or None.
|
||||
def _latest_approval_for_candidate(repository: CleanReleaseRepository, candidate_id: str):
|
||||
decisions = getattr(repository, "approval_decisions", [])
|
||||
scoped = [item for item in decisions if item.candidate_id == candidate_id]
|
||||
if not scoped:
|
||||
return None
|
||||
return sorted(scoped, key=lambda item: item.decided_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)[0]
|
||||
# [/DEF:_latest_approval_for_candidate:Function]
|
||||
|
||||
|
||||
# [DEF:publish_candidate:Function]
|
||||
# @PURPOSE: Create immutable publication record for approved candidate.
|
||||
# @PRE: Candidate exists, report belongs to candidate, latest approval is APPROVED.
|
||||
# @POST: New ACTIVE publication record is appended.
|
||||
def publish_candidate(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
candidate_id: str,
|
||||
report_id: str,
|
||||
published_by: str,
|
||||
target_channel: str,
|
||||
publication_ref: str | None = None,
|
||||
) -> PublicationRecord:
|
||||
with belief_scope("publication_service.publish_candidate"):
|
||||
logger.reason(f"[REASON] Evaluating publish gate candidate_id={candidate_id} report_id={report_id}")
|
||||
|
||||
if not published_by or not published_by.strip():
|
||||
raise PublicationGateError("published_by must be non-empty")
|
||||
if not target_channel or not target_channel.strip():
|
||||
raise PublicationGateError("target_channel must be non-empty")
|
||||
|
||||
candidate = repository.get_candidate(candidate_id)
|
||||
if candidate is None:
|
||||
raise PublicationGateError(f"candidate '{candidate_id}' not found")
|
||||
|
||||
report = repository.get_report(report_id)
|
||||
if report is None:
|
||||
raise PublicationGateError(f"report '{report_id}' not found")
|
||||
if report.candidate_id != candidate_id:
|
||||
raise PublicationGateError("report belongs to another candidate")
|
||||
|
||||
latest_approval = _latest_approval_for_candidate(repository, candidate_id)
|
||||
if latest_approval is None or latest_approval.decision != ApprovalDecisionType.APPROVED.value:
|
||||
raise PublicationGateError("publish requires APPROVED decision")
|
||||
|
||||
latest_publication = _latest_publication_for_candidate(repository, candidate_id)
|
||||
if latest_publication is not None and latest_publication.status == PublicationStatus.ACTIVE.value:
|
||||
raise PublicationGateError("candidate already has active publication")
|
||||
|
||||
if candidate.status == CandidateStatus.APPROVED.value:
|
||||
try:
|
||||
candidate.transition_to(CandidateStatus.PUBLISHED)
|
||||
repository.save_candidate(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.explore(f"[EXPLORE] Candidate transition to PUBLISHED failed candidate_id={candidate_id}: {exc}")
|
||||
raise PublicationGateError(str(exc)) from exc
|
||||
|
||||
record = PublicationRecord(
|
||||
id=f"pub-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
report_id=report_id,
|
||||
published_by=published_by,
|
||||
published_at=datetime.now(timezone.utc),
|
||||
target_channel=target_channel,
|
||||
publication_ref=publication_ref,
|
||||
status=PublicationStatus.ACTIVE.value,
|
||||
)
|
||||
_get_or_init_publications_store(repository).append(record)
|
||||
audit_preparation(candidate_id, "PUBLISHED", repository=repository, actor=published_by)
|
||||
logger.reflect(f"[REFLECT] Publication persisted candidate_id={candidate_id} publication_id={record.id}")
|
||||
return record
|
||||
# [/DEF:publish_candidate:Function]
|
||||
|
||||
|
||||
# [DEF:revoke_publication:Function]
|
||||
# @PURPOSE: Revoke existing publication record without deleting history.
|
||||
# @PRE: publication_id exists in repository publication store.
|
||||
# @POST: Target publication status becomes REVOKED and updated record is returned.
|
||||
def revoke_publication(
|
||||
*,
|
||||
repository: CleanReleaseRepository,
|
||||
publication_id: str,
|
||||
revoked_by: str,
|
||||
comment: str | None = None,
|
||||
) -> PublicationRecord:
|
||||
with belief_scope("publication_service.revoke_publication"):
|
||||
logger.reason(f"[REASON] Evaluating revoke gate publication_id={publication_id}")
|
||||
|
||||
if not revoked_by or not revoked_by.strip():
|
||||
raise PublicationGateError("revoked_by must be non-empty")
|
||||
if not publication_id or not publication_id.strip():
|
||||
raise PublicationGateError("publication_id must be non-empty")
|
||||
|
||||
records = _get_or_init_publications_store(repository)
|
||||
record = next((item for item in records if item.id == publication_id), None)
|
||||
if record is None:
|
||||
raise PublicationGateError(f"publication '{publication_id}' not found")
|
||||
if record.status == PublicationStatus.REVOKED.value:
|
||||
raise PublicationGateError("publication is already revoked")
|
||||
|
||||
record.status = PublicationStatus.REVOKED.value
|
||||
candidate = repository.get_candidate(record.candidate_id)
|
||||
if candidate is not None:
|
||||
# Lifecycle remains publication-driven; republish after revoke is supported by new publication record.
|
||||
repository.save_candidate(candidate)
|
||||
|
||||
audit_preparation(
|
||||
record.candidate_id,
|
||||
f"REVOKED:{comment or ''}".strip(":"),
|
||||
repository=repository,
|
||||
actor=revoked_by,
|
||||
)
|
||||
logger.reflect(f"[REFLECT] Publication revoked publication_id={publication_id}")
|
||||
return record
|
||||
# [/DEF:revoke_publication:Function]
|
||||
|
||||
# [/DEF:backend.src.services.clean_release.publication_service:Module]
|
||||
@@ -19,7 +19,8 @@ from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
from typing import List
|
||||
|
||||
from ...models.clean_release import CheckFinalStatus, ComplianceCheckRun, ComplianceReport, ComplianceViolation
|
||||
from .enums import RunStatus, ComplianceDecision
|
||||
from ...models.clean_release import ComplianceRun, ComplianceReport, ComplianceViolation
|
||||
from .repository import CleanReleaseRepository
|
||||
|
||||
|
||||
@@ -27,32 +28,39 @@ class ComplianceReportBuilder:
|
||||
def __init__(self, repository: CleanReleaseRepository):
|
||||
self.repository = repository
|
||||
|
||||
def build_report_payload(self, check_run: ComplianceCheckRun, violations: List[ComplianceViolation]) -> ComplianceReport:
|
||||
if check_run.final_status == CheckFinalStatus.RUNNING:
|
||||
def build_report_payload(self, check_run: ComplianceRun, violations: List[ComplianceViolation]) -> ComplianceReport:
|
||||
if check_run.status == RunStatus.RUNNING:
|
||||
raise ValueError("Cannot build report for non-terminal run")
|
||||
|
||||
violations_count = len(violations)
|
||||
blocking_violations_count = sum(1 for v in violations if v.blocked_release)
|
||||
blocking_violations_count = sum(
|
||||
1
|
||||
for v in violations
|
||||
if bool(getattr(v, "blocked_release", False))
|
||||
or bool(getattr(v, "evidence_json", {}).get("blocked_release", False))
|
||||
)
|
||||
|
||||
if check_run.final_status == CheckFinalStatus.BLOCKED and blocking_violations_count <= 0:
|
||||
if check_run.final_status == ComplianceDecision.BLOCKED and blocking_violations_count <= 0:
|
||||
raise ValueError("Blocked run requires at least one blocking violation")
|
||||
|
||||
summary = (
|
||||
"Compliance passed with no blocking violations"
|
||||
if check_run.final_status == CheckFinalStatus.COMPLIANT
|
||||
if check_run.final_status == ComplianceDecision.PASSED
|
||||
else f"Blocked with {blocking_violations_count} blocking violation(s)"
|
||||
)
|
||||
|
||||
return ComplianceReport(
|
||||
report_id=f"CCR-{uuid4()}",
|
||||
check_run_id=check_run.check_run_id,
|
||||
id=f"CCR-{uuid4()}",
|
||||
run_id=check_run.id,
|
||||
candidate_id=check_run.candidate_id,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
final_status=check_run.final_status,
|
||||
operator_summary=summary,
|
||||
structured_payload_ref=f"inmemory://check-runs/{check_run.check_run_id}/report",
|
||||
violations_count=violations_count,
|
||||
blocking_violations_count=blocking_violations_count,
|
||||
summary_json={
|
||||
"operator_summary": summary,
|
||||
"violations_count": violations_count,
|
||||
"blocking_violations_count": blocking_violations_count,
|
||||
},
|
||||
immutable=True,
|
||||
)
|
||||
|
||||
def persist_report(self, report: ComplianceReport) -> ComplianceReport:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user