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,1066 @@
1
+ """boot CLI: 잠금 → 중단된 격리 재개 → 판정 → 조치 → 무결성 → 상태 파일 → exec (DESIGN §4-2).
2
+
3
+ ``python -m django_sqlite_ops.boot --db PATH --config PATH [...] -- CMD [ARGS...]``
4
+
5
+ 표준 라이브러리만 쓴다. Django 를 import 하지 않는다(DESIGN §4-1).
6
+ 판정은 ``decide()`` 하나가 한다. 이 모듈은 입력을 모으고 조치를 실행한다. 예외는 판정에 넣지
7
+ 않은 이상 상태 하나뿐이다: DB 파일 없이 ``-wal``/``-shm``/``-journal`` 이 남은 경우(§4-3).
8
+
9
+ 순서가 안전성이다.
10
+ - 복원은 항상 DB 옆의 새 임시 디렉터리에 먼저 끝내고 검증한다. 성공했을 때만 로컬을 격리한다.
11
+ 그래서 복원이 실패하면 로컬은 하나도 바뀌지 않는다(L7).
12
+ - 격리는 ``<db>.stale-<ts>.partial/`` 로 대상을 하나씩 옮긴 뒤 디렉터리를 ``<db>.stale-<ts>/`` 로
13
+ rename 한다. ``.partial`` 이 남아 있으면 격리 도중 죽은 것이고, 다음 부팅이 판정 전에 끝낸다(L6).
14
+ """
15
+
16
+ import argparse
17
+ import errno
18
+ import json
19
+ import math
20
+ import os
21
+ import re
22
+ import secrets
23
+ import shutil
24
+ import sqlite3
25
+ import stat
26
+ import sys
27
+ import time
28
+ import unicodedata
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from urllib.parse import quote
32
+
33
+ from . import litestream as ls
34
+ from .decide import (
35
+ Action,
36
+ OnUnknown,
37
+ ReasonCode,
38
+ RemoteEmpty,
39
+ RemoteError,
40
+ RemoteTxid,
41
+ State,
42
+ decide,
43
+ )
44
+
45
+ try:
46
+ import fcntl
47
+ except ImportError: # pragma: no cover - Windows
48
+ fcntl = None # type: ignore[assignment]
49
+
50
+ __all__ = [
51
+ "EXIT_EXEC",
52
+ "EXIT_INTEGRITY",
53
+ "EXIT_LOCK",
54
+ "EXIT_REFUSE",
55
+ "EXIT_RESTORE",
56
+ "EXIT_USAGE",
57
+ "main",
58
+ ]
59
+
60
+ # DESIGN §4-5. 0 은 exec 라 돌아오지 않는다.
61
+ EXIT_REFUSE = 2
62
+ EXIT_INTEGRITY = 3
63
+ EXIT_RESTORE = 4
64
+ EXIT_LOCK = 5
65
+ EXIT_USAGE = 64
66
+ EXIT_EXEC = 127
67
+
68
+ # DB 옆에 남는 SQLite 파일. 격리 대상이며, DB 없이 이것만 있으면 이상 상태다.
69
+ SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
70
+ # DB 없이 사이드카만 남은 경우의 사유 코드. decide() 를 부르지 않고 CLI 가 정한다.
71
+ ORPHAN_SIDECARS = "orphan_sidecars"
72
+
73
+ _MANIFEST = "manifest.json"
74
+ # 원자적 쓰기의 임시 파일 이름: <이름>.tmp-<rand>
75
+ _TMP_INFIX = ".tmp-"
76
+ # boot 가 DB 디렉터리에 만드는 디렉터리 이름: 임시 복원 <db>.restore-<ts>-<rand>/,
77
+ # 격리 <db>.stale-<ts>.partial/ → <db>.stale-<ts>/. 이름 규칙은 여기 한 곳에 둔다(#39).
78
+ _RESTORE_INFIX = ".restore-"
79
+ _STALE_INFIX = ".stale-"
80
+ _STATE_VERSION = 1
81
+
82
+
83
+ class _Exit(Exception):
84
+ """단계 함수가 부팅을 끝낼 때 던진다. ``main()`` 이 종료 코드로 바꾼다."""
85
+
86
+ def __init__(self, code: int, message: str) -> None:
87
+ super().__init__(message)
88
+ self.code = code
89
+ self.message = message
90
+
91
+
92
+ def log(message: str) -> None:
93
+ print(f"[boot] {message}", file=sys.stderr, flush=True)
94
+
95
+
96
+ # --- 경로 --------------------------------------------------------------------------------
97
+
98
+
99
+ def real_path(raw: str | os.PathLike[str]) -> Path:
100
+ """``--db``·``--meta-path`` 를 검증해 한 번 정한다. 실제 경로만 받는다(D-15).
101
+
102
+ 이름이 ``real_path`` 인 이유: 경로를 고쳐 주지 않고, 이미 실제 경로인지 확인만 한다.
103
+ 앞선 라운드처럼 ``abspath`` 로 ``..`` 를 접거나 부모 링크를 ``realpath`` 로 바꾸면, 다른
104
+ 파일을 가리키거나 Litestream 설정의 ``dbs[].path`` 와 어긋났다. 그래서
105
+ - 원문에 ``..`` 구성요소가 있으면 거부한다.
106
+ - ``normpath(abspath(raw))`` 의 마지막 구성요소가 ``.``·``..``·빈 문자열이면 거부한다.
107
+ - 부모는 존재하는 디렉터리이고 ``realpath(parent, strict=True)`` 와 같아야 한다(부모 경로
108
+ 어디에도 링크·없는 구성요소가 없음).
109
+ 마지막 구성요소는 없어도 되고(새 DB), 그것이 링크인지는 따로 검사한다(DB 는 정규 파일만,
110
+ 격리 대상은 링크 거부). 이 경로를 잠금·격리·상태 파일과 Litestream 호출에 모두 쓴다.
111
+ 어긋나면 ``ValueError``(CLI 에서는 exit 64).
112
+ """
113
+ text = os.fspath(raw)
114
+ hint = "use the real path; the litestream config dbs[].path must be the same real path (D-15)"
115
+ if ".." in text.split(os.sep):
116
+ raise ValueError(f"path must not contain '..': {text!r}; {hint}")
117
+ if os.path.basename(text) in ("", ".", ".."):
118
+ raise ValueError(f"path must end with a file name: {text!r}")
119
+ path = os.path.normpath(os.path.abspath(text))
120
+ if os.path.basename(path) in ("", ".", ".."):
121
+ raise ValueError(f"path must end with a file name: {text!r}")
122
+ parent = os.path.dirname(path)
123
+ if not os.path.isdir(parent):
124
+ raise ValueError(f"parent directory {parent} does not exist or is not a directory; {hint}")
125
+ try:
126
+ real = os.path.realpath(parent, strict=True)
127
+ except OSError as exc:
128
+ raise ValueError(f"cannot resolve {parent}: {exc}; {hint}") from None
129
+ if real != parent:
130
+ raise ValueError(
131
+ f"parent directory {parent} is not a real path (it resolves to {real}); "
132
+ f"pass {os.path.join(real, os.path.basename(path))} instead; {hint}"
133
+ )
134
+ return Path(path)
135
+
136
+
137
+ def _real_pair(db: Path, meta: Path) -> tuple[Path, Path]:
138
+ """단계 함수를 직접 부를 때도 같은 계약을 지킨다. 어긋나면 exit 64."""
139
+ try:
140
+ return real_path(db), real_path(meta)
141
+ except ValueError as exc:
142
+ raise _Exit(EXIT_USAGE, str(exc)) from None
143
+
144
+
145
+ def _file_id(path: Path) -> tuple[int, int] | None:
146
+ """경로 항목 자체의 ``(st_dev, st_ino)``. 없으면 ``None``. 그 밖의 ``stat`` 실패는 올린다.
147
+
148
+ ``lstat`` 이다: 격리는 경로 항목을 rename 하므로 항목이 같은지가 중요하고, 부모 경로에는
149
+ 링크가 없다(D-15). 대소문자 무시 파일시스템의 ``data``·``DATA`` 나 하드링크는 같은 값이다.
150
+ """
151
+ try:
152
+ st = os.lstat(path)
153
+ except (FileNotFoundError, NotADirectoryError):
154
+ return None
155
+ return st.st_dev, st.st_ino
156
+
157
+
158
+ def _chain_ids(path: Path) -> set[tuple[int, int]]:
159
+ """``path`` 와 그 조상 중 있는 것의 파일 동일성."""
160
+ ids = (_file_id(p) for p in (path, *path.parents))
161
+ return {i for i in ids if i is not None}
162
+
163
+
164
+ def _contains(a: Path, b: Path) -> bool:
165
+ """``a`` 가 ``b`` 와 같거나 ``b`` 의 조상이면 참. 문자열과 파일 동일성 중 하나라도 맞으면 참."""
166
+ if a == b or a in b.parents:
167
+ return True
168
+ ident = _file_id(a)
169
+ return ident is not None and ident in _chain_ids(b)
170
+
171
+
172
+ def _overlaps(a: Path, b: Path) -> bool:
173
+ return _contains(a, b) or _contains(b, a)
174
+
175
+
176
+ def _same_dir(a: Path, b: Path) -> bool:
177
+ if a == b:
178
+ return True
179
+ ident = _file_id(a)
180
+ return ident is not None and ident == _file_id(b)
181
+
182
+
183
+ _META_HINT = (
184
+ "set --meta-path to the meta-path in the litestream config: a directory of its own "
185
+ "(default .<db>-litestream next to the db)"
186
+ )
187
+
188
+
189
+ def _fold(name: str) -> str:
190
+ """이름 비교용: 조합형·완성형(NFD·NFC)과 대소문자 차이를 없앤다.
191
+
192
+ 유니코드 표준의 canonical caseless matching(§3.13 D145: ``NFD(casefold(NFD(s)))``)이다.
193
+ casefold 는 정규형을 바꿀 수 있어 앞뒤로 정규화해야 한다. ``NFC(s).casefold()`` 만 하면
194
+ ``ΐ``(U+0390) 와 ``Ϊ́`` 의 두 철자가 다르게 접혔다(#39 리뷰 2 재현).
195
+ """
196
+ nfd = unicodedata.normalize("NFD", name)
197
+ return unicodedata.normalize("NFD", nfd.casefold())
198
+
199
+
200
+ def _boot_owned_name(db: Path, name: str) -> bool:
201
+ """DB 디렉터리 안에서 boot 가 쓰는 이름이면 참: DB·사이드카·잠금·상태 파일, 임시 복원·격리
202
+ 디렉터리, 상태 임시 파일. 아직 없을 수 있으므로 동일성이 아니라 이름으로 보며, 대소문자·
203
+ 유니코드 정규형을 무시하는 파일시스템(macOS APFS)을 위해 모든 플랫폼에서 양쪽을
204
+ ``_fold()``(canonical caseless matching)로 비교한다(대소문자 구분 볼륨의
205
+ ``APP.DB.BOOT.LOCK`` 같은 이름도 거부한다, #39).
206
+ """
207
+ folded = _fold(name)
208
+ exact = [p.name for p in (db, *sidecars(db), lock_path(db), state_path(db))]
209
+ prefixes = (db.name + _RESTORE_INFIX, db.name + _STALE_INFIX, state_path(db).name + _TMP_INFIX)
210
+ return folded in {_fold(n) for n in exact} or folded.startswith(
211
+ tuple(_fold(p) for p in prefixes)
212
+ )
213
+
214
+
215
+ def meta_conflict(db: Path, meta: Path, config: Path | None = None) -> str | None:
216
+ """``--meta-path`` 가 boot 소유 경로와 겹치면 사유, 아니면 ``None`` (#39).
217
+
218
+ boot 소유 경로: DB 와 사이드카, 잠금 파일, 상태 파일, ``config`` (주어지면 원문과 실제 경로),
219
+ DB 디렉터리 안의 임시 복원·격리 디렉터리와 상태 임시 파일 이름. 같거나 한쪽이 다른 쪽의
220
+ 조상이면 겹친다. 메타는 격리 대상이므로 이것들과 겹치면 boot 가 자기 잠금·상태·DB 를 옮긴다.
221
+ 메타가 DB 의 조상(격리 목적지를 품음)인 경우도 여기서 걸린다. 인자 해석(exit 64)과
222
+ ``check_no_overlap``(exit 2)이 이 판정 하나를 쓴다.
223
+ """
224
+ owned = [
225
+ *quarantine_roles(db, meta)[:-1],
226
+ ("boot lock", lock_path(db)),
227
+ ("boot state file", state_path(db)),
228
+ ]
229
+ if config is not None:
230
+ text = os.path.normpath(os.path.abspath(config))
231
+ owned += [("--config", Path(text)), ("--config", Path(os.path.realpath(text)))]
232
+ for what, path in owned:
233
+ if _overlaps(meta, path):
234
+ return f"--meta-path {meta} overlaps the {what} {path}; {_META_HINT}"
235
+ for p in (meta, *meta.parents):
236
+ if p != p.parent and _boot_owned_name(db, p.name) and _same_dir(p.parent, db.parent):
237
+ return (
238
+ f"--meta-path {meta} uses a name boot owns next to the db: {p.name} "
239
+ f"(the db, its sidecars, boot lock/state files, {db.name}{_RESTORE_INFIX}*, "
240
+ f"{db.name}{_STALE_INFIX}*; compared case-insensitively); {_META_HINT}"
241
+ )
242
+ return None
243
+
244
+
245
+ def check_no_overlap(db: Path, meta: Path) -> None:
246
+ """메타가 boot 소유 경로와 겹치거나(``meta_conflict``), 격리 대상끼리, 그리고 대상과 격리
247
+ 목적지(DB 디렉터리 안의 .partial·final·임시 복원)가 같은 경로이거나 한쪽이 다른 쪽의
248
+ 조상이면 거부한다(exit 2). 예: 메타가 DB 의 부모면 DB 를 옮긴 뒤 메타를 자기 안으로 옮기려다
249
+ 실패하고, 재개도 같은 오류를 반복한다. 메타가 잠금 파일이면 boot 가 자기 잠금을 옮긴다(#39).
250
+ """
251
+ try:
252
+ _check_no_overlap(db, meta)
253
+ except OSError as exc:
254
+ raise _Exit(EXIT_REFUSE, f"cannot quarantine: cannot compare paths: {exc}") from None
255
+
256
+
257
+ def _check_no_overlap(db: Path, meta: Path) -> None:
258
+ conflict = meta_conflict(db, meta)
259
+ if conflict is not None:
260
+ raise _Exit(EXIT_REFUSE, f"cannot quarantine: {conflict}")
261
+ targets = quarantine_targets(db, meta)
262
+ for i, a in enumerate(targets):
263
+ for b in targets[i + 1 :]:
264
+ if _overlaps(a, b):
265
+ raise _Exit(EXIT_REFUSE, f"cannot quarantine: {a} and {b} overlap")
266
+ if _contains(a, db.parent):
267
+ raise _Exit(
268
+ EXIT_REFUSE,
269
+ f"cannot quarantine: {a} contains the quarantine destination {db.parent}",
270
+ )
271
+
272
+
273
+ def lock_path(db: Path) -> Path:
274
+ return db.with_name(db.name + ".boot.lock")
275
+
276
+
277
+ def state_path(db: Path) -> Path:
278
+ return db.with_name(db.name + ".boot-state.json")
279
+
280
+
281
+ def sidecars(db: Path) -> list[Path]:
282
+ return [db.with_name(db.name + s) for s in SIDECAR_SUFFIXES]
283
+
284
+
285
+ def quarantine_roles(db: Path, meta: Path) -> list[tuple[str, Path]]:
286
+ """격리 대상의 (역할, 경로)와 순서. 이 목록 하나만 쓴다(격리·재개 검증·사이드카 판단 모두).
287
+
288
+ DB 를 먼저 옮긴다. 도중에 죽으면 DB 는 이미 빠져 있으므로, 남은 사이드카·메타가 다음
289
+ 부팅에서 '진행 중 격리'로 이어서 옮겨진다. 메타는 마지막이다.
290
+ """
291
+ side = zip(("wal", "shm", "journal"), sidecars(db), strict=True)
292
+ return [("db", db), *side, ("meta", meta)]
293
+
294
+
295
+ def quarantine_targets(db: Path, meta: Path) -> list[Path]:
296
+ return [p for _, p in quarantine_roles(db, meta)]
297
+
298
+
299
+ def _timestamp() -> str:
300
+ now = time.time_ns()
301
+ base = time.strftime("%Y%m%dT%H%M%S", time.gmtime(now // 1_000_000_000))
302
+ return f"{base}.{now % 1_000_000_000 // 1000:06d}Z"
303
+
304
+
305
+ def _partial_re(db: Path) -> re.Pattern[str]:
306
+ return re.compile(re.escape(db.name + _STALE_INFIX) + r"([0-9T.Z]+)\.partial")
307
+
308
+
309
+ # --- 내구화 -------------------------------------------------------------------------------
310
+
311
+
312
+ def fsync_path(path: Path) -> None:
313
+ """파일이나 디렉터리를 fsync 한다. rename 을 내구화하려면 부모 디렉터리를 fsync 한다."""
314
+ flags = os.O_RDONLY
315
+ if path.is_dir():
316
+ flags |= getattr(os, "O_DIRECTORY", 0)
317
+ fd = os.open(path, flags)
318
+ try:
319
+ os.fsync(fd)
320
+ finally:
321
+ os.close(fd)
322
+
323
+
324
+ def _fsync_dirs(*dirs: Path) -> None:
325
+ seen: set[str] = set()
326
+ for d in dirs:
327
+ key = os.path.abspath(d)
328
+ if key not in seen:
329
+ seen.add(key)
330
+ fsync_path(d)
331
+
332
+
333
+ # --- 1. 잠금 ------------------------------------------------------------------------------
334
+
335
+
336
+ def acquire_lock(db: Path) -> int:
337
+ """``<db>.boot.lock`` 에 배타 잠금을 건 fd. exec 에 상속시켜 명령이 사는 동안 유지된다.
338
+
339
+ 잠금 파일은 정규 파일이어야 한다(링크·디렉터리·FIFO 거부). 잠근 뒤 경로가 아직 그 파일을
340
+ 가리키는지(inode) 확인한다. 이 검사는 획득 순간만 본다. 그 뒤 누가 잠금 파일을 지우거나
341
+ 바꾸면 flock 의 보호는 사라진다(DESIGN §4-2).
342
+ """
343
+ if fcntl is None: # pragma: no cover - Windows
344
+ raise _Exit(
345
+ EXIT_USAGE,
346
+ "file locking needs POSIX fcntl; boot is not verified on this platform (DESIGN §12)",
347
+ )
348
+ path = lock_path(db)
349
+ try:
350
+ st = os.lstat(path)
351
+ except FileNotFoundError:
352
+ pass
353
+ except OSError as exc:
354
+ raise _Exit(EXIT_LOCK, f"cannot stat lock file {path}: {exc}") from None
355
+ else:
356
+ if not stat.S_ISREG(st.st_mode):
357
+ raise _Exit(EXIT_LOCK, f"lock file {path} is not a regular file (or is a symlink)")
358
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
359
+ try:
360
+ fd = os.open(path, flags, 0o644)
361
+ except OSError as exc:
362
+ raise _Exit(EXIT_LOCK, f"cannot open lock file {path}: {exc}") from None
363
+ try:
364
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
365
+ raise _Exit(EXIT_LOCK, f"lock file {path} is not a regular file")
366
+ try:
367
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
368
+ except OSError as exc:
369
+ if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
370
+ raise _Exit(
371
+ EXIT_LOCK,
372
+ f"lock {path} is held by another boot or by the command it exec'd "
373
+ "(another process is using this volume)",
374
+ ) from None
375
+ raise _Exit(EXIT_LOCK, f"cannot lock {path}: {exc}") from None
376
+ held = os.fstat(fd)
377
+ try:
378
+ now = os.lstat(path)
379
+ except OSError:
380
+ now = None
381
+ if now is None or (now.st_dev, now.st_ino) != (held.st_dev, held.st_ino):
382
+ raise _Exit(EXIT_LOCK, f"lock file {path} was replaced while locking")
383
+ except BaseException:
384
+ os.close(fd)
385
+ raise
386
+ return fd
387
+
388
+
389
+ # --- 2·5. 격리 ----------------------------------------------------------------------------
390
+ #
391
+ # manifest 는 "무엇을 옮기려 했는지"의 기록일 뿐 이동 명령이 아니다. 재개는 현재 DB(와
392
+ # --meta-path)에서 다시 유도한 대상과 대조해 일치할 때만, 전체를 먼저 검증한 뒤에 옮긴다.
393
+ # 이 검증은 사고·손상 대비이며, 쓰기 권한을 가진 공격자에 대한 인증이 아니다.
394
+
395
+ _MANIFEST_VERSION = 1
396
+
397
+
398
+ def _write_json_atomic(path: Path, data: dict) -> None:
399
+ tmp = path.with_name(f"{path.name}{_TMP_INFIX}{secrets.token_hex(4)}")
400
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
401
+ try:
402
+ os.write(fd, (json.dumps(data, indent=2) + "\n").encode())
403
+ os.fsync(fd)
404
+ finally:
405
+ os.close(fd)
406
+ os.rename(tmp, path)
407
+ fsync_path(path.parent)
408
+
409
+
410
+ def _reserved(name: str) -> bool:
411
+ """격리 디렉터리 안에서 우리가 쓰는 이름(manifest 와 그 임시 파일)과 겹치면 참.
412
+
413
+ 격리 대상 이름을 거르는 자리(격리 전 사전 검사, manifest 항목 검증)에서 쓴다. 대소문자·
414
+ 정규형을 무시하는 볼륨에서는 ``MANIFEST.JSON`` 이 partial 안의 manifest 와 같은 파일이므로
415
+ 모든 플랫폼에서 ``_fold()`` 로 비교한다(#50). partial 안에 남은 파일이 boot 가 쓴 것인지
416
+ 가리는 자리는 정확 비교인 ``_own_manifest_file()`` 을 쓴다.
417
+ """
418
+ folded = _fold(name)
419
+ return folded == _fold(_MANIFEST) or folded.startswith(_fold(_MANIFEST + _TMP_INFIX))
420
+
421
+
422
+ def _own_manifest_file(name: str) -> bool:
423
+ """boot 가 partial 안에 쓴 manifest 임시 파일 이름 그대로면 참(정확 비교).
424
+
425
+ manifest 없는 partial 을 치울지 가릴 때 쓴다. 지워도 되는 것은 boot 가 쓴 이름뿐이다.
426
+ ``os.listdir`` 는 만들 때의 철자를 돌려주므로 boot 가 쓴 파일은 정확히 이 꼴이고, 이름만
427
+ 비슷한 다른 파일은 지우지 않고 거부한다(#50).
428
+ """
429
+ return name.startswith(_MANIFEST + _TMP_INFIX)
430
+
431
+
432
+ def check_meta_kind(meta: Path) -> None:
433
+ """있는 메타 경로가 디렉터리인지 ``os.stat``(링크를 따라감)으로 본다. 아니면 exit 2 (#39).
434
+
435
+ 없으면 통과한다(새 볼륨). 디렉터리를 가리키는 링크도 통과한다(조회 규약, DESIGN §4-3; 격리
436
+ 시 링크 거부는 ``check_quarantinable`` 이 따로 한다). 끊어진 링크·stat 실패는 무엇인지
437
+ 판정할 수 없으므로 거부한다.
438
+ """
439
+ try:
440
+ st = os.stat(meta)
441
+ except FileNotFoundError:
442
+ if os.path.lexists(meta):
443
+ raise _Exit(
444
+ EXIT_REFUSE, f"--meta-path {meta} is a broken symbolic link; {_META_HINT}"
445
+ ) from None
446
+ return
447
+ except OSError as exc:
448
+ raise _Exit(EXIT_REFUSE, f"cannot stat --meta-path {meta}: {exc}") from None
449
+ if not stat.S_ISDIR(st.st_mode):
450
+ raise _Exit(
451
+ EXIT_REFUSE,
452
+ f"--meta-path {meta} exists but is not a directory; boot does not quarantine it; "
453
+ f"{_META_HINT}",
454
+ )
455
+
456
+
457
+ def check_quarantinable(db: Path, meta: Path) -> None:
458
+ """격리를 시작하기 전에(복원보다도 먼저) 옮길 수 있는지 본다. 아니면 exit 2.
459
+
460
+ - 격리 대상(DB·사이드카·메타) 경로 자체가 심볼릭 링크면 거부한다. 링크를 rename 하면 실체는
461
+ 밖에 남고 격리본에는 끊어진 링크만 남는다. 링크 실체 보존은 v0.1 에서 지원하지 않는다.
462
+ (판정·PROCEED·KEEP_LOCAL 에서 메타 디렉터리 링크를 따라가는 조회 규약과는 별개다.)
463
+ - 대상 이름이 서로 겹치거나 격리 디렉터리의 예약 이름(manifest)과 겹치면 거부한다.
464
+ 대소문자·정규형만 달라도 겹친 것으로 본다(``_fold()``, #50).
465
+ - rename 은 같은 파일시스템 안에서만 원자적이다. 다른 파일시스템이면 거부한다.
466
+ - 메타가 있는데 디렉터리가 아니면 거부한다. 정규 파일·FIFO 를 메타로 격리하지 않는다(#39).
467
+ """
468
+ db, meta = _real_pair(db, meta)
469
+ check_no_overlap(db, meta)
470
+ check_meta_kind(meta)
471
+ names = [p.name for _, p in quarantine_roles(db, meta)]
472
+ # 모두 같은 partial 안으로 옮기므로 대소문자·정규형만 다른 이름도 겹친다(#50).
473
+ if len({_fold(n) for n in names}) != len(names):
474
+ raise _Exit(EXIT_REFUSE, f"cannot quarantine: duplicate target names {names}")
475
+ clash = [n for n in names if _reserved(n)]
476
+ if clash:
477
+ raise _Exit(
478
+ EXIT_REFUSE,
479
+ f"cannot quarantine: {clash} collide with the reserved name {_MANIFEST!r}; "
480
+ "rename the db or --meta-path",
481
+ )
482
+ try:
483
+ dev = os.stat(db.parent).st_dev
484
+ for _, p in quarantine_roles(db, meta):
485
+ if not os.path.lexists(p):
486
+ continue
487
+ if os.path.islink(p):
488
+ raise _Exit(
489
+ EXIT_REFUSE,
490
+ f"cannot quarantine {p}: it is a symbolic link; boot does not move link "
491
+ "targets (v0.1). Replace the link with the real file or directory",
492
+ )
493
+ if os.stat(p.parent).st_dev != dev:
494
+ raise _Exit(
495
+ EXIT_REFUSE,
496
+ f"cannot quarantine {p}: not on the same filesystem as {db.parent}",
497
+ )
498
+ except OSError as exc:
499
+ raise _Exit(EXIT_REFUSE, f"cannot quarantine: {exc}") from None
500
+
501
+
502
+ def _move_all(partial: Path, entries: list[tuple[str, Path, str]]) -> None:
503
+ """검증이 끝난 목록에서 아직 원래 자리에 있는 대상을 순서대로 ``partial`` 안으로 옮긴다."""
504
+ for _, src, name in entries:
505
+ if os.path.lexists(src):
506
+ os.rename(src, partial / name)
507
+ log(f"quarantine: moved {src} -> {partial / name}")
508
+
509
+
510
+ def _finish(partial: Path, final: Path, entries: list[tuple[str, Path, str]]) -> None:
511
+ _move_all(partial, entries)
512
+ _fsync_dirs(partial, partial.parent, *(src.parent for _, src, _ in entries))
513
+ os.rename(partial, final)
514
+ fsync_path(final.parent)
515
+ log(f"quarantine: done -> {final}")
516
+
517
+
518
+ def quarantine(db: Path, meta: Path) -> Path | None:
519
+ """있는 격리 대상을 ``<db>.stale-<ts>/`` 로 옮긴다. 옮길 것이 없으면 ``None``.
520
+
521
+ 먼저 ``.partial`` 디렉터리와 manifest(대상 DB, 항목마다 역할·원래 경로·이름, 순서)를
522
+ 내구화한 뒤 옮긴다.
523
+ """
524
+ db, meta = _real_pair(db, meta)
525
+ check_quarantinable(db, meta)
526
+ present = [(role, p) for role, p in quarantine_roles(db, meta) if os.path.lexists(p)]
527
+ if not present:
528
+ return None
529
+ while True:
530
+ ts = _timestamp()
531
+ partial = db.with_name(f"{db.name}{_STALE_INFIX}{ts}.partial")
532
+ final = db.with_name(f"{db.name}{_STALE_INFIX}{ts}")
533
+ if os.path.lexists(final):
534
+ continue
535
+ try:
536
+ os.mkdir(partial, 0o755)
537
+ except FileExistsError:
538
+ continue
539
+ break
540
+ fsync_path(partial.parent)
541
+ entries = [(role, p, p.name) for role, p in present]
542
+ manifest = {
543
+ "version": _MANIFEST_VERSION,
544
+ "db": str(db),
545
+ "entries": [{"role": r, "src": str(src), "name": n} for r, src, n in entries],
546
+ }
547
+ _write_json_atomic(partial / _MANIFEST, manifest)
548
+ log(f"quarantine: started {partial} ({', '.join(n for _, _, n in entries)})")
549
+ _finish(partial, final, entries)
550
+ return final
551
+
552
+
553
+ def _refuse_resume(partial: Path, why: str) -> _Exit:
554
+ return _Exit(EXIT_REFUSE, f"cannot resume interrupted quarantine {partial}: {why}")
555
+
556
+
557
+ def _load_manifest(db: Path, meta: Path, partial: Path) -> list[tuple[str, Path, str]]:
558
+ """manifest 를 읽어 현재 DB·메타에서 유도한 대상과 대조한다. 어긋나면 exit 2."""
559
+ manifest = partial / _MANIFEST
560
+ if not stat.S_ISREG(os.lstat(manifest).st_mode):
561
+ raise _refuse_resume(partial, "manifest.json is not a regular file (or is a symlink)")
562
+ try:
563
+ data = json.loads(manifest.read_text())
564
+ except (OSError, ValueError) as exc:
565
+ raise _refuse_resume(partial, f"cannot read manifest: {exc}") from None
566
+ if (
567
+ type(data) is not dict
568
+ or type(data.get("version")) is not int
569
+ or data["version"] != _MANIFEST_VERSION
570
+ or type(data.get("db")) is not str
571
+ or type(data.get("entries")) is not list
572
+ ):
573
+ raise _refuse_resume(partial, "manifest schema mismatch")
574
+ if data["db"] != str(db):
575
+ raise _refuse_resume(partial, f"manifest is for db {data['db']!r}, not {db}")
576
+
577
+ expected = dict(quarantine_roles(db, meta))
578
+ order = list(expected)
579
+ entries: list[tuple[str, Path, str]] = []
580
+ for item in data["entries"]:
581
+ if type(item) is not dict or set(item) != {"role", "src", "name"}:
582
+ raise _refuse_resume(partial, "manifest entry schema mismatch")
583
+ role, src, name = item["role"], item["src"], item["name"]
584
+ if not all(type(v) is str for v in (role, src, name)) or role not in expected:
585
+ raise _refuse_resume(partial, f"manifest entry has invalid role {role!r}")
586
+ if src != str(expected[role]):
587
+ if role == "meta":
588
+ raise _refuse_resume(
589
+ partial,
590
+ f"meta path in manifest {src!r} differs from --meta-path {expected[role]}; "
591
+ "rerun with the original --meta-path",
592
+ )
593
+ raise _refuse_resume(partial, f"manifest entry {role} has unexpected src {src!r}")
594
+ if name != expected[role].name or _reserved(name):
595
+ raise _refuse_resume(partial, f"manifest entry {role} has invalid name {name!r}")
596
+ entries.append((role, expected[role], name))
597
+ roles = [r for r, _, _ in entries]
598
+ if len(set(roles)) != len(roles) or roles != sorted(roles, key=order.index):
599
+ raise _refuse_resume(partial, "manifest entries are duplicated or out of order")
600
+ return entries
601
+
602
+
603
+ def resume_quarantine(db: Path, meta: Path) -> None:
604
+ """앞선 부팅이 격리 도중 죽었으면(``.partial`` 이 남음) 판정 전에 끝낸다.
605
+
606
+ 전체를 먼저 검증하고, 하나라도 어긋나면 아무것도 옮기지 않고 exit 2 다. 둘 이상이면 거부.
607
+ """
608
+ db, meta = _real_pair(db, meta)
609
+ check_no_overlap(db, meta)
610
+ pattern = _partial_re(db)
611
+ try:
612
+ found = sorted(n for n in os.listdir(db.parent) if pattern.fullmatch(n))
613
+ except OSError as exc:
614
+ raise _Exit(EXIT_REFUSE, f"cannot list {db.parent}: {exc}") from None
615
+ if not found:
616
+ return
617
+ if len(found) > 1:
618
+ raise _Exit(
619
+ EXIT_REFUSE,
620
+ f"more than one interrupted quarantine in {db.parent}: {', '.join(found)}; "
621
+ "inspect and merge manually",
622
+ )
623
+ partial = db.parent / found[0]
624
+ final = db.with_name(found[0].removesuffix(".partial"))
625
+ if not stat.S_ISDIR(os.lstat(partial).st_mode):
626
+ raise _refuse_resume(partial, "it is not a directory (or is a symlink)")
627
+ contents = set(os.listdir(partial))
628
+ if _MANIFEST not in contents:
629
+ # manifest 를 쓰기 전에 죽었다. manifest 는 무엇보다 먼저 쓰므로 옮긴 파일이 없다.
630
+ leftovers = sorted(n for n in contents if not _own_manifest_file(n))
631
+ if leftovers:
632
+ raise _refuse_resume(partial, f"no manifest but contains {leftovers}")
633
+ shutil.rmtree(partial)
634
+ fsync_path(partial.parent)
635
+ log(f"quarantine: removed {partial} (interrupted before any file was moved)")
636
+ return
637
+ entries = _load_manifest(db, meta, partial)
638
+ unknown = sorted(contents - {_MANIFEST} - {n for _, _, n in entries})
639
+ if unknown:
640
+ raise _refuse_resume(partial, f"unexpected files {unknown}")
641
+ # manifest 에 없는 역할의 파일이 어디에든 있으면 거부한다. 자동으로 보충하지 않는다.
642
+ # (예: WAL 항목이 빠진 manifest 로 DB 만 격리하면 DB 와 WAL 이 갈라진다.)
643
+ listed = {role for role, _, _ in entries}
644
+ for role, path in quarantine_roles(db, meta):
645
+ if role in listed:
646
+ continue
647
+ for where in (path, partial / path.name):
648
+ if os.path.lexists(where):
649
+ raise _refuse_resume(partial, f"{role} {where} exists but is not in the manifest")
650
+ for role, src, name in entries:
651
+ here, there = os.path.lexists(src), os.path.lexists(partial / name)
652
+ if here == there:
653
+ where = "both the original place and" if here else "neither the original place nor"
654
+ raise _refuse_resume(partial, f"{role} {name} is in {where} the quarantine")
655
+ if os.path.islink(src if here else partial / name):
656
+ raise _refuse_resume(partial, f"{role} {name} is a symbolic link")
657
+ if os.path.lexists(final):
658
+ raise _refuse_resume(partial, f"{final} already exists")
659
+ log(f"quarantine: resuming interrupted {partial}")
660
+ _finish(partial, final, entries)
661
+
662
+
663
+ # --- 3. 판정 입력 -------------------------------------------------------------------------
664
+
665
+
666
+ def local_db_state(db: Path) -> tuple[bool, bool]:
667
+ """(DB 가 정규 파일로 있음, 그 파일이 0바이트). lstat 한 번으로 둘 다 본다.
668
+
669
+ 없으면 ``(False, False)``. 그 밖의 것(디렉터리·링크 등)이면 거부. 잠금을 쥔 뒤에 부르므로
670
+ 다른 boot 와 경쟁하지 않는다. 잠금 밖의 프로세스가 그 사이에 쓰는 경우는 막지 않는다.
671
+ """
672
+ try:
673
+ st = os.lstat(db)
674
+ except FileNotFoundError:
675
+ return False, False
676
+ except OSError as exc:
677
+ raise _Exit(EXIT_REFUSE, f"cannot stat {db}: {exc}") from None
678
+ if not stat.S_ISREG(st.st_mode):
679
+ raise _Exit(EXIT_REFUSE, f"{db} exists but is not a regular file")
680
+ return True, st.st_size == 0
681
+
682
+
683
+ def local_db_exists(db: Path) -> bool:
684
+ """DB 경로가 정규 파일이면 참, 없으면 거짓. 그 밖의 것(디렉터리·링크 등)이면 거부."""
685
+ return local_db_state(db)[0]
686
+
687
+
688
+ # --- 5. 임시 복원·설치 -----------------------------------------------------------------------
689
+
690
+
691
+ def restore_to_temp(db: Path, args: argparse.Namespace) -> tuple[Path, int]:
692
+ """DB 와 같은 디렉터리의 새 ``<db>.restore-<ts>-<rand>/`` 로 복원한다. 실패하면 exit 4.
693
+
694
+ 실패한 임시 디렉터리는 조사용으로 남긴다. 다음 부팅은 이 디렉터리를 판정에 쓰지 않는다.
695
+ """
696
+ tmpdir = db.with_name(f"{db.name}{_RESTORE_INFIX}{_timestamp()}-{secrets.token_hex(4)}")
697
+ try:
698
+ os.mkdir(tmpdir, 0o755)
699
+ except OSError as exc:
700
+ raise _Exit(EXIT_RESTORE, f"cannot create restore directory {tmpdir}: {exc}") from None
701
+ out = tmpdir / db.name
702
+ log(f"restore: {db} -> {out}")
703
+ result = ls.restore(
704
+ db,
705
+ out,
706
+ config=args.config,
707
+ binary=args.litestream,
708
+ timeout=args.restore_timeout,
709
+ )
710
+ if not result.ok:
711
+ raise _Exit(EXIT_RESTORE, f"restore failed: {result.reason} (left {tmpdir} for inspection)")
712
+ extra = [n for n in os.listdir(tmpdir) if n != db.name and n != db.name + "-shm"]
713
+ if extra:
714
+ raise _Exit(
715
+ EXIT_RESTORE,
716
+ f"restore left unexpected files {sorted(extra)} in {tmpdir}; not installing",
717
+ )
718
+ assert result.txid is not None
719
+ log(f"restore: ok, txid {result.txid}")
720
+ return out, result.txid
721
+
722
+
723
+ def install(restored: Path, db: Path) -> None:
724
+ """임시 복원 DB 를 DB 경로로 rename 한다. 그 자리에 무엇이 있으면 거부한다."""
725
+ if os.path.lexists(db):
726
+ raise _Exit(EXIT_REFUSE, f"cannot install restored db: {db} exists")
727
+ for side in sidecars(db):
728
+ if os.path.lexists(side):
729
+ raise _Exit(EXIT_REFUSE, f"cannot install restored db: {side} exists")
730
+ fsync_path(restored)
731
+ fsync_path(restored.parent)
732
+ os.rename(restored, db)
733
+ fsync_path(db.parent)
734
+ shutil.rmtree(restored.parent)
735
+ log(f"install: {db}")
736
+
737
+
738
+ # --- 6. 무결성 ----------------------------------------------------------------------------
739
+
740
+
741
+ def integrity_problem(db: Path) -> str | None:
742
+ """읽기 전용 URI 로 ``PRAGMA quick_check``. 정상이면 ``None``, 아니면 사유."""
743
+ uri = f"file:{quote(os.path.abspath(db))}?mode=ro"
744
+ try:
745
+ conn = sqlite3.connect(uri, uri=True)
746
+ try:
747
+ rows = conn.execute("PRAGMA quick_check").fetchall()
748
+ finally:
749
+ conn.close()
750
+ except sqlite3.Error as exc:
751
+ return f"cannot check {db}: {exc}"
752
+ if rows != [("ok",)]:
753
+ return f"quick_check failed: {' | '.join(str(r[0]) for r in rows[:5])}"
754
+ return None
755
+
756
+
757
+ # --- 7. 상태 파일 -------------------------------------------------------------------------
758
+
759
+
760
+ def write_state(db: Path, plan: "_Plan", *, litestream_version: str | None) -> Path:
761
+ path = state_path(db)
762
+ _write_json_atomic(
763
+ path,
764
+ {
765
+ "version": _STATE_VERSION,
766
+ "state": plan.state,
767
+ "action": plan.action,
768
+ "reason_code": plan.reason_code,
769
+ "reason": plan.reason,
770
+ "unknown_at_boot": plan.action == Action.KEEP_LOCAL,
771
+ "litestream_version": litestream_version,
772
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
773
+ },
774
+ )
775
+ return path
776
+
777
+
778
+ # --- 인자 ---------------------------------------------------------------------------------
779
+
780
+
781
+ class _Parser(argparse.ArgumentParser):
782
+ def error(self, message: str) -> None: # type: ignore[override]
783
+ self.print_usage(sys.stderr)
784
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
785
+ raise SystemExit(EXIT_USAGE)
786
+
787
+
788
+ def _seconds(text: str) -> float:
789
+ try:
790
+ value = float(text)
791
+ except ValueError:
792
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
793
+ if not math.isfinite(value) or value <= 0:
794
+ raise argparse.ArgumentTypeError(f"must be a positive number of seconds: {text!r}")
795
+ return value
796
+
797
+
798
+ def build_parser() -> argparse.ArgumentParser:
799
+ p = _Parser(
800
+ prog="python -m django_sqlite_ops.boot",
801
+ description=(
802
+ "Decide whether the local SQLite DB can be used, restore it from Litestream if "
803
+ "needed, then exec the command after '--'. Refuses to start when it cannot decide."
804
+ ),
805
+ epilog="exit codes: 2 refused, 3 integrity, 4 restore failed, 5 lock, 64 usage, "
806
+ "127 exec failed",
807
+ )
808
+ p.add_argument("--db", required=True, help="SQLite database path")
809
+ p.add_argument("--config", required=True, type=Path, help="litestream config path")
810
+ p.add_argument(
811
+ "--on-unknown",
812
+ choices=[o.value for o in OnUnknown],
813
+ default=OnUnknown.REFUSE.value,
814
+ help="what to do when the state is unknown (default: refuse)",
815
+ )
816
+ p.add_argument(
817
+ "--adopt-existing",
818
+ action="store_true",
819
+ help="first deploy of an existing db to litestream; turn off afterwards (D-11)",
820
+ )
821
+ p.add_argument(
822
+ "--init-new",
823
+ action="store_true",
824
+ help="first deploy ever: start a new db on an empty replica; turn off afterwards (D-13)",
825
+ )
826
+ p.add_argument("--meta-path", help="litestream meta dir (default: .<db>-litestream)")
827
+ p.add_argument("--litestream", default="litestream", help="litestream binary")
828
+ p.add_argument("--ltx-timeout", type=_seconds, default=ls.DEFAULT_LTX_TIMEOUT)
829
+ p.add_argument("--restore-timeout", type=_seconds, default=ls.DEFAULT_RESTORE_TIMEOUT)
830
+ return p
831
+
832
+
833
+ def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
834
+ parser = build_parser()
835
+ if "--" in argv:
836
+ i = argv.index("--")
837
+ own, command = argv[:i], argv[i + 1 :]
838
+ else:
839
+ own, command = argv, []
840
+ args = parser.parse_args(own)
841
+ if not command:
842
+ parser.error("missing command after '--'")
843
+ # 잠금·사이드카·임시 복원·격리·설치·상태 파일과 Litestream 호출이 모두 이 경로를 쓴다.
844
+ try:
845
+ args.db = real_path(args.db)
846
+ meta = ls.default_meta_path(args.db) if args.meta_path is None else args.meta_path
847
+ args.meta_path = real_path(meta)
848
+ except ValueError as exc:
849
+ parser.error(str(exc))
850
+ # 잠금 파일을 만들기 전에 거부한다. 메타가 잠금 파일이면 격리가 boot 의 잠금을 옮긴다(#39).
851
+ try:
852
+ conflict = meta_conflict(args.db, args.meta_path, args.config)
853
+ except OSError as exc:
854
+ parser.error(f"cannot compare --meta-path with boot-owned paths: {exc}")
855
+ if conflict is not None:
856
+ parser.error(conflict)
857
+ return args, command
858
+
859
+
860
+ # --- 판정과 조치 ---------------------------------------------------------------------------
861
+
862
+
863
+ @dataclass(frozen=True, slots=True)
864
+ class _Plan:
865
+ state: str
866
+ action: str
867
+ reason_code: str
868
+ reason: str
869
+ # 사유 코드의 기본 힌트 대신 쓸 힌트. 같은 사유 코드라도 입력에 따라 안내가 다를 때만 쓴다.
870
+ hint: str | None = None
871
+
872
+
873
+ _HINTS = {
874
+ ReasonCode.NO_REPLICA_NO_LOCAL: (
875
+ "if this is the very first deploy, run once with --init-new and remove it afterwards "
876
+ "(D-13); otherwise check the replica path/prefix in the litestream config"
877
+ ),
878
+ ReasonCode.REMOTE_ERROR: (
879
+ "fix replica access (endpoint needs http:// for plain HTTP, credentials, network) and "
880
+ "retry; boot refuses on lookup failure regardless of --on-unknown (D-12)"
881
+ ),
882
+ ReasonCode.NO_LOCAL_META: (
883
+ "if the previous boot restored this db and litestream died before writing its first "
884
+ "L0 (D-14), rerun once with --on-unknown restore; to put an existing db on an empty "
885
+ "replica for the first time, use --adopt-existing once (D-11)"
886
+ ),
887
+ ReasonCode.STALE_META: (
888
+ "litestream meta exists without the db; rerun with --on-unknown restore to quarantine "
889
+ "it and restore from the replica"
890
+ ),
891
+ ReasonCode.REMOTE_EMPTY: (
892
+ "local db has replicated before but the replica is empty: check the replica path/prefix; "
893
+ "to keep the local db anyway use --on-unknown keep-local"
894
+ ),
895
+ ReasonCode.REMOTE_AHEAD: (
896
+ "the replica is newer than this volume (another host wrote after it); rerun with "
897
+ "--on-unknown restore to quarantine local files and restore"
898
+ ),
899
+ ReasonCode.EMPTY_DB: (
900
+ "the db file is 0 bytes but litestream meta says it was replicated, so its txid is "
901
+ "meaningless and --on-unknown keep-local is refused too (D-17); check that the file is "
902
+ "really empty, then if the replica has data rerun with --on-unknown restore to "
903
+ "quarantine the empty file, sidecars and meta and restore from it; if the replica is "
904
+ "empty, inspect and remove the empty file, sidecars and meta yourself and boot once "
905
+ "with --init-new (D-13); if the remote lookup failed, fix replica access first (D-12)"
906
+ ),
907
+ ORPHAN_SIDECARS: (
908
+ "-wal/-shm/-journal exist without the db file; inspect them, or rerun with "
909
+ "--on-unknown restore to quarantine them and restore from the replica"
910
+ ),
911
+ }
912
+
913
+ # 0바이트 + 메타 없음(no_local_meta)의 힌트. 원격 결과로 고른다(#44, D-18).
914
+ # 원격 조회 실패는 remote_error 로 먼저 가므로 여기 오지 않는다.
915
+ _EMPTY_NO_META_HINTS = {
916
+ RemoteTxid: (
917
+ "the db file is 0 bytes and has no litestream meta, so --on-unknown keep-local is "
918
+ "refused too (D-18); check that the file is really empty, then rerun with "
919
+ "--on-unknown restore to quarantine the empty file and sidecars and restore from the "
920
+ "replica"
921
+ ),
922
+ RemoteEmpty: (
923
+ "the db file is 0 bytes and has no litestream meta, and the replica is empty; "
924
+ "--adopt-existing does not adopt a 0-byte file and --on-unknown keep-local is refused "
925
+ "too (D-18); check the replica path/prefix, then inspect and remove the empty file and "
926
+ "sidecars yourself and boot once with --init-new (D-13)"
927
+ ),
928
+ }
929
+
930
+
931
+ def _plan(args: argparse.Namespace) -> _Plan:
932
+ db, meta = args.db, args.meta_path
933
+ local_exists, local_empty = local_db_state(db)
934
+ local_txid = ls.local_max_txid(db, meta_path=meta)
935
+ remote = ls.remote_max_txid(
936
+ db, config=args.config, binary=args.litestream, timeout=args.ltx_timeout
937
+ )
938
+ if type(remote) is RemoteTxid:
939
+ remote_text = f"txid {remote.txid}"
940
+ elif type(remote) is RemoteError:
941
+ remote_text = f"error: {remote.message}"
942
+ else:
943
+ remote_text = "empty"
944
+ log(
945
+ f"inputs: local_exists={local_exists} local_empty={local_empty} "
946
+ f"local_txid={local_txid} remote={remote_text}"
947
+ )
948
+
949
+ orphans = [p.name for p in sidecars(db) if os.path.lexists(p)]
950
+ if not local_exists and orphans:
951
+ reason = f"no local db but {', '.join(orphans)} exist; remote {remote_text}"
952
+ if args.on_unknown == OnUnknown.RESTORE.value and type(remote) is RemoteTxid:
953
+ return _Plan(State.UNKNOWN, Action.QUARANTINE_AND_RESTORE, ORPHAN_SIDECARS, reason)
954
+ return _Plan(State.UNKNOWN, Action.REFUSE, ORPHAN_SIDECARS, reason)
955
+
956
+ d = decide(
957
+ local_exists=local_exists,
958
+ local_empty=local_empty,
959
+ local_txid=local_txid,
960
+ remote=remote,
961
+ on_unknown=args.on_unknown,
962
+ adopt_existing=args.adopt_existing,
963
+ init_new=args.init_new,
964
+ )
965
+ reason = d.reason
966
+ empty_reason = d.reason_code in (ReasonCode.EMPTY_DB, ReasonCode.NO_LOCAL_META)
967
+ if local_empty and empty_reason and orphans:
968
+ # 0바이트 DB 옆에 남은 사이드카는 격리 대상이다. 무엇이 있는지 사유에 남긴다.
969
+ present = ", ".join(name.removeprefix(db.name) for name in orphans)
970
+ reason = reason.replace("0 bytes", f"0 bytes ({present} present)", 1)
971
+ hint = None
972
+ if local_empty and d.reason_code is ReasonCode.NO_LOCAL_META:
973
+ hint = _EMPTY_NO_META_HINTS.get(type(remote))
974
+ return _Plan(d.state, d.action, d.reason_code, reason, hint)
975
+
976
+
977
+ def _act(args: argparse.Namespace, plan: _Plan) -> None:
978
+ db, meta = args.db, args.meta_path
979
+ if plan.action == Action.REFUSE:
980
+ hint = plan.hint or _HINTS.get(plan.reason_code)
981
+ if hint:
982
+ log(f"hint: {hint}")
983
+ raise _Exit(EXIT_REFUSE, f"refused ({plan.reason_code}): {plan.reason}")
984
+ if plan.action in (Action.RESTORE, Action.QUARANTINE_AND_RESTORE):
985
+ # 복원·검증을 먼저 끝낸다. 실패하면 로컬은 그대로다(L7).
986
+ check_quarantinable(db, meta)
987
+ restored, _ = restore_to_temp(db, args)
988
+ stale = quarantine(db, meta)
989
+ if stale is None and plan.action == Action.QUARANTINE_AND_RESTORE:
990
+ log("quarantine: nothing to move")
991
+ install(restored, db)
992
+ return
993
+ if plan.action == Action.KEEP_LOCAL:
994
+ log(f"WARNING: unknown_at_boot ({plan.reason_code}); keeping local db as requested")
995
+ return
996
+ assert plan.action == Action.PROCEED
997
+ if plan.state == State.ADOPT:
998
+ log("note: turn off --adopt-existing after this first deploy (D-11)")
999
+ elif plan.reason_code == ReasonCode.NEW_DB:
1000
+ log("note: new db will be created by the app; turn off --init-new after this deploy (D-13)")
1001
+
1002
+
1003
+ def run(args: argparse.Namespace, command: list[str]) -> int:
1004
+ """잠금부터 exec 까지. exec 에 성공하면 돌아오지 않는다(잠금은 exec 된 명령이 쥔다)."""
1005
+ lock_fd = acquire_lock(args.db)
1006
+ try:
1007
+ return _run_locked(args, command, lock_fd)
1008
+ finally:
1009
+ # exec 에 성공하면 여기 오지 않는다. 실패·거부로 돌아올 때만 잠금을 푼다.
1010
+ os.close(lock_fd)
1011
+
1012
+
1013
+ def _run_locked(args: argparse.Namespace, command: list[str], lock_fd: int) -> int:
1014
+ db = args.db
1015
+ log(f"lock: {lock_path(db)}")
1016
+ # 재개·판정보다 먼저. 정규 파일·FIFO 메타를 판정 입력이나 격리 대상으로 쓰지 않는다(#39).
1017
+ check_meta_kind(args.meta_path)
1018
+ try:
1019
+ resume_quarantine(db, args.meta_path)
1020
+ except OSError as exc:
1021
+ raise _Exit(EXIT_REFUSE, f"cannot finish interrupted quarantine: {exc}") from None
1022
+
1023
+ plan = _plan(args)
1024
+ log(
1025
+ f"decision: state={plan.state} action={plan.action} "
1026
+ f"reason_code={plan.reason_code} reason={plan.reason}"
1027
+ )
1028
+ try:
1029
+ _act(args, plan)
1030
+ except OSError as exc:
1031
+ # 격리·설치 도중의 파일 오류. 격리가 반쯤이면 .partial 이 남고 다음 부팅이 이어서 끝낸다.
1032
+ raise _Exit(EXIT_RESTORE, f"restore/quarantine step failed: {exc}") from None
1033
+
1034
+ if local_db_exists(db):
1035
+ problem = integrity_problem(db)
1036
+ if problem is not None:
1037
+ raise _Exit(EXIT_INTEGRITY, f"integrity: {problem}")
1038
+ log("integrity: quick_check ok")
1039
+ else:
1040
+ log("integrity: skipped (no db file yet; the app creates it)")
1041
+
1042
+ try:
1043
+ path = write_state(db, plan, litestream_version=ls.version(binary=args.litestream))
1044
+ except OSError as exc:
1045
+ raise _Exit(EXIT_REFUSE, f"cannot write boot state: {exc}") from None
1046
+ log(f"state: {path}")
1047
+
1048
+ # 잠금을 exec 된 명령에 넘긴다. 그 명령(과 그 자식)이 살아 있는 동안 두 번째 boot 가 막힌다.
1049
+ os.set_inheritable(lock_fd, True)
1050
+ log(f"exec: {' '.join(command)}")
1051
+ sys.stdout.flush()
1052
+ sys.stderr.flush()
1053
+ try:
1054
+ os.execvp(command[0], command)
1055
+ except OSError as exc:
1056
+ raise _Exit(EXIT_EXEC, f"cannot exec {command[0]}: {exc}") from None
1057
+ raise AssertionError("unreachable") # pragma: no cover
1058
+
1059
+
1060
+ def main(argv: list[str] | None = None) -> int:
1061
+ args, command = parse_args(sys.argv[1:] if argv is None else argv)
1062
+ try:
1063
+ return run(args, command)
1064
+ except _Exit as exc:
1065
+ log(f"exit {exc.code}: {exc.message}")
1066
+ return exc.code