codexLB-up 1.25.0b10__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.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- config/additional_quota_registry.json +11 -0
app/db/migrate.py
ADDED
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterator, cast
|
|
13
|
+
|
|
14
|
+
from alembic import command
|
|
15
|
+
from alembic.autogenerate import compare_metadata
|
|
16
|
+
from alembic.config import Config
|
|
17
|
+
from alembic.migration import MigrationContext
|
|
18
|
+
from alembic.script import ScriptDirectory
|
|
19
|
+
from alembic.script.revision import RevisionError
|
|
20
|
+
from alembic.util.exc import CommandError
|
|
21
|
+
from anyio import to_thread
|
|
22
|
+
from sqlalchemy import create_engine, inspect, text
|
|
23
|
+
from sqlalchemy import exc as sa_exc
|
|
24
|
+
from sqlalchemy.engine import Connection
|
|
25
|
+
|
|
26
|
+
from app.core.config.settings import get_settings
|
|
27
|
+
from app.db.alembic.revision_ids import LEGACY_MIGRATION_TO_NEW_REVISION, OLD_TO_NEW_REVISION_MAP, REVISION_ID_PATTERN
|
|
28
|
+
from app.db.migration_lock import migration_lock
|
|
29
|
+
from app.db.migration_url import to_sync_database_url
|
|
30
|
+
from app.db.models import Base
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_ALEMBIC_VERSION_TABLE = "alembic_version"
|
|
35
|
+
_ALEMBIC_VERSION_COLUMN = "version_num"
|
|
36
|
+
_LEGACY_MIGRATIONS_TABLE = "schema_migrations"
|
|
37
|
+
_RUNTIME_SENTINELS_TABLE = "runtime_sentinels"
|
|
38
|
+
_REQUIRED_TABLES_FOR_LEGACY_STAMP = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"accounts",
|
|
41
|
+
"usage_history",
|
|
42
|
+
"request_logs",
|
|
43
|
+
"sticky_sessions",
|
|
44
|
+
"dashboard_settings",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
LEGACY_MIGRATION_ORDER: tuple[str, ...] = (
|
|
49
|
+
"001_normalize_account_plan_types",
|
|
50
|
+
"002_add_request_logs_reasoning_effort",
|
|
51
|
+
"003_add_accounts_reset_at",
|
|
52
|
+
"004_add_accounts_chatgpt_account_id",
|
|
53
|
+
"005_add_dashboard_settings",
|
|
54
|
+
"006_add_dashboard_settings_totp",
|
|
55
|
+
"007_add_dashboard_settings_password",
|
|
56
|
+
"008_add_api_keys",
|
|
57
|
+
"009_add_api_key_limits",
|
|
58
|
+
"010_add_idx_logs_requested_at",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
LEGACY_TO_REVISION: dict[str, str] = {
|
|
62
|
+
migration_name: LEGACY_MIGRATION_TO_NEW_REVISION[migration_name] for migration_name in LEGACY_MIGRATION_ORDER
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_BRANCHED_ENFORCEMENT_LEGACY_REVISION = "013_add_api_key_enforcement_fields"
|
|
66
|
+
_BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR = OLD_TO_NEW_REVISION_MAP[_BRANCHED_ENFORCEMENT_LEGACY_REVISION]
|
|
67
|
+
_BRANCHED_ENFORCEMENT_DESCENDANT_REVISIONS = frozenset(
|
|
68
|
+
{
|
|
69
|
+
"20260225_000000_add_dashboard_settings_routing_strategy",
|
|
70
|
+
"20260228_020000_align_api_key_limit_enum_types",
|
|
71
|
+
"20260228_030000_add_api_firewall_allowlist",
|
|
72
|
+
"20260307_000000_add_api_key_enforcement_fields",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
_MANUAL_DRIFT_INDEX_REQUIREMENTS: dict[str, frozenset[str]] = {
|
|
76
|
+
"usage_history": frozenset(
|
|
77
|
+
{
|
|
78
|
+
"idx_usage_window_account_latest",
|
|
79
|
+
"idx_usage_window_account_time",
|
|
80
|
+
"idx_usage_window_raw_account_latest",
|
|
81
|
+
"idx_usage_window_account_time_covering",
|
|
82
|
+
"idx_usage_window_raw_account_time_covering",
|
|
83
|
+
}
|
|
84
|
+
),
|
|
85
|
+
"request_logs": frozenset(
|
|
86
|
+
{
|
|
87
|
+
"idx_logs_requested_at_id",
|
|
88
|
+
"idx_logs_deleted_at_requested_at_id",
|
|
89
|
+
"idx_logs_requested_at_model_tier",
|
|
90
|
+
"idx_logs_model_effort_time",
|
|
91
|
+
"idx_logs_status_error_time",
|
|
92
|
+
"idx_logs_source_requested_at",
|
|
93
|
+
"idx_logs_dash_usage_covering",
|
|
94
|
+
"idx_logs_missing_cost",
|
|
95
|
+
"idx_logs_live_api_key",
|
|
96
|
+
"idx_logs_live_model_effort",
|
|
97
|
+
"idx_logs_live_status_error",
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
"additional_usage_history": frozenset(
|
|
101
|
+
{
|
|
102
|
+
"ix_additional_usage_distinct_labels",
|
|
103
|
+
"ix_additional_usage_alias_limit_latest",
|
|
104
|
+
"ix_additional_usage_alias_feature_latest",
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
"account_limit_warmups": frozenset(
|
|
108
|
+
{
|
|
109
|
+
"idx_account_limit_warmups_account_attempted",
|
|
110
|
+
"idx_account_limit_warmups_status_attempted",
|
|
111
|
+
}
|
|
112
|
+
),
|
|
113
|
+
"api_keys": frozenset({"idx_api_keys_name"}),
|
|
114
|
+
}
|
|
115
|
+
_SQLITE_FLOAT_TYPE_COMPAT_COLUMNS = frozenset(
|
|
116
|
+
{
|
|
117
|
+
("dashboard_settings", "sticky_reallocation_primary_budget_threshold_pct"),
|
|
118
|
+
("dashboard_settings", "sticky_reallocation_secondary_budget_threshold_pct"),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
# Columns the ORM no longer maps but the schema still carries. A replica running
|
|
122
|
+
# the previous release keeps mapping them and renders explicit NULLs in its
|
|
123
|
+
# request-log INSERTs, so the physical drop must wait one release after the
|
|
124
|
+
# mapping retirement (the Helm migration Job runs before old replicas drain).
|
|
125
|
+
_LEGACY_EXTRA_COLUMNS = frozenset(
|
|
126
|
+
{
|
|
127
|
+
("request_logs", "slim_summary_json"),
|
|
128
|
+
# Retired from the ORM in retire-prewarm-canary-column-mappings; drop
|
|
129
|
+
# revision + removal from this set is queued for the following release.
|
|
130
|
+
("request_logs", "prewarm_canary_bucket"),
|
|
131
|
+
("request_logs", "prewarm_eligible_reason"),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class LegacyBootstrapResult:
|
|
138
|
+
stamped_revision: str | None
|
|
139
|
+
legacy_row_count: int
|
|
140
|
+
unknown_migrations: tuple[str, ...]
|
|
141
|
+
had_non_contiguous_entries: bool
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class MigrationRunResult:
|
|
146
|
+
current_revision: str | None
|
|
147
|
+
bootstrap: LegacyBootstrapResult
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class MigrationState:
|
|
152
|
+
current_revision: str | None
|
|
153
|
+
head_revision: str
|
|
154
|
+
has_alembic_version_table: bool
|
|
155
|
+
has_legacy_migrations_table: bool
|
|
156
|
+
needs_upgrade: bool
|
|
157
|
+
unknown_revisions: tuple[str, ...]
|
|
158
|
+
is_ahead: bool
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MigrationBootstrapError(RuntimeError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _script_location() -> str:
|
|
166
|
+
return str((Path(__file__).resolve().parent / "alembic").resolve())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_alembic_config(database_url: str) -> Config:
|
|
170
|
+
config = Config()
|
|
171
|
+
config.set_main_option("script_location", _script_location())
|
|
172
|
+
sync_database_url = to_sync_database_url(database_url)
|
|
173
|
+
# `to_sync_database_url` routes the URL through `make_url().render_as_string()`,
|
|
174
|
+
# which percent-encodes the path on Windows (e.g.
|
|
175
|
+
# `sqlite:///C%3A%5CUsers%5C...%5Cstore.db`). Alembic stores option values in
|
|
176
|
+
# a `configparser` using `BasicInterpolation`, which treats a bare `%` as
|
|
177
|
+
# interpolation syntax and raises `ValueError: invalid interpolation syntax`
|
|
178
|
+
# from `set_main_option`. Escape `%` -> `%%` so ConfigParser keeps the value
|
|
179
|
+
# verbatim; `get_main_option` decodes `%%` back to `%`, so the URL handed to
|
|
180
|
+
# SQLAlchemy is unchanged.
|
|
181
|
+
config.set_main_option("sqlalchemy.url", sync_database_url.replace("%", "%%"))
|
|
182
|
+
config.attributes["configure_logger"] = False
|
|
183
|
+
return config
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _required_sqlalchemy_url(config: Config) -> str:
|
|
187
|
+
sync_database_url = config.get_main_option("sqlalchemy.url")
|
|
188
|
+
if not sync_database_url:
|
|
189
|
+
raise MigrationBootstrapError("sqlalchemy.url is missing in alembic config")
|
|
190
|
+
return sync_database_url
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@contextmanager
|
|
194
|
+
def _sync_connection(sync_database_url: str) -> Iterator[Connection]:
|
|
195
|
+
engine = create_engine(sync_database_url, future=True)
|
|
196
|
+
try:
|
|
197
|
+
with engine.connect() as connection:
|
|
198
|
+
yield connection
|
|
199
|
+
finally:
|
|
200
|
+
engine.dispose()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def _sync_transaction(sync_database_url: str) -> Iterator[Connection]:
|
|
205
|
+
engine = create_engine(sync_database_url, future=True)
|
|
206
|
+
try:
|
|
207
|
+
with engine.begin() as connection:
|
|
208
|
+
yield connection
|
|
209
|
+
finally:
|
|
210
|
+
engine.dispose()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _read_table_names(connection: Connection) -> set[str]:
|
|
214
|
+
inspector = inspect(connection)
|
|
215
|
+
return set(inspector.get_table_names())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _read_legacy_migration_names(connection: Connection) -> set[str]:
|
|
219
|
+
result = connection.execute(text(f"SELECT name FROM {_LEGACY_MIGRATIONS_TABLE}"))
|
|
220
|
+
names = {str(row[0]) for row in result.fetchall() if row and row[0] is not None}
|
|
221
|
+
return names
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_current_revisions_from_connection(connection: Connection) -> tuple[str, ...]:
|
|
225
|
+
try:
|
|
226
|
+
rows = connection.execute(text(f"SELECT {_ALEMBIC_VERSION_COLUMN} FROM {_ALEMBIC_VERSION_TABLE}")).fetchall()
|
|
227
|
+
except (sa_exc.ProgrammingError, sa_exc.OperationalError) as exc:
|
|
228
|
+
# PostgreSQL can still raise UndefinedTable here on a fresh database if
|
|
229
|
+
# the alembic_version table is absent when startup migration state is
|
|
230
|
+
# re-read. SQLite raises OperationalError for the same missing-table
|
|
231
|
+
# path. Treat both the same as "no revision yet".
|
|
232
|
+
message = str(exc).lower()
|
|
233
|
+
if _ALEMBIC_VERSION_TABLE in message and (
|
|
234
|
+
"does not exist" in message or "undefinedtable" in message or "no such table" in message
|
|
235
|
+
):
|
|
236
|
+
return ()
|
|
237
|
+
raise
|
|
238
|
+
revisions = {str(row[0]) for row in rows if row and row[0]}
|
|
239
|
+
return tuple(sorted(revisions))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _read_current_revision_from_connection(connection: Connection) -> str | None:
|
|
243
|
+
revisions = list(_read_current_revisions_from_connection(connection))
|
|
244
|
+
if not revisions:
|
|
245
|
+
return None
|
|
246
|
+
if len(revisions) == 1:
|
|
247
|
+
return revisions[0]
|
|
248
|
+
return ",".join(sorted(revisions))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _contiguous_prefix_count(applied: set[str]) -> int:
|
|
252
|
+
contiguous = 0
|
|
253
|
+
for migration_name in LEGACY_MIGRATION_ORDER:
|
|
254
|
+
if migration_name in applied:
|
|
255
|
+
contiguous += 1
|
|
256
|
+
continue
|
|
257
|
+
break
|
|
258
|
+
return contiguous
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _detect_non_contiguous_entries(applied: set[str], contiguous_prefix_count: int) -> bool:
|
|
262
|
+
trailing = LEGACY_MIGRATION_ORDER[contiguous_prefix_count:]
|
|
263
|
+
return any(name in applied for name in trailing)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _missing_required_legacy_tables_for_stamp(tables: set[str]) -> tuple[str, ...]:
|
|
267
|
+
return tuple(sorted(table for table in _REQUIRED_TABLES_FOR_LEGACY_STAMP if table not in tables))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _bootstrap_legacy_history(config: Config) -> LegacyBootstrapResult:
|
|
271
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
272
|
+
|
|
273
|
+
with _sync_connection(sync_database_url) as connection:
|
|
274
|
+
tables = _read_table_names(connection)
|
|
275
|
+
if _ALEMBIC_VERSION_TABLE in tables:
|
|
276
|
+
return LegacyBootstrapResult(
|
|
277
|
+
stamped_revision=None,
|
|
278
|
+
legacy_row_count=0,
|
|
279
|
+
unknown_migrations=(),
|
|
280
|
+
had_non_contiguous_entries=False,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if _LEGACY_MIGRATIONS_TABLE not in tables:
|
|
284
|
+
return LegacyBootstrapResult(
|
|
285
|
+
stamped_revision=None,
|
|
286
|
+
legacy_row_count=0,
|
|
287
|
+
unknown_migrations=(),
|
|
288
|
+
had_non_contiguous_entries=False,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
applied = _read_legacy_migration_names(connection)
|
|
292
|
+
|
|
293
|
+
if not applied:
|
|
294
|
+
return LegacyBootstrapResult(
|
|
295
|
+
stamped_revision=None,
|
|
296
|
+
legacy_row_count=0,
|
|
297
|
+
unknown_migrations=(),
|
|
298
|
+
had_non_contiguous_entries=False,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
unknown = tuple(sorted(name for name in applied if name not in LEGACY_TO_REVISION))
|
|
302
|
+
contiguous_count = _contiguous_prefix_count(applied)
|
|
303
|
+
has_non_contiguous = _detect_non_contiguous_entries(applied, contiguous_count)
|
|
304
|
+
|
|
305
|
+
if contiguous_count <= 0:
|
|
306
|
+
return LegacyBootstrapResult(
|
|
307
|
+
stamped_revision=None,
|
|
308
|
+
legacy_row_count=len(applied),
|
|
309
|
+
unknown_migrations=unknown,
|
|
310
|
+
had_non_contiguous_entries=has_non_contiguous,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
missing_required_tables = _missing_required_legacy_tables_for_stamp(tables)
|
|
314
|
+
if missing_required_tables:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Skipping legacy bootstrap stamp due to missing required tables tables=%s",
|
|
317
|
+
missing_required_tables,
|
|
318
|
+
)
|
|
319
|
+
return LegacyBootstrapResult(
|
|
320
|
+
stamped_revision=None,
|
|
321
|
+
legacy_row_count=len(applied),
|
|
322
|
+
unknown_migrations=unknown,
|
|
323
|
+
had_non_contiguous_entries=has_non_contiguous,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
target_legacy_name = LEGACY_MIGRATION_ORDER[contiguous_count - 1]
|
|
327
|
+
target_revision = LEGACY_TO_REVISION[target_legacy_name]
|
|
328
|
+
_ensure_alembic_version_table_capacity(config)
|
|
329
|
+
command.stamp(config, target_revision)
|
|
330
|
+
|
|
331
|
+
return LegacyBootstrapResult(
|
|
332
|
+
stamped_revision=target_revision,
|
|
333
|
+
legacy_row_count=len(applied),
|
|
334
|
+
unknown_migrations=unknown,
|
|
335
|
+
had_non_contiguous_entries=has_non_contiguous,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _read_current_revision(sync_database_url: str) -> str | None:
|
|
340
|
+
with _sync_connection(sync_database_url) as connection:
|
|
341
|
+
tables = _read_table_names(connection)
|
|
342
|
+
if _ALEMBIC_VERSION_TABLE not in tables:
|
|
343
|
+
return None
|
|
344
|
+
return _read_current_revision_from_connection(connection)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _head_revision(config: Config) -> str:
|
|
348
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
349
|
+
heads = sorted(script_directory.get_heads())
|
|
350
|
+
if not heads:
|
|
351
|
+
raise MigrationBootstrapError("No Alembic head revision found")
|
|
352
|
+
if len(heads) == 1:
|
|
353
|
+
return heads[0]
|
|
354
|
+
return ",".join(heads)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _known_revisions(config: Config) -> set[str]:
|
|
358
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
359
|
+
return {revision.revision for revision in script_directory.walk_revisions() if revision.revision}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _max_revision_id_length(config: Config) -> int:
|
|
363
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
364
|
+
lengths = [len(revision.revision) for revision in script_directory.walk_revisions() if revision.revision]
|
|
365
|
+
if not lengths:
|
|
366
|
+
raise MigrationBootstrapError("No Alembic revisions found")
|
|
367
|
+
return max(lengths)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _ensure_alembic_version_table_capacity_for_connection(connection: Connection, *, required_length: int) -> None:
|
|
371
|
+
if connection.dialect.name != "postgresql":
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
inspector = inspect(connection)
|
|
375
|
+
if not inspector.has_table(_ALEMBIC_VERSION_TABLE):
|
|
376
|
+
# IF NOT EXISTS is defense-in-depth against concurrent out-of-band
|
|
377
|
+
# `alembic upgrade` invocations that bypass run_upgrade's migration
|
|
378
|
+
# lock; the product paths are already serialized by migration_lock.
|
|
379
|
+
connection.execute(
|
|
380
|
+
text(
|
|
381
|
+
" ".join(
|
|
382
|
+
(
|
|
383
|
+
f"CREATE TABLE IF NOT EXISTS {_ALEMBIC_VERSION_TABLE} (",
|
|
384
|
+
f"{_ALEMBIC_VERSION_COLUMN} VARCHAR({required_length}) NOT NULL,",
|
|
385
|
+
f"PRIMARY KEY ({_ALEMBIC_VERSION_COLUMN})",
|
|
386
|
+
")",
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
columns = inspector.get_columns(_ALEMBIC_VERSION_TABLE)
|
|
394
|
+
version_num_column = next((column for column in columns if column.get("name") == _ALEMBIC_VERSION_COLUMN), None)
|
|
395
|
+
if version_num_column is None:
|
|
396
|
+
raise MigrationBootstrapError(
|
|
397
|
+
f"{_ALEMBIC_VERSION_TABLE}.{_ALEMBIC_VERSION_COLUMN} is missing from migration metadata table"
|
|
398
|
+
)
|
|
399
|
+
version_num_type = version_num_column.get("type")
|
|
400
|
+
version_num_length = getattr(version_num_type, "length", None)
|
|
401
|
+
if version_num_length is None or version_num_length >= required_length:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
connection.execute(
|
|
405
|
+
text(
|
|
406
|
+
f"ALTER TABLE {_ALEMBIC_VERSION_TABLE} "
|
|
407
|
+
f"ALTER COLUMN {_ALEMBIC_VERSION_COLUMN} TYPE VARCHAR({required_length})"
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _ensure_alembic_version_table_capacity(config: Config) -> None:
|
|
413
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
414
|
+
required_length = _max_revision_id_length(config)
|
|
415
|
+
with _sync_transaction(sync_database_url) as connection:
|
|
416
|
+
_ensure_alembic_version_table_capacity_for_connection(connection, required_length=required_length)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _collect_migration_policy_violations(config: Config) -> tuple[str, ...]:
|
|
420
|
+
violations: list[str] = []
|
|
421
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
422
|
+
|
|
423
|
+
heads = sorted(script_directory.get_heads())
|
|
424
|
+
if len(heads) != 1:
|
|
425
|
+
violations.append(f"alembic_head_count_invalid expected=1 actual={len(heads)} heads={','.join(heads)}")
|
|
426
|
+
|
|
427
|
+
seen_revision_ids: set[str] = set()
|
|
428
|
+
for revision in script_directory.walk_revisions():
|
|
429
|
+
revision_id = revision.revision
|
|
430
|
+
if not revision_id:
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
if revision_id in seen_revision_ids:
|
|
434
|
+
violations.append(f"alembic_revision_duplicate revision={revision_id}")
|
|
435
|
+
else:
|
|
436
|
+
seen_revision_ids.add(revision_id)
|
|
437
|
+
|
|
438
|
+
if not REVISION_ID_PATTERN.fullmatch(revision_id):
|
|
439
|
+
violations.append(f"alembic_revision_id_format_invalid revision={revision_id}")
|
|
440
|
+
|
|
441
|
+
revision_path = getattr(revision, "path", None)
|
|
442
|
+
if revision_path:
|
|
443
|
+
actual_name = Path(str(revision_path)).name
|
|
444
|
+
expected_name = f"{revision_id}.py"
|
|
445
|
+
if actual_name != expected_name:
|
|
446
|
+
violations.append(
|
|
447
|
+
"alembic_revision_filename_mismatch "
|
|
448
|
+
f"revision={revision_id} expected={expected_name} actual={actual_name}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return tuple(sorted(violations))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def check_migration_policy(database_url: str) -> tuple[str, ...]:
|
|
455
|
+
config = _build_alembic_config(database_url)
|
|
456
|
+
return _collect_migration_policy_violations(config)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _remap_legacy_alembic_revisions(config: Config) -> tuple[str, ...]:
|
|
460
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
461
|
+
known_revisions = _known_revisions(config)
|
|
462
|
+
|
|
463
|
+
with _sync_transaction(sync_database_url) as connection:
|
|
464
|
+
tables = _read_table_names(connection)
|
|
465
|
+
if _ALEMBIC_VERSION_TABLE not in tables:
|
|
466
|
+
return ()
|
|
467
|
+
|
|
468
|
+
current_revisions = _read_current_revisions_from_connection(connection)
|
|
469
|
+
if not current_revisions:
|
|
470
|
+
return ()
|
|
471
|
+
|
|
472
|
+
unsupported = tuple(
|
|
473
|
+
sorted(
|
|
474
|
+
revision
|
|
475
|
+
for revision in current_revisions
|
|
476
|
+
if revision not in known_revisions and revision not in OLD_TO_NEW_REVISION_MAP
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
if unsupported:
|
|
480
|
+
raise MigrationBootstrapError(
|
|
481
|
+
"Unsupported alembic_version revision ids detected; manual intervention required "
|
|
482
|
+
f"unsupported={','.join(unsupported)}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
remapped_set = {OLD_TO_NEW_REVISION_MAP.get(revision, revision) for revision in current_revisions}
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
_BRANCHED_ENFORCEMENT_LEGACY_REVISION in current_revisions
|
|
489
|
+
and _BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR in remapped_set
|
|
490
|
+
and remapped_set & _BRANCHED_ENFORCEMENT_DESCENDANT_REVISIONS
|
|
491
|
+
):
|
|
492
|
+
remapped_set.discard(_BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR)
|
|
493
|
+
|
|
494
|
+
remapped = tuple(sorted(remapped_set))
|
|
495
|
+
if remapped == current_revisions:
|
|
496
|
+
return ()
|
|
497
|
+
|
|
498
|
+
connection.execute(text(f"DELETE FROM {_ALEMBIC_VERSION_TABLE}"))
|
|
499
|
+
for revision in remapped:
|
|
500
|
+
connection.execute(
|
|
501
|
+
text(
|
|
502
|
+
f"INSERT INTO {_ALEMBIC_VERSION_TABLE} ({_ALEMBIC_VERSION_COLUMN}) "
|
|
503
|
+
f"VALUES (:{_ALEMBIC_VERSION_COLUMN})"
|
|
504
|
+
),
|
|
505
|
+
{_ALEMBIC_VERSION_COLUMN: revision},
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
logger.info(
|
|
509
|
+
"Remapped legacy alembic revision ids old=%s new=%s",
|
|
510
|
+
current_revisions,
|
|
511
|
+
remapped,
|
|
512
|
+
)
|
|
513
|
+
return current_revisions
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def inspect_migration_state(database_url: str) -> MigrationState:
|
|
517
|
+
config = _build_alembic_config(database_url)
|
|
518
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
519
|
+
head_revision = _head_revision(config)
|
|
520
|
+
|
|
521
|
+
with _sync_connection(sync_database_url) as connection:
|
|
522
|
+
tables = _read_table_names(connection)
|
|
523
|
+
has_alembic = _ALEMBIC_VERSION_TABLE in tables
|
|
524
|
+
has_legacy = _LEGACY_MIGRATIONS_TABLE in tables
|
|
525
|
+
current_revisions = _read_current_revisions_from_connection(connection) if has_alembic else ()
|
|
526
|
+
|
|
527
|
+
if not current_revisions:
|
|
528
|
+
current = None
|
|
529
|
+
elif len(current_revisions) == 1:
|
|
530
|
+
current = current_revisions[0]
|
|
531
|
+
else:
|
|
532
|
+
current = ",".join(current_revisions)
|
|
533
|
+
|
|
534
|
+
known_revisions = _known_revisions(config)
|
|
535
|
+
unknown_revisions = tuple(
|
|
536
|
+
sorted(
|
|
537
|
+
revision
|
|
538
|
+
for revision in current_revisions
|
|
539
|
+
if revision not in known_revisions and revision not in OLD_TO_NEW_REVISION_MAP
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
if has_alembic:
|
|
544
|
+
needs_upgrade = current != head_revision
|
|
545
|
+
else:
|
|
546
|
+
# Missing alembic_version always requires bootstrap and/or upgrade.
|
|
547
|
+
needs_upgrade = True
|
|
548
|
+
|
|
549
|
+
return MigrationState(
|
|
550
|
+
current_revision=current,
|
|
551
|
+
head_revision=head_revision,
|
|
552
|
+
has_alembic_version_table=has_alembic,
|
|
553
|
+
has_legacy_migrations_table=has_legacy,
|
|
554
|
+
needs_upgrade=needs_upgrade,
|
|
555
|
+
unknown_revisions=unknown_revisions,
|
|
556
|
+
is_ahead=bool(unknown_revisions),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _sqlite_index_names(connection: Connection, table_name: str) -> set[str]:
|
|
561
|
+
escaped_name = table_name.replace('"', '""')
|
|
562
|
+
rows = connection.execute(text(f'PRAGMA index_list("{escaped_name}")')).fetchall()
|
|
563
|
+
return {str(row[1]) for row in rows if len(row) > 1 and row[1] is not None}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _postgresql_index_names(connection: Connection, table_name: str) -> set[str]:
|
|
567
|
+
rows = connection.execute(
|
|
568
|
+
text(
|
|
569
|
+
"""
|
|
570
|
+
SELECT indexname
|
|
571
|
+
FROM pg_indexes
|
|
572
|
+
WHERE schemaname = ANY (current_schemas(false))
|
|
573
|
+
AND tablename = :table_name
|
|
574
|
+
"""
|
|
575
|
+
),
|
|
576
|
+
{"table_name": table_name},
|
|
577
|
+
).fetchall()
|
|
578
|
+
return {str(row[0]) for row in rows if row and row[0] is not None}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _read_index_names_for_drift(connection: Connection, table_name: str) -> set[str]:
|
|
582
|
+
if connection.dialect.name == "sqlite":
|
|
583
|
+
return _sqlite_index_names(connection, table_name)
|
|
584
|
+
if connection.dialect.name == "postgresql":
|
|
585
|
+
return _postgresql_index_names(connection, table_name)
|
|
586
|
+
|
|
587
|
+
inspector = inspect(connection)
|
|
588
|
+
if not inspector.has_table(table_name):
|
|
589
|
+
return set()
|
|
590
|
+
return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name") is not None}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _manual_schema_drift_diffs(connection: Connection) -> tuple[str, ...]:
|
|
594
|
+
diffs: list[str] = []
|
|
595
|
+
for table_name, required_indexes in _MANUAL_DRIFT_INDEX_REQUIREMENTS.items():
|
|
596
|
+
existing_indexes = _read_index_names_for_drift(connection, table_name)
|
|
597
|
+
for index_name in sorted(required_indexes - existing_indexes):
|
|
598
|
+
diffs.append(repr(("missing_index", table_name, index_name)))
|
|
599
|
+
return tuple(diffs)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _unwrap_schema_drift_diff(diff: object) -> object:
|
|
603
|
+
if isinstance(diff, list) and len(diff) == 1:
|
|
604
|
+
return diff[0]
|
|
605
|
+
return diff
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _is_ignored_schema_drift(connection: Connection, diff: object) -> bool:
|
|
609
|
+
diff = _unwrap_schema_drift_diff(diff)
|
|
610
|
+
if not isinstance(diff, tuple) or not diff:
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
if diff[0] == "remove_column" and len(diff) >= 4:
|
|
614
|
+
column = diff[3]
|
|
615
|
+
column_name = getattr(column, "name", None)
|
|
616
|
+
if (str(diff[2]), str(column_name)) in _LEGACY_EXTRA_COLUMNS:
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
if connection.dialect.name == "sqlite" and diff[0] == "modify_type" and len(diff) >= 7:
|
|
620
|
+
table_name = str(diff[2])
|
|
621
|
+
column_name = str(diff[3])
|
|
622
|
+
if (table_name, column_name) not in _SQLITE_FLOAT_TYPE_COMPAT_COLUMNS:
|
|
623
|
+
return False
|
|
624
|
+
existing_type = diff[5]
|
|
625
|
+
metadata_type = diff[6]
|
|
626
|
+
return str(existing_type).upper() == "REAL" and str(metadata_type).upper() in {"FLOAT", "REAL"}
|
|
627
|
+
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
_OBJECT_ADDRESS_RE = re.compile(r" object at 0x[0-9a-fA-F]+>")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _stable_diff_repr(diff: object) -> str:
|
|
635
|
+
"""Render an autogenerate diff without CPython object addresses.
|
|
636
|
+
|
|
637
|
+
SQLAlchemy renders constraint members as ``<... object at 0x7f...>``, so the
|
|
638
|
+
repr of an otherwise identical foreign-key diff differs between two calls in
|
|
639
|
+
the same process. Drift output is compared and shown to operators, so it has
|
|
640
|
+
to be stable.
|
|
641
|
+
"""
|
|
642
|
+
return _OBJECT_ADDRESS_RE.sub(" object>", repr(diff))
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def check_schema_drift(database_url: str) -> tuple[str, ...]:
|
|
646
|
+
config = _build_alembic_config(database_url)
|
|
647
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
648
|
+
|
|
649
|
+
with _sync_connection(sync_database_url) as connection:
|
|
650
|
+
migration_context = MigrationContext.configure(
|
|
651
|
+
connection=connection,
|
|
652
|
+
opts={
|
|
653
|
+
"target_metadata": Base.metadata,
|
|
654
|
+
"compare_type": True,
|
|
655
|
+
"compare_server_default": True,
|
|
656
|
+
},
|
|
657
|
+
)
|
|
658
|
+
with warnings.catch_warnings():
|
|
659
|
+
warnings.filterwarnings(
|
|
660
|
+
"ignore",
|
|
661
|
+
message=r"Skipped unsupported reflection of expression-based index .*",
|
|
662
|
+
)
|
|
663
|
+
warnings.filterwarnings(
|
|
664
|
+
"ignore",
|
|
665
|
+
message=r"autogenerate skipping metadata-specified expression-based index .*",
|
|
666
|
+
)
|
|
667
|
+
diffs = [
|
|
668
|
+
diff
|
|
669
|
+
for diff in compare_metadata(migration_context, Base.metadata)
|
|
670
|
+
if not _is_ignored_schema_drift(connection, diff)
|
|
671
|
+
]
|
|
672
|
+
manual_diffs = _manual_schema_drift_diffs(connection)
|
|
673
|
+
|
|
674
|
+
return tuple(_stable_diff_repr(diff) for diff in diffs) + manual_diffs
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
_NO_LEGACY_BOOTSTRAP = LegacyBootstrapResult(
|
|
678
|
+
stamped_revision=None,
|
|
679
|
+
legacy_row_count=0,
|
|
680
|
+
unknown_migrations=(),
|
|
681
|
+
had_non_contiguous_entries=False,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _resolved_lock_timeout_seconds(lock_timeout_seconds: float | None) -> float:
|
|
686
|
+
if lock_timeout_seconds is not None:
|
|
687
|
+
return lock_timeout_seconds
|
|
688
|
+
return get_settings().database_migration_lock_timeout_seconds
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
#: The revision that copied the legacy dashboard credentials onto the account
|
|
692
|
+
#: row (release N), and the one that drops the columns they lived in (this
|
|
693
|
+
#: release). The drop **descends** from the re-projection, so any upgrade that
|
|
694
|
+
#: crosses the drop re-projects first, in the same run: a database stamped at
|
|
695
|
+
#: any older revision reaches head in one command with its credentials intact,
|
|
696
|
+
#: and refusing that jump would keep every existing install from starting.
|
|
697
|
+
CREDENTIAL_REPROJECTION_REVISION = "20260909_020000_reproject_compat_admin_credentials"
|
|
698
|
+
CREDENTIAL_DROP_REVISION = "20260912_010000_drop_legacy_dashboard_credentials"
|
|
699
|
+
|
|
700
|
+
#: The table the dropped columns live in, and therefore the evidence that there
|
|
701
|
+
#: is an install here at all: a replica of an earlier release maps those columns
|
|
702
|
+
#: and loads this row whole, so until the table exists there is nothing for one
|
|
703
|
+
#: to be serving and nothing to drain.
|
|
704
|
+
_CREDENTIAL_MIRROR_TABLE = "dashboard_settings"
|
|
705
|
+
|
|
706
|
+
_CREDENTIAL_DROP_DRAIN_WARNING = (
|
|
707
|
+
"Dropping the legacy dashboard credential columns (%s). Replicas of any earlier release map these "
|
|
708
|
+
"columns and load the settings row whole, so their settings reads fail once this commits: stop them "
|
|
709
|
+
"before this migration runs, not after. Rollback is supported to the immediately previous release only."
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
#: Durable "the legacy credential layer is retired" marker, written into
|
|
714
|
+
#: ``runtime_sentinels`` by ``CREDENTIAL_DROP_REVISION`` and read back here.
|
|
715
|
+
#: Frozen as a literal on both sides for the same reason the revision freezes
|
|
716
|
+
#: its identifiers: neither may import the other.
|
|
717
|
+
LEGACY_CREDENTIALS_RETIRED_SENTINEL = "dashboard_legacy_credentials_retired"
|
|
718
|
+
|
|
719
|
+
_LEDGER_BEHIND_SCHEMA_WARNING = (
|
|
720
|
+
"Database has already retired the legacy dashboard credential columns (%s recorded it in "
|
|
721
|
+
"%s), but its Alembic ledger says %s, which does not include that revision. Stamping the "
|
|
722
|
+
"ledger at it rather than replaying the chain over this schema: the replay re-creates those "
|
|
723
|
+
"columns empty and the re-projection between them reads that emptiness as a removed "
|
|
724
|
+
"password, which would clear the account row that now holds the only dashboard credential "
|
|
725
|
+
"this install has."
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _remapped(revision: str) -> str:
|
|
730
|
+
return OLD_TO_NEW_REVISION_MAP.get(revision, revision)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _pending_revisions(config: Config, current_revisions: Sequence[str], revision: str) -> frozenset[str]:
|
|
734
|
+
"""Exactly the revisions ``command.upgrade(config, revision)`` would apply.
|
|
735
|
+
|
|
736
|
+
``revision`` is whatever the caller passed, which is every target form
|
|
737
|
+
Alembic accepts: ``head``/``heads``, a full id, an unambiguous id *prefix*,
|
|
738
|
+
and a relative spec such as ``+1`` or ``<prefix>+2``. Resolving it by hand
|
|
739
|
+
-- special-casing ``head`` and otherwise treating the string as a literal
|
|
740
|
+
id -- silently mis-reads the last three: they resolve fine inside
|
|
741
|
+
``command.upgrade`` a moment later, so the drop would run with nothing
|
|
742
|
+
said. This asks ``ScriptDirectory._upgrade_revs``, which is the function
|
|
743
|
+
``command.upgrade`` itself calls to plan the run, so the check and the
|
|
744
|
+
command cannot disagree. ``iterate_revisions`` is the public spelling and
|
|
745
|
+
was the first attempt; it raises ``RangeNotAncestorError`` for a ledger
|
|
746
|
+
holding several heads when the target is on one branch, where the upgrade
|
|
747
|
+
succeeds -- one resolver, not two, is the point.
|
|
748
|
+
|
|
749
|
+
Empty on anything unanswerable: an id this build does not know (the caller
|
|
750
|
+
is a schema stamped ahead, which ``_run_upgrade_locked`` refuses on its own)
|
|
751
|
+
or a target Alembic itself cannot resolve (it raises the same error a beat
|
|
752
|
+
later, and a warning about a command that is going to fail helps nobody).
|
|
753
|
+
"""
|
|
754
|
+
|
|
755
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
756
|
+
known = _known_revisions(config)
|
|
757
|
+
resolved_current = tuple(_remapped(item) for item in current_revisions)
|
|
758
|
+
if any(item not in known for item in resolved_current):
|
|
759
|
+
return frozenset()
|
|
760
|
+
try:
|
|
761
|
+
# Alembic annotates ``current_rev`` as ``str``, but ``command.upgrade``
|
|
762
|
+
# itself hands it the migration context's *tuple* of heads and the
|
|
763
|
+
# resolver takes any collection. Narrowing to one would mis-plan a
|
|
764
|
+
# ledger that holds several.
|
|
765
|
+
steps = script_directory._upgrade_revs(revision, cast("Any", resolved_current))
|
|
766
|
+
except (CommandError, RevisionError):
|
|
767
|
+
return frozenset()
|
|
768
|
+
return frozenset(step.revision.revision for step in steps if step.revision.revision)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _check_legacy_credential_drop(config: Config, sync_database_url: str, revision: str) -> None:
|
|
772
|
+
"""Read the ledger *and* the schema, then decide; no side effects, no DDL."""
|
|
773
|
+
|
|
774
|
+
with _sync_connection(sync_database_url) as connection:
|
|
775
|
+
tables = _read_table_names(connection)
|
|
776
|
+
current_revisions = (
|
|
777
|
+
_read_current_revisions_from_connection(connection) if _ALEMBIC_VERSION_TABLE in tables else ()
|
|
778
|
+
)
|
|
779
|
+
has_existing_schema = _CREDENTIAL_MIRROR_TABLE in tables
|
|
780
|
+
check_legacy_credential_drop(
|
|
781
|
+
config,
|
|
782
|
+
current_revisions,
|
|
783
|
+
revision,
|
|
784
|
+
has_existing_schema=has_existing_schema,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def check_legacy_credential_drop(
|
|
789
|
+
config: Config,
|
|
790
|
+
current_revisions: Sequence[str],
|
|
791
|
+
revision: str,
|
|
792
|
+
*,
|
|
793
|
+
has_existing_schema: bool,
|
|
794
|
+
) -> None:
|
|
795
|
+
"""State the drain requirement before the legacy credential columns go.
|
|
796
|
+
|
|
797
|
+
Called before any DDL, and it deliberately claims only what the ledger and
|
|
798
|
+
the schema can show. Nothing is refused: ``CREDENTIAL_REPROJECTION_REVISION``
|
|
799
|
+
is an ancestor of ``CREDENTIAL_DROP_REVISION``, so a database stamped
|
|
800
|
+
anywhere below both reaches head in one command with the credentials copied
|
|
801
|
+
onto the account rows before the columns they came from disappear, and the
|
|
802
|
+
ordering that makes that true is pinned by a test rather than re-checked
|
|
803
|
+
here. What no signal anywhere can show is whether a replica of an earlier
|
|
804
|
+
release is serving right now, so the drain requirement is a warning an
|
|
805
|
+
operator must act on rather than a condition this process can verify.
|
|
806
|
+
|
|
807
|
+
An empty ledger is *not* on its own a fresh install, which is why
|
|
808
|
+
``has_existing_schema`` is asked for separately. ``alembic_version`` can be
|
|
809
|
+
absent, truncated, or restored without its rows over a schema that has been
|
|
810
|
+
serving for months, and that database reads identically to a new one -- yet
|
|
811
|
+
it is the one that most needs to be told, because the replay it is about to
|
|
812
|
+
run reaches ``CREDENTIAL_DROP_REVISION`` and drops the columns for real (the
|
|
813
|
+
chain's early revisions are inspector-guarded, so replaying over an existing
|
|
814
|
+
schema does not fail first). The fresh case is therefore read off the data:
|
|
815
|
+
``_CREDENTIAL_MIRROR_TABLE`` is where the doomed columns live, and until it
|
|
816
|
+
exists there is no install here for a replica of an earlier release to be
|
|
817
|
+
serving. Both halves have to hold -- an empty ledger *and* no such table.
|
|
818
|
+
|
|
819
|
+
``config.attributes["codex_lb_fresh_install"]`` is deliberately not that
|
|
820
|
+
signal: it is false whenever an ``alembic_version`` table merely exists, so
|
|
821
|
+
an empty one left behind by a crashed first run would turn today's missing
|
|
822
|
+
warning into a warning about a database with nothing to drain -- and a
|
|
823
|
+
published revision already reads that attribute to pick its defaults, so it
|
|
824
|
+
is not free to redefine.
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
if not current_revisions and not has_existing_schema:
|
|
828
|
+
# No ledger and no schema: a fresh install. The chain creates the
|
|
829
|
+
# tables without the dropped columns ever holding anything.
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
if CREDENTIAL_DROP_REVISION in _pending_revisions(config, current_revisions, revision):
|
|
833
|
+
logger.warning(_CREDENTIAL_DROP_DRAIN_WARNING, CREDENTIAL_DROP_REVISION)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _read_runtime_sentinel(connection: Connection, name: str) -> str | None:
|
|
837
|
+
if _RUNTIME_SENTINELS_TABLE not in _read_table_names(connection):
|
|
838
|
+
return None
|
|
839
|
+
row = connection.execute(
|
|
840
|
+
text(f"SELECT value FROM {_RUNTIME_SENTINELS_TABLE} WHERE name = :name"),
|
|
841
|
+
{"name": name},
|
|
842
|
+
).first()
|
|
843
|
+
return None if row is None else str(row[0])
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _ledger_has_applied(config: Config, current_revisions: Sequence[str], revision: str) -> bool:
|
|
847
|
+
"""Whether ``revision`` is at or below any of ``current_revisions``."""
|
|
848
|
+
|
|
849
|
+
script_directory = ScriptDirectory.from_config(config)
|
|
850
|
+
return any(
|
|
851
|
+
item.revision == revision
|
|
852
|
+
for current in current_revisions
|
|
853
|
+
for item in script_directory.iterate_revisions(current, "base")
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _reconcile_retired_credential_ledger(config: Config) -> str | None:
|
|
858
|
+
"""Re-stamp a ledger that sits below a schema which already retired the credentials.
|
|
859
|
+
|
|
860
|
+
The same shape as ``_bootstrap_legacy_history``: a database whose ledger
|
|
861
|
+
does not describe it is placed at the revision its own data proves it
|
|
862
|
+
reached, rather than replayed. The evidence is the marker
|
|
863
|
+
``CREDENTIAL_DROP_REVISION`` writes into ``runtime_sentinels``, and only
|
|
864
|
+
that revision writes it (its downgrade removes it again), so the marker
|
|
865
|
+
means the three columns are gone -- whatever the ledger says. A ledger that
|
|
866
|
+
disagrees has been lost, rewound or restored from a partial backup, and it
|
|
867
|
+
is the ledger that is wrong.
|
|
868
|
+
|
|
869
|
+
The evidence has to be data, because the ledger is the thing that went
|
|
870
|
+
missing. And the replay it prevents is not merely wasteful, it is
|
|
871
|
+
destructive: ``20260213_000600``/``20260213_000700`` re-add
|
|
872
|
+
``password_hash``, ``totp_secret_encrypted`` and ``totp_last_verified_step``
|
|
873
|
+
to an existing ``dashboard_settings`` as **empty** columns, and
|
|
874
|
+
``CREDENTIAL_REPROJECTION_REVISION`` then reads that emptiness as "the
|
|
875
|
+
previous release removed the password" and NULLs the credential on the
|
|
876
|
+
account row -- which, after this release, is the only copy, so the install
|
|
877
|
+
is locked out of its own dashboard. (Replaying
|
|
878
|
+
``20260909_010000_add_dashboard_users`` over a schema whose ledger was
|
|
879
|
+
rewound *past* the column re-creation fails outright instead, on
|
|
880
|
+
``no such column: password_hash``.) Both of those revisions are published
|
|
881
|
+
and must not be edited to defend themselves -- an install that has applied
|
|
882
|
+
a revision never applies it again -- so the defence is here, before the
|
|
883
|
+
first one runs.
|
|
884
|
+
|
|
885
|
+
Call this *after* ``_remap_legacy_alembic_revisions``: it needs a ledger
|
|
886
|
+
Alembic can resolve, and a ledger it cannot is left alone so the upgrade
|
|
887
|
+
raises its own error rather than a stamp raising one earlier.
|
|
888
|
+
|
|
889
|
+
Returns the stamped revision, or ``None`` when nothing was stamped.
|
|
890
|
+
"""
|
|
891
|
+
|
|
892
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
893
|
+
with _sync_connection(sync_database_url) as connection:
|
|
894
|
+
has_ledger = _ALEMBIC_VERSION_TABLE in _read_table_names(connection)
|
|
895
|
+
marker = _read_runtime_sentinel(connection, LEGACY_CREDENTIALS_RETIRED_SENTINEL)
|
|
896
|
+
current_revisions = _read_current_revisions_from_connection(connection) if has_ledger else ()
|
|
897
|
+
|
|
898
|
+
if marker is None:
|
|
899
|
+
return None
|
|
900
|
+
if any(revision not in _known_revisions(config) for revision in current_revisions):
|
|
901
|
+
return None
|
|
902
|
+
if current_revisions and _ledger_has_applied(config, current_revisions, CREDENTIAL_DROP_REVISION):
|
|
903
|
+
return None
|
|
904
|
+
|
|
905
|
+
logger.warning(
|
|
906
|
+
_LEDGER_BEHIND_SCHEMA_WARNING,
|
|
907
|
+
marker,
|
|
908
|
+
_RUNTIME_SENTINELS_TABLE,
|
|
909
|
+
",".join(current_revisions) if current_revisions else f"no {_ALEMBIC_VERSION_TABLE} table",
|
|
910
|
+
)
|
|
911
|
+
_ensure_alembic_version_table_capacity(config)
|
|
912
|
+
command.stamp(config, CREDENTIAL_DROP_REVISION)
|
|
913
|
+
return CREDENTIAL_DROP_REVISION
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _schema_ahead_error(state: MigrationState) -> MigrationBootstrapError:
|
|
917
|
+
return MigrationBootstrapError(
|
|
918
|
+
f"Database schema revision(s) {','.join(state.unknown_revisions)} are not known to this build "
|
|
919
|
+
f"(head={state.head_revision}); the schema was likely migrated by a newer version. "
|
|
920
|
+
"Deploy a matching or newer image, or run an Alembic downgrade to a revision this build knows."
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def run_upgrade(
|
|
925
|
+
database_url: str,
|
|
926
|
+
revision: str = "head",
|
|
927
|
+
*,
|
|
928
|
+
bootstrap_legacy: bool,
|
|
929
|
+
auto_remap_legacy_revisions: bool = True,
|
|
930
|
+
lock_timeout_seconds: float | None = None,
|
|
931
|
+
) -> MigrationRunResult:
|
|
932
|
+
config = _build_alembic_config(database_url)
|
|
933
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
934
|
+
with migration_lock(
|
|
935
|
+
sync_database_url,
|
|
936
|
+
timeout_seconds=_resolved_lock_timeout_seconds(lock_timeout_seconds),
|
|
937
|
+
):
|
|
938
|
+
return _run_upgrade_locked(
|
|
939
|
+
config,
|
|
940
|
+
database_url,
|
|
941
|
+
revision,
|
|
942
|
+
bootstrap_legacy=bootstrap_legacy,
|
|
943
|
+
auto_remap_legacy_revisions=auto_remap_legacy_revisions,
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _run_upgrade_locked(
|
|
948
|
+
config: Config,
|
|
949
|
+
database_url: str,
|
|
950
|
+
revision: str,
|
|
951
|
+
*,
|
|
952
|
+
bootstrap_legacy: bool,
|
|
953
|
+
auto_remap_legacy_revisions: bool,
|
|
954
|
+
) -> MigrationRunResult:
|
|
955
|
+
state_before = inspect_migration_state(database_url)
|
|
956
|
+
if state_before.is_ahead:
|
|
957
|
+
raise _schema_ahead_error(state_before)
|
|
958
|
+
|
|
959
|
+
# Post-acquire re-check: a peer holding the lock first may already have
|
|
960
|
+
# migrated to head. When alembic_version exists the legacy bootstrap never
|
|
961
|
+
# stamps, and current == head means no legacy remap is pending, so skipping
|
|
962
|
+
# is safe and lets the losing replica complete startup successfully.
|
|
963
|
+
if revision == "head" and state_before.has_alembic_version_table and not state_before.needs_upgrade:
|
|
964
|
+
logger.info(
|
|
965
|
+
"Database schema already at Alembic head revision=%s; skipping upgrade "
|
|
966
|
+
"(migrations were applied by this or another replica)",
|
|
967
|
+
state_before.current_revision,
|
|
968
|
+
)
|
|
969
|
+
return MigrationRunResult(
|
|
970
|
+
current_revision=state_before.current_revision,
|
|
971
|
+
bootstrap=_NO_LEGACY_BOOTSTRAP,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
config.attributes["codex_lb_fresh_install"] = (
|
|
975
|
+
state_before.current_revision is None
|
|
976
|
+
and not state_before.has_alembic_version_table
|
|
977
|
+
and not state_before.has_legacy_migrations_table
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
bootstrap_result = _NO_LEGACY_BOOTSTRAP
|
|
981
|
+
|
|
982
|
+
if bootstrap_legacy:
|
|
983
|
+
bootstrap_result = _bootstrap_legacy_history(config)
|
|
984
|
+
if bootstrap_result.stamped_revision is not None:
|
|
985
|
+
config.attributes["codex_lb_fresh_install"] = False
|
|
986
|
+
|
|
987
|
+
_ensure_alembic_version_table_capacity(config)
|
|
988
|
+
if auto_remap_legacy_revisions:
|
|
989
|
+
_remap_legacy_alembic_revisions(config)
|
|
990
|
+
# The second ledger repair, and the one that must not be skipped: replaying
|
|
991
|
+
# the chain over a schema that already retired the legacy credential
|
|
992
|
+
# columns destroys the account credentials (see the function). It runs
|
|
993
|
+
# after the remap because it needs a ledger Alembic can resolve.
|
|
994
|
+
if _reconcile_retired_credential_ledger(config) is not None:
|
|
995
|
+
config.attributes["codex_lb_fresh_install"] = False
|
|
996
|
+
# Last read before the first DDL: the legacy bootstrap, the remap and the
|
|
997
|
+
# reconciliation may all have moved the ledger, and the question is what
|
|
998
|
+
# this database has actually applied at the moment the upgrade starts.
|
|
999
|
+
_check_legacy_credential_drop(config, _required_sqlalchemy_url(config), revision)
|
|
1000
|
+
command.upgrade(config, revision)
|
|
1001
|
+
|
|
1002
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
1003
|
+
current_revision = _read_current_revision(sync_database_url)
|
|
1004
|
+
|
|
1005
|
+
if bootstrap_result.unknown_migrations:
|
|
1006
|
+
logger.warning(
|
|
1007
|
+
"Unknown legacy migration names detected names=%s",
|
|
1008
|
+
bootstrap_result.unknown_migrations,
|
|
1009
|
+
)
|
|
1010
|
+
if bootstrap_result.had_non_contiguous_entries:
|
|
1011
|
+
logger.warning("Legacy migration table has non-contiguous applied entries")
|
|
1012
|
+
|
|
1013
|
+
return MigrationRunResult(current_revision=current_revision, bootstrap=bootstrap_result)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
async def run_startup_migrations(database_url: str) -> MigrationRunResult:
|
|
1017
|
+
auto_remap = get_settings().database_alembic_auto_remap_enabled
|
|
1018
|
+
return await to_thread.run_sync(
|
|
1019
|
+
lambda: run_upgrade(
|
|
1020
|
+
database_url,
|
|
1021
|
+
"head",
|
|
1022
|
+
bootstrap_legacy=True,
|
|
1023
|
+
auto_remap_legacy_revisions=auto_remap,
|
|
1024
|
+
),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def current_revision(database_url: str) -> str | None:
|
|
1029
|
+
state = inspect_migration_state(database_url)
|
|
1030
|
+
return state.current_revision
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def stamp_revision(database_url: str, revision: str, *, lock_timeout_seconds: float | None = None) -> None:
|
|
1034
|
+
config = _build_alembic_config(database_url)
|
|
1035
|
+
sync_database_url = _required_sqlalchemy_url(config)
|
|
1036
|
+
with migration_lock(
|
|
1037
|
+
sync_database_url,
|
|
1038
|
+
timeout_seconds=_resolved_lock_timeout_seconds(lock_timeout_seconds),
|
|
1039
|
+
):
|
|
1040
|
+
_ensure_alembic_version_table_capacity(config)
|
|
1041
|
+
command.stamp(config, revision)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def wait_for_connection(
|
|
1045
|
+
database_url: str,
|
|
1046
|
+
*,
|
|
1047
|
+
timeout_seconds: float,
|
|
1048
|
+
interval_seconds: float = 2.0,
|
|
1049
|
+
) -> None:
|
|
1050
|
+
if timeout_seconds <= 0:
|
|
1051
|
+
raise ValueError("timeout_seconds must be greater than 0")
|
|
1052
|
+
if interval_seconds <= 0:
|
|
1053
|
+
raise ValueError("interval_seconds must be greater than 0")
|
|
1054
|
+
|
|
1055
|
+
started_at = time.monotonic()
|
|
1056
|
+
last_error: Exception | None = None
|
|
1057
|
+
sync_database_url = to_sync_database_url(database_url)
|
|
1058
|
+
|
|
1059
|
+
while True:
|
|
1060
|
+
try:
|
|
1061
|
+
with _sync_connection(sync_database_url):
|
|
1062
|
+
return
|
|
1063
|
+
except Exception as exc:
|
|
1064
|
+
last_error = exc
|
|
1065
|
+
elapsed = time.monotonic() - started_at
|
|
1066
|
+
if elapsed >= timeout_seconds:
|
|
1067
|
+
if last_error is not None:
|
|
1068
|
+
raise TimeoutError(
|
|
1069
|
+
f"Timed out waiting for database connectivity after {timeout_seconds:.1f}s: {last_error}"
|
|
1070
|
+
) from last_error
|
|
1071
|
+
raise TimeoutError(f"Timed out waiting for database connectivity after {timeout_seconds:.1f}s")
|
|
1072
|
+
time.sleep(min(interval_seconds, timeout_seconds - elapsed))
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def wait_for_head(
|
|
1076
|
+
database_url: str,
|
|
1077
|
+
*,
|
|
1078
|
+
timeout_seconds: float,
|
|
1079
|
+
interval_seconds: float = 2.0,
|
|
1080
|
+
) -> MigrationState:
|
|
1081
|
+
if timeout_seconds <= 0:
|
|
1082
|
+
raise ValueError("timeout_seconds must be greater than 0")
|
|
1083
|
+
if interval_seconds <= 0:
|
|
1084
|
+
raise ValueError("interval_seconds must be greater than 0")
|
|
1085
|
+
|
|
1086
|
+
started_at = time.monotonic()
|
|
1087
|
+
last_error: Exception | None = None
|
|
1088
|
+
|
|
1089
|
+
while True:
|
|
1090
|
+
try:
|
|
1091
|
+
state = inspect_migration_state(database_url)
|
|
1092
|
+
if not state.needs_upgrade:
|
|
1093
|
+
return state
|
|
1094
|
+
except Exception as exc:
|
|
1095
|
+
last_error = exc
|
|
1096
|
+
elapsed = time.monotonic() - started_at
|
|
1097
|
+
if elapsed >= timeout_seconds:
|
|
1098
|
+
if last_error is not None:
|
|
1099
|
+
raise TimeoutError(
|
|
1100
|
+
f"Timed out waiting for database schema to reach Alembic head after {timeout_seconds:.1f}s: "
|
|
1101
|
+
f"{last_error}"
|
|
1102
|
+
) from last_error
|
|
1103
|
+
raise TimeoutError(
|
|
1104
|
+
f"Timed out waiting for database schema to reach Alembic head after {timeout_seconds:.1f}s"
|
|
1105
|
+
)
|
|
1106
|
+
time.sleep(min(interval_seconds, timeout_seconds - elapsed))
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _non_empty_database_url(value: str) -> str:
|
|
1110
|
+
if value == "":
|
|
1111
|
+
raise argparse.ArgumentTypeError("database URL must not be empty")
|
|
1112
|
+
return value
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _parse_args() -> argparse.Namespace:
|
|
1116
|
+
parser = argparse.ArgumentParser(description="Database migration utility for codex-lb.")
|
|
1117
|
+
parser.add_argument(
|
|
1118
|
+
"--db-url",
|
|
1119
|
+
type=_non_empty_database_url,
|
|
1120
|
+
default=None,
|
|
1121
|
+
help="Database URL to migrate. Defaults to CODEX_LB_DATABASE_URL from settings.",
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1125
|
+
|
|
1126
|
+
upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade schema to a target revision.")
|
|
1127
|
+
upgrade_parser.add_argument("revision", nargs="?", default="head")
|
|
1128
|
+
upgrade_parser.add_argument(
|
|
1129
|
+
"--no-bootstrap-legacy",
|
|
1130
|
+
action="store_true",
|
|
1131
|
+
help="Disable automatic legacy schema_migrations bootstrap before upgrade.",
|
|
1132
|
+
)
|
|
1133
|
+
upgrade_parser.add_argument(
|
|
1134
|
+
"--no-auto-remap-legacy-revisions",
|
|
1135
|
+
action="store_true",
|
|
1136
|
+
help="Disable automatic remap of legacy Alembic revision IDs.",
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
subparsers.add_parser("current", help="Print current alembic revision.")
|
|
1140
|
+
|
|
1141
|
+
subparsers.add_parser("check", help="Check Alembic policy and model/schema drift.")
|
|
1142
|
+
|
|
1143
|
+
wait_parser = subparsers.add_parser(
|
|
1144
|
+
"wait-for-head",
|
|
1145
|
+
help="Wait until the database schema reaches Alembic head without applying migrations locally.",
|
|
1146
|
+
)
|
|
1147
|
+
wait_parser.add_argument(
|
|
1148
|
+
"--timeout-seconds",
|
|
1149
|
+
type=float,
|
|
1150
|
+
default=300.0,
|
|
1151
|
+
help="Maximum seconds to wait for the schema to reach Alembic head.",
|
|
1152
|
+
)
|
|
1153
|
+
wait_parser.add_argument(
|
|
1154
|
+
"--interval-seconds",
|
|
1155
|
+
type=float,
|
|
1156
|
+
default=2.0,
|
|
1157
|
+
help="Polling interval in seconds while waiting for the schema to reach Alembic head.",
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
connect_parser = subparsers.add_parser(
|
|
1161
|
+
"wait-for-connection",
|
|
1162
|
+
help="Wait until the database accepts connections without applying migrations locally.",
|
|
1163
|
+
)
|
|
1164
|
+
connect_parser.add_argument(
|
|
1165
|
+
"--timeout-seconds",
|
|
1166
|
+
type=float,
|
|
1167
|
+
default=300.0,
|
|
1168
|
+
help="Maximum seconds to wait for database connectivity.",
|
|
1169
|
+
)
|
|
1170
|
+
connect_parser.add_argument(
|
|
1171
|
+
"--interval-seconds",
|
|
1172
|
+
type=float,
|
|
1173
|
+
default=2.0,
|
|
1174
|
+
help="Polling interval in seconds while waiting for database connectivity.",
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
stamp_parser = subparsers.add_parser("stamp", help="Set current revision without running migrations.")
|
|
1178
|
+
stamp_parser.add_argument("revision")
|
|
1179
|
+
|
|
1180
|
+
return parser.parse_args()
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def main() -> None:
|
|
1184
|
+
args = _parse_args()
|
|
1185
|
+
database_url = get_settings().database_url if args.db_url is None else args.db_url
|
|
1186
|
+
|
|
1187
|
+
if args.command == "upgrade":
|
|
1188
|
+
result = run_upgrade(
|
|
1189
|
+
database_url,
|
|
1190
|
+
args.revision,
|
|
1191
|
+
bootstrap_legacy=not bool(args.no_bootstrap_legacy),
|
|
1192
|
+
auto_remap_legacy_revisions=not bool(args.no_auto_remap_legacy_revisions),
|
|
1193
|
+
)
|
|
1194
|
+
print(f"current_revision={result.current_revision or 'none'}")
|
|
1195
|
+
if result.bootstrap.stamped_revision:
|
|
1196
|
+
print(f"legacy_bootstrap_stamped={result.bootstrap.stamped_revision}")
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
if args.command == "current":
|
|
1200
|
+
revision = current_revision(database_url)
|
|
1201
|
+
print(revision or "none")
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
if args.command == "check":
|
|
1205
|
+
policy_violations = check_migration_policy(database_url)
|
|
1206
|
+
if policy_violations:
|
|
1207
|
+
print("migration_policy_violations_detected")
|
|
1208
|
+
for violation in policy_violations:
|
|
1209
|
+
print(violation)
|
|
1210
|
+
drift = check_schema_drift(database_url)
|
|
1211
|
+
if drift:
|
|
1212
|
+
print("schema_drift_detected")
|
|
1213
|
+
for diff in drift:
|
|
1214
|
+
print(diff)
|
|
1215
|
+
if policy_violations or drift:
|
|
1216
|
+
raise SystemExit(1)
|
|
1217
|
+
print("migration_policy=ok")
|
|
1218
|
+
print("schema_drift=none")
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
if args.command == "stamp":
|
|
1222
|
+
stamp_revision(database_url, args.revision)
|
|
1223
|
+
print(f"stamped={args.revision}")
|
|
1224
|
+
return
|
|
1225
|
+
|
|
1226
|
+
if args.command == "wait-for-head":
|
|
1227
|
+
state = wait_for_head(
|
|
1228
|
+
database_url,
|
|
1229
|
+
timeout_seconds=args.timeout_seconds,
|
|
1230
|
+
interval_seconds=args.interval_seconds,
|
|
1231
|
+
)
|
|
1232
|
+
print(f"current_revision={state.current_revision or 'none'}")
|
|
1233
|
+
print(f"head_revision={state.head_revision}")
|
|
1234
|
+
return
|
|
1235
|
+
|
|
1236
|
+
if args.command == "wait-for-connection":
|
|
1237
|
+
wait_for_connection(
|
|
1238
|
+
database_url,
|
|
1239
|
+
timeout_seconds=args.timeout_seconds,
|
|
1240
|
+
interval_seconds=args.interval_seconds,
|
|
1241
|
+
)
|
|
1242
|
+
print("database_connection=ready")
|
|
1243
|
+
return
|
|
1244
|
+
|
|
1245
|
+
raise RuntimeError(f"unsupported command: {args.command}")
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
if __name__ == "__main__":
|
|
1249
|
+
main()
|