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.
- django_sqlite_ops/__init__.py +1 -0
- django_sqlite_ops/apps.py +19 -0
- django_sqlite_ops/boot/__init__.py +4 -0
- django_sqlite_ops/boot/__main__.py +6 -0
- django_sqlite_ops/boot/cli.py +1066 -0
- django_sqlite_ops/boot/decide.py +350 -0
- django_sqlite_ops/boot/litestream.py +542 -0
- django_sqlite_ops/checks.py +400 -0
- django_sqlite_ops/compat/__init__.py +1 -0
- django_sqlite_ops/compat/_channels_lite_aio.py +49 -0
- django_sqlite_ops/compat/channels_lite.py +132 -0
- django_sqlite_ops/database.py +118 -0
- django_sqlite_ops/doctor.py +1051 -0
- django_sqlite_ops/health.py +1129 -0
- django_sqlite_ops/management/__init__.py +0 -0
- django_sqlite_ops/management/commands/__init__.py +0 -0
- django_sqlite_ops/management/commands/sqlite_doctor.py +161 -0
- django_sqlite_ops/restore_check.py +709 -0
- django_sqlite_ops/wal.py +287 -0
- django_sqlite_ops-0.1.0a1.dist-info/METADATA +987 -0
- django_sqlite_ops-0.1.0a1.dist-info/RECORD +23 -0
- django_sqlite_ops-0.1.0a1.dist-info/WHEEL +4 -0
- django_sqlite_ops-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|