fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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.
- confiture/__init__.py +48 -0
- confiture/_core.cpython-311-darwin.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1893 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +184 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +882 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +24 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +265 -0
- confiture/models/schema.py +203 -0
- confiture/models/sql_file_migration.py +225 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +100 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/loader.py +225 -0
- confiture/testing/pytest/__init__.py +38 -0
- confiture/testing/pytest_plugin.py +190 -0
- confiture/testing/sandbox.py +304 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
- fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
- fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""Blue-green migration orchestration.
|
|
2
|
+
|
|
3
|
+
Provides utilities for zero-downtime database migrations using
|
|
4
|
+
blue-green deployment patterns with atomic schema swapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MigrationPhase(Enum):
|
|
19
|
+
"""Phases of blue-green migration."""
|
|
20
|
+
|
|
21
|
+
INIT = "init"
|
|
22
|
+
SCHEMA_CREATED = "schema_created"
|
|
23
|
+
DATA_SYNCING = "data_syncing"
|
|
24
|
+
DATA_SYNCED = "data_synced"
|
|
25
|
+
VERIFYING = "verifying"
|
|
26
|
+
TRAFFIC_SWITCHING = "traffic_switching"
|
|
27
|
+
TRAFFIC_SWITCHED = "traffic_switched"
|
|
28
|
+
CLEANUP_PENDING = "cleanup_pending"
|
|
29
|
+
COMPLETE = "complete"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
ROLLED_BACK = "rolled_back"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BlueGreenConfig:
|
|
36
|
+
"""Configuration for blue-green migration.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
source_schema: Current production schema (default: public)
|
|
40
|
+
target_schema: New schema to migrate to
|
|
41
|
+
health_check_interval: Seconds between health check retries
|
|
42
|
+
health_check_retries: Number of health check attempts
|
|
43
|
+
sync_timeout: Maximum seconds for data sync
|
|
44
|
+
traffic_switch_delay: Seconds to wait before switching
|
|
45
|
+
skip_cleanup: If True, don't drop old schema
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
source_schema: str = "public"
|
|
49
|
+
target_schema: str = "public_new"
|
|
50
|
+
health_check_interval: float = 5.0
|
|
51
|
+
health_check_retries: int = 3
|
|
52
|
+
sync_timeout: int = 3600
|
|
53
|
+
traffic_switch_delay: float = 10.0
|
|
54
|
+
skip_cleanup: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class MigrationState:
|
|
59
|
+
"""Current state of blue-green migration.
|
|
60
|
+
|
|
61
|
+
Tracks the migration progress and any errors that occur.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
phase: MigrationPhase = MigrationPhase.INIT
|
|
65
|
+
source_schema: str = "public"
|
|
66
|
+
target_schema: str = "public_new"
|
|
67
|
+
started_at: str | None = None
|
|
68
|
+
completed_at: str | None = None
|
|
69
|
+
error: str | None = None
|
|
70
|
+
rollback_available: bool = True
|
|
71
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
"""Convert to dictionary."""
|
|
75
|
+
return {
|
|
76
|
+
"phase": self.phase.value,
|
|
77
|
+
"source_schema": self.source_schema,
|
|
78
|
+
"target_schema": self.target_schema,
|
|
79
|
+
"started_at": self.started_at,
|
|
80
|
+
"completed_at": self.completed_at,
|
|
81
|
+
"error": self.error,
|
|
82
|
+
"rollback_available": self.rollback_available,
|
|
83
|
+
"metadata": self.metadata,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class HealthCheckResult:
|
|
89
|
+
"""Result of a health check."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
passed: bool
|
|
93
|
+
message: str | None = None
|
|
94
|
+
duration_ms: int = 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class BlueGreenOrchestrator:
|
|
98
|
+
"""Orchestrate blue-green database migrations.
|
|
99
|
+
|
|
100
|
+
Provides a structured approach to zero-downtime migrations:
|
|
101
|
+
1. Create target schema
|
|
102
|
+
2. Sync data (via FDW or other mechanism)
|
|
103
|
+
3. Verify data integrity
|
|
104
|
+
4. Run health checks
|
|
105
|
+
5. Atomic schema swap
|
|
106
|
+
6. Cleanup (optional)
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> config = BlueGreenConfig(target_schema="public_v2")
|
|
110
|
+
>>> orchestrator = BlueGreenOrchestrator(conn, config)
|
|
111
|
+
>>> orchestrator.add_health_check("api_health", check_api)
|
|
112
|
+
>>> state = orchestrator.execute()
|
|
113
|
+
>>> print(f"Migration: {state.phase.value}")
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, connection: Any, config: BlueGreenConfig | None = None):
|
|
117
|
+
"""Initialize orchestrator.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
connection: Database connection
|
|
121
|
+
config: Migration configuration
|
|
122
|
+
"""
|
|
123
|
+
self.connection = connection
|
|
124
|
+
self.config = config or BlueGreenConfig()
|
|
125
|
+
self.state = MigrationState(
|
|
126
|
+
source_schema=self.config.source_schema,
|
|
127
|
+
target_schema=self.config.target_schema,
|
|
128
|
+
)
|
|
129
|
+
self._health_checks: list[tuple[str, Callable[[], bool]]] = []
|
|
130
|
+
self._on_phase_change: list[Callable[[MigrationPhase, MigrationPhase], None]] = []
|
|
131
|
+
self._data_sync_fn: Callable[[], None] | None = None
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def current_phase(self) -> MigrationPhase:
|
|
135
|
+
"""Get current migration phase."""
|
|
136
|
+
return self.state.phase
|
|
137
|
+
|
|
138
|
+
def add_health_check(self, name: str, check: Callable[[], bool]) -> None:
|
|
139
|
+
"""Add a health check function.
|
|
140
|
+
|
|
141
|
+
Health checks are run before traffic switch. All must pass.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Name for this health check
|
|
145
|
+
check: Function returning True if healthy
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> def check_api():
|
|
149
|
+
... response = requests.get("http://localhost/health")
|
|
150
|
+
... return response.status_code == 200
|
|
151
|
+
>>> orchestrator.add_health_check("api", check_api)
|
|
152
|
+
"""
|
|
153
|
+
self._health_checks.append((name, check))
|
|
154
|
+
|
|
155
|
+
def on_phase_change(self, callback: Callable[[MigrationPhase, MigrationPhase], None]) -> None:
|
|
156
|
+
"""Register callback for phase changes.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
callback: Function called with (old_phase, new_phase)
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> def log_phase(old, new):
|
|
163
|
+
... print(f"Phase changed: {old.value} -> {new.value}")
|
|
164
|
+
>>> orchestrator.on_phase_change(log_phase)
|
|
165
|
+
"""
|
|
166
|
+
self._on_phase_change.append(callback)
|
|
167
|
+
|
|
168
|
+
def set_data_sync_function(self, fn: Callable[[], None]) -> None:
|
|
169
|
+
"""Set custom data sync function.
|
|
170
|
+
|
|
171
|
+
By default, sync is a placeholder. Set this to integrate
|
|
172
|
+
with your data sync mechanism (FDW, pg_dump, etc.).
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
fn: Function to sync data from source to target
|
|
176
|
+
"""
|
|
177
|
+
self._data_sync_fn = fn
|
|
178
|
+
|
|
179
|
+
def _set_phase(self, phase: MigrationPhase) -> None:
|
|
180
|
+
"""Update phase and notify callbacks."""
|
|
181
|
+
old_phase = self.state.phase
|
|
182
|
+
self.state.phase = phase
|
|
183
|
+
logger.info(f"Phase: {old_phase.value} -> {phase.value}")
|
|
184
|
+
|
|
185
|
+
for callback in self._on_phase_change:
|
|
186
|
+
try:
|
|
187
|
+
callback(old_phase, phase)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.warning(f"Phase callback failed: {e}")
|
|
190
|
+
|
|
191
|
+
def execute(self) -> MigrationState:
|
|
192
|
+
"""Execute full blue-green migration.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
MigrationState with final status
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If migration fails and cannot be rolled back
|
|
199
|
+
"""
|
|
200
|
+
self.state.started_at = datetime.datetime.now(datetime.UTC).isoformat()
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
self._create_target_schema()
|
|
204
|
+
self._sync_data()
|
|
205
|
+
self._verify_sync()
|
|
206
|
+
|
|
207
|
+
health_results = self._run_health_checks()
|
|
208
|
+
if not all(r.passed for r in health_results):
|
|
209
|
+
failed = [r.name for r in health_results if not r.passed]
|
|
210
|
+
raise RuntimeError(f"Health checks failed: {', '.join(failed)}")
|
|
211
|
+
|
|
212
|
+
self._switch_traffic()
|
|
213
|
+
|
|
214
|
+
if not self.config.skip_cleanup:
|
|
215
|
+
self._cleanup()
|
|
216
|
+
|
|
217
|
+
self.state.completed_at = datetime.datetime.now(datetime.UTC).isoformat()
|
|
218
|
+
self._set_phase(MigrationPhase.COMPLETE)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
self.state.error = str(e)
|
|
222
|
+
self._set_phase(MigrationPhase.FAILED)
|
|
223
|
+
logger.error(f"Migration failed: {e}")
|
|
224
|
+
self._attempt_rollback()
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
return self.state
|
|
228
|
+
|
|
229
|
+
def _create_target_schema(self) -> None:
|
|
230
|
+
"""Create target schema for new version."""
|
|
231
|
+
with self.connection.cursor() as cur:
|
|
232
|
+
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.config.target_schema}")
|
|
233
|
+
self.connection.commit()
|
|
234
|
+
self._set_phase(MigrationPhase.SCHEMA_CREATED)
|
|
235
|
+
logger.info(f"Created schema: {self.config.target_schema}")
|
|
236
|
+
|
|
237
|
+
def _sync_data(self) -> None:
|
|
238
|
+
"""Sync data from source to target schema."""
|
|
239
|
+
self._set_phase(MigrationPhase.DATA_SYNCING)
|
|
240
|
+
|
|
241
|
+
if self._data_sync_fn:
|
|
242
|
+
self._data_sync_fn()
|
|
243
|
+
else:
|
|
244
|
+
logger.info(
|
|
245
|
+
"No data sync function set. "
|
|
246
|
+
"Use set_data_sync_function() to integrate with FDW or other sync."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
self._set_phase(MigrationPhase.DATA_SYNCED)
|
|
250
|
+
|
|
251
|
+
def _verify_sync(self) -> None:
|
|
252
|
+
"""Verify data sync is complete."""
|
|
253
|
+
self._set_phase(MigrationPhase.VERIFYING)
|
|
254
|
+
|
|
255
|
+
discrepancies = self._compare_schemas()
|
|
256
|
+
if discrepancies:
|
|
257
|
+
for table, diff in discrepancies.items():
|
|
258
|
+
logger.warning(
|
|
259
|
+
f"Row count mismatch in {table}: "
|
|
260
|
+
f"source={diff['source']}, target={diff['target']}"
|
|
261
|
+
)
|
|
262
|
+
self.state.metadata["sync_discrepancies"] = discrepancies
|
|
263
|
+
|
|
264
|
+
logger.info("Data sync verification complete")
|
|
265
|
+
|
|
266
|
+
def _compare_schemas(self) -> dict[str, dict[str, int]]:
|
|
267
|
+
"""Compare row counts between schemas.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Dictionary of tables with mismatched counts
|
|
271
|
+
"""
|
|
272
|
+
discrepancies: dict[str, dict[str, int]] = {}
|
|
273
|
+
|
|
274
|
+
with self.connection.cursor() as cur:
|
|
275
|
+
cur.execute(
|
|
276
|
+
"""
|
|
277
|
+
SELECT schemaname, relname, n_live_tup
|
|
278
|
+
FROM pg_stat_user_tables
|
|
279
|
+
WHERE schemaname IN (%s, %s)
|
|
280
|
+
ORDER BY relname, schemaname
|
|
281
|
+
""",
|
|
282
|
+
(self.config.source_schema, self.config.target_schema),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
stats: dict[str, dict[str, int]] = {}
|
|
286
|
+
for row in cur.fetchall():
|
|
287
|
+
schema, table, count = row
|
|
288
|
+
if table not in stats:
|
|
289
|
+
stats[table] = {}
|
|
290
|
+
stats[table][schema] = count or 0
|
|
291
|
+
|
|
292
|
+
for table, counts in stats.items():
|
|
293
|
+
source_count = counts.get(self.config.source_schema, 0)
|
|
294
|
+
target_count = counts.get(self.config.target_schema, 0)
|
|
295
|
+
|
|
296
|
+
if source_count != target_count:
|
|
297
|
+
discrepancies[table] = {
|
|
298
|
+
"source": source_count,
|
|
299
|
+
"target": target_count,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return discrepancies
|
|
303
|
+
|
|
304
|
+
def _run_health_checks(self) -> list[HealthCheckResult]:
|
|
305
|
+
"""Run all health checks with retries.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
List of health check results
|
|
309
|
+
"""
|
|
310
|
+
if not self._health_checks:
|
|
311
|
+
logger.info("No health checks configured, proceeding")
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
results: list[HealthCheckResult] = []
|
|
315
|
+
|
|
316
|
+
for attempt in range(self.config.health_check_retries):
|
|
317
|
+
results = []
|
|
318
|
+
all_passed = True
|
|
319
|
+
|
|
320
|
+
for name, check in self._health_checks:
|
|
321
|
+
start_time = time.perf_counter()
|
|
322
|
+
try:
|
|
323
|
+
passed = check()
|
|
324
|
+
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
325
|
+
results.append(
|
|
326
|
+
HealthCheckResult(
|
|
327
|
+
name=name,
|
|
328
|
+
passed=passed,
|
|
329
|
+
duration_ms=duration_ms,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
if not passed:
|
|
333
|
+
logger.warning(f"Health check '{name}' failed")
|
|
334
|
+
all_passed = False
|
|
335
|
+
except Exception as e:
|
|
336
|
+
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
337
|
+
results.append(
|
|
338
|
+
HealthCheckResult(
|
|
339
|
+
name=name,
|
|
340
|
+
passed=False,
|
|
341
|
+
message=str(e),
|
|
342
|
+
duration_ms=duration_ms,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
logger.warning(f"Health check '{name}' error: {e}")
|
|
346
|
+
all_passed = False
|
|
347
|
+
|
|
348
|
+
if all_passed:
|
|
349
|
+
logger.info(f"All {len(self._health_checks)} health checks passed")
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
if attempt < self.config.health_check_retries - 1:
|
|
353
|
+
logger.info(
|
|
354
|
+
f"Retrying health checks in {self.config.health_check_interval}s "
|
|
355
|
+
f"(attempt {attempt + 2}/{self.config.health_check_retries})"
|
|
356
|
+
)
|
|
357
|
+
time.sleep(self.config.health_check_interval)
|
|
358
|
+
|
|
359
|
+
self.state.metadata["health_check_results"] = [r.__dict__ for r in results]
|
|
360
|
+
return results
|
|
361
|
+
|
|
362
|
+
def _switch_traffic(self) -> None:
|
|
363
|
+
"""Switch traffic by renaming schemas atomically."""
|
|
364
|
+
self._set_phase(MigrationPhase.TRAFFIC_SWITCHING)
|
|
365
|
+
|
|
366
|
+
if self.config.traffic_switch_delay > 0:
|
|
367
|
+
logger.info(f"Waiting {self.config.traffic_switch_delay}s before traffic switch...")
|
|
368
|
+
time.sleep(self.config.traffic_switch_delay)
|
|
369
|
+
|
|
370
|
+
backup_schema = f"{self.config.source_schema}_backup_{int(time.time())}"
|
|
371
|
+
|
|
372
|
+
with self.connection.cursor() as cur:
|
|
373
|
+
# Atomic schema swap using a single transaction
|
|
374
|
+
cur.execute(
|
|
375
|
+
f"""
|
|
376
|
+
ALTER SCHEMA {self.config.source_schema} RENAME TO {backup_schema};
|
|
377
|
+
ALTER SCHEMA {self.config.target_schema}
|
|
378
|
+
RENAME TO {self.config.source_schema};
|
|
379
|
+
"""
|
|
380
|
+
)
|
|
381
|
+
self.connection.commit()
|
|
382
|
+
|
|
383
|
+
self.state.metadata["backup_schema"] = backup_schema
|
|
384
|
+
self.state.rollback_available = True
|
|
385
|
+
self._set_phase(MigrationPhase.TRAFFIC_SWITCHED)
|
|
386
|
+
logger.info(f"Traffic switched. Old schema backed up as: {backup_schema}")
|
|
387
|
+
|
|
388
|
+
def _cleanup(self) -> None:
|
|
389
|
+
"""Mark cleanup as pending (actual cleanup is manual)."""
|
|
390
|
+
self._set_phase(MigrationPhase.CLEANUP_PENDING)
|
|
391
|
+
|
|
392
|
+
backup_schema = self.state.metadata.get("backup_schema")
|
|
393
|
+
if backup_schema:
|
|
394
|
+
logger.info(f"Old schema preserved as: {backup_schema}")
|
|
395
|
+
logger.info(
|
|
396
|
+
f"To remove: DROP SCHEMA {backup_schema} CASCADE; "
|
|
397
|
+
"or run 'confiture migrate cleanup'"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _attempt_rollback(self) -> None:
|
|
401
|
+
"""Attempt to rollback on failure."""
|
|
402
|
+
if not self.state.rollback_available:
|
|
403
|
+
logger.warning("Rollback not available from current state")
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
phase = self.state.phase
|
|
407
|
+
|
|
408
|
+
if phase in (
|
|
409
|
+
MigrationPhase.INIT,
|
|
410
|
+
MigrationPhase.SCHEMA_CREATED,
|
|
411
|
+
MigrationPhase.DATA_SYNCING,
|
|
412
|
+
MigrationPhase.DATA_SYNCED,
|
|
413
|
+
MigrationPhase.VERIFYING,
|
|
414
|
+
MigrationPhase.FAILED,
|
|
415
|
+
):
|
|
416
|
+
# Safe to rollback - just drop target schema
|
|
417
|
+
self._rollback_drop_target()
|
|
418
|
+
|
|
419
|
+
elif phase == MigrationPhase.TRAFFIC_SWITCHED:
|
|
420
|
+
# Need to swap schemas back
|
|
421
|
+
self._rollback_swap_back()
|
|
422
|
+
|
|
423
|
+
else:
|
|
424
|
+
logger.warning(f"Cannot auto-rollback from phase: {phase.value}")
|
|
425
|
+
|
|
426
|
+
def _rollback_drop_target(self) -> None:
|
|
427
|
+
"""Rollback by dropping target schema."""
|
|
428
|
+
try:
|
|
429
|
+
with self.connection.cursor() as cur:
|
|
430
|
+
cur.execute(f"DROP SCHEMA IF EXISTS {self.config.target_schema} CASCADE")
|
|
431
|
+
self.connection.commit()
|
|
432
|
+
self._set_phase(MigrationPhase.ROLLED_BACK)
|
|
433
|
+
logger.info(f"Rolled back: dropped {self.config.target_schema}")
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.error(f"Rollback failed: {e}")
|
|
436
|
+
self.state.rollback_available = False
|
|
437
|
+
|
|
438
|
+
def _rollback_swap_back(self) -> None:
|
|
439
|
+
"""Rollback by swapping schemas back."""
|
|
440
|
+
backup_schema = self.state.metadata.get("backup_schema")
|
|
441
|
+
if not backup_schema:
|
|
442
|
+
logger.error("Cannot rollback: backup schema not found")
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
temp_schema = f"_rollback_temp_{int(time.time())}"
|
|
447
|
+
with self.connection.cursor() as cur:
|
|
448
|
+
# Swap back: current -> temp, backup -> current
|
|
449
|
+
cur.execute(
|
|
450
|
+
f"""
|
|
451
|
+
ALTER SCHEMA {self.config.source_schema} RENAME TO {temp_schema};
|
|
452
|
+
ALTER SCHEMA {backup_schema} RENAME TO {self.config.source_schema};
|
|
453
|
+
DROP SCHEMA {temp_schema} CASCADE;
|
|
454
|
+
"""
|
|
455
|
+
)
|
|
456
|
+
self.connection.commit()
|
|
457
|
+
self._set_phase(MigrationPhase.ROLLED_BACK)
|
|
458
|
+
logger.info("Rolled back: restored original schema")
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error(f"Rollback failed: {e}")
|
|
461
|
+
self.state.rollback_available = False
|
|
462
|
+
|
|
463
|
+
def rollback(self) -> bool:
|
|
464
|
+
"""Manually trigger rollback.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
True if rollback succeeded
|
|
468
|
+
"""
|
|
469
|
+
if not self.state.rollback_available:
|
|
470
|
+
logger.warning("Rollback not available")
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
self._attempt_rollback()
|
|
474
|
+
return self.state.phase == MigrationPhase.ROLLED_BACK
|
|
475
|
+
|
|
476
|
+
def cleanup_backup(self) -> bool:
|
|
477
|
+
"""Remove the backup schema.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
True if cleanup succeeded
|
|
481
|
+
"""
|
|
482
|
+
backup_schema = self.state.metadata.get("backup_schema")
|
|
483
|
+
if not backup_schema:
|
|
484
|
+
logger.warning("No backup schema to clean up")
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
with self.connection.cursor() as cur:
|
|
489
|
+
cur.execute(f"DROP SCHEMA IF EXISTS {backup_schema} CASCADE")
|
|
490
|
+
self.connection.commit()
|
|
491
|
+
self.state.rollback_available = False
|
|
492
|
+
logger.info(f"Cleaned up backup schema: {backup_schema}")
|
|
493
|
+
return True
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.error(f"Cleanup failed: {e}")
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class TrafficController:
|
|
500
|
+
"""Control application traffic during migration.
|
|
501
|
+
|
|
502
|
+
Provides utilities for managing read-only mode and draining
|
|
503
|
+
connections during blue-green migrations.
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
>>> controller = TrafficController()
|
|
507
|
+
>>> controller.set_read_only(conn, True)
|
|
508
|
+
>>> # ... perform migration ...
|
|
509
|
+
>>> controller.set_read_only(conn, False)
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def __init__(
|
|
513
|
+
self,
|
|
514
|
+
redis_client: Any | None = None,
|
|
515
|
+
feature_flag_client: Any | None = None,
|
|
516
|
+
):
|
|
517
|
+
"""Initialize traffic controller.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
redis_client: Optional Redis client for state storage
|
|
521
|
+
feature_flag_client: Optional feature flag client
|
|
522
|
+
"""
|
|
523
|
+
self.redis = redis_client
|
|
524
|
+
self.feature_flags = feature_flag_client
|
|
525
|
+
self._read_only = False
|
|
526
|
+
|
|
527
|
+
def set_read_only(self, connection: Any, enabled: bool) -> None:
|
|
528
|
+
"""Enable/disable read-only mode.
|
|
529
|
+
|
|
530
|
+
When enabled, sets the database connection to read-only
|
|
531
|
+
and optionally updates Redis/feature flags.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
connection: Database connection
|
|
535
|
+
enabled: True to enable read-only mode
|
|
536
|
+
"""
|
|
537
|
+
self._read_only = enabled
|
|
538
|
+
|
|
539
|
+
# Set database connection to read-only transaction
|
|
540
|
+
if enabled:
|
|
541
|
+
with connection.cursor() as cur:
|
|
542
|
+
cur.execute("SET default_transaction_read_only = ON")
|
|
543
|
+
connection.commit()
|
|
544
|
+
logger.info("Database connection set to read-only")
|
|
545
|
+
else:
|
|
546
|
+
with connection.cursor() as cur:
|
|
547
|
+
cur.execute("SET default_transaction_read_only = OFF")
|
|
548
|
+
connection.commit()
|
|
549
|
+
logger.info("Database connection set to read-write")
|
|
550
|
+
|
|
551
|
+
# Update external state stores
|
|
552
|
+
if self.redis:
|
|
553
|
+
if enabled:
|
|
554
|
+
self.redis.set("confiture:read_only", "1")
|
|
555
|
+
else:
|
|
556
|
+
self.redis.delete("confiture:read_only")
|
|
557
|
+
logger.info(f"Redis read_only flag: {enabled}")
|
|
558
|
+
|
|
559
|
+
if self.feature_flags:
|
|
560
|
+
self.feature_flags.set("database_read_only", enabled)
|
|
561
|
+
logger.info(f"Feature flag database_read_only: {enabled}")
|
|
562
|
+
|
|
563
|
+
def is_read_only(self) -> bool:
|
|
564
|
+
"""Check if read-only mode is enabled.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
True if read-only mode is active
|
|
568
|
+
"""
|
|
569
|
+
if self.redis:
|
|
570
|
+
value = self.redis.get("confiture:read_only")
|
|
571
|
+
return value == "1" or value == b"1"
|
|
572
|
+
return self._read_only
|
|
573
|
+
|
|
574
|
+
def get_active_connections(self, connection: Any) -> list[dict[str, Any]]:
|
|
575
|
+
"""Get list of active database connections.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
connection: Database connection
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
List of connection info dictionaries
|
|
582
|
+
"""
|
|
583
|
+
with connection.cursor() as cur:
|
|
584
|
+
cur.execute(
|
|
585
|
+
"""
|
|
586
|
+
SELECT
|
|
587
|
+
pid,
|
|
588
|
+
usename,
|
|
589
|
+
application_name,
|
|
590
|
+
client_addr,
|
|
591
|
+
state,
|
|
592
|
+
query_start,
|
|
593
|
+
wait_event_type,
|
|
594
|
+
wait_event
|
|
595
|
+
FROM pg_stat_activity
|
|
596
|
+
WHERE datname = current_database()
|
|
597
|
+
AND pid != pg_backend_pid()
|
|
598
|
+
ORDER BY query_start DESC
|
|
599
|
+
"""
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
columns = [desc[0] for desc in cur.description]
|
|
603
|
+
return [dict(zip(columns, row, strict=True)) for row in cur.fetchall()]
|
|
604
|
+
|
|
605
|
+
def drain_connections(
|
|
606
|
+
self,
|
|
607
|
+
connection: Any,
|
|
608
|
+
timeout: int = 30,
|
|
609
|
+
check_interval: float = 1.0,
|
|
610
|
+
exclude_apps: list[str] | None = None,
|
|
611
|
+
) -> bool:
|
|
612
|
+
"""Wait for active connections to drain.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
connection: Database connection
|
|
616
|
+
timeout: Maximum seconds to wait
|
|
617
|
+
check_interval: Seconds between checks
|
|
618
|
+
exclude_apps: Application names to exclude from check
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
True if all connections drained within timeout
|
|
622
|
+
"""
|
|
623
|
+
exclude_apps = exclude_apps or ["confiture"]
|
|
624
|
+
start_time = time.time()
|
|
625
|
+
|
|
626
|
+
logger.info(f"Draining connections (timeout={timeout}s)...")
|
|
627
|
+
|
|
628
|
+
while time.time() - start_time < timeout:
|
|
629
|
+
active = self.get_active_connections(connection)
|
|
630
|
+
|
|
631
|
+
# Filter out excluded applications
|
|
632
|
+
active = [
|
|
633
|
+
c
|
|
634
|
+
for c in active
|
|
635
|
+
if c.get("application_name") not in exclude_apps and c.get("state") != "idle"
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
if not active:
|
|
639
|
+
logger.info("All connections drained")
|
|
640
|
+
return True
|
|
641
|
+
|
|
642
|
+
logger.info(f"Waiting for {len(active)} active connections...")
|
|
643
|
+
time.sleep(check_interval)
|
|
644
|
+
|
|
645
|
+
logger.warning("Timeout waiting for connections to drain")
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
def terminate_connections(
|
|
649
|
+
self,
|
|
650
|
+
connection: Any,
|
|
651
|
+
exclude_apps: list[str] | None = None,
|
|
652
|
+
) -> int:
|
|
653
|
+
"""Terminate active connections (use with caution).
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
connection: Database connection
|
|
657
|
+
exclude_apps: Application names to exclude
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Number of connections terminated
|
|
661
|
+
"""
|
|
662
|
+
exclude_apps = exclude_apps or ["confiture"]
|
|
663
|
+
terminated = 0
|
|
664
|
+
|
|
665
|
+
active = self.get_active_connections(connection)
|
|
666
|
+
|
|
667
|
+
with connection.cursor() as cur:
|
|
668
|
+
for conn_info in active:
|
|
669
|
+
app_name = conn_info.get("application_name", "")
|
|
670
|
+
if app_name in exclude_apps:
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
pid = conn_info["pid"]
|
|
674
|
+
try:
|
|
675
|
+
cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
|
|
676
|
+
terminated += 1
|
|
677
|
+
logger.info(f"Terminated connection: pid={pid}, app={app_name}")
|
|
678
|
+
except Exception as e:
|
|
679
|
+
logger.warning(f"Failed to terminate pid={pid}: {e}")
|
|
680
|
+
|
|
681
|
+
connection.commit()
|
|
682
|
+
logger.info(f"Terminated {terminated} connections")
|
|
683
|
+
return terminated
|