django-sqlite-ops 0.1.0a1__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.
@@ -0,0 +1,709 @@
1
+ """``sqlite_doctor --restore-test``: 복제본을 실제로 복원해 본다 (DESIGN §6-2, #52·#54).
2
+
3
+ 헬스(§7)는 "지금 복제가 따라오는가"만 본다. 이 모드는 "복제본으로 실제 복구할 수 있는가"를 본다:
4
+ ``--restore-dir`` 아래 새 작업 디렉터리에 복원하고, 복원본의 무결성과 TXID 를 확인한다.
5
+
6
+ **Django DB 연결을 열지 않는다.** 대상 경로는 ``get_connection_params()`` 로만 정하고(연결하지
7
+ 않는다), 복원본은 표준 ``sqlite3`` 의 ``mode=ro&immutable=1`` URI 로 연다. 그래서 일반 doctor 와
8
+ 달리 ``init_command`` 부작용이 없다.
9
+
10
+ 운영 자원(운영 DB·사이드카·메타·boot 소유 경로·DB 부모 디렉터리)과 겹치는 ``--restore-dir`` 은
11
+ 아무것도 만들기 전에 거부한다. ``litestream restore -o OUT`` 은 DB_PATH 쪽을 읽지도 쓰지도 않는다
12
+ (0.5.17, 전후 해시·mtime 과 strace 로 재현함, #54). 출력 옆에 ``OUT.tmp`` 를 만든 뒤 rename 한다.
13
+
14
+ Litestream 호출은 boot 헬퍼(``boot/litestream.py``)를, 경로 계약은 boot CLI 의 ``real_path()``
15
+ (D-15)와 §4-2 비교 함수를 그대로 쓴다.
16
+ """
17
+
18
+ import os
19
+ import shutil
20
+ import sqlite3
21
+ import stat
22
+ import tempfile
23
+ import threading
24
+ import time
25
+ from collections.abc import Iterable
26
+ from pathlib import Path
27
+ from urllib.parse import quote
28
+
29
+ from django.conf import settings
30
+ from django.db import connections
31
+
32
+ from .boot import litestream
33
+ from .boot.cli import _contains, _fold, _overlaps, lock_path, real_path, sidecars, state_path
34
+ from .boot.decide import RemoteEmpty, RemoteError
35
+ from .doctor import (
36
+ Item,
37
+ _binary,
38
+ _caller_path,
39
+ _file_size,
40
+ _one_line,
41
+ _Target,
42
+ _target,
43
+ select_aliases,
44
+ )
45
+ from .health import load_config, redact
46
+
47
+ __all__ = [
48
+ "DEFAULT_INTEGRITY_TIMEOUT",
49
+ "INTEGRITY_MODES",
50
+ "SECTION",
51
+ "check_integrity",
52
+ "check_restore_dir",
53
+ "protected_paths",
54
+ "restore_test",
55
+ ]
56
+
57
+ SECTION = "restore"
58
+ INTEGRITY_MODES = ("full", "quick")
59
+ # 무결성 검사의 별도 예산(복원 시간과 합치지 않는다). 700MB DB 의 full 검사가 2.6초였다(#54 실측).
60
+ DEFAULT_INTEGRITY_TIMEOUT = 1800.0
61
+ # 복원 직전 원격 조회(ltx -level all)의 시간 제한. 테스트가 줄일 수 있도록 모듈 값으로 둔다.
62
+ _LTX_TIMEOUT = litestream.DEFAULT_LTX_TIMEOUT
63
+ # 필요한 여유 공간 = (로컬 DB + -wal) × 1.2 (분수로 둔다). 로컬 크기로 복원본 크기를 추정한다.
64
+ _SPACE_FACTOR = (6, 5)
65
+ # 무결성 실패 때 message 에 담는 오류 줄 수.
66
+ _MAX_PROBLEMS = 5
67
+ # 시한이 지난 뒤 검사가 끝날 때까지 interrupt() 를 다시 부르는 간격(초, #63).
68
+ _INTERRUPT_RETRY = 0.01
69
+ _PRAGMAS = {"full": "integrity_check", "quick": "quick_check"}
70
+ NO_TARGET = "no write file database to restore-test"
71
+
72
+
73
+ def _item(alias: str | None, key: str, level: str, value=None, expected=None, message=""):
74
+ return Item(SECTION, alias, key, level, value, expected, message)
75
+
76
+
77
+ def _hex(txid: int | None) -> str | None:
78
+ return None if txid is None else f"{txid:016x}"
79
+
80
+
81
+ # --- 운영 경로 보호 (D-25) ----------------------------------------------------------------
82
+
83
+
84
+ def protected_paths(db: Path, meta: Path) -> list[tuple[str, Path]]:
85
+ """``--restore-dir`` 이 겹치면 안 되는 운영 경로 ``(역할, 경로)``.
86
+
87
+ DB·사이드카·boot 잠금·상태 파일·메타·DB 부모 디렉터리. 링크일 수 있는 것(메타는 D-15 조회
88
+ 규약상 링크를 허용한다)은 실체(``realpath``)도 넣는다. boot 의 다른 예약 이름(``.restore-*``·
89
+ ``.stale-*``·상태 임시 파일)은 모두 DB 부모 디렉터리 안이라 부모 규칙이 함께 막는다.
90
+ """
91
+ named = [
92
+ ("database", db),
93
+ *(("sidecar", s) for s in sidecars(db)),
94
+ ("boot lock", lock_path(db)),
95
+ ("boot state file", state_path(db)),
96
+ ("litestream meta", meta),
97
+ ("database directory", db.parent),
98
+ ]
99
+ out: list[tuple[str, Path]] = []
100
+ for what, path in named:
101
+ out.append((what, path))
102
+ real = Path(os.path.realpath(path))
103
+ if real != path:
104
+ out.append((f"{what} (resolved)", real))
105
+ return out
106
+
107
+
108
+ def _fold_overlaps(a: Path, b: Path) -> bool:
109
+ """대소문자·유니코드 정규형을 무시한 경로 문자열 비교(``_fold``). 아직 없는 경로도 막는다."""
110
+ fa, fb = _fold(str(a)), _fold(str(b))
111
+ return fa == fb or fb.startswith(fa.rstrip(os.sep) + os.sep) or fa.startswith(fb + os.sep)
112
+
113
+
114
+ def check_restore_dir(
115
+ raw: str | os.PathLike[str], protected: Iterable[tuple[str | None, str, Path]]
116
+ ) -> tuple[Path | None, str | None]:
117
+ """``--restore-dir`` 을 검사한다. ``(실제 경로, None)`` 또는 ``(None, 사유)``.
118
+
119
+ 마지막 구성요소까지 링크가 아닌 실제 디렉터리여야 한다: 부모는 ``real_path()``(D-15), 마지막
120
+ 구성요소는 ``lstat`` 으로 본다. ``protected`` 는 ``(별칭, 역할, 경로)`` 이고, 그중 하나와
121
+ 같거나 조상·자손이면 거부한다(경로 문자열·``(st_dev, st_ino)``·``_fold`` 비교). 별칭이
122
+ ``None`` 이면 역할에 주인을 적는다(Litestream 설정에만 있는 DB, #57). 아무것도 만들지 않는다.
123
+ """
124
+ text = os.fspath(raw)
125
+ if not text:
126
+ return None, "--restore-dir is empty"
127
+ stripped = text.rstrip(os.sep)
128
+ if not stripped:
129
+ return None, f"--restore-dir {text} is the filesystem root"
130
+ if os.path.basename(stripped) in (".", ".."):
131
+ return None, f"--restore-dir {text}: pass the directory's real absolute path"
132
+ try:
133
+ path = real_path(stripped)
134
+ except ValueError as exc:
135
+ return None, f"--restore-dir: {exc}"
136
+ try:
137
+ st = os.lstat(path)
138
+ except FileNotFoundError:
139
+ return None, f"--restore-dir {path} does not exist; create it first"
140
+ except OSError as exc:
141
+ return None, f"--restore-dir {path}: cannot stat: {exc}"
142
+ if stat.S_ISLNK(st.st_mode):
143
+ return None, f"--restore-dir {path} is a symlink; pass the real directory"
144
+ if not stat.S_ISDIR(st.st_mode):
145
+ return None, f"--restore-dir {path} is not a directory"
146
+ for alias, what, other in protected:
147
+ try:
148
+ hit = _overlaps(path, other) or _fold_overlaps(path, other)
149
+ except OSError as exc:
150
+ return None, f"--restore-dir {path}: cannot compare with {other}: {exc}"
151
+ if hit:
152
+ owner = "" if alias is None else f" of alias {alias!r}"
153
+ return None, (
154
+ f"--restore-dir {path} overlaps the {what}{owner}: {other}; "
155
+ "use a directory outside the database directory and the litestream meta"
156
+ )
157
+ return path, None
158
+
159
+
160
+ # --- 무결성 -------------------------------------------------------------------------------
161
+
162
+
163
+ def _connect_ro(path: Path) -> sqlite3.Connection:
164
+ uri = f"file:{quote(os.path.abspath(path))}?mode=ro&immutable=1"
165
+ return sqlite3.connect(uri, uri=True, check_same_thread=False)
166
+
167
+
168
+ def check_integrity(
169
+ path: Path, mode: str, timeout: float, *, alias: str | None = None
170
+ ) -> tuple[bool, list[Item]]:
171
+ """복원본에 ``PRAGMA integrity_check``/``quick_check`` 를 우리가 실행한다(D-24).
172
+
173
+ ``(무결성 ok 여부, 항목)``. 항목은 ``integrity`` 하나와, 검사가 끝까지 돌았으면 구조 요약
174
+ (``tables``·``page_count``)이다. 시간이 ``timeout`` 을 넘으면 타이머 스레드가 검사가 끝날
175
+ 때까지 ``_INTERRUPT_RETRY`` 간격으로 ``Connection.interrupt()`` 를 부른다(700MB full 검사에서
176
+ 1ms 안에 멈춤, #54 실측. 문장 시작 전 발동도 다음 재시도에서 끊긴다, #63).
177
+ """
178
+ pragma = _PRAGMAS[mode]
179
+ try:
180
+ conn = _connect_ro(path)
181
+ except sqlite3.Error as exc:
182
+ return False, [_item(alias, "integrity", "error", mode, None, f"cannot open: {exc}")]
183
+ try:
184
+ fired = threading.Event()
185
+ done = threading.Event()
186
+
187
+ def stop() -> None:
188
+ if done.wait(timeout):
189
+ return
190
+ fired.set()
191
+ # 실행 중인 문장이 없을 때의 interrupt() 는 no-op 이라(SQLite c3ref/interrupt), 시한이
192
+ # 문장 시작 전에 지나면 한 번으로는 끊지 못한다. 검사가 끝날 때까지 다시 끊는다(#63).
193
+ while True:
194
+ conn.interrupt()
195
+ if done.wait(_INTERRUPT_RETRY):
196
+ return
197
+
198
+ timer = threading.Thread(target=stop, daemon=True)
199
+ start = time.monotonic()
200
+ timer.start()
201
+ try:
202
+ rows = conn.execute(f"PRAGMA {pragma}").fetchall()
203
+ failure = None
204
+ except sqlite3.Error as exc:
205
+ rows, failure = None, exc
206
+ finally:
207
+ # 타이머 스레드가 끝난 뒤에만 다음 문장(_structure)을 연다. 그 전에 남은 interrupt 는
208
+ # 실행 중인 문장이 없을 때 걸린 것이라 다음 문장 시작에서 풀린다.
209
+ done.set()
210
+ timer.join()
211
+ elapsed = time.monotonic() - start
212
+ took = f"{elapsed * 1000:.0f} ms"
213
+ if failure is not None:
214
+ if fired.is_set() and "interrupt" in str(failure):
215
+ message = (
216
+ f"timeout: {pragma} did not finish within --integrity-timeout {timeout:g}s "
217
+ f"(interrupted after {elapsed:.1f}s)"
218
+ )
219
+ elif fired.is_set():
220
+ # 타이머가 발동했지만 다른 오류로 끝났다: 두 사실을 함께 적는다(리뷰 1 F8).
221
+ message = (
222
+ f"{pragma} failed after {took}: {failure} "
223
+ f"(the --integrity-timeout {timeout:g}s timer had also fired)"
224
+ )
225
+ else:
226
+ message = f"{pragma} failed after {took}: {failure}"
227
+ return False, [_item(alias, "integrity", "error", mode, None, message)]
228
+ if fired.is_set() and rows == [("ok",)]:
229
+ # 재시도 사이에 검사가 끝나 interrupt() 가 한 번도 실행 중인 문장에 닿지 못한 경계다
230
+ # (#60·#63). 결과가 ok 여도 예산을 넘겼으니 시간 초과로 본다(§6-2).
231
+ message = (
232
+ f"timeout: {pragma} did not finish within --integrity-timeout {timeout:g}s "
233
+ f"(the timer fired but the check was not interrupted; "
234
+ f"it ran to completion in {took})"
235
+ )
236
+ return False, [_item(alias, "integrity", "error", mode, None, message)]
237
+ if rows != [("ok",)]:
238
+ shown = " | ".join(str(r[0]) for r in rows[:_MAX_PROBLEMS])
239
+ more = f" (+{len(rows) - _MAX_PROBLEMS} more)" if len(rows) > _MAX_PROBLEMS else ""
240
+ # 손상 결과와 타이머 발동이 겹치면 두 사실을 함께 적는다(F8 와 같은 규칙, #62 P3-1).
241
+ also = (
242
+ f" (the --integrity-timeout {timeout:g}s timer had also fired)"
243
+ if fired.is_set()
244
+ else ""
245
+ )
246
+ return False, [
247
+ _item(alias, "integrity", "error", mode, None, f"{pragma}: {shown}{more}{also}"),
248
+ *_structure(conn, alias),
249
+ ]
250
+ return True, [
251
+ _item(alias, "integrity", "ok", mode, None, f"{pragma}: ok ({took})"),
252
+ *_structure(conn, alias),
253
+ ]
254
+ finally:
255
+ conn.close()
256
+
257
+
258
+ def _structure(conn: sqlite3.Connection, alias: str | None) -> list[Item]:
259
+ """복원본 구조 요약(보고 전용). 읽지 못하면 ``tables`` 하나를 ``error`` 로."""
260
+ try:
261
+ tables = conn.execute("SELECT count(*) FROM sqlite_master WHERE type = 'table'").fetchone()
262
+ pages = conn.execute("PRAGMA page_count").fetchone()
263
+ size = conn.execute("PRAGMA page_size").fetchone()
264
+ except sqlite3.Error as exc:
265
+ return [_item(alias, "tables", "error", None, None, f"cannot read the schema: {exc}")]
266
+ return [
267
+ _item(alias, "tables", "ok", tables[0]),
268
+ _item(alias, "page_count", "ok", pages[0], None, f"page_size {size[0]}"),
269
+ ]
270
+
271
+
272
+ # --- 정리 ---------------------------------------------------------------------------------
273
+
274
+
275
+ def _finish(alias: str, work: Path, passed: bool, keep: bool) -> list[Item]:
276
+ """``restore``·``integrity`` 가 둘 다 ok 면 작업 디렉터리를 지우고, 아니면 남긴다.
277
+
278
+ TXID 가 원격에 못 미치거나(``warn``) 원격을 조회하지 못했어도(``unknown``) 지운다: 산출물은
279
+ 그 원인(원격 쪽 상태)을 설명하지 않는다.
280
+ """
281
+ if keep:
282
+ return [_item(alias, "output", "ok", str(work), None, "kept (--keep)")]
283
+ if not passed:
284
+ message = "kept for inspection; remove it when done"
285
+ return [_item(alias, "output", "ok", str(work), None, message)]
286
+ try:
287
+ shutil.rmtree(work)
288
+ except OSError as exc:
289
+ return [_item(alias, "output", "warn", str(work), None, f"cleanup failed: {exc}")]
290
+ return []
291
+
292
+
293
+ # --- 별칭 하나 ------------------------------------------------------------------------------
294
+
295
+
296
+ def _one(
297
+ alias: str,
298
+ db: Path,
299
+ root: Path,
300
+ *,
301
+ config: str,
302
+ binary: str,
303
+ integrity: str,
304
+ restore_timeout: float,
305
+ integrity_timeout: float,
306
+ keep: bool,
307
+ identity: tuple[int, int] | None = None,
308
+ ) -> list[Item]:
309
+ items: list[Item] = []
310
+ # 1. 용량: 이 별칭을 시작하기 직전의 여유로 본다(앞 별칭이 남긴 산출물이 자연히 반영된다).
311
+ local = (_file_size(str(db)) or 0) + (_file_size(f"{db}-wal") or 0)
312
+ need = -(-local * _SPACE_FACTOR[0] // _SPACE_FACTOR[1]) # 정수 올림(부동소수 오차 없이)
313
+ estimate = f"estimated from the local db + -wal size ({local} bytes) x 1.2"
314
+ try:
315
+ free = shutil.disk_usage(root).free
316
+ except OSError as exc:
317
+ return [_item(alias, "space", "error", None, need, f"cannot read free space: {exc}")]
318
+ if free < need:
319
+ return [_item(alias, "space", "error", free, need, f"not enough free space; {estimate}")]
320
+ items.append(_item(alias, "space", "ok", free, need, estimate))
321
+
322
+ # 2. 작업 디렉터리: 실행마다 새로 만든다(0700). 겹치는 실행끼리 충돌하지 않는다. 검사한
323
+ # --restore-dir 이 그대로인지(같은 디렉터리, 링크 아님) 바로 앞에서 다시 본다(R5-4).
324
+ if identity is None or _dir_identity(root) != identity:
325
+ message = "--restore-dir changed since it was checked (replaced or turned into a symlink)"
326
+ items.append(_item(alias, "restore_dir", "error", str(root), None, message))
327
+ return items
328
+ try:
329
+ work = Path(tempfile.mkdtemp(prefix="dso-restore-", dir=root))
330
+ except OSError as exc:
331
+ items.append(_item(alias, "restore_dir", "error", str(root), None, f"mkdtemp: {exc}"))
332
+ return items
333
+ items.append(_item(alias, "restore_dir", "ok", str(work)))
334
+ out = work / db.name
335
+
336
+ # 3. 복원 직전 원격 최대 TXID.
337
+ remote = litestream.remote_max_txid(db, config=config, binary=binary, timeout=_LTX_TIMEOUT)
338
+ if isinstance(remote, RemoteEmpty):
339
+ items.append(
340
+ _item(
341
+ alias,
342
+ "remote_txid",
343
+ "error",
344
+ None,
345
+ None,
346
+ "the replica lists no LTX files: nothing to restore (litestream 0.5.17 reports a "
347
+ "mistyped replica path the same way)",
348
+ )
349
+ )
350
+ return items + _finish(alias, work, False, keep)
351
+ remote_txid = None
352
+ if isinstance(remote, RemoteError):
353
+ items.append(_item(alias, "remote_txid", "unknown", None, None, redact(remote.message)))
354
+ else:
355
+ remote_txid = remote.txid
356
+ items.append(_item(alias, "remote_txid", "ok", _hex(remote_txid)))
357
+
358
+ # 4. 복원. 무결성은 Litestream 에 맡기지 않는다(D-24): 실패 사유가 한 줄이라 내려받기
359
+ # 실패와 구분되지 않는다.
360
+ start = time.monotonic()
361
+ result = litestream.restore(
362
+ db, out, config=config, binary=binary, timeout=restore_timeout, integrity_check="none"
363
+ )
364
+ duration = round((time.monotonic() - start) * 1000)
365
+ if not result.ok:
366
+ items.append(_item(alias, "restore", "error", "failed", None, redact(result.reason)))
367
+ items.append(_item(alias, "duration_ms", "ok", duration))
368
+ return items + _finish(alias, work, False, keep)
369
+ items.append(_item(alias, "restore", "ok", "restored", None, str(out)))
370
+ if remote_txid is None:
371
+ items.append(
372
+ _item(
373
+ alias,
374
+ "restored_txid",
375
+ "unknown",
376
+ _hex(result.txid),
377
+ None,
378
+ "the remote TXID was not available; not compared",
379
+ )
380
+ )
381
+ elif result.txid >= remote_txid:
382
+ items.append(_item(alias, "restored_txid", "ok", _hex(result.txid), _hex(remote_txid)))
383
+ else:
384
+ items.append(
385
+ _item(
386
+ alias,
387
+ "restored_txid",
388
+ "warn",
389
+ _hex(result.txid),
390
+ _hex(remote_txid),
391
+ "the restored TXID is below the remote TXID read just before the restore",
392
+ )
393
+ )
394
+ items.append(_item(alias, "duration_ms", "ok", duration))
395
+ items.append(_item(alias, "size", "ok", _file_size(str(out))))
396
+
397
+ # 5. 무결성(우리가 실행, 별도 시간 예산).
398
+ passed, checked = check_integrity(out, integrity, integrity_timeout, alias=alias)
399
+ items.extend(checked)
400
+ return items + _finish(alias, work, passed, keep)
401
+
402
+
403
+ # --- 조립 ---------------------------------------------------------------------------------
404
+
405
+
406
+ def _meta_paths() -> tuple[dict[str, str], str | None]:
407
+ """헬스 설정의 별칭별 ``meta_path``. 설정이 잘못됐으면 ``({}, 사유)``."""
408
+ health, errors = load_config()
409
+ if errors:
410
+ return {}, "; ".join(message for message, _ in errors)
411
+ if health is None:
412
+ return {}, None
413
+ return {c.alias: c.meta_path for c in health.databases if c.meta_path is not None}, None
414
+
415
+
416
+ def _base_paths(name: str) -> list[Path]:
417
+ """보호 합집합의 기준 경로: 글자 그대로 접은 경로와, 커널처럼 링크를 따라간 실체(R5-1).
418
+
419
+ ``abspath`` 는 ``..`` 를 글자 단위로 먼저 접으므로 ``srv/x/../data``(``srv/x`` 가 링크)의 실체를
420
+ 놓친다. ``realpath`` 는 구성요소를 차례로 풀어 링크 뒤의 ``..`` 를 커널과 같이 처리한다.
421
+ DB 파일이 링크면 SQLite 는 실체 옆에 사이드카를 만들므로 실체 기준의 부모 디렉터리·사이드카도
422
+ 넣는다.
423
+ """
424
+ literal = Path(os.path.normpath(os.path.abspath(name)))
425
+ real = Path(os.path.realpath(name))
426
+ return [literal] if real == literal else [literal, real]
427
+
428
+
429
+ def _config_protected(listed: Iterable[str]) -> list[tuple[str | None, str, Path]]:
430
+ """Litestream 설정의 DB 경로마다 보호 경로(D-25, #57). ``DATABASES`` 에 없는 DB 도 넣는다.
431
+
432
+ 같은 ``litestream.yml`` 이 복제하는 다른 서비스 DB 의 디렉터리·기본 메타를 막는다. 설정의
433
+ ``meta-path`` 는 ``databases -json`` 이 내보내지 않으므로(0.5.17) 기본 메타만 넣는다.
434
+ """
435
+ out: list[tuple[str | None, str, Path]] = []
436
+ for path in listed:
437
+ owner = f" of litestream config database {path}"
438
+ for base in _base_paths(path):
439
+ for what, p in protected_paths(base, litestream.default_meta_path(base)):
440
+ out.append((None, f"{what}{owner}", p))
441
+ return out
442
+
443
+
444
+ def _under(root: Path, path: Path) -> bool:
445
+ """``path`` 가 ``root`` 와 같거나 그 아래면 참(문자열·파일 동일성·``_fold`` 비교)."""
446
+ fr, fp = _fold(str(root)).rstrip(os.sep), _fold(str(path))
447
+ return _contains(root, path) or fp == fr or fp.startswith(fr + os.sep)
448
+
449
+
450
+ def _dir_identity(path: Path) -> tuple[int, int] | None:
451
+ """링크가 아닌 디렉터리의 ``(st_dev, st_ino)``. 아니거나 읽지 못하면 ``None``."""
452
+ try:
453
+ st = os.lstat(path)
454
+ except OSError:
455
+ return None
456
+ if not stat.S_ISDIR(st.st_mode):
457
+ return None
458
+ return st.st_dev, st.st_ino
459
+
460
+
461
+ def _dir_entry(listed: Iterable[str], cwd: str) -> tuple[str, bool] | None:
462
+ """``databases -json`` 목록에서 조회한 임시 cwd 기준으로 풀린 항목 ``(경로, dir: 항목인가)``.
463
+
464
+ 0.5.17 은 ``dir:`` 항목을 감시 디렉터리가 아니라 실행한 cwd 자체로 내보내고(절대·상대 ``dir:``
465
+ 모두), 상대 ``path:`` 는 cwd 아래로 푼다(재현함 #54). 앞은 감시 디렉터리를, 뒤는 replicate 가
466
+ 어디서 도느냐에 따라 바뀌는 DB 위치를 알 수 없다. 둘 다 거부한다(D-26).
467
+ """
468
+ roots = {os.path.normpath(cwd), os.path.realpath(cwd)}
469
+ for path in listed:
470
+ for root in roots:
471
+ if path == root:
472
+ return path, True
473
+ if path.startswith(root.rstrip(os.sep) + os.sep):
474
+ return path, False
475
+ return None
476
+
477
+
478
+ def _sqlite_targets() -> tuple[dict[str, _Target], list[tuple[str, str]]]:
479
+ """보호 합집합을 만들 별칭: ``vendor == "sqlite"`` 인 ``DATABASES`` 별칭 전부(D-25, R4-1).
480
+
481
+ ENGINE 문자열 일치가 아니라 백엔드의 ``vendor`` 로 본다(sqlite3 를 상속한 사용자 백엔드·
482
+ SpatiaLite 도 SQLite 파일을 쓴다). ``connections[alias]`` 는 래퍼만 만들고 연결하지 않는다.
483
+ 백엔드를 불러오지 못하는 별칭은 ``(별칭, 사유 한 줄)`` 로 따로 돌려준다(SQLite 인지 모른다).
484
+ """
485
+ targets: dict[str, _Target] = {}
486
+ unloadable: list[tuple[str, str]] = []
487
+ for alias, config in settings.DATABASES.items():
488
+ try:
489
+ vendor = connections[alias].vendor
490
+ except Exception as exc: # noqa: BLE001 - ImproperlyConfigured·ImportError 등을 한 줄로
491
+ unloadable.append((alias, _one_line(f"{type(exc).__name__}: {exc}")))
492
+ continue
493
+ if vendor == "sqlite":
494
+ targets[alias] = _target(alias, config)
495
+ return targets, unloadable
496
+
497
+
498
+ def restore_test(
499
+ aliases: Iterable[str] | None,
500
+ *,
501
+ restore_dir: str,
502
+ litestream_config: str,
503
+ litestream_binary: str = "litestream",
504
+ integrity: str = "full",
505
+ restore_timeout: float = litestream.DEFAULT_RESTORE_TIMEOUT,
506
+ integrity_timeout: float = DEFAULT_INTEGRITY_TIMEOUT,
507
+ keep: bool = False,
508
+ ) -> list[Item]:
509
+ """별칭마다 차례로 복원 검증한 항목. 대상은 쓰기 파일 DB 뿐이다(DESIGN §6-2).
510
+
511
+ ``aliases`` 가 없으면 쓰기 파일 DB 인 sqlite3 별칭만 실행하고 나머지는 ``alias`` ``ok``
512
+ 'skipped' 로 남긴다. 명시한 별칭이 쓰기 파일 DB 가 아니면 ``unknown`` 이다. 하나도 실행하지
513
+ 못했고 다른 ``error`` 도 없으면 ``alias`` ``error``(별칭 ``null``)다.
514
+ """
515
+ if integrity not in INTEGRITY_MODES:
516
+ raise ValueError(f"integrity must be one of {INTEGRITY_MODES}, got {integrity!r}")
517
+ explicit = aliases is not None
518
+ _, selected, items = select_aliases(aliases, SECTION)
519
+ items = list(items)
520
+ metas, meta_error = _meta_paths()
521
+
522
+ targets, unloadable = _sqlite_targets()
523
+ protected: list[tuple[str | None, str, Path]] = []
524
+ # 경로 계산이 ValueError(NUL 등)를 내는 별칭은 보호 경로를 정할 수 없다(R6-2).
525
+ unresolvable: list[str] = []
526
+ for alias, target in targets.items():
527
+ if target.path is None:
528
+ continue
529
+ meta = metas.get(alias)
530
+ try:
531
+ for base in _base_paths(target.path):
532
+ meta_path = (
533
+ Path(os.path.normpath(meta)) if meta else litestream.default_meta_path(base)
534
+ )
535
+ protected.extend((alias, what, p) for what, p in protected_paths(base, meta_path))
536
+ except ValueError as exc:
537
+ unresolvable.append(f"{alias!r} ({_one_line(str(exc))})")
538
+
539
+ runnable: list[tuple[str, Path]] = []
540
+ for alias in selected:
541
+ target = targets[alias]
542
+ if target.role == "error":
543
+ items.append(_item(alias, "alias", "error", None, None, target.reason or ""))
544
+ continue
545
+ if target.role != "write" or target.path is None:
546
+ # --database 로 고른 별칭이면 판정할 수 없다는 뜻이고, 기본 선택이면 대상이 아닐 뿐이다.
547
+ level, message = ("unknown", "only write file databases are restore-tested")
548
+ if not explicit:
549
+ level, message = "ok", f"skipped ({target.role}): {message}"
550
+ items.append(_item(alias, "alias", level, target.role, "write", message))
551
+ continue
552
+ try:
553
+ db = real_path(target.path)
554
+ except ValueError as exc:
555
+ items.append(_item(alias, "database", "error", target.path, None, str(exc)))
556
+ continue
557
+ if db.is_symlink():
558
+ # boot(D-15)와 같은 계약: DB 는 정규 파일 경로로만 받는다. SQLite 는 링크를 따라가
559
+ # 실체 옆에 사이드카를 만들어, 이 경로와 Litestream 설정의 경로가 어긋난다.
560
+ message = "the database file is a symlink; use the real file path (D-15)"
561
+ items.append(_item(alias, "database", "error", str(db), None, message))
562
+ continue
563
+ runnable.append((alias, db))
564
+
565
+ # 운영 경로와 겹치는지는 아무것도 만들기 전에, 모든 별칭의 합집합으로 본다.
566
+ if meta_error is not None:
567
+ items.append(
568
+ _item(
569
+ None,
570
+ "restore_dir",
571
+ "error",
572
+ restore_dir,
573
+ None,
574
+ f"SQLITE_OPS['HEALTH'] is invalid, so the meta paths to protect are unknown: "
575
+ f"{meta_error}",
576
+ )
577
+ )
578
+ return items
579
+ # 보호할 로컬 경로를 정할 수 없는 sqlite3 별칭(설정 오류 error, URI 판정 불가 unknown)이 있으면
580
+ # 합집합이 불완전하다: 판정할 수 없으므로 거부한다(R2-1·R3-1). URI 에서 경로를 짐작해 꺼내지
581
+ # 않는다. memory·vfs 는 로컬 DB 파일이 없으므로 통과한다.
582
+ unknown = [
583
+ f"{alias!r} (role {target.role}: {target.reason or 'no reason given'})"
584
+ for alias, target in targets.items()
585
+ if target.role in ("error", "unknown")
586
+ ] + unresolvable
587
+ if unloadable:
588
+ names = ", ".join(f"{alias!r} ({reason})" for alias, reason in unloadable)
589
+ items.append(
590
+ _item(
591
+ None,
592
+ "restore_dir",
593
+ "error",
594
+ restore_dir,
595
+ None,
596
+ f"cannot load the database backend of alias {names}, so it is unknown whether "
597
+ "it is a SQLite database whose paths must be protected; fix or remove the "
598
+ "DATABASES entry",
599
+ )
600
+ )
601
+ return items
602
+ if unknown:
603
+ items.append(
604
+ _item(
605
+ None,
606
+ "restore_dir",
607
+ "error",
608
+ restore_dir,
609
+ None,
610
+ f"cannot determine the paths of alias {', '.join(unknown)}, so the operational "
611
+ "paths to protect are unknown; fix the DATABASES entry",
612
+ )
613
+ )
614
+ return items
615
+ root, problem = check_restore_dir(restore_dir, protected)
616
+ if problem is not None:
617
+ items.append(_item(None, "restore_dir", "error", restore_dir, None, problem))
618
+ return items
619
+ # 별칭마다 mkdtemp 직전에 같은 디렉터리인지 다시 본다(앞 별칭의 복원·검사 동안 바뀔 수 있다).
620
+ identity = _dir_identity(root)
621
+ if not runnable:
622
+ # 아무것도 검증하지 않고 성공하면 감시(cron·systemd)에서 거짓 초록이 된다(리뷰 1 F1).
623
+ if not any(i.level == "error" for i in items):
624
+ items.append(_item(None, "alias", "error", None, None, NO_TARGET))
625
+ return items
626
+
627
+ config = _caller_path(litestream_config)
628
+ binary = _binary(litestream_binary)
629
+ # 설정 목록은 아래 임시 cwd 에서 읽는다. 그 cwd 가 --restore-dir 아래면 설정에만 있는 DB 와의
630
+ # 겹침을 보기 전에 --restore-dir 안에 무언가를 만드는 셈이다: 판정 전이므로 거부한다(#57).
631
+ temp_base = Path(os.path.realpath(tempfile.gettempdir()))
632
+ try:
633
+ temp_under = _under(root, temp_base)
634
+ except OSError as exc:
635
+ temp_under, temp_problem = True, f"cannot compare with {temp_base}: {exc}"
636
+ else:
637
+ temp_problem = (
638
+ f"the temporary directory {temp_base} (TMPDIR) is inside it, and "
639
+ "litestream databases runs in a new directory there before the overlap check"
640
+ )
641
+ if temp_under:
642
+ message = f"--restore-dir {root}: {temp_problem}; set TMPDIR outside --restore-dir"
643
+ items.append(_item(None, "restore_dir", "error", restore_dir, None, message))
644
+ return items
645
+ # 상대 경로·dir: 항목은 Litestream 작업 디렉터리 기준으로 풀리므로 새 임시 cwd 에서 읽는다.
646
+ # 실패하면 보호 합집합이 불완전하므로 거부한다(작업 디렉터리를 만들기 전이다).
647
+ with tempfile.TemporaryDirectory(prefix="sqlite-doctor-") as cwd:
648
+ listed = litestream.config_databases(config, binary=binary, cwd=cwd)
649
+ if isinstance(listed, str):
650
+ items.append(_item(None, "config", "error", config, None, redact(listed)))
651
+ return items
652
+ entry = _dir_entry(listed, cwd)
653
+ if entry is not None:
654
+ path, is_dir = entry
655
+ if is_dir:
656
+ message = (
657
+ "the litestream config has a dir: entry: litestream databases lists it as its "
658
+ f"working directory {path}, so the watched directory cannot be determined and a "
659
+ "restore under --restore-dir may be picked up and replicated (D-26)"
660
+ )
661
+ else:
662
+ message = (
663
+ f"relative path {path} in the litestream config; its location depends on where "
664
+ "litestream runs, so the paths to protect are unknown (D-26)"
665
+ )
666
+ items.append(_item(None, "config", "error", config, None, message))
667
+ return items
668
+ # 같은 설정이 복제하는 다른 DB(DATABASES 에 없는 것 포함)의 경로도 막는다(D-25, #57). 앞의
669
+ # 검사로 DATABASES 쪽은 이미 걸렀으므로 메시지가 바뀌지 않는다.
670
+ try:
671
+ from_config = _config_protected(listed)
672
+ except ValueError as exc:
673
+ message = f"cannot determine the paths of a litestream config database: {exc}"
674
+ items.append(_item(None, "restore_dir", "error", restore_dir, None, message))
675
+ return items
676
+ root, problem = check_restore_dir(restore_dir, [*protected, *from_config])
677
+ if problem is not None or _dir_identity(root) != identity:
678
+ message = problem or "--restore-dir changed while the litestream config was read"
679
+ items.append(_item(None, "restore_dir", "error", restore_dir, None, message))
680
+ return items
681
+ in_config = set(listed)
682
+ for alias, db in runnable:
683
+ if str(db) not in in_config:
684
+ items.append(
685
+ _item(
686
+ alias,
687
+ "database",
688
+ "error",
689
+ str(db),
690
+ None,
691
+ "not in the litestream config (dbs[].path must be this same real path, D-15)",
692
+ )
693
+ )
694
+ continue
695
+ items.extend(
696
+ _one(
697
+ alias,
698
+ db,
699
+ root,
700
+ config=config,
701
+ binary=binary,
702
+ integrity=integrity,
703
+ restore_timeout=restore_timeout,
704
+ integrity_timeout=integrity_timeout,
705
+ keep=keep,
706
+ identity=identity,
707
+ )
708
+ )
709
+ return items