dploydb 0.1.0__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.
- dploydb/__init__.py +1 -0
- dploydb/__main__.py +6 -0
- dploydb/backup.py +455 -0
- dploydb/candidate.py +868 -0
- dploydb/cli.py +1111 -0
- dploydb/config.py +784 -0
- dploydb/cutover.py +326 -0
- dploydb/deploy.py +1163 -0
- dploydb/deployment_dependencies.py +366 -0
- dploydb/deployment_evidence.py +136 -0
- dploydb/diagnostics.py +1020 -0
- dploydb/errors.py +140 -0
- dploydb/health.py +625 -0
- dploydb/locking.py +609 -0
- dploydb/manual_restore.py +825 -0
- dploydb/migration.py +575 -0
- dploydb/models.py +927 -0
- dploydb/recovery.py +1210 -0
- dploydb/redaction.py +229 -0
- dploydb/releases.py +611 -0
- dploydb/restore.py +461 -0
- dploydb/retention.py +165 -0
- dploydb/runners/__init__.py +33 -0
- dploydb/runners/base.py +389 -0
- dploydb/runners/docker_compose.py +590 -0
- dploydb/runners/docker_compose_production.py +966 -0
- dploydb/sqlite_checks.py +136 -0
- dploydb/state.py +604 -0
- dploydb/storage/__init__.py +13 -0
- dploydb/storage/base.py +52 -0
- dploydb/storage/local.py +301 -0
- dploydb/storage/s3.py +737 -0
- dploydb/subprocesses.py +641 -0
- dploydb/traffic.py +165 -0
- dploydb-0.1.0.dist-info/METADATA +583 -0
- dploydb-0.1.0.dist-info/RECORD +40 -0
- dploydb-0.1.0.dist-info/WHEEL +4 -0
- dploydb-0.1.0.dist-info/entry_points.txt +2 -0
- dploydb-0.1.0.dist-info/licenses/LICENSE +201 -0
- dploydb-0.1.0.dist-info/licenses/NOTICE +4 -0
dploydb/migration.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Migration rehearsal against a disposable copy of a verified SQLite snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
from dploydb.backup import calculate_sha256, create_verified_backup
|
|
17
|
+
from dploydb.config import LoadedConfiguration, configuration_fingerprint
|
|
18
|
+
from dploydb.errors import (
|
|
19
|
+
DployDBError,
|
|
20
|
+
ExternalCommandError,
|
|
21
|
+
OperationFailedError,
|
|
22
|
+
RecoveryRequiredError,
|
|
23
|
+
SafetyCheckError,
|
|
24
|
+
)
|
|
25
|
+
from dploydb.locking import DeploymentLock
|
|
26
|
+
from dploydb.models import (
|
|
27
|
+
BackupArtifact,
|
|
28
|
+
BackupPurpose,
|
|
29
|
+
CapturedCommandOutput,
|
|
30
|
+
FailureRecord,
|
|
31
|
+
LockOwnerState,
|
|
32
|
+
MigrationCommandEvidence,
|
|
33
|
+
MigrationRehearsalResult,
|
|
34
|
+
OperationStatus,
|
|
35
|
+
SafetyFacts,
|
|
36
|
+
utc_now,
|
|
37
|
+
)
|
|
38
|
+
from dploydb.sqlite_checks import verify_sqlite_database
|
|
39
|
+
from dploydb.state import DIRECTORY_MODE, StateStore
|
|
40
|
+
from dploydb.storage.local import LocalBackupStorage
|
|
41
|
+
from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
|
|
42
|
+
|
|
43
|
+
WORKSPACE_DIRECTORY_NAME: Final = "rehearsals"
|
|
44
|
+
WORKSPACE_DATABASE_NAME: Final = "rehearsal.db"
|
|
45
|
+
WORKSPACE_FILE_MODE: Final = 0o600
|
|
46
|
+
WORKSPACE_COPY_TIMEOUT_SECONDS: Final = 120.0
|
|
47
|
+
WORKSPACE_COPY_CHUNK_BYTES: Final = 1024 * 1024
|
|
48
|
+
MIGRATION_MAX_OUTPUT_BYTES: Final = 256 * 1024
|
|
49
|
+
_OPERATION_ID = re.compile(r"^op_[0-9a-f]{32}$")
|
|
50
|
+
|
|
51
|
+
CommandEvidenceSink = Callable[[MigrationCommandEvidence], None]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MigrationWorkspaceCleanupError(OperationFailedError):
|
|
55
|
+
"""A private rehearsal workspace could not be proven clean."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ActiveMigrationRehearsal:
|
|
60
|
+
"""A verified migrated database that exists only inside its context."""
|
|
61
|
+
|
|
62
|
+
database_path: Path
|
|
63
|
+
result: MigrationRehearsalResult
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def rehearse_configured_migration(
|
|
67
|
+
loaded: LoadedConfiguration,
|
|
68
|
+
*,
|
|
69
|
+
config_path: Path,
|
|
70
|
+
command_environment: Mapping[str, str] | None = None,
|
|
71
|
+
command_runner: SubprocessRunner | None = None,
|
|
72
|
+
cancellation_event: threading.Event | None = None,
|
|
73
|
+
) -> MigrationRehearsalResult:
|
|
74
|
+
"""Run one exclusive, durable rehearsal without modifying production."""
|
|
75
|
+
config = loaded.config
|
|
76
|
+
secrets = loaded.secrets
|
|
77
|
+
store = StateStore(config.state_directory, secrets=secrets)
|
|
78
|
+
lock = DeploymentLock(config.state_directory, secrets=secrets)
|
|
79
|
+
|
|
80
|
+
with lock:
|
|
81
|
+
require_clean_operation_state(lock, store)
|
|
82
|
+
operation = store.create_operation(
|
|
83
|
+
operation_type="rehearsal",
|
|
84
|
+
project=config.project,
|
|
85
|
+
configuration_fingerprint=configuration_fingerprint(config, secrets=secrets),
|
|
86
|
+
evidence={"database_path": str(config.database.path)},
|
|
87
|
+
)
|
|
88
|
+
lock.record_owner(operation_id=operation.operation_id, operation_type="rehearsal")
|
|
89
|
+
operation_log = store.operation_paths(operation.operation_id).events
|
|
90
|
+
try:
|
|
91
|
+
preflight = verify_sqlite_database(config.database.path)
|
|
92
|
+
store.transition(
|
|
93
|
+
operation.operation_id,
|
|
94
|
+
status=OperationStatus.IN_PROGRESS,
|
|
95
|
+
stage="preflight_passed",
|
|
96
|
+
message="Production SQLite preflight passed without mutation.",
|
|
97
|
+
evidence=preflight.model_dump(mode="json"),
|
|
98
|
+
)
|
|
99
|
+
snapshot = create_verified_backup(
|
|
100
|
+
config.database.path,
|
|
101
|
+
project=secrets.redact_text(config.project),
|
|
102
|
+
purpose=BackupPurpose.REHEARSAL,
|
|
103
|
+
storage=LocalBackupStorage(config.backup.local_directory),
|
|
104
|
+
operation_id=operation.operation_id,
|
|
105
|
+
metadata_source_path=_safe_metadata_path(config.database.path, loaded),
|
|
106
|
+
)
|
|
107
|
+
store.transition(
|
|
108
|
+
operation.operation_id,
|
|
109
|
+
status=OperationStatus.IN_PROGRESS,
|
|
110
|
+
stage="snapshot_verified",
|
|
111
|
+
message="Verified rehearsal snapshot completed before migration execution.",
|
|
112
|
+
evidence={
|
|
113
|
+
"backup_id": snapshot.metadata.backup_id,
|
|
114
|
+
"backup_path": str(snapshot.database_path),
|
|
115
|
+
"sha256": snapshot.metadata.sha256,
|
|
116
|
+
"size_bytes": snapshot.metadata.size_bytes,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def record_command(evidence: MigrationCommandEvidence) -> None:
|
|
121
|
+
store.append_event(
|
|
122
|
+
operation.operation_id,
|
|
123
|
+
message="Migration command reached a terminal outcome.",
|
|
124
|
+
evidence={"migration_command": evidence.model_dump(mode="json")},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
runner = command_runner or SubprocessRunner(
|
|
128
|
+
secrets=secrets,
|
|
129
|
+
max_output_bytes=MIGRATION_MAX_OUTPUT_BYTES,
|
|
130
|
+
)
|
|
131
|
+
environment = dict(os.environ if command_environment is None else command_environment)
|
|
132
|
+
working_directory = config_path.resolve().parent
|
|
133
|
+
with migration_rehearsal(
|
|
134
|
+
snapshot,
|
|
135
|
+
operation_id=operation.operation_id,
|
|
136
|
+
command=config.migration.command,
|
|
137
|
+
database_environment_name=config.database.path_env,
|
|
138
|
+
timeout_seconds=config.migration.timeout_seconds,
|
|
139
|
+
workspace_root=config.state_directory / WORKSPACE_DIRECTORY_NAME,
|
|
140
|
+
working_directory=working_directory,
|
|
141
|
+
environment=environment,
|
|
142
|
+
runner=runner,
|
|
143
|
+
command_evidence_sink=record_command,
|
|
144
|
+
cancellation_event=cancellation_event,
|
|
145
|
+
log_path=operation_log,
|
|
146
|
+
) as active:
|
|
147
|
+
result = active.result
|
|
148
|
+
|
|
149
|
+
store.transition(
|
|
150
|
+
operation.operation_id,
|
|
151
|
+
status=OperationStatus.SUCCEEDED,
|
|
152
|
+
stage="rehearsal_passed",
|
|
153
|
+
message="Migration rehearsal and disposable-workspace cleanup passed.",
|
|
154
|
+
evidence={
|
|
155
|
+
"backup_id": result.backup_id,
|
|
156
|
+
"backup_sha256": result.backup_sha256,
|
|
157
|
+
"database_size_bytes": result.database_size_bytes,
|
|
158
|
+
"database_sha256": result.database_sha256,
|
|
159
|
+
"migration_outcome": result.command.outcome,
|
|
160
|
+
"migration_duration_seconds": result.command.duration_seconds,
|
|
161
|
+
"sqlite": result.sqlite.model_dump(mode="json"),
|
|
162
|
+
},
|
|
163
|
+
safety=SafetyFacts(
|
|
164
|
+
production_changed=False,
|
|
165
|
+
previous_application_running=None,
|
|
166
|
+
recovery_required=False,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
return result
|
|
170
|
+
except DployDBError as error:
|
|
171
|
+
_finish_failure(store, operation.operation_id, error)
|
|
172
|
+
raise
|
|
173
|
+
except Exception as raw_error:
|
|
174
|
+
failure = OperationFailedError(
|
|
175
|
+
f"migration rehearsal failed safely: {secrets.redact_text(str(raw_error))}",
|
|
176
|
+
production_changed=False,
|
|
177
|
+
previous_application_running=None,
|
|
178
|
+
log_path=operation_log,
|
|
179
|
+
next_safe_action=(
|
|
180
|
+
"Production was not changed; inspect the rehearsal log, correct the "
|
|
181
|
+
"failure, and retry."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
_finish_failure(store, operation.operation_id, failure)
|
|
185
|
+
raise failure from None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@contextmanager
|
|
189
|
+
def migration_rehearsal(
|
|
190
|
+
snapshot: BackupArtifact,
|
|
191
|
+
*,
|
|
192
|
+
operation_id: str,
|
|
193
|
+
command: tuple[str, ...],
|
|
194
|
+
database_environment_name: str,
|
|
195
|
+
timeout_seconds: float,
|
|
196
|
+
workspace_root: Path,
|
|
197
|
+
working_directory: Path,
|
|
198
|
+
environment: Mapping[str, str],
|
|
199
|
+
runner: SubprocessRunner,
|
|
200
|
+
command_evidence_sink: CommandEvidenceSink,
|
|
201
|
+
cancellation_event: threading.Event | None,
|
|
202
|
+
log_path: Path,
|
|
203
|
+
) -> Iterator[ActiveMigrationRehearsal]:
|
|
204
|
+
"""Yield a checked migrated copy, then remove its workspace idempotently."""
|
|
205
|
+
workspace: Path | None = None
|
|
206
|
+
primary_error: BaseException | None = None
|
|
207
|
+
try:
|
|
208
|
+
_verify_snapshot(snapshot)
|
|
209
|
+
workspace = _create_workspace(workspace_root, operation_id)
|
|
210
|
+
database_path = _materialize_snapshot(snapshot, workspace)
|
|
211
|
+
child_environment = dict(environment)
|
|
212
|
+
child_environment[database_environment_name] = str(database_path)
|
|
213
|
+
command_result = runner.run(
|
|
214
|
+
command,
|
|
215
|
+
timeout_seconds=timeout_seconds,
|
|
216
|
+
environment=child_environment,
|
|
217
|
+
working_directory=working_directory,
|
|
218
|
+
cancellation_event=cancellation_event,
|
|
219
|
+
)
|
|
220
|
+
command_evidence = migration_command_evidence(command_result)
|
|
221
|
+
command_evidence_sink(command_evidence)
|
|
222
|
+
_require_usable_command_result(command_result, log_path)
|
|
223
|
+
try:
|
|
224
|
+
sqlite_evidence = verify_sqlite_database(database_path)
|
|
225
|
+
except DployDBError as error:
|
|
226
|
+
raise OperationFailedError(
|
|
227
|
+
"post-migration SQLite verification failed: " + error.payload.what_failed,
|
|
228
|
+
production_changed=False,
|
|
229
|
+
previous_application_running=None,
|
|
230
|
+
log_path=log_path,
|
|
231
|
+
next_safe_action=(
|
|
232
|
+
"Production was not changed; correct the migration so the rehearsed "
|
|
233
|
+
"database passes every SQLite check."
|
|
234
|
+
),
|
|
235
|
+
) from None
|
|
236
|
+
size_bytes, sha256 = calculate_sha256(database_path)
|
|
237
|
+
result = MigrationRehearsalResult(
|
|
238
|
+
operation_id=operation_id,
|
|
239
|
+
backup_id=snapshot.metadata.backup_id,
|
|
240
|
+
backup_sha256=snapshot.metadata.sha256,
|
|
241
|
+
database_size_bytes=size_bytes,
|
|
242
|
+
database_sha256=sha256,
|
|
243
|
+
command=command_evidence,
|
|
244
|
+
sqlite=sqlite_evidence,
|
|
245
|
+
completed_at=utc_now(),
|
|
246
|
+
)
|
|
247
|
+
yield ActiveMigrationRehearsal(database_path=database_path, result=result)
|
|
248
|
+
except BaseException as error:
|
|
249
|
+
primary_error = error
|
|
250
|
+
raise
|
|
251
|
+
finally:
|
|
252
|
+
cleanup_error = _cleanup_workspace(workspace)
|
|
253
|
+
if cleanup_error is not None:
|
|
254
|
+
if primary_error is None:
|
|
255
|
+
raise _workspace_cleanup_error(cleanup_error, log_path) from None
|
|
256
|
+
if isinstance(primary_error, RecoveryRequiredError):
|
|
257
|
+
raise RecoveryRequiredError(
|
|
258
|
+
primary_error.payload.what_failed
|
|
259
|
+
+ f"; rehearsal workspace cleanup also failed: {cleanup_error}",
|
|
260
|
+
production_changed=False,
|
|
261
|
+
previous_application_running=None,
|
|
262
|
+
log_path=log_path,
|
|
263
|
+
next_safe_action=(
|
|
264
|
+
"Preserve the operation evidence, confirm the migration process group "
|
|
265
|
+
"is gone, and clean the private rehearsal workspace before recovery."
|
|
266
|
+
),
|
|
267
|
+
) from None
|
|
268
|
+
if isinstance(primary_error, Exception):
|
|
269
|
+
detail = (
|
|
270
|
+
primary_error.payload.what_failed
|
|
271
|
+
if isinstance(primary_error, DployDBError)
|
|
272
|
+
else str(primary_error)
|
|
273
|
+
)
|
|
274
|
+
raise MigrationWorkspaceCleanupError(
|
|
275
|
+
detail + f"; rehearsal workspace cleanup also failed: {cleanup_error}",
|
|
276
|
+
production_changed=False,
|
|
277
|
+
previous_application_running=None,
|
|
278
|
+
log_path=log_path,
|
|
279
|
+
next_safe_action=(
|
|
280
|
+
"Production was not changed; inspect the log and remove only the "
|
|
281
|
+
"recorded private rehearsal workspace before retrying."
|
|
282
|
+
),
|
|
283
|
+
) from None
|
|
284
|
+
primary_error.add_note(f"Rehearsal workspace cleanup also failed: {cleanup_error}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def migration_command_evidence(result: CommandResult) -> MigrationCommandEvidence:
|
|
288
|
+
"""Convert one bounded subprocess result into durable migration evidence."""
|
|
289
|
+
return MigrationCommandEvidence(
|
|
290
|
+
command=result.command,
|
|
291
|
+
working_directory=result.working_directory,
|
|
292
|
+
environment_keys=result.environment_keys,
|
|
293
|
+
outcome=result.outcome.value,
|
|
294
|
+
exit_code=result.exit_code,
|
|
295
|
+
stdout=CapturedCommandOutput(
|
|
296
|
+
text=result.stdout.text,
|
|
297
|
+
total_bytes=result.stdout.total_bytes,
|
|
298
|
+
retained_bytes=result.stdout.retained_bytes,
|
|
299
|
+
truncated=result.stdout.truncated,
|
|
300
|
+
),
|
|
301
|
+
stderr=CapturedCommandOutput(
|
|
302
|
+
text=result.stderr.text,
|
|
303
|
+
total_bytes=result.stderr.total_bytes,
|
|
304
|
+
retained_bytes=result.stderr.retained_bytes,
|
|
305
|
+
truncated=result.stderr.truncated,
|
|
306
|
+
),
|
|
307
|
+
duration_seconds=result.duration_seconds,
|
|
308
|
+
termination_reason=(
|
|
309
|
+
None if result.termination_reason is None else result.termination_reason.value
|
|
310
|
+
),
|
|
311
|
+
termination_attempted=result.termination_attempted,
|
|
312
|
+
forced_kill=result.forced_kill,
|
|
313
|
+
start_error=result.start_error,
|
|
314
|
+
cleanup_error=result.cleanup_error,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _require_usable_command_result(result: CommandResult, log_path: Path) -> None:
|
|
319
|
+
if result.stdout.truncated or result.stderr.truncated:
|
|
320
|
+
raise OperationFailedError(
|
|
321
|
+
"migration output exceeded the complete-capture safety bound",
|
|
322
|
+
production_changed=False,
|
|
323
|
+
previous_application_running=None,
|
|
324
|
+
log_path=log_path,
|
|
325
|
+
next_safe_action=(
|
|
326
|
+
"Production was not changed; reduce migration output and retry so complete "
|
|
327
|
+
"stdout and stderr can be preserved."
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
if result.outcome is CommandOutcome.SUCCEEDED:
|
|
331
|
+
return
|
|
332
|
+
if result.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
333
|
+
raise RecoveryRequiredError(
|
|
334
|
+
"migration process cleanup could not be proven: "
|
|
335
|
+
+ (result.cleanup_error or "unknown cleanup failure"),
|
|
336
|
+
production_changed=False,
|
|
337
|
+
previous_application_running=None,
|
|
338
|
+
log_path=log_path,
|
|
339
|
+
next_safe_action=(
|
|
340
|
+
"Production was not changed. Preserve the evidence and confirm the complete "
|
|
341
|
+
"migration process group is stopped before running recovery."
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
if result.outcome is CommandOutcome.NONZERO_EXIT:
|
|
345
|
+
detail = f"migration command exited with status {result.exit_code}"
|
|
346
|
+
elif result.outcome is CommandOutcome.TIMED_OUT:
|
|
347
|
+
detail = "migration command timed out and its process group was terminated"
|
|
348
|
+
elif result.outcome is CommandOutcome.CANCELLED:
|
|
349
|
+
detail = "migration command was cancelled and its process group was terminated"
|
|
350
|
+
else:
|
|
351
|
+
detail = "migration command could not start: " + (result.start_error or "unknown error")
|
|
352
|
+
raise ExternalCommandError(
|
|
353
|
+
detail,
|
|
354
|
+
production_changed=False,
|
|
355
|
+
previous_application_running=None,
|
|
356
|
+
log_path=log_path,
|
|
357
|
+
next_safe_action=(
|
|
358
|
+
"Production was not changed; inspect the captured migration output, correct the "
|
|
359
|
+
"command, and retry."
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _verify_snapshot(snapshot: BackupArtifact) -> None:
|
|
365
|
+
before = calculate_sha256(snapshot.database_path)
|
|
366
|
+
if before != (snapshot.metadata.size_bytes, snapshot.metadata.sha256):
|
|
367
|
+
raise SafetyCheckError(
|
|
368
|
+
"rehearsal snapshot no longer matches its verified metadata",
|
|
369
|
+
production_changed=False,
|
|
370
|
+
previous_application_running=None,
|
|
371
|
+
log_path=snapshot.database_path,
|
|
372
|
+
next_safe_action="Create a new verified rehearsal snapshot before retrying.",
|
|
373
|
+
)
|
|
374
|
+
verify_sqlite_database(snapshot.database_path)
|
|
375
|
+
after = calculate_sha256(snapshot.database_path)
|
|
376
|
+
if after != before:
|
|
377
|
+
raise SafetyCheckError(
|
|
378
|
+
"rehearsal snapshot changed while it was reverified",
|
|
379
|
+
production_changed=False,
|
|
380
|
+
previous_application_running=None,
|
|
381
|
+
log_path=snapshot.database_path,
|
|
382
|
+
next_safe_action="Create a new verified rehearsal snapshot before retrying.",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _create_workspace(root: Path, operation_id: str) -> Path:
|
|
387
|
+
if not root.is_absolute() or root == Path(root.anchor):
|
|
388
|
+
raise ValueError("rehearsal workspace root must be an absolute non-root path")
|
|
389
|
+
if _OPERATION_ID.fullmatch(operation_id) is None:
|
|
390
|
+
raise ValueError("operation ID is invalid or unsafe for a rehearsal workspace")
|
|
391
|
+
if root.is_symlink():
|
|
392
|
+
raise OSError("rehearsal workspace root must not be a symlink")
|
|
393
|
+
root.mkdir(mode=DIRECTORY_MODE, parents=False, exist_ok=True)
|
|
394
|
+
details = root.stat()
|
|
395
|
+
if not stat.S_ISDIR(details.st_mode) or stat.S_IMODE(details.st_mode) != DIRECTORY_MODE:
|
|
396
|
+
raise OSError("rehearsal workspace root must be a mode-0700 directory")
|
|
397
|
+
workspace = root / operation_id
|
|
398
|
+
workspace_created = False
|
|
399
|
+
try:
|
|
400
|
+
workspace.mkdir(mode=DIRECTORY_MODE, exist_ok=False)
|
|
401
|
+
workspace_created = True
|
|
402
|
+
_fsync_directory(root)
|
|
403
|
+
except OSError:
|
|
404
|
+
if workspace_created:
|
|
405
|
+
try:
|
|
406
|
+
workspace.rmdir()
|
|
407
|
+
except OSError:
|
|
408
|
+
pass
|
|
409
|
+
raise
|
|
410
|
+
return workspace
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _materialize_snapshot(snapshot: BackupArtifact, workspace: Path) -> Path:
|
|
414
|
+
target = workspace / WORKSPACE_DATABASE_NAME
|
|
415
|
+
source_descriptor = -1
|
|
416
|
+
target_descriptor = -1
|
|
417
|
+
deadline = time.monotonic() + WORKSPACE_COPY_TIMEOUT_SECONDS
|
|
418
|
+
source_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
419
|
+
target_flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
420
|
+
target_flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
421
|
+
try:
|
|
422
|
+
source_descriptor = os.open(snapshot.database_path, source_flags)
|
|
423
|
+
source_details = os.fstat(source_descriptor)
|
|
424
|
+
if not stat.S_ISREG(source_details.st_mode):
|
|
425
|
+
raise OSError("verified snapshot is not a regular file")
|
|
426
|
+
target_descriptor = os.open(target, target_flags, WORKSPACE_FILE_MODE)
|
|
427
|
+
os.fchmod(target_descriptor, WORKSPACE_FILE_MODE)
|
|
428
|
+
while True:
|
|
429
|
+
if time.monotonic() >= deadline:
|
|
430
|
+
raise TimeoutError(
|
|
431
|
+
f"rehearsal copy timed out after {WORKSPACE_COPY_TIMEOUT_SECONDS:g} seconds"
|
|
432
|
+
)
|
|
433
|
+
chunk = os.read(source_descriptor, WORKSPACE_COPY_CHUNK_BYTES)
|
|
434
|
+
if not chunk:
|
|
435
|
+
break
|
|
436
|
+
_write_all(target_descriptor, chunk)
|
|
437
|
+
os.fsync(target_descriptor)
|
|
438
|
+
except Exception:
|
|
439
|
+
try:
|
|
440
|
+
target.unlink(missing_ok=True)
|
|
441
|
+
except OSError:
|
|
442
|
+
pass
|
|
443
|
+
raise
|
|
444
|
+
finally:
|
|
445
|
+
if target_descriptor >= 0:
|
|
446
|
+
os.close(target_descriptor)
|
|
447
|
+
if source_descriptor >= 0:
|
|
448
|
+
os.close(source_descriptor)
|
|
449
|
+
_fsync_directory(workspace)
|
|
450
|
+
size_bytes, sha256 = calculate_sha256(target)
|
|
451
|
+
if size_bytes != snapshot.metadata.size_bytes or sha256 != snapshot.metadata.sha256:
|
|
452
|
+
raise SafetyCheckError(
|
|
453
|
+
"disposable rehearsal database does not match the verified snapshot",
|
|
454
|
+
production_changed=False,
|
|
455
|
+
previous_application_running=None,
|
|
456
|
+
log_path=target,
|
|
457
|
+
next_safe_action="Discard the rehearsal workspace and create a new verified snapshot.",
|
|
458
|
+
)
|
|
459
|
+
verify_sqlite_database(target)
|
|
460
|
+
return target
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _cleanup_workspace(workspace: Path | None) -> str | None:
|
|
464
|
+
if workspace is None:
|
|
465
|
+
return None
|
|
466
|
+
try:
|
|
467
|
+
if not workspace.exists():
|
|
468
|
+
return None
|
|
469
|
+
if workspace.is_symlink():
|
|
470
|
+
raise OSError("refusing a symlinked rehearsal workspace")
|
|
471
|
+
for name in (
|
|
472
|
+
f"{WORKSPACE_DATABASE_NAME}-wal",
|
|
473
|
+
f"{WORKSPACE_DATABASE_NAME}-shm",
|
|
474
|
+
f"{WORKSPACE_DATABASE_NAME}-journal",
|
|
475
|
+
WORKSPACE_DATABASE_NAME,
|
|
476
|
+
):
|
|
477
|
+
path = workspace / name
|
|
478
|
+
if not path.exists() and not path.is_symlink():
|
|
479
|
+
continue
|
|
480
|
+
details = path.lstat()
|
|
481
|
+
if path.is_symlink() or not stat.S_ISREG(details.st_mode):
|
|
482
|
+
raise OSError(f"refusing unsafe rehearsal artifact: {path.name}")
|
|
483
|
+
path.unlink()
|
|
484
|
+
unexpected = list(workspace.iterdir())
|
|
485
|
+
if unexpected:
|
|
486
|
+
raise OSError(f"rehearsal workspace contains unexpected entry: {unexpected[0].name}")
|
|
487
|
+
workspace.rmdir()
|
|
488
|
+
_fsync_directory(workspace.parent)
|
|
489
|
+
except OSError as error:
|
|
490
|
+
return str(error)
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _workspace_cleanup_error(detail: str, log_path: Path) -> MigrationWorkspaceCleanupError:
|
|
495
|
+
return MigrationWorkspaceCleanupError(
|
|
496
|
+
f"rehearsal workspace cleanup failed: {detail}",
|
|
497
|
+
production_changed=False,
|
|
498
|
+
previous_application_running=None,
|
|
499
|
+
log_path=log_path,
|
|
500
|
+
next_safe_action=(
|
|
501
|
+
"Production was not changed; inspect the log and remove only the recorded private "
|
|
502
|
+
"rehearsal workspace before retrying."
|
|
503
|
+
),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def require_clean_operation_state(lock: DeploymentLock, store: StateStore) -> None:
|
|
508
|
+
"""Refuse new work while durable evidence requires diagnosis or recovery."""
|
|
509
|
+
if lock.previous_owner is not None and lock.previous_owner.state is LockOwnerState.ACTIVE:
|
|
510
|
+
raise RecoveryRequiredError(
|
|
511
|
+
"A prior operation left active lock-owner evidence.",
|
|
512
|
+
production_changed=False,
|
|
513
|
+
previous_application_running=None,
|
|
514
|
+
log_path=lock.owner_path,
|
|
515
|
+
next_safe_action="Run dploydb status and preserve the interrupted evidence.",
|
|
516
|
+
)
|
|
517
|
+
latest = store.latest_operation()
|
|
518
|
+
if latest is not None and latest.status in {
|
|
519
|
+
OperationStatus.IN_PROGRESS,
|
|
520
|
+
OperationStatus.RECOVERY_REQUIRED,
|
|
521
|
+
}:
|
|
522
|
+
raise RecoveryRequiredError(
|
|
523
|
+
"An unfinished or recovery-required operation blocks migration rehearsal.",
|
|
524
|
+
production_changed=latest.safety.production_changed,
|
|
525
|
+
previous_application_running=latest.safety.previous_application_running,
|
|
526
|
+
log_path=store.operation_paths(latest.operation_id).events,
|
|
527
|
+
next_safe_action="Run dploydb status and resolve the recorded operation first.",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _finish_failure(store: StateStore, operation_id: str, error: DployDBError) -> None:
|
|
532
|
+
status = (
|
|
533
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
534
|
+
if error.payload.recovery_required
|
|
535
|
+
else OperationStatus.FAILED_SAFE
|
|
536
|
+
)
|
|
537
|
+
store.transition(
|
|
538
|
+
operation_id,
|
|
539
|
+
status=status,
|
|
540
|
+
stage=status.value,
|
|
541
|
+
message="Migration rehearsal did not complete.",
|
|
542
|
+
safety=SafetyFacts(
|
|
543
|
+
production_changed=error.payload.production_changed,
|
|
544
|
+
previous_application_running=error.payload.previous_application_running,
|
|
545
|
+
recovery_required=error.payload.recovery_required,
|
|
546
|
+
),
|
|
547
|
+
failure=FailureRecord(
|
|
548
|
+
error_code=error.payload.error_code,
|
|
549
|
+
what_failed=error.payload.what_failed,
|
|
550
|
+
log_path=error.payload.log_path,
|
|
551
|
+
next_safe_action=error.payload.next_safe_action,
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
|
|
557
|
+
safe = Path(loaded.secrets.redact_text(str(path)))
|
|
558
|
+
return safe if safe.is_absolute() else Path("/[REDACTED]")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
562
|
+
written = 0
|
|
563
|
+
while written < len(payload):
|
|
564
|
+
count = os.write(descriptor, payload[written:])
|
|
565
|
+
if count <= 0:
|
|
566
|
+
raise OSError("rehearsal copy made no progress")
|
|
567
|
+
written += count
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _fsync_directory(path: Path) -> None:
|
|
571
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
572
|
+
try:
|
|
573
|
+
os.fsync(descriptor)
|
|
574
|
+
finally:
|
|
575
|
+
os.close(descriptor)
|