kiri-cli 1.0.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kiri-cli might be problematic. Click here for more details.
- kiri_adapters/__init__.py +193 -0
- kiri_adapters/backends/__init__.py +0 -0
- kiri_adapters/backends/databricks.manifest.yml +55 -0
- kiri_adapters/backends/databricks.py +2980 -0
- kiri_adapters/backends/fabric_dw.manifest.yml +17 -0
- kiri_adapters/backends/fabric_dw.py +307 -0
- kiri_adapters/backends/fabric_lakehouse.manifest.yml +19 -0
- kiri_adapters/backends/fabric_lakehouse.py +365 -0
- kiri_adapters/backends/iceberg.manifest.yml +17 -0
- kiri_adapters/backends/iceberg.py +451 -0
- kiri_adapters/backends/localduckdb.manifest.yml +43 -0
- kiri_adapters/backends/localduckdb.py +1295 -0
- kiri_adapters/backends/mssql.manifest.yml +17 -0
- kiri_adapters/backends/mssql.py +366 -0
- kiri_adapters/backends/postgres.manifest.yml +17 -0
- kiri_adapters/backends/postgres.py +434 -0
- kiri_adapters/backends/trino.manifest.yml +17 -0
- kiri_adapters/backends/trino.py +542 -0
- kiri_adapters/base.py +892 -0
- kiri_adapters/doctor_checks.py +81 -0
- kiri_adapters/dv_planner.py +1205 -0
- kiri_adapters/dv_planner_bridge.py +265 -0
- kiri_adapters/dv_planner_pit.py +265 -0
- kiri_adapters/dv_planner_ref.py +403 -0
- kiri_adapters/dv_planner_sat_variants.py +668 -0
- kiri_adapters/framework.py +112 -0
- kiri_adapters/incident/__init__.py +30 -0
- kiri_adapters/incident/_http.py +121 -0
- kiri_adapters/incident/base.py +89 -0
- kiri_adapters/incident/jira.py +365 -0
- kiri_adapters/incident/servicenow.py +314 -0
- kiri_adapters/incident/zendesk.py +285 -0
- kiri_adapters/manifest.py +466 -0
- kiri_adapters/orchestration/__init__.py +30 -0
- kiri_adapters/orchestration/airflow.py +117 -0
- kiri_adapters/orchestration/artifact_emitter.py +99 -0
- kiri_adapters/orchestration/autoloader_emitter.py +141 -0
- kiri_adapters/orchestration/azure_data_factory.py +137 -0
- kiri_adapters/orchestration/base.py +626 -0
- kiri_adapters/orchestration/databricks_workflows.py +1768 -0
- kiri_adapters/orchestration/k8s_cron.py +555 -0
- kiri_adapters/orchestration/lifecycle.py +222 -0
- kiri_adapters/orchestration/pipeline_compat.py +145 -0
- kiri_adapters/orchestration/runtime_python.py +188 -0
- kiri_adapters/paths.py +38 -0
- kiri_adapters/protocols/__init__.py +64 -0
- kiri_adapters/protocols/ai_service/__init__.py +7 -0
- kiri_adapters/protocols/ai_service/conformance.py +120 -0
- kiri_adapters/protocols/base.py +220 -0
- kiri_adapters/protocols/catalog/__init__.py +7 -0
- kiri_adapters/protocols/catalog/conformance.py +138 -0
- kiri_adapters/protocols/framework/__init__.py +7 -0
- kiri_adapters/protocols/framework/conformance.py +98 -0
- kiri_adapters/protocols/incident/__init__.py +7 -0
- kiri_adapters/protocols/incident/conformance.py +64 -0
- kiri_adapters/protocols/orchestration/__init__.py +7 -0
- kiri_adapters/protocols/orchestration/conformance.py +84 -0
- kiri_adapters/protocols/platform/__init__.py +7 -0
- kiri_adapters/protocols/platform/conformance.py +120 -0
- kiri_adapters/protocols/sql_dialect/__init__.py +7 -0
- kiri_adapters/protocols/sql_dialect/conformance.py +91 -0
- kiri_adapters/protocols/vault/__init__.py +7 -0
- kiri_adapters/protocols/vault/conformance.py +65 -0
- kiri_adapters/py.typed +0 -0
- kiri_adapters/registry.py +104 -0
- kiri_adapters/semantic/__init__.py +60 -0
- kiri_adapters/semantic/apply.py +274 -0
- kiri_adapters/semantic/base.py +530 -0
- kiri_adapters/semantic/cube.py +280 -0
- kiri_adapters/semantic/dbt_semantic.py +92 -0
- kiri_adapters/semantic/metric_views.py +249 -0
- kiri_adapters/semantic/metricflow.py +345 -0
- kiri_adapters/semantic/snowflake_views.py +75 -0
- kiri_adapters/sql_dialect.py +1592 -0
- kiri_adapters/tsql_to_spark.py +653 -0
- kiri_ai_gateway/__init__.py +147 -0
- kiri_ai_gateway/audit.py +334 -0
- kiri_ai_gateway/batch.py +293 -0
- kiri_ai_gateway/config.py +165 -0
- kiri_ai_gateway/gateway.py +642 -0
- kiri_ai_gateway/placeholder_guard.py +145 -0
- kiri_ai_gateway/prices/__init__.py +77 -0
- kiri_ai_gateway/prices/prices.yaml +64 -0
- kiri_ai_gateway/prompts/__init__.py +54 -0
- kiri_ai_gateway/prompts/airbyte_source_setup.yaml +212 -0
- kiri_ai_gateway/prompts/bdv_translation.yaml +90 -0
- kiri_ai_gateway/prompts/contract_draft_mvp.yaml +117 -0
- kiri_ai_gateway/prompts/describe_table.yaml +19 -0
- kiri_ai_gateway/prompts/design_gold_from_question.yaml +130 -0
- kiri_ai_gateway/prompts/detect_pii.yaml +59 -0
- kiri_ai_gateway/prompts/discover_classify_column.yaml +69 -0
- kiri_ai_gateway/prompts/discover_describe_table.yaml +61 -0
- kiri_ai_gateway/prompts/dv_quality_summary.yaml +36 -0
- kiri_ai_gateway/prompts/incident_assist.yaml +58 -0
- kiri_ai_gateway/prompts/infer_source_schema.yaml +31 -0
- kiri_ai_gateway/prompts/ingest_backend_chooser.yaml +116 -0
- kiri_ai_gateway/prompts/interactive_source_edit.yaml +94 -0
- kiri_ai_gateway/prompts/map_needed_entity_to_source.yaml +72 -0
- kiri_ai_gateway/prompts/meet_kiri.yaml +127 -0
- kiri_ai_gateway/prompts/native_source_setup.yaml +211 -0
- kiri_ai_gateway/prompts/source_grouping_reason.yaml +29 -0
- kiri_ai_gateway/prompts/suggest_attribute_classification.yaml +42 -0
- kiri_ai_gateway/prompts/suggest_data_tests.yaml +71 -0
- kiri_ai_gateway/prompts/suggest_gold_dimension.yaml +108 -0
- kiri_ai_gateway/prompts/suggest_gold_fact.yaml +107 -0
- kiri_ai_gateway/prompts/suggest_gold_model.yaml +70 -0
- kiri_ai_gateway/prompts/suggest_semantic_intent.yaml +91 -0
- kiri_ai_gateway/prompts/suggest_silver_model.yaml +140 -0
- kiri_ai_gateway/providers/__init__.py +7 -0
- kiri_ai_gateway/providers/anthropic_provider.py +26 -0
- kiri_ai_gateway/providers/gateway_provider.py +188 -0
- kiri_ai_gateway/providers/local_provider.py +177 -0
- kiri_ai_gateway/py.typed +0 -0
- kiri_ai_gateway/service.py +283 -0
- kiri_ai_gateway/transport.py +103 -0
- kiri_ai_gateway/transports/__init__.py +12 -0
- kiri_ai_gateway/transports/anthropic_transport.py +73 -0
- kiri_ai_gateway/transports/ollama_transport.py +82 -0
- kiri_ai_gateway/transports/openai_compatible_transport.py +125 -0
- kiri_ai_gateway/workspace_policy.py +52 -0
- kiri_catalog/__init__.py +199 -0
- kiri_catalog/annotation_plan.py +183 -0
- kiri_catalog/appearances.py +155 -0
- kiri_catalog/backends/__init__.py +79 -0
- kiri_catalog/backends/base.py +167 -0
- kiri_catalog/backends/memory.py +241 -0
- kiri_catalog/backends/postgres.py +497 -0
- kiri_catalog/backends/redacting.py +195 -0
- kiri_catalog/backends/sqlite.py +480 -0
- kiri_catalog/context.py +94 -0
- kiri_catalog/federation/__init__.py +114 -0
- kiri_catalog/federation/api.py +200 -0
- kiri_catalog/federation/cache.py +149 -0
- kiri_catalog/federation/emit.py +209 -0
- kiri_catalog/federation/fs_resolver.py +295 -0
- kiri_catalog/federation/http_resolver.py +486 -0
- kiri_catalog/federation/metrics.py +123 -0
- kiri_catalog/federation/release.py +342 -0
- kiri_catalog/federation/resolver.py +332 -0
- kiri_catalog/federation/schema.py +460 -0
- kiri_catalog/importer.py +668 -0
- kiri_catalog/lineage_drift.py +280 -0
- kiri_catalog/lineage_graph.py +176 -0
- kiri_catalog/lineage_graph_explain.py +96 -0
- kiri_catalog/lineage_graph_mermaid.py +230 -0
- kiri_catalog/observed_uc_lineage.py +216 -0
- kiri_catalog/pii_detect.py +176 -0
- kiri_catalog/push.py +122 -0
- kiri_catalog/py.typed +0 -0
- kiri_catalog/redact.py +101 -0
- kiri_catalog/review_actions.py +119 -0
- kiri_catalog/schema/__init__.py +70 -0
- kiri_catalog/schema/asset.py +142 -0
- kiri_catalog/schema/column.py +247 -0
- kiri_catalog/schema/enums.py +195 -0
- kiri_catalog/schema/job.py +51 -0
- kiri_catalog/schema/lineage.py +33 -0
- kiri_catalog/schema/policy.py +69 -0
- kiri_catalog/schema/primitives.py +90 -0
- kiri_catalog/schema/review_state.py +111 -0
- kiri_catalog/schema/urn.py +45 -0
- kiri_catalog/schema_discovery.py +160 -0
- kiri_catalog/sinks/__init__.py +64 -0
- kiri_catalog/sinks/base.py +178 -0
- kiri_catalog/sinks/horizon.py +195 -0
- kiri_catalog/sinks/purview.py +216 -0
- kiri_catalog/sinks/unity.py +352 -0
- kiri_catalog/sinks/unity_executor.py +543 -0
- kiri_catalog/sources/__init__.py +42 -0
- kiri_catalog/sources/base.py +104 -0
- kiri_catalog/sources/unity.py +465 -0
- kiri_catalog/steward_writeback.py +158 -0
- kiri_catalog/tag_sql.py +99 -0
- kiri_cli/LICENSE +105 -0
- kiri_cli/__init__.py +1 -0
- kiri_cli/_apply_cli.py +63 -0
- kiri_cli/_apply_reporter.py +38 -0
- kiri_cli/_closed_menu.py +36 -0
- kiri_cli/_fixture_option.py +18 -0
- kiri_cli/_layer_detect.py +158 -0
- kiri_cli/_project_option.py +79 -0
- kiri_cli/adapter_cmd.py +192 -0
- kiri_cli/ai_batch_cmd.py +258 -0
- kiri_cli/ai_cmd.py +352 -0
- kiri_cli/approve_cmd.py +659 -0
- kiri_cli/attr_cmd.py +575 -0
- kiri_cli/audit_cmd.py +348 -0
- kiri_cli/audit_correlate_cmd.py +648 -0
- kiri_cli/audit_join.py +437 -0
- kiri_cli/audit_join_cmd.py +160 -0
- kiri_cli/auto_cmd.py +97 -0
- kiri_cli/azure_cmd.py +243 -0
- kiri_cli/catalog.py +1293 -0
- kiri_cli/cli_reference_live.py +147 -0
- kiri_cli/compile_cmd.py +493 -0
- kiri_cli/compliance_cmd.py +221 -0
- kiri_cli/contract_approval_cmd.py +94 -0
- kiri_cli/contract_cmd.py +64 -0
- kiri_cli/contract_codeowners_cmd.py +162 -0
- kiri_cli/contract_diff.py +111 -0
- kiri_cli/contract_emit.py +90 -0
- kiri_cli/contract_export.py +192 -0
- kiri_cli/contract_lint.py +1505 -0
- kiri_cli/contract_merge_cmd.py +264 -0
- kiri_cli/contract_new.py +326 -0
- kiri_cli/contract_promote.py +216 -0
- kiri_cli/contract_scaffold_cmd.py +134 -0
- kiri_cli/contract_schema_export.py +69 -0
- kiri_cli/contract_show.py +150 -0
- kiri_cli/contract_stubs.py +60 -0
- kiri_cli/contract_sync_from_live.py +327 -0
- kiri_cli/contract_validate.py +154 -0
- kiri_cli/contract_verify_live.py +242 -0
- kiri_cli/cost_cmd.py +243 -0
- kiri_cli/databricks_health.py +1254 -0
- kiri_cli/databricks_setup_cmd.py +712 -0
- kiri_cli/discover_cmd.py +600 -0
- kiri_cli/docs_cmd.py +35 -0
- kiri_cli/doctor_cmd.py +145 -0
- kiri_cli/dv_cmd.py +814 -0
- kiri_cli/dv_conformance_cmd.py +154 -0
- kiri_cli/dv_install.py +493 -0
- kiri_cli/dv_migrate_cmd.py +183 -0
- kiri_cli/dv_test_cmd.py +175 -0
- kiri_cli/federation_cmd.py +315 -0
- kiri_cli/fk_cmd.py +160 -0
- kiri_cli/flows_cmd.py +910 -0
- kiri_cli/governance_cmd.py +358 -0
- kiri_cli/incident_api_cmd.py +180 -0
- kiri_cli/incident_cmd.py +410 -0
- kiri_cli/incident_setup_cmd.py +239 -0
- kiri_cli/ingest.py +427 -0
- kiri_cli/ingest_discover.py +480 -0
- kiri_cli/ingest_new_source.py +386 -0
- kiri_cli/intent_cmd.py +122 -0
- kiri_cli/invariants_cmd.py +118 -0
- kiri_cli/kiri_banner.py +161 -0
- kiri_cli/kpi_cmd.py +222 -0
- kiri_cli/layer_cmd.py +464 -0
- kiri_cli/lineage.py +601 -0
- kiri_cli/main.py +2472 -0
- kiri_cli/maturity_cmd.py +222 -0
- kiri_cli/migrate_cmd.py +1347 -0
- kiri_cli/onboard_cmd.py +223 -0
- kiri_cli/pack_cmd.py +496 -0
- kiri_cli/pipeline_cmd.py +127 -0
- kiri_cli/pre_classify_cmd.py +274 -0
- kiri_cli/project_cmd.py +592 -0
- kiri_cli/py.typed +0 -0
- kiri_cli/quality_cmd.py +295 -0
- kiri_cli/rbac.py +139 -0
- kiri_cli/rbac_cmd.py +291 -0
- kiri_cli/release.py +850 -0
- kiri_cli/release_semver.py +265 -0
- kiri_cli/repo_cmd.py +155 -0
- kiri_cli/retention_cmd.py +333 -0
- kiri_cli/sample_data/__init__.py +12 -0
- kiri_cli/sample_data/customers.contract.yml +47 -0
- kiri_cli/sample_data/customers.csv +25 -0
- kiri_cli/scaffold_cmd.py +721 -0
- kiri_cli/schema_cmd.py +185 -0
- kiri_cli/semantic_cmd.py +605 -0
- kiri_cli/silver_cmd.py +348 -0
- kiri_cli/skills_cmd.py +243 -0
- kiri_cli/skills_data/README.md +51 -0
- kiri_cli/skills_data/add-source/SKILL.md +116 -0
- kiri_cli/skills_data/azure-deploy/SKILL.md +261 -0
- kiri_cli/skills_data/azure-status/SKILL.md +111 -0
- kiri_cli/skills_data/compliance-check/SKILL.md +99 -0
- kiri_cli/skills_data/databricks-build-pipeline/SKILL.md +112 -0
- kiri_cli/skills_data/databricks-project-setup/SKILL.md +157 -0
- kiri_cli/skills_data/debug-apply/SKILL.md +186 -0
- kiri_cli/skills_data/draft-gold-model/SKILL.md +203 -0
- kiri_cli/skills_data/explain-kpi/SKILL.md +158 -0
- kiri_cli/skills_data/incident-coach/SKILL.md +135 -0
- kiri_cli/skills_data/incident-setup/SKILL.md +112 -0
- kiri_cli/skills_data/maturity-coach/SKILL.md +120 -0
- kiri_cli/skills_data/promote-contract/SKILL.md +91 -0
- kiri_cli/skills_data/review-attributes/SKILL.md +94 -0
- kiri_cli/sla_cmd.py +769 -0
- kiri_cli/source_cmd.py +909 -0
- kiri_cli/templates/__init__.py +6 -0
- kiri_cli/templates/dv4dbt_packages.yml.j2 +11 -0
- kiri_cli/upgrade_cmd.py +896 -0
- kiri_cli/use_cmd.py +120 -0
- kiri_cli/vault_cmd.py +241 -0
- kiri_cli/verify_generated.py +241 -0
- kiri_cli-1.0.0b3.dist-info/METADATA +212 -0
- kiri_cli-1.0.0b3.dist-info/RECORD +743 -0
- kiri_cli-1.0.0b3.dist-info/WHEEL +4 -0
- kiri_cli-1.0.0b3.dist-info/entry_points.txt +4 -0
- kiri_compliance/__init__.py +33 -0
- kiri_compliance/audit_coverage.py +354 -0
- kiri_compliance/controls.py +763 -0
- kiri_compliance/evidence.py +155 -0
- kiri_compliance/py.typed +0 -0
- kiri_compliance/quality_engine.py +246 -0
- kiri_compliance/renderer.py +195 -0
- kiri_compliance/report.py +44 -0
- kiri_compliance/schema.py +129 -0
- kiri_core/__init__.py +185 -0
- kiri_core/adapter_surfaces.py +149 -0
- kiri_core/ai_query_gate/__init__.py +92 -0
- kiri_core/ai_query_gate/audit.py +153 -0
- kiri_core/ai_query_gate/evaluator.py +484 -0
- kiri_core/ai_query_gate/models.py +162 -0
- kiri_core/ai_query_gate/parser.py +436 -0
- kiri_core/ai_query_gate/resolver.py +274 -0
- kiri_core/ai_query_gate/store.py +234 -0
- kiri_core/ai_query_gate/store_postgres.py +409 -0
- kiri_core/api_keys.py +243 -0
- kiri_core/apply/__init__.py +27 -0
- kiri_core/apply/conformance.py +99 -0
- kiri_core/apply/incident_router.py +176 -0
- kiri_core/apply/models.py +140 -0
- kiri_core/apply/reporter.py +46 -0
- kiri_core/apply/runner.py +3083 -0
- kiri_core/apply_log_store.py +155 -0
- kiri_core/approval_flow/__init__.py +133 -0
- kiri_core/approval_flow/integration.py +156 -0
- kiri_core/approval_flow/models.py +443 -0
- kiri_core/approval_flow/notifier.py +263 -0
- kiri_core/approval_flow/orchestrator.py +660 -0
- kiri_core/approval_flow/reconcile_signoff.py +53 -0
- kiri_core/approval_flow/store.py +220 -0
- kiri_core/approval_flow/store_jsonl.py +217 -0
- kiri_core/approval_flow/store_postgres.py +580 -0
- kiri_core/approval_policy.py +116 -0
- kiri_core/asgi_trace.py +160 -0
- kiri_core/compliance.py +802 -0
- kiri_core/cutover_approval.py +187 -0
- kiri_core/discovery/__init__.py +109 -0
- kiri_core/discovery/ai/__init__.py +37 -0
- kiri_core/discovery/ai/_payload.py +115 -0
- kiri_core/discovery/ai/budget.py +83 -0
- kiri_core/discovery/ai/classify.py +223 -0
- kiri_core/discovery/ai/describe.py +200 -0
- kiri_core/discovery/ai/errors.py +49 -0
- kiri_core/discovery/discover.py +648 -0
- kiri_core/discovery/heuristics/__init__.py +42 -0
- kiri_core/discovery/heuristics/classification.py +174 -0
- kiri_core/discovery/heuristics/medallion.py +157 -0
- kiri_core/discovery/heuristics/pii.py +92 -0
- kiri_core/discovery/introspector.py +636 -0
- kiri_core/discovery/introspector_databricks.py +396 -0
- kiri_core/discovery/introspector_postgres.py +394 -0
- kiri_core/discovery/introspector_snowflake.py +383 -0
- kiri_core/discovery/reporting/__init__.py +17 -0
- kiri_core/discovery/reporting/discovery_report.py +190 -0
- kiri_core/discovery/scaffold/__init__.py +41 -0
- kiri_core/discovery/scaffold/contract_writer.py +632 -0
- kiri_core/discovery/scaffold/provenance_overlay.py +143 -0
- kiri_core/discovery/scaffold/source_writer.py +211 -0
- kiri_core/discovery/scaffold/todo_marker.py +98 -0
- kiri_core/domain_policy.py +122 -0
- kiri_core/goal_coverage.py +82 -0
- kiri_core/gold_registry.py +211 -0
- kiri_core/health.py +192 -0
- kiri_core/incident.py +204 -0
- kiri_core/incident_builders.py +256 -0
- kiri_core/incident_log.py +418 -0
- kiri_core/intent/__init__.py +46 -0
- kiri_core/intent/agent_runner.py +82 -0
- kiri_core/intent/project_dv.py +261 -0
- kiri_core/intent/propose.py +132 -0
- kiri_core/intent/semantic_intent.py +446 -0
- kiri_core/invariants.py +301 -0
- kiri_core/kpi.py +373 -0
- kiri_core/latency.py +150 -0
- kiri_core/lifecycle/__init__.py +555 -0
- kiri_core/lifecycle/ai_index.py +453 -0
- kiri_core/lifecycle/auto_mode.py +172 -0
- kiri_core/lifecycle/bootstrap.py +316 -0
- kiri_core/lifecycle/cli_reference.py +1064 -0
- kiri_core/lifecycle/commit.py +258 -0
- kiri_core/lifecycle/compile_dispatch.py +296 -0
- kiri_core/lifecycle/compile_range.py +665 -0
- kiri_core/lifecycle/contract_inventory.py +354 -0
- kiri_core/lifecycle/contracts.py +250 -0
- kiri_core/lifecycle/doctor.py +412 -0
- kiri_core/lifecycle/git_remote.py +948 -0
- kiri_core/lifecycle/identity.py +147 -0
- kiri_core/lifecycle/ingestion_modes.py +215 -0
- kiri_core/lifecycle/layer_contract.py +452 -0
- kiri_core/lifecycle/layer_contract_migrate.py +969 -0
- kiri_core/lifecycle/layer_contract_parser.py +436 -0
- kiri_core/lifecycle/layer_contract_validator.py +2291 -0
- kiri_core/lifecycle/layout.py +298 -0
- kiri_core/lifecycle/onboarding.py +306 -0
- kiri_core/lifecycle/openlineage_emit.py +153 -0
- kiri_core/lifecycle/patch.py +231 -0
- kiri_core/lifecycle/plan.py +241 -0
- kiri_core/lifecycle/probe.py +204 -0
- kiri_core/lifecycle/project_context.py +334 -0
- kiri_core/lifecycle/project_status.py +532 -0
- kiri_core/lifecycle/scaffold.py +455 -0
- kiri_core/lifecycle/source_grouping.py +725 -0
- kiri_core/lifecycle/source_introspection.py +488 -0
- kiri_core/lifecycle/source_inventory.py +561 -0
- kiri_core/lifecycle/source_patch.py +353 -0
- kiri_core/lifecycle/sources.py +470 -0
- kiri_core/lifecycle/sync_from_live.py +727 -0
- kiri_core/lifecycle/validate.py +116 -0
- kiri_core/lifecycle/verify_annotations.py +391 -0
- kiri_core/lifecycle/verify_live.py +389 -0
- kiri_core/lifecycle/verify_metric_views.py +321 -0
- kiri_core/lifecycle/watermark.py +371 -0
- kiri_core/lifecycle/watermark_apply.py +656 -0
- kiri_core/lineage.py +418 -0
- kiri_core/loader.py +377 -0
- kiri_core/logging.py +374 -0
- kiri_core/maturity.py +592 -0
- kiri_core/migration_layer_policy.py +285 -0
- kiri_core/names.py +163 -0
- kiri_core/observed_lineage.py +62 -0
- kiri_core/odcs/__init__.py +15 -0
- kiri_core/otel_bootstrap.py +326 -0
- kiri_core/pipeline_ir.py +332 -0
- kiri_core/pipeline_loader.py +82 -0
- kiri_core/planning.py +110 -0
- kiri_core/project.py +1576 -0
- kiri_core/project_docs.py +199 -0
- kiri_core/py.typed +0 -0
- kiri_core/rate_limit.py +456 -0
- kiri_core/release_state/__init__.py +37 -0
- kiri_core/release_state/models.py +108 -0
- kiri_core/release_state/store.py +634 -0
- kiri_core/resilience/__init__.py +288 -0
- kiri_core/resilience/retry.py +173 -0
- kiri_core/runtime.py +40 -0
- kiri_core/schema/__init__.py +62 -0
- kiri_core/schema/column.py +140 -0
- kiri_core/schema/ingestion.py +665 -0
- kiri_core/schema/model.py +44 -0
- kiri_core/schema/reporting_goal.py +160 -0
- kiri_core/schema/source.py +188 -0
- kiri_core/silver/__init__.py +148 -0
- kiri_core/silver/apply_runner.py +830 -0
- kiri_core/silver/backfill.py +280 -0
- kiri_core/silver/dv_backend.py +426 -0
- kiri_core/silver/dv_graph.py +932 -0
- kiri_core/silver/dv_graph_mermaid.py +443 -0
- kiri_core/silver/dv_graph_payload.py +79 -0
- kiri_core/silver/dv_graph_views.py +416 -0
- kiri_core/silver/graph_projector.py +566 -0
- kiri_core/silver/hash_canonicaliser.py +172 -0
- kiri_core/silver/hash_policy.py +195 -0
- kiri_core/silver/load_ts_policy.py +202 -0
- kiri_core/silver/schema_evolution.py +410 -0
- kiri_core/silver/zone_rules.py +81 -0
- kiri_core/sizing.py +134 -0
- kiri_core/storage_paths.py +130 -0
- kiri_core/technique_validator.py +700 -0
- kiri_core/trace.py +409 -0
- kiri_core/types.py +41 -0
- kiri_core/urn.py +161 -0
- kiri_dbt_bridge/__init__.py +22 -0
- kiri_dbt_bridge/_schema_resolve.py +38 -0
- kiri_dbt_bridge/bridge.py +94 -0
- kiri_dbt_bridge/dbt.manifest.yml +26 -0
- kiri_dbt_bridge/dv4dbt/__init__.py +143 -0
- kiri_dbt_bridge/dv4dbt/backend.py +204 -0
- kiri_dbt_bridge/dv4dbt/bridge_generator.py +289 -0
- kiri_dbt_bridge/dv4dbt/compat.py +232 -0
- kiri_dbt_bridge/dv4dbt/hub_generator.py +282 -0
- kiri_dbt_bridge/dv4dbt/link_generator.py +306 -0
- kiri_dbt_bridge/dv4dbt/manifest.py +203 -0
- kiri_dbt_bridge/dv4dbt/pit_generator.py +318 -0
- kiri_dbt_bridge/dv4dbt/policy_translation.py +249 -0
- kiri_dbt_bridge/dv4dbt/project_writer.py +588 -0
- kiri_dbt_bridge/dv4dbt/ref_generator.py +404 -0
- kiri_dbt_bridge/dv4dbt/render.py +180 -0
- kiri_dbt_bridge/dv4dbt/sat_generator.py +480 -0
- kiri_dbt_bridge/dv4dbt/sat_variant_generator.py +529 -0
- kiri_dbt_bridge/dv4dbt/templates/__init__.py +16 -0
- kiri_dbt_bridge/dv4dbt/templates/bridge.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/dbt_project.yml.j2 +37 -0
- kiri_dbt_bridge/dv4dbt/templates/hub.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/link.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/packages.yml.j2 +10 -0
- kiri_dbt_bridge/dv4dbt/templates/pit.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/ref_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/ref_non_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_effectivity.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_multi_active.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_non_historised.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_record_tracking.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sat_virtual_end_dated.sql.j2 +9 -0
- kiri_dbt_bridge/dv4dbt/templates/sources.yml.j2 +39 -0
- kiri_dbt_bridge/framework_adapter.py +310 -0
- kiri_dbt_bridge/generate.py +372 -0
- kiri_dbt_bridge/gold/__init__.py +21 -0
- kiri_dbt_bridge/gold/_sql_emit.py +128 -0
- kiri_dbt_bridge/gold/assemble.py +90 -0
- kiri_dbt_bridge/gold/canonical_columns.py +168 -0
- kiri_dbt_bridge/gold/computed_expressions.py +179 -0
- kiri_dbt_bridge/gold/dialects/__init__.py +69 -0
- kiri_dbt_bridge/gold/dialects/databricks.py +150 -0
- kiri_dbt_bridge/gold/dialects/duckdb.py +120 -0
- kiri_dbt_bridge/gold/dialects/trino.py +136 -0
- kiri_dbt_bridge/gold/dim_generator.py +688 -0
- kiri_dbt_bridge/gold/dimension_refs.py +255 -0
- kiri_dbt_bridge/gold/dispatch.py +69 -0
- kiri_dbt_bridge/gold/fact_generator.py +534 -0
- kiri_dbt_bridge/gold/flat_generator.py +144 -0
- kiri_dbt_bridge/gold/lineage.py +484 -0
- kiri_dbt_bridge/gold/localized_twins.py +218 -0
- kiri_dbt_bridge/gold/measures.py +49 -0
- kiri_dbt_bridge/gold/role_playing.py +75 -0
- kiri_dbt_bridge/gold/surrogate_key.py +114 -0
- kiri_dbt_bridge/macros/kiri_scd1_dim.sql +57 -0
- kiri_dbt_bridge/macros/kiri_scd2_dim.sql +188 -0
- kiri_dbt_bridge/macros/kiri_sha256.sql +32 -0
- kiri_dbt_bridge/macros/kiri_string_cast.sql +37 -0
- kiri_dbt_bridge/macros/kiri_surrogate_key.sql +43 -0
- kiri_dbt_bridge/profiles.py +162 -0
- kiri_dbt_bridge/py.typed +0 -0
- kiri_dbt_bridge/safe_ident.py +44 -0
- kiri_dbt_bridge/silver_conformance.py +188 -0
- kiri_dbt_bridge/silver_kimball/__init__.py +27 -0
- kiri_dbt_bridge/silver_kimball/assemble.py +174 -0
- kiri_dbt_bridge/silver_kimball/dim_generator.py +602 -0
- kiri_dbt_bridge/silver_kimball/dispatch.py +86 -0
- kiri_dbt_bridge/silver_kimball/fact_generator.py +320 -0
- kiri_dbt_bridge/silver_kimball/flat_generator.py +156 -0
- kiri_dbt_bridge/silver_kimball/lineage.py +464 -0
- kiri_dbt_bridge/silver_kimball/watermark_window.py +199 -0
- kiri_dbt_bridge/silver_kimball/zone.py +68 -0
- kiri_dbt_bridge/silver_scd2_curated/__init__.py +46 -0
- kiri_dbt_bridge/silver_scd2_curated/assemble.py +213 -0
- kiri_dbt_bridge/silver_scd2_curated/curated_generator.py +351 -0
- kiri_dbt_bridge/silver_scd2_curated/standardized_generator.py +261 -0
- kiri_dbt_bridge/silver_scd2_curated/standardized_plan.py +254 -0
- kiri_iceberg/__init__.py +11 -0
- kiri_iceberg/catalog.py +128 -0
- kiri_iceberg/profile.py +131 -0
- kiri_iceberg/py.typed +0 -0
- kiri_ingestion/__init__.py +63 -0
- kiri_ingestion/airbyte_client.py +428 -0
- kiri_ingestion/database_fetcher.py +224 -0
- kiri_ingestion/layout.py +71 -0
- kiri_ingestion/mssql_introspect.py +565 -0
- kiri_ingestion/py.typed +0 -0
- kiri_ingestion/rest_fetcher.py +472 -0
- kiri_ingestion/runner.py +341 -0
- kiri_ingestion/schema_drift.py +222 -0
- kiri_ingestion/storage.py +621 -0
- kiri_mcp/__init__.py +5 -0
- kiri_mcp/audit.py +99 -0
- kiri_mcp/audit_decorators.py +252 -0
- kiri_mcp/main.py +22 -0
- kiri_mcp/py.typed +0 -0
- kiri_mcp/rbac.py +110 -0
- kiri_mcp/server.py +766 -0
- kiri_mcp/skills.py +297 -0
- kiri_mcp/tools/__init__.py +70 -0
- kiri_mcp/tools/_shared.py +58 -0
- kiri_mcp/tools/adapter.py +150 -0
- kiri_mcp/tools/cli_reference.py +59 -0
- kiri_mcp/tools/compliance.py +119 -0
- kiri_mcp/tools/contract.py +374 -0
- kiri_mcp/tools/dv.py +62 -0
- kiri_mcp/tools/federation.py +152 -0
- kiri_mcp/tools/intent.py +71 -0
- kiri_mcp/tools/lineage.py +204 -0
- kiri_mcp/tools/lineage_drift.py +75 -0
- kiri_mcp/tools/plan.py +66 -0
- kiri_mcp/tools/project.py +311 -0
- kiri_migration/__init__.py +56 -0
- kiri_migration/name_preservation.py +271 -0
- kiri_migration/predeploy.py +311 -0
- kiri_migration/py.typed +0 -0
- kiri_migration/reconcile.py +1891 -0
- kiri_migration/scaffold/__init__.py +90 -0
- kiri_migration/scaffold/_audit_columns.py +74 -0
- kiri_migration/scaffold/_classify.py +100 -0
- kiri_migration/scaffold/_typemap.py +71 -0
- kiri_migration/scaffold/_yaml.py +33 -0
- kiri_migration/scaffold/bronze.py +279 -0
- kiri_migration/scaffold/compile_targets/__init__.py +271 -0
- kiri_migration/scaffold/compile_targets/_common.py +193 -0
- kiri_migration/scaffold/compile_targets/copy_into.py +33 -0
- kiri_migration/scaffold/compile_targets/dlt.py +125 -0
- kiri_migration/scaffold/compile_targets/dlt_with_append_flows.py +163 -0
- kiri_migration/scaffold/compile_targets/dlt_with_apply_changes.py +192 -0
- kiri_migration/scaffold/compile_targets/dlt_with_expectations.py +276 -0
- kiri_migration/scaffold/compile_targets/layer_dispatch.py +258 -0
- kiri_migration/scaffold/compile_targets/sql_full_snapshot.py +385 -0
- kiri_migration/scaffold/compile_targets/streaming_table.py +37 -0
- kiri_migration/scaffold/mart.py +234 -0
- kiri_migration/scaffold/plans/__init__.py +49 -0
- kiri_migration/scaffold/plans/_registry.py +308 -0
- kiri_migration/scaffold/plans/minimal.py +550 -0
- kiri_migration/scaffold/plans/plan_b_layer.py +640 -0
- kiri_migration/scaffold/silver_from_marts.py +256 -0
- kiri_migration/scaffold/silver_pattern.py +451 -0
- kiri_migration/sp_translator.py +398 -0
- kiri_odcs/LICENSE +204 -0
- kiri_odcs/__init__.py +234 -0
- kiri_odcs/ai_trust_tier.py +475 -0
- kiri_odcs/canonical.py +263 -0
- kiri_odcs/diff.py +63 -0
- kiri_odcs/errors.py +51 -0
- kiri_odcs/invariant_sql.py +136 -0
- kiri_odcs/kiri_bridge.py +71 -0
- kiri_odcs/kiri_ext.py +3166 -0
- kiri_odcs/kiri_fk.py +447 -0
- kiri_odcs/kiri_pit.py +121 -0
- kiri_odcs/kiri_ref.py +57 -0
- kiri_odcs/openlineage_adapter.py +105 -0
- kiri_odcs/parser.py +1213 -0
- kiri_odcs/pii_guardrails.py +159 -0
- kiri_odcs/py.typed +0 -0
- kiri_odcs/schema_export.py +223 -0
- kiri_openlineage/__init__.py +58 -0
- kiri_openlineage/builder.py +146 -0
- kiri_openlineage/cost.py +190 -0
- kiri_openlineage/emitter.py +165 -0
- kiri_openlineage/py.typed +0 -0
- kiri_openlineage/schema.py +121 -0
- kiri_packs/__init__.py +67 -0
- kiri_packs/installer.py +500 -0
- kiri_packs/manifest.py +134 -0
- kiri_packs/publisher.py +605 -0
- kiri_packs/py.typed +0 -0
- kiri_packs/validator.py +295 -0
- kiri_quality/__init__.py +108 -0
- kiri_quality/ai_audit_correlator.py +293 -0
- kiri_quality/ai_audit_correlator_apply.py +526 -0
- kiri_quality/conformance_evidence.py +272 -0
- kiri_quality/dbt_test_collector.py +214 -0
- kiri_quality/dq_codegen.py +300 -0
- kiri_quality/dv_checks.py +393 -0
- kiri_quality/dv_column_lineage.py +279 -0
- kiri_quality/dv_column_lineage_apply.py +856 -0
- kiri_quality/dv_conformance.py +841 -0
- kiri_quality/dv_fixture_runner.py +478 -0
- kiri_quality/dv_invariants.py +986 -0
- kiri_quality/dv_kiri.py +97 -0
- kiri_quality/dv_markdown.py +169 -0
- kiri_quality/dv_observed_uc_reader.py +187 -0
- kiri_quality/dv_runner.py +389 -0
- kiri_quality/evidence_status.py +337 -0
- kiri_quality/fk_checks.py +800 -0
- kiri_quality/fk_ddl.py +402 -0
- kiri_quality/fk_pit_contract.py +168 -0
- kiri_quality/fk_runner.py +950 -0
- kiri_quality/kernel.py +376 -0
- kiri_quality/pii.py +60 -0
- kiri_quality/pre_classify/__init__.py +55 -0
- kiri_quality/pre_classify/engine.py +346 -0
- kiri_quality/pre_classify/locale_packs/en.yaml +117 -0
- kiri_quality/pre_classify/locale_packs/sv.yaml +73 -0
- kiri_quality/pre_classify/merge.py +297 -0
- kiri_quality/pre_classify/model.py +163 -0
- kiri_quality/py.typed +0 -0
- kiri_quality/reject_lane.py +261 -0
- kiri_quality/rule_runner.py +565 -0
- kiri_quality/schema_evolution_check.py +88 -0
- kiri_quality/silver_quality.py +494 -0
- kiri_quality/silver_quality_markdown.py +187 -0
- kiri_quality/uc_security_compile.py +951 -0
- kiri_quality/uc_security_compile_apply.py +1186 -0
- kiri_rbac/__init__.py +87 -0
- kiri_rbac/access.py +371 -0
- kiri_rbac/loader.py +104 -0
- kiri_rbac/pr_gate.py +213 -0
- kiri_rbac/providers/__init__.py +29 -0
- kiri_rbac/providers/_jwt.py +121 -0
- kiri_rbac/providers/azure_ad.py +93 -0
- kiri_rbac/providers/base.py +107 -0
- kiri_rbac/providers/factory.py +48 -0
- kiri_rbac/providers/github.py +134 -0
- kiri_rbac/providers/oidc_generic.py +99 -0
- kiri_rbac/py.typed +0 -0
- kiri_rbac/resolver.py +118 -0
- kiri_rbac/schema.py +219 -0
- kiri_schema_registry/__init__.py +22 -0
- kiri_schema_registry/avro.py +127 -0
- kiri_schema_registry/base.py +71 -0
- kiri_schema_registry/confluent.py +184 -0
- kiri_schema_registry/glue.py +178 -0
- kiri_schema_registry/py.typed +0 -0
- kiri_slack_bot/__init__.py +24 -0
- kiri_slack_bot/handler.py +248 -0
- kiri_slack_bot/py.typed +0 -0
- kiri_slack_bot/queries.py +90 -0
- kiri_slack_bot/signature.py +94 -0
- kiri_vault/__init__.py +59 -0
- kiri_vault/audit.py +64 -0
- kiri_vault/aws_vault.py +123 -0
- kiri_vault/azure_vault.py +236 -0
- kiri_vault/boundaries.py +219 -0
- kiri_vault/client.py +54 -0
- kiri_vault/databricks_vault.py +292 -0
- kiri_vault/env_vault.py +77 -0
- kiri_vault/py.typed +0 -0
- kiri_vault/ref.py +84 -0
- kirimana_automation/__init__.py +0 -0
- kirimana_automation/business_calendar.py +179 -0
- kirimana_automation/dag_snapshot.py +252 -0
- kirimana_automation/db/__init__.py +5 -0
- kirimana_automation/db/migrate.py +571 -0
- kirimana_automation/db/migrations/0001_release_state.down.duckdb.sql +29 -0
- kirimana_automation/db/migrations/0001_release_state.down.postgres.sql +37 -0
- kirimana_automation/db/migrations/0001_release_state.duckdb.sql +170 -0
- kirimana_automation/db/migrations/0001_release_state.postgres.sql +176 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.down.duckdb.sql +9 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.down.postgres.sql +9 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.duckdb.sql +14 -0
- kirimana_automation/db/migrations/0002_rate_limit_buckets.postgres.sql +18 -0
- kirimana_automation/db/migrations/0003_flow_runs.down.duckdb.sql +18 -0
- kirimana_automation/db/migrations/0003_flow_runs.down.postgres.sql +19 -0
- kirimana_automation/db/migrations/0003_flow_runs.duckdb.sql +85 -0
- kirimana_automation/db/migrations/0003_flow_runs.postgres.sql +102 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.duckdb.sql +11 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.down.postgres.sql +11 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.duckdb.sql +15 -0
- kirimana_automation/db/migrations/0004_flow_dag_snapshots.postgres.sql +31 -0
- kirimana_automation/db/migrations/0005_calendar_skip.down.duckdb.sql +109 -0
- kirimana_automation/db/migrations/0005_calendar_skip.down.postgres.sql +42 -0
- kirimana_automation/db/migrations/0005_calendar_skip.duckdb.sql +113 -0
- kirimana_automation/db/migrations/0005_calendar_skip.postgres.sql +42 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.down.duckdb.sql +9 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.down.postgres.sql +11 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.duckdb.sql +45 -0
- kirimana_automation/db/migrations/0006_flow_apply_log.postgres.sql +49 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.duckdb.sql +14 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.down.postgres.sql +2 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.duckdb.sql +7 -0
- kirimana_automation/db/migrations/0007_flow_task_error_redacted.postgres.sql +11 -0
- kirimana_automation/db/migrations/README.md +84 -0
- kirimana_automation/flow_loader.py +220 -0
- kirimana_automation/freeze.py +133 -0
- kirimana_automation/kiri_recommender.py +313 -0
- kirimana_automation/main.py +195 -0
- kirimana_automation/py.typed +0 -0
- kirimana_automation/run_history.py +593 -0
- kirimana_automation/run_history_analysis.py +607 -0
- kirimana_automation/schedule.py +343 -0
- kirimana_automation/schedule_validator.py +572 -0
- kirimana_automation/workflow_compiler.py +1288 -0
kiri_cli/sla_cmd.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""``kiri sla check`` — runtime SLA enforcement (#161 / ADR 0036).
|
|
2
|
+
|
|
3
|
+
Reads each contract's ``kiri.sla.*`` block (#160), checks declared
|
|
4
|
+
freshness + quality thresholds against observed signals, and routes
|
|
5
|
+
breaches via the prefix-dispatcher (slack: / teams: / local:).
|
|
6
|
+
|
|
7
|
+
Two pure layers + one I/O layer:
|
|
8
|
+
|
|
9
|
+
- :func:`evaluate_freshness` / :func:`evaluate_quality` — pure
|
|
10
|
+
functions that compare a SlaMeta to a single observed value and
|
|
11
|
+
return a :class:`SlaCheckResult`. Pure = directly unit-testable.
|
|
12
|
+
- :func:`build_breach_message` — turns a breached report into the
|
|
13
|
+
payload shape webhooks accept. Also pure.
|
|
14
|
+
- :func:`dispatch` — the HTTP layer. Mockable through a callable
|
|
15
|
+
parameter so tests don't speak HTTP.
|
|
16
|
+
|
|
17
|
+
The CLI command (``sla_check_cmd``) glues them together: load
|
|
18
|
+
project, fetch signals via the platform adapter, evaluate, dispatch.
|
|
19
|
+
|
|
20
|
+
Why route is a free-form prefix descriptor rather than a typed enum
|
|
21
|
+
of providers: each provider has its own auth shape (Slack uses an
|
|
22
|
+
incoming webhook URL from env, PagerDuty needs a routing key, etc).
|
|
23
|
+
The descriptor stays in the contract; the env wiring lives in the
|
|
24
|
+
deployment. Adding a new provider = one prefix branch in
|
|
25
|
+
:func:`dispatch` + one env var documented in ADR 0036.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Callable, Mapping
|
|
34
|
+
from datetime import UTC, datetime, timedelta
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Annotated, Any, Literal
|
|
37
|
+
from urllib.request import Request, urlopen
|
|
38
|
+
|
|
39
|
+
import typer
|
|
40
|
+
from kiri_adapters import get_adapter
|
|
41
|
+
from kiri_adapters.base import ProjectManifest
|
|
42
|
+
from kiri_core import Project, load_project
|
|
43
|
+
from kiri_core.odcs import CanonicalContract, SlaMeta
|
|
44
|
+
from pydantic import BaseModel, ConfigDict
|
|
45
|
+
from rich.console import Console
|
|
46
|
+
from rich.table import Table
|
|
47
|
+
|
|
48
|
+
from kiri_cli._project_option import resolve_project
|
|
49
|
+
|
|
50
|
+
console = Console()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Match SlaMeta.freshness shape — same regex, kept local so we can
|
|
54
|
+
# tokenise the digit + unit cleanly without re-parsing.
|
|
55
|
+
_DURATION_RE = re.compile(r"^(\d+)([smhdw])$", re.IGNORECASE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Result models
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
CheckStatus = Literal["ok", "breach", "skipped", "unmeasured"]
|
|
64
|
+
CheckKind = Literal["freshness", "quality"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SlaCheckResult(BaseModel):
|
|
68
|
+
"""One SLA check outcome on one contract.
|
|
69
|
+
|
|
70
|
+
``status="skipped"`` means the check itself doesn't apply (e.g. the
|
|
71
|
+
contract didn't declare a threshold) — operators read it as "no
|
|
72
|
+
SLA set, nothing to evaluate".
|
|
73
|
+
|
|
74
|
+
``status="unmeasured"`` means the check applies but the observed
|
|
75
|
+
signal isn't wired through yet (e.g. catalog quality signal not
|
|
76
|
+
plumbed into ``sla check``). Operators read it as "we owe you a
|
|
77
|
+
real answer here" — distinct from "passed".
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
81
|
+
|
|
82
|
+
contract_name: str
|
|
83
|
+
kind: CheckKind
|
|
84
|
+
status: CheckStatus
|
|
85
|
+
threshold: str | None = None # human-readable, e.g. "4h" or "0.99"
|
|
86
|
+
observed: str | None = None
|
|
87
|
+
message: str = ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SlaContractReport(BaseModel):
|
|
91
|
+
"""Aggregate per-contract report — every check + the route used."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
94
|
+
|
|
95
|
+
contract_name: str
|
|
96
|
+
results: list[SlaCheckResult]
|
|
97
|
+
route: str | None = None
|
|
98
|
+
severity: str | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def has_breach(self) -> bool:
|
|
102
|
+
return any(r.status == "breach" for r in self.results)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Pure evaluators
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_duration(spec: str) -> timedelta:
|
|
111
|
+
"""Convert a freshness spec like ``4h`` to a :class:`timedelta`."""
|
|
112
|
+
match = _DURATION_RE.match(spec)
|
|
113
|
+
if match is None: # pragma: no cover — gated by SlaMeta validator
|
|
114
|
+
raise ValueError(f"invalid duration: {spec!r}")
|
|
115
|
+
n, unit = int(match.group(1)), match.group(2).lower()
|
|
116
|
+
multipliers: dict[str, timedelta] = {
|
|
117
|
+
"s": timedelta(seconds=1),
|
|
118
|
+
"m": timedelta(minutes=1),
|
|
119
|
+
"h": timedelta(hours=1),
|
|
120
|
+
"d": timedelta(days=1),
|
|
121
|
+
"w": timedelta(weeks=1),
|
|
122
|
+
}
|
|
123
|
+
return n * multipliers[unit]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def evaluate_freshness(
|
|
127
|
+
*,
|
|
128
|
+
contract_name: str,
|
|
129
|
+
sla: SlaMeta,
|
|
130
|
+
manifest: ProjectManifest | None,
|
|
131
|
+
now: datetime | None = None,
|
|
132
|
+
) -> SlaCheckResult:
|
|
133
|
+
"""Compare ``sla.freshness`` to the latest apply timestamp.
|
|
134
|
+
|
|
135
|
+
``now`` is injectable for deterministic tests. ``manifest`` is
|
|
136
|
+
fetched once by the caller for the active ``(project, target)`` and
|
|
137
|
+
is None when no apply has stamped the manifest yet.
|
|
138
|
+
|
|
139
|
+
Returns ``status="skipped"`` when the contract didn't declare
|
|
140
|
+
``kiri.sla.freshness`` — silent skip beats noisy "no SLA set" rows
|
|
141
|
+
in the report.
|
|
142
|
+
"""
|
|
143
|
+
if sla.freshness is None:
|
|
144
|
+
return SlaCheckResult(
|
|
145
|
+
contract_name=contract_name,
|
|
146
|
+
kind="freshness",
|
|
147
|
+
status="skipped",
|
|
148
|
+
message="no kiri.sla.freshness declared",
|
|
149
|
+
)
|
|
150
|
+
latest_apply_iso = manifest.release_ts if manifest else None
|
|
151
|
+
if latest_apply_iso is None:
|
|
152
|
+
return SlaCheckResult(
|
|
153
|
+
contract_name=contract_name,
|
|
154
|
+
kind="freshness",
|
|
155
|
+
status="skipped",
|
|
156
|
+
threshold=sla.freshness,
|
|
157
|
+
message="no apply has stamped the manifest yet",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
threshold = _parse_duration(sla.freshness)
|
|
161
|
+
try:
|
|
162
|
+
latest = datetime.fromisoformat(latest_apply_iso.replace("Z", "+00:00"))
|
|
163
|
+
except ValueError:
|
|
164
|
+
return SlaCheckResult(
|
|
165
|
+
contract_name=contract_name,
|
|
166
|
+
kind="freshness",
|
|
167
|
+
status="skipped",
|
|
168
|
+
threshold=sla.freshness,
|
|
169
|
+
message=f"can't parse manifest release_ts: {latest_apply_iso!r}",
|
|
170
|
+
)
|
|
171
|
+
if latest.tzinfo is None:
|
|
172
|
+
latest = latest.replace(tzinfo=UTC)
|
|
173
|
+
|
|
174
|
+
current = now if now is not None else datetime.now(UTC)
|
|
175
|
+
age = current - latest
|
|
176
|
+
age_str = _format_timedelta(age)
|
|
177
|
+
|
|
178
|
+
if age <= threshold:
|
|
179
|
+
return SlaCheckResult(
|
|
180
|
+
contract_name=contract_name,
|
|
181
|
+
kind="freshness",
|
|
182
|
+
status="ok",
|
|
183
|
+
threshold=sla.freshness,
|
|
184
|
+
observed=age_str,
|
|
185
|
+
)
|
|
186
|
+
return SlaCheckResult(
|
|
187
|
+
contract_name=contract_name,
|
|
188
|
+
kind="freshness",
|
|
189
|
+
status="breach",
|
|
190
|
+
threshold=sla.freshness,
|
|
191
|
+
observed=age_str,
|
|
192
|
+
message=(f"latest apply was {age_str} ago, exceeds {sla.freshness}"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def evaluate_quality(
|
|
197
|
+
*,
|
|
198
|
+
contract_name: str,
|
|
199
|
+
sla: SlaMeta,
|
|
200
|
+
latest_pass_rate: float | None,
|
|
201
|
+
) -> SlaCheckResult:
|
|
202
|
+
"""Compare ``sla.quality_threshold`` to the latest GE pass-rate.
|
|
203
|
+
|
|
204
|
+
The catalog wiring that surfaces ``latest_pass_rate`` lives in a
|
|
205
|
+
separate task (see TODO at ``sla_check_cmd`` and follow-up issue
|
|
206
|
+
referenced in CODEX-TECH-DEBT-V1 slice F / finding F11). When the
|
|
207
|
+
threshold is declared but the signal isn't wired through, this
|
|
208
|
+
function emits ``status="unmeasured"`` — explicitly different from
|
|
209
|
+
"skipped" (= no threshold to evaluate). Operators see at a glance
|
|
210
|
+
that the check is owed a real answer, not silently passing.
|
|
211
|
+
"""
|
|
212
|
+
if sla.quality_threshold is None:
|
|
213
|
+
return SlaCheckResult(
|
|
214
|
+
contract_name=contract_name,
|
|
215
|
+
kind="quality",
|
|
216
|
+
status="skipped",
|
|
217
|
+
message="no kiri.sla.quality_threshold declared",
|
|
218
|
+
)
|
|
219
|
+
if latest_pass_rate is None:
|
|
220
|
+
return SlaCheckResult(
|
|
221
|
+
contract_name=contract_name,
|
|
222
|
+
kind="quality",
|
|
223
|
+
status="unmeasured",
|
|
224
|
+
threshold=f"{sla.quality_threshold:.2f}",
|
|
225
|
+
message=(
|
|
226
|
+
"quality signal not wired — GE pass-rate is not yet "
|
|
227
|
+
"plumbed into `sla check` (CODEX-TECH-DEBT-V1 F11)"
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
threshold = sla.quality_threshold
|
|
231
|
+
if latest_pass_rate >= threshold:
|
|
232
|
+
return SlaCheckResult(
|
|
233
|
+
contract_name=contract_name,
|
|
234
|
+
kind="quality",
|
|
235
|
+
status="ok",
|
|
236
|
+
threshold=f"{threshold:.2f}",
|
|
237
|
+
observed=f"{latest_pass_rate:.2f}",
|
|
238
|
+
)
|
|
239
|
+
return SlaCheckResult(
|
|
240
|
+
contract_name=contract_name,
|
|
241
|
+
kind="quality",
|
|
242
|
+
status="breach",
|
|
243
|
+
threshold=f"{threshold:.2f}",
|
|
244
|
+
observed=f"{latest_pass_rate:.2f}",
|
|
245
|
+
message=(f"pass-rate {latest_pass_rate:.2f} below threshold {threshold:.2f}"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _format_timedelta(td: timedelta) -> str:
|
|
250
|
+
"""Compact human-readable timedelta — '3d 4h' / '15m' / '12h'."""
|
|
251
|
+
total_minutes = int(td.total_seconds() // 60)
|
|
252
|
+
if total_minutes < 1:
|
|
253
|
+
return f"{int(td.total_seconds())}s"
|
|
254
|
+
if total_minutes < 60:
|
|
255
|
+
return f"{total_minutes}m"
|
|
256
|
+
days, hours = divmod(total_minutes // 60, 24)
|
|
257
|
+
if days:
|
|
258
|
+
return f"{days}d {hours}h" if hours else f"{days}d"
|
|
259
|
+
return f"{hours}h"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Breach message + dispatcher
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def build_breach_message(report: SlaContractReport) -> dict[str, Any]:
|
|
268
|
+
"""Render a breached report as a webhook-friendly payload.
|
|
269
|
+
|
|
270
|
+
Same shape for every transport (Slack/Teams/local) — we keep the
|
|
271
|
+
payload structured (not pre-formatted markdown) so each
|
|
272
|
+
transport's renderer can choose its own visual treatment. For
|
|
273
|
+
Slack/Teams that's a Block Kit / Adaptive Card; for local stdout
|
|
274
|
+
it's just JSON dumped.
|
|
275
|
+
"""
|
|
276
|
+
breaches = [r for r in report.results if r.status == "breach"]
|
|
277
|
+
return {
|
|
278
|
+
"contract": report.contract_name,
|
|
279
|
+
"severity": report.severity or "medium",
|
|
280
|
+
"breaches": [
|
|
281
|
+
{
|
|
282
|
+
"kind": b.kind,
|
|
283
|
+
"threshold": b.threshold,
|
|
284
|
+
"observed": b.observed,
|
|
285
|
+
"message": b.message,
|
|
286
|
+
}
|
|
287
|
+
for b in breaches
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
HttpPoster = Callable[[str, str, Mapping[str, str] | None], int]
|
|
293
|
+
"""Callable that POSTs ``body`` to ``url`` with optional extra headers and
|
|
294
|
+
returns the status code.
|
|
295
|
+
|
|
296
|
+
Injected so tests don't speak HTTP. The default implementation uses
|
|
297
|
+
``urllib.request.urlopen`` — no extra dependency, pure stdlib.
|
|
298
|
+
|
|
299
|
+
Sprint 1.7 (2026-05-23): the third positional ``headers`` argument is
|
|
300
|
+
required by the OpsGenie route, which authenticates via an
|
|
301
|
+
``Authorization: GenieKey <api_key>`` header rather than a webhook URL
|
|
302
|
+
that embeds the secret. Slack / Teams / PagerDuty paths pass
|
|
303
|
+
``headers=None``. Tests inject deterministic posters that mirror the
|
|
304
|
+
same three-positional signature.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _default_http_post(
|
|
309
|
+
url: str,
|
|
310
|
+
body: str,
|
|
311
|
+
headers: Mapping[str, str] | None = None,
|
|
312
|
+
) -> int:
|
|
313
|
+
"""Default poster: stdlib urlopen with JSON content-type plus any
|
|
314
|
+
operator-supplied headers (e.g. OpsGenie ``Authorization: GenieKey``).
|
|
315
|
+
"""
|
|
316
|
+
request_headers = {"Content-Type": "application/json"}
|
|
317
|
+
if headers:
|
|
318
|
+
request_headers.update(headers)
|
|
319
|
+
req = Request( # noqa: S310 — URL is operator-supplied via env
|
|
320
|
+
url,
|
|
321
|
+
data=body.encode("utf-8"),
|
|
322
|
+
headers=request_headers,
|
|
323
|
+
method="POST",
|
|
324
|
+
)
|
|
325
|
+
with urlopen(req, timeout=10) as resp: # noqa: S310
|
|
326
|
+
return int(resp.status)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def dispatch(
|
|
330
|
+
route: str,
|
|
331
|
+
payload: dict[str, Any],
|
|
332
|
+
*,
|
|
333
|
+
poster: HttpPoster | None = None,
|
|
334
|
+
dry_run: bool = False,
|
|
335
|
+
) -> str:
|
|
336
|
+
"""Send a breach notification via the route's prefix dispatcher.
|
|
337
|
+
|
|
338
|
+
Supported prefixes:
|
|
339
|
+
|
|
340
|
+
- ``local:`` — print JSON to stdout (no HTTP). Useful in CI / dev.
|
|
341
|
+
- ``slack:`` — POST to ``$SLACK_WEBHOOK_URL`` (operator configures
|
|
342
|
+
the incoming webhook URL out-of-band).
|
|
343
|
+
- ``teams:`` — POST to ``$TEAMS_WEBHOOK_URL``.
|
|
344
|
+
|
|
345
|
+
Sprint 1.7 (2026-05-23) closes the deferred PagerDuty + Opsgenie
|
|
346
|
+
wiring. The route prefix selects the transport, the suffix is the
|
|
347
|
+
instance identifier (PagerDuty Events v2 service-routing key,
|
|
348
|
+
Opsgenie team alias). The secret itself MUST come from an
|
|
349
|
+
environment variable, never the CLI argument, so the routing key
|
|
350
|
+
is never logged to shell history.
|
|
351
|
+
|
|
352
|
+
``dry_run=True`` short-circuits transport and returns a "(dry-run)"
|
|
353
|
+
marker — same control-flow path as a real send but without I/O.
|
|
354
|
+
"""
|
|
355
|
+
poster = poster or _default_http_post
|
|
356
|
+
body = json.dumps(payload, indent=2)
|
|
357
|
+
|
|
358
|
+
if dry_run:
|
|
359
|
+
return f"(dry-run) would route to {route}: {payload['contract']}"
|
|
360
|
+
|
|
361
|
+
if route.startswith("local:"):
|
|
362
|
+
print(body)
|
|
363
|
+
return f"local: printed payload for {payload['contract']}"
|
|
364
|
+
|
|
365
|
+
if route.startswith("slack:"):
|
|
366
|
+
url = os.environ.get("SLACK_WEBHOOK_URL")
|
|
367
|
+
if not url:
|
|
368
|
+
return f"slack: SLACK_WEBHOOK_URL not set — skipped {payload['contract']}"
|
|
369
|
+
# Slack incoming webhooks accept simple {"text": "..."} bodies.
|
|
370
|
+
text = _format_for_slack(payload)
|
|
371
|
+
status = poster(url, json.dumps({"text": text}), None)
|
|
372
|
+
return f"slack: HTTP {status} for {payload['contract']}"
|
|
373
|
+
|
|
374
|
+
if route.startswith("teams:"):
|
|
375
|
+
url = os.environ.get("TEAMS_WEBHOOK_URL")
|
|
376
|
+
if not url:
|
|
377
|
+
return f"teams: TEAMS_WEBHOOK_URL not set — skipped {payload['contract']}"
|
|
378
|
+
text = _format_for_teams(payload)
|
|
379
|
+
status = poster(url, json.dumps({"text": text}), None)
|
|
380
|
+
return f"teams: HTTP {status} for {payload['contract']}"
|
|
381
|
+
|
|
382
|
+
if route.startswith("pagerduty:"):
|
|
383
|
+
# PagerDuty Events API v2 (https://events.pagerduty.com/v2/enqueue).
|
|
384
|
+
# The routing key is a 32-char service-integration token; we
|
|
385
|
+
# require it via PAGERDUTY_ROUTING_KEY rather than splicing it
|
|
386
|
+
# into the route argument so it never lands in shell history or
|
|
387
|
+
# in a git-tracked kiri.yml.
|
|
388
|
+
routing_key = os.environ.get("PAGERDUTY_ROUTING_KEY")
|
|
389
|
+
if not routing_key:
|
|
390
|
+
return f"pagerduty: PAGERDUTY_ROUTING_KEY not set — skipped {payload['contract']}"
|
|
391
|
+
event_body = json.dumps(_format_for_pagerduty(payload, routing_key))
|
|
392
|
+
status = poster("https://events.pagerduty.com/v2/enqueue", event_body, None)
|
|
393
|
+
return f"pagerduty: HTTP {status} for {payload['contract']}"
|
|
394
|
+
|
|
395
|
+
if route.startswith("opsgenie:"):
|
|
396
|
+
# Opsgenie Alerts API
|
|
397
|
+
# (https://api.opsgenie.com/v2/alerts). Auth header is
|
|
398
|
+
# ``Authorization: GenieKey <api_key>``; the key comes from
|
|
399
|
+
# OPSGENIE_API_KEY for the same reason PagerDuty's routing key
|
|
400
|
+
# does not live on the CLI.
|
|
401
|
+
api_key = os.environ.get("OPSGENIE_API_KEY")
|
|
402
|
+
if not api_key:
|
|
403
|
+
return f"opsgenie: OPSGENIE_API_KEY not set — skipped {payload['contract']}"
|
|
404
|
+
alert_body = json.dumps(_format_for_opsgenie(payload))
|
|
405
|
+
status = poster(
|
|
406
|
+
"https://api.opsgenie.com/v2/alerts",
|
|
407
|
+
alert_body,
|
|
408
|
+
{"Authorization": f"GenieKey {api_key}"},
|
|
409
|
+
)
|
|
410
|
+
return f"opsgenie: HTTP {status} for {payload['contract']}"
|
|
411
|
+
|
|
412
|
+
return f"unknown route prefix: {route!r} (skipped {payload['contract']})"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _format_for_slack(payload: dict[str, Any]) -> str:
|
|
416
|
+
"""Plain-text Slack message — one line per breach, severity tag."""
|
|
417
|
+
lines = [
|
|
418
|
+
f":rotating_light: *SLA breach* on `{payload['contract']}` (severity {payload['severity']})"
|
|
419
|
+
]
|
|
420
|
+
for b in payload["breaches"]:
|
|
421
|
+
lines.append(
|
|
422
|
+
f" • {b['kind']}: {b['message']} "
|
|
423
|
+
f"(threshold {b['threshold']}, observed {b['observed']})"
|
|
424
|
+
)
|
|
425
|
+
return "\n".join(lines)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _format_for_teams(payload: dict[str, Any]) -> str:
|
|
429
|
+
"""Plain-text Teams message — same shape, different emoji conventions."""
|
|
430
|
+
lines = [f"⚠️ SLA breach on **{payload['contract']}** (severity {payload['severity']})"]
|
|
431
|
+
for b in payload["breaches"]:
|
|
432
|
+
lines.append(
|
|
433
|
+
f"- {b['kind']}: {b['message']} (threshold {b['threshold']}, observed {b['observed']})"
|
|
434
|
+
)
|
|
435
|
+
return "\n".join(lines)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
_PAGERDUTY_SEVERITY_MAP: dict[str, str] = {
|
|
439
|
+
"critical": "critical",
|
|
440
|
+
"high": "error",
|
|
441
|
+
"warning": "warning",
|
|
442
|
+
"low": "info",
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _format_for_pagerduty(payload: dict[str, Any], routing_key: str) -> dict[str, Any]:
|
|
447
|
+
"""Build a PagerDuty Events API v2 ``trigger`` event.
|
|
448
|
+
|
|
449
|
+
The ``dedup_key`` keys on contract identity so re-firing the same
|
|
450
|
+
breach updates the same incident instead of creating duplicates.
|
|
451
|
+
The full breach payload rides in ``custom_details`` for
|
|
452
|
+
on-call responders.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
severity = _PAGERDUTY_SEVERITY_MAP.get(str(payload.get("severity", "")).lower(), "warning")
|
|
456
|
+
summaries = [
|
|
457
|
+
f"{b['kind']}: {b['message']} (threshold {b['threshold']}, observed {b['observed']})"
|
|
458
|
+
for b in payload["breaches"]
|
|
459
|
+
]
|
|
460
|
+
summary = (
|
|
461
|
+
f"SLA breach on {payload['contract']} — {len(summaries)} "
|
|
462
|
+
f"breach{'es' if len(summaries) != 1 else ''}"
|
|
463
|
+
)
|
|
464
|
+
return {
|
|
465
|
+
"routing_key": routing_key,
|
|
466
|
+
"event_action": "trigger",
|
|
467
|
+
"dedup_key": f"kirimana:sla:{payload['contract']}",
|
|
468
|
+
"payload": {
|
|
469
|
+
"summary": summary,
|
|
470
|
+
"severity": severity,
|
|
471
|
+
"source": "kirimana",
|
|
472
|
+
"component": payload["contract"],
|
|
473
|
+
"group": "sla",
|
|
474
|
+
"class": "sla_breach",
|
|
475
|
+
"custom_details": {
|
|
476
|
+
"contract": payload["contract"],
|
|
477
|
+
"severity_input": payload.get("severity"),
|
|
478
|
+
"breaches": payload["breaches"],
|
|
479
|
+
"details": summaries,
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
_OPSGENIE_PRIORITY_MAP: dict[str, str] = {
|
|
486
|
+
"critical": "P1",
|
|
487
|
+
"high": "P2",
|
|
488
|
+
"warning": "P3",
|
|
489
|
+
"low": "P4",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _format_for_opsgenie(payload: dict[str, Any]) -> dict[str, Any]:
|
|
494
|
+
"""Build an Opsgenie alert payload.
|
|
495
|
+
|
|
496
|
+
``alias`` keys on contract identity (same dedup semantics as the
|
|
497
|
+
PagerDuty ``dedup_key``); ``priority`` derives from the SLA
|
|
498
|
+
severity via the closed-menu mapping above. The full breach
|
|
499
|
+
payload rides in ``details`` so on-call responders can drill in
|
|
500
|
+
without leaving Opsgenie.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
priority = _OPSGENIE_PRIORITY_MAP.get(str(payload.get("severity", "")).lower(), "P3")
|
|
504
|
+
message = (
|
|
505
|
+
f"SLA breach on {payload['contract']} ({len(payload['breaches'])} "
|
|
506
|
+
f"breach{'es' if len(payload['breaches']) != 1 else ''})"
|
|
507
|
+
)
|
|
508
|
+
description_lines = [
|
|
509
|
+
f"{b['kind']}: {b['message']} (threshold {b['threshold']}, observed {b['observed']})"
|
|
510
|
+
for b in payload["breaches"]
|
|
511
|
+
]
|
|
512
|
+
return {
|
|
513
|
+
"message": message,
|
|
514
|
+
"alias": f"kirimana:sla:{payload['contract']}",
|
|
515
|
+
"priority": priority,
|
|
516
|
+
"source": "kirimana",
|
|
517
|
+
"tags": ["kirimana", "sla", payload["contract"]],
|
|
518
|
+
"description": "\n".join(description_lines),
|
|
519
|
+
"details": {
|
|
520
|
+
"contract": payload["contract"],
|
|
521
|
+
"severity_input": str(payload.get("severity") or ""),
|
|
522
|
+
"breach_count": str(len(payload["breaches"])),
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# CLI command
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _evaluate_contract(
|
|
533
|
+
contract: CanonicalContract,
|
|
534
|
+
*,
|
|
535
|
+
manifest: ProjectManifest | None,
|
|
536
|
+
latest_pass_rate: float | None,
|
|
537
|
+
now: datetime | None = None,
|
|
538
|
+
) -> SlaContractReport:
|
|
539
|
+
"""Run both checks on one contract."""
|
|
540
|
+
sla = contract.kiri.sla
|
|
541
|
+
name = contract.contract.name
|
|
542
|
+
results = [
|
|
543
|
+
evaluate_freshness(
|
|
544
|
+
contract_name=name,
|
|
545
|
+
sla=sla,
|
|
546
|
+
manifest=manifest,
|
|
547
|
+
now=now,
|
|
548
|
+
),
|
|
549
|
+
evaluate_quality(
|
|
550
|
+
contract_name=name,
|
|
551
|
+
sla=sla,
|
|
552
|
+
latest_pass_rate=latest_pass_rate,
|
|
553
|
+
),
|
|
554
|
+
]
|
|
555
|
+
route = sla.on_breach.route if sla.on_breach else None
|
|
556
|
+
severity = sla.on_breach.severity.value if sla.on_breach else None
|
|
557
|
+
return SlaContractReport(
|
|
558
|
+
contract_name=name,
|
|
559
|
+
results=results,
|
|
560
|
+
route=route,
|
|
561
|
+
severity=severity,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
_STATUS_GLYPHS = {
|
|
566
|
+
"ok": "[green]✓[/green]",
|
|
567
|
+
"breach": "[red]✗[/red]",
|
|
568
|
+
"skipped": "[dim]·[/dim]",
|
|
569
|
+
"unmeasured": "[yellow]?[/yellow]",
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _render_report(reports: list[SlaContractReport]) -> None:
|
|
574
|
+
table = Table(show_header=True, header_style="bold")
|
|
575
|
+
table.add_column("Contract")
|
|
576
|
+
table.add_column("Check")
|
|
577
|
+
table.add_column("", width=2)
|
|
578
|
+
table.add_column("Threshold")
|
|
579
|
+
table.add_column("Observed")
|
|
580
|
+
table.add_column("Detail", overflow="fold")
|
|
581
|
+
for rep in reports:
|
|
582
|
+
for r in rep.results:
|
|
583
|
+
table.add_row(
|
|
584
|
+
rep.contract_name,
|
|
585
|
+
r.kind,
|
|
586
|
+
_STATUS_GLYPHS[r.status],
|
|
587
|
+
r.threshold or "—",
|
|
588
|
+
r.observed or "—",
|
|
589
|
+
r.message,
|
|
590
|
+
)
|
|
591
|
+
console.print(table)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def sla_check_cmd(
|
|
595
|
+
project: Annotated[
|
|
596
|
+
Path | None,
|
|
597
|
+
typer.Option("--project", "-p", help="Project root."),
|
|
598
|
+
] = None,
|
|
599
|
+
target: Annotated[
|
|
600
|
+
str | None,
|
|
601
|
+
typer.Option(
|
|
602
|
+
"--target",
|
|
603
|
+
help="Environment target (default: project's default_target).",
|
|
604
|
+
),
|
|
605
|
+
] = None,
|
|
606
|
+
contract: Annotated[
|
|
607
|
+
str | None,
|
|
608
|
+
typer.Option(
|
|
609
|
+
"--contract",
|
|
610
|
+
help="Check only this contract (by name).",
|
|
611
|
+
),
|
|
612
|
+
] = None,
|
|
613
|
+
dry_run: Annotated[
|
|
614
|
+
bool,
|
|
615
|
+
typer.Option(
|
|
616
|
+
"--dry-run",
|
|
617
|
+
help="Don't actually dispatch breaches; print what would happen.",
|
|
618
|
+
),
|
|
619
|
+
] = False,
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Check SLA declarations across the project + route breaches.
|
|
622
|
+
|
|
623
|
+
Reads each contract's ``kiri.sla.*`` block, fetches the platform's
|
|
624
|
+
manifest for the freshness signal, evaluates, dispatches breaches
|
|
625
|
+
via the configured ``on_breach.route``.
|
|
626
|
+
|
|
627
|
+
Exit codes:
|
|
628
|
+
|
|
629
|
+
- ``0`` — no breaches (every check ok or skipped).
|
|
630
|
+
- ``1`` — at least one breach detected.
|
|
631
|
+
- ``2`` — project failed to load.
|
|
632
|
+
"""
|
|
633
|
+
project = resolve_project(project)
|
|
634
|
+
try:
|
|
635
|
+
p = load_project(project)
|
|
636
|
+
except Exception as e:
|
|
637
|
+
console.print(f"[red]✗[/red] failed to load project: {e}")
|
|
638
|
+
raise typer.Exit(code=2) from e
|
|
639
|
+
|
|
640
|
+
chosen_target = target or p.config.default_target
|
|
641
|
+
profile = p.config.profile_for(chosen_target)
|
|
642
|
+
adapter = get_adapter(p.config.adapter, profile)
|
|
643
|
+
|
|
644
|
+
# Filter to contracts that declare any kiri.sla.* — otherwise the
|
|
645
|
+
# report is mostly skipped rows. Operators wanting "everything"
|
|
646
|
+
# can pass --contract.
|
|
647
|
+
declared = (
|
|
648
|
+
[c for c in p.contracts if c.contract.name == contract]
|
|
649
|
+
if contract is not None
|
|
650
|
+
else [
|
|
651
|
+
c
|
|
652
|
+
for c in p.contracts
|
|
653
|
+
if c.kiri.sla.freshness is not None
|
|
654
|
+
or c.kiri.sla.quality_threshold is not None
|
|
655
|
+
or c.kiri.sla.on_breach is not None
|
|
656
|
+
]
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if not declared:
|
|
660
|
+
if contract is not None:
|
|
661
|
+
console.print(f"[red]✗[/red] no contract named {contract!r}.")
|
|
662
|
+
raise typer.Exit(code=2)
|
|
663
|
+
console.print("[yellow]![/yellow] no contracts declare kiri.sla.* — nothing to check.")
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# read_manifest returns None when no apply has stamped the slot yet
|
|
667
|
+
# — fine, evaluate_freshness emits "skipped" with a clear message.
|
|
668
|
+
try:
|
|
669
|
+
manifest = adapter.read_manifest(p.config.name, chosen_target)
|
|
670
|
+
except Exception:
|
|
671
|
+
manifest = None
|
|
672
|
+
|
|
673
|
+
reports: list[SlaContractReport] = []
|
|
674
|
+
for c in declared:
|
|
675
|
+
# Catalog quality signal lands later (CODEX-TECH-DEBT-V1 F11) —
|
|
676
|
+
# pass None so the quality check renders as ``unmeasured`` with
|
|
677
|
+
# a "signal not wired" message rather than a misleading
|
|
678
|
+
# ``skipped`` row. The status taxonomy distinguishes the two:
|
|
679
|
+
# ``skipped`` = check doesn't apply; ``unmeasured`` = check
|
|
680
|
+
# applies but we owe a real answer.
|
|
681
|
+
reports.append(
|
|
682
|
+
_evaluate_contract(
|
|
683
|
+
c,
|
|
684
|
+
manifest=manifest,
|
|
685
|
+
latest_pass_rate=None,
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Surface the wiring gap loudly above the table so an operator who
|
|
690
|
+
# only glances at the report can't mistake ``unmeasured`` quality
|
|
691
|
+
# rows for ``ok``.
|
|
692
|
+
if any(
|
|
693
|
+
r.status == "unmeasured" and r.kind == "quality" for rep in reports for r in rep.results
|
|
694
|
+
):
|
|
695
|
+
console.print(
|
|
696
|
+
"[yellow]![/yellow] quality signal not wired — GE pass-rate "
|
|
697
|
+
"is not yet plumbed into `sla check`. Quality rows below "
|
|
698
|
+
"will show `unmeasured`, not `ok`. "
|
|
699
|
+
"(CODEX-TECH-DEBT-V1 F11)"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
_render_report(reports)
|
|
703
|
+
|
|
704
|
+
breaches = [r for r in reports if r.has_breach]
|
|
705
|
+
if not breaches:
|
|
706
|
+
console.print("\n[green]✓ no breaches[/green]")
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
console.print(f"\n[bold]{len(breaches)} breach(es) — dispatching:[/bold]")
|
|
710
|
+
for rep in breaches:
|
|
711
|
+
if not rep.route:
|
|
712
|
+
console.print(
|
|
713
|
+
f" [yellow]·[/yellow] {rep.contract_name}: "
|
|
714
|
+
"no on_breach.route declared — logged only"
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
outcome = dispatch(rep.route, build_breach_message(rep), dry_run=dry_run)
|
|
718
|
+
console.print(f" → {outcome}")
|
|
719
|
+
# ADR 0038 #173: also dispatch to ITSM if configured. Peer
|
|
720
|
+
# surface to slack/teams — operators get both a notification
|
|
721
|
+
# and a tracked ticket.
|
|
722
|
+
if not dry_run:
|
|
723
|
+
_route_to_itsm(p, project, rep, chosen_target)
|
|
724
|
+
raise typer.Exit(code=1)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _route_to_itsm(
|
|
728
|
+
project_obj: Project,
|
|
729
|
+
project_root: Path,
|
|
730
|
+
report: SlaContractReport,
|
|
731
|
+
target_env: str,
|
|
732
|
+
) -> None:
|
|
733
|
+
"""Best-effort fan-out to the ITSM dispatcher (#173).
|
|
734
|
+
|
|
735
|
+
Imported lazily so projects without an ``incidents:`` block don't
|
|
736
|
+
pay the kiri_adapters import cost on every SLA check.
|
|
737
|
+
"""
|
|
738
|
+
from kiri_core import incident_from_sla_breach
|
|
739
|
+
from kiri_core.apply.incident_router import route_incident
|
|
740
|
+
|
|
741
|
+
incident = incident_from_sla_breach(report, target_env=target_env)
|
|
742
|
+
result = route_incident(project_obj, project_root, incident)
|
|
743
|
+
if result is not None:
|
|
744
|
+
console.print(
|
|
745
|
+
f" → itsm:{result.backend} ticket={result.ticket_id} ({result.action.value})"
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def build_sla_app() -> typer.Typer:
|
|
750
|
+
"""Build the ``kiri sla ...`` sub-app."""
|
|
751
|
+
sla_app = typer.Typer(
|
|
752
|
+
name="sla",
|
|
753
|
+
help="Data SLA enforcement — check freshness/quality + route breaches (ADR 0036).",
|
|
754
|
+
no_args_is_help=True,
|
|
755
|
+
)
|
|
756
|
+
sla_app.command("check")(sla_check_cmd)
|
|
757
|
+
return sla_app
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
__all__ = [
|
|
761
|
+
"SlaCheckResult",
|
|
762
|
+
"SlaContractReport",
|
|
763
|
+
"build_breach_message",
|
|
764
|
+
"build_sla_app",
|
|
765
|
+
"dispatch",
|
|
766
|
+
"evaluate_freshness",
|
|
767
|
+
"evaluate_quality",
|
|
768
|
+
"sla_check_cmd",
|
|
769
|
+
]
|