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,1051 @@
|
|
|
1
|
+
"""``manage.py sqlite_doctor`` 의 진단 로직 (DESIGN §6-2).
|
|
2
|
+
|
|
3
|
+
정적 체크(§6-1)와 달리 DB 에 실제로 연결한다. 연결하면 Django 가 ``init_command`` 를 실행하므로
|
|
4
|
+
진단이 DB 상태를 바꿀 수 있다(예: ``PRAGMA journal_mode=WAL`` 은 파일에 남는다). 그래서 명시적으로
|
|
5
|
+
실행하는 명령으로만 둔다. **DB 파일이 없으면 연결하지 않는다.** 연결하면 빈 DB 가 생겨 boot 의
|
|
6
|
+
복원 판정을 망친다(DESIGN §4-1).
|
|
7
|
+
|
|
8
|
+
항목마다 수준은 ``ok`` · ``warn`` · ``error`` · ``unknown`` 이다. 판정할 수 없으면 숨기지 않고
|
|
9
|
+
``unknown`` 으로 적고, 종료 코드에서는 경고로 센다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections.abc import Iterable, Mapping
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
from django.db import connections
|
|
24
|
+
|
|
25
|
+
from .boot import litestream
|
|
26
|
+
from .checks import _TRUE, _is_litestream_vfs, _options, _profile, _role, _uri
|
|
27
|
+
from .compat import channels_lite
|
|
28
|
+
from .database import DEFAULT_PROFILE, ENGINE, recommended
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import resource
|
|
32
|
+
except ImportError: # Windows 등. 표준 라이브러리 resource 는 Unix 전용이다
|
|
33
|
+
resource = None
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"LEVELS",
|
|
37
|
+
"SCHEMA_VERSION",
|
|
38
|
+
"Item",
|
|
39
|
+
"classify_fstype",
|
|
40
|
+
"diagnose",
|
|
41
|
+
"exit_code",
|
|
42
|
+
"filesystem_type",
|
|
43
|
+
"format_text",
|
|
44
|
+
"mountinfo_fstype",
|
|
45
|
+
"mount_output_fstype",
|
|
46
|
+
"nofile_items",
|
|
47
|
+
"to_json",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
SCHEMA_VERSION = 1
|
|
51
|
+
LEVELS = ("ok", "warn", "error", "unknown")
|
|
52
|
+
SECTIONS = ("settings", "database", "mount", "litestream", "channels", "process", "restore")
|
|
53
|
+
|
|
54
|
+
# PRAGMA 값 매핑의 정본. SQLite 는 synchronous 를 정수로 돌려준다(https://www.sqlite.org/pragma.html).
|
|
55
|
+
_SYNCHRONOUS = {0: "OFF", 1: "NORMAL", 2: "FULL", 3: "EXTRA"}
|
|
56
|
+
# 읽기 전용 별칭에는 의미가 없거나 따르면 연결이 깨지는 쓰기 권고(정적 체크 W001·W002 와 같은 원칙).
|
|
57
|
+
_WRITE_KEYS = frozenset({"transaction_mode", "journal_mode", "synchronous"})
|
|
58
|
+
|
|
59
|
+
_NETWORK_FS_DOC = "https://www.sqlite.org/useovernet.html"
|
|
60
|
+
# 네트워크 파일시스템. SQLite 의 파일 잠금을 믿을 수 없고 WAL 은 동작하지 않는다.
|
|
61
|
+
_NETWORK_FS = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"nfs",
|
|
64
|
+
"nfs4",
|
|
65
|
+
"cifs",
|
|
66
|
+
"smb",
|
|
67
|
+
"smb2",
|
|
68
|
+
"smb3",
|
|
69
|
+
"smbfs",
|
|
70
|
+
"afpfs",
|
|
71
|
+
"webdav",
|
|
72
|
+
"davfs",
|
|
73
|
+
"fuse.davfs",
|
|
74
|
+
"sshfs",
|
|
75
|
+
"fuse.sshfs",
|
|
76
|
+
"9p",
|
|
77
|
+
"ceph",
|
|
78
|
+
"fuse.ceph",
|
|
79
|
+
"glusterfs",
|
|
80
|
+
"fuse.glusterfs",
|
|
81
|
+
"lustre",
|
|
82
|
+
"gpfs",
|
|
83
|
+
"fuse.s3fs",
|
|
84
|
+
"fuse.rclone",
|
|
85
|
+
"fuse.gcsfuse",
|
|
86
|
+
"fuse.juicefs",
|
|
87
|
+
"efs",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
# 로컬 디스크·메모리 파일시스템. 여기에도 네트워크에도 없으면 판정하지 않는다(unknown).
|
|
91
|
+
_LOCAL_FS = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"ext2",
|
|
94
|
+
"ext3",
|
|
95
|
+
"ext4",
|
|
96
|
+
"xfs",
|
|
97
|
+
"btrfs",
|
|
98
|
+
"zfs",
|
|
99
|
+
"f2fs",
|
|
100
|
+
"jfs",
|
|
101
|
+
"reiserfs",
|
|
102
|
+
"bcachefs",
|
|
103
|
+
"overlay",
|
|
104
|
+
"tmpfs",
|
|
105
|
+
"ramfs",
|
|
106
|
+
"apfs",
|
|
107
|
+
"hfs",
|
|
108
|
+
"ufs",
|
|
109
|
+
"ffs",
|
|
110
|
+
"msdos",
|
|
111
|
+
"vfat",
|
|
112
|
+
"exfat",
|
|
113
|
+
"ntfs",
|
|
114
|
+
"ntfs3",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class Item:
|
|
121
|
+
"""진단 항목 하나. ``--json`` 의 ``items`` 배열 원소와 같은 모양이다(DESIGN §6-2)."""
|
|
122
|
+
|
|
123
|
+
section: str
|
|
124
|
+
alias: str | None
|
|
125
|
+
key: str
|
|
126
|
+
level: str
|
|
127
|
+
value: Any = None
|
|
128
|
+
expected: Any = None
|
|
129
|
+
message: str = ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _one_line(text: object) -> str:
|
|
133
|
+
return " ".join(str(text).split())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- 마운트 -----------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def classify_fstype(fstype: str | None) -> tuple[str, str]:
|
|
140
|
+
"""파일시스템 종류를 ``(수준, 사유)`` 로 판정한다."""
|
|
141
|
+
if not fstype:
|
|
142
|
+
return "unknown", "cannot determine the filesystem type"
|
|
143
|
+
name = fstype.lower()
|
|
144
|
+
if name in _NETWORK_FS:
|
|
145
|
+
return (
|
|
146
|
+
"warn",
|
|
147
|
+
f"network filesystem: SQLite file locking is unreliable and WAL does not work "
|
|
148
|
+
f"over a network filesystem ({_NETWORK_FS_DOC})",
|
|
149
|
+
)
|
|
150
|
+
if name in _LOCAL_FS:
|
|
151
|
+
return "ok", "local filesystem"
|
|
152
|
+
return "unknown", f"filesystem type {fstype!r} is not known to be local or network"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _unescape_mountinfo(field: str) -> str:
|
|
156
|
+
# 공백·탭·줄바꿈·역슬래시는 8진수 이스케이프로 나온다(proc(5) mountinfo).
|
|
157
|
+
out, i = [], 0
|
|
158
|
+
while i < len(field):
|
|
159
|
+
if field[i] == "\\" and field[i + 1 : i + 4].isdigit():
|
|
160
|
+
out.append(chr(int(field[i + 1 : i + 4], 8)))
|
|
161
|
+
i += 4
|
|
162
|
+
else:
|
|
163
|
+
out.append(field[i])
|
|
164
|
+
i += 1
|
|
165
|
+
return "".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _under(path: str, mount_point: str) -> bool:
|
|
169
|
+
if mount_point == "/":
|
|
170
|
+
return path.startswith("/")
|
|
171
|
+
return path == mount_point or path.startswith(mount_point.rstrip("/") + "/")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def mountinfo_fstype(text: str, path: str) -> str | None:
|
|
175
|
+
"""``/proc/self/mountinfo`` 텍스트에서 ``path`` 가 속한 마운트의 종류를 찾는다.
|
|
176
|
+
|
|
177
|
+
가장 긴 마운트 지점이 이긴다. 같은 지점에 여러 번 마운트됐으면 나중 줄(위에 덮인 것)이 이긴다.
|
|
178
|
+
``path`` 는 실제 경로(링크를 푼 절대 경로)여야 한다.
|
|
179
|
+
"""
|
|
180
|
+
best: tuple[int, str] | None = None
|
|
181
|
+
for line in text.splitlines():
|
|
182
|
+
left, sep, right = line.partition(" - ")
|
|
183
|
+
if not sep:
|
|
184
|
+
continue
|
|
185
|
+
fields = left.split()
|
|
186
|
+
rest = right.split()
|
|
187
|
+
if len(fields) < 5 or not rest:
|
|
188
|
+
continue
|
|
189
|
+
mount_point = _unescape_mountinfo(fields[4])
|
|
190
|
+
if _under(path, mount_point) and (best is None or len(mount_point) >= best[0]):
|
|
191
|
+
best = (len(mount_point), rest[0])
|
|
192
|
+
return best[1] if best else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def mount_output_fstype(text: str, path: str) -> str | None:
|
|
196
|
+
"""BSD·macOS ``mount`` 출력(``<장치> on <지점> (<종류>, ...)``)에서 종류를 찾는다."""
|
|
197
|
+
best: tuple[int, str] | None = None
|
|
198
|
+
for line in text.splitlines():
|
|
199
|
+
head, sep, tail = line.rpartition(" (")
|
|
200
|
+
if not sep or " on " not in head:
|
|
201
|
+
continue
|
|
202
|
+
mount_point = head.split(" on ", 1)[1]
|
|
203
|
+
fstype = tail.split(",", 1)[0].rstrip(")").strip()
|
|
204
|
+
if fstype and _under(path, mount_point) and (best is None or len(mount_point) >= best[0]):
|
|
205
|
+
best = (len(mount_point), fstype)
|
|
206
|
+
return best[1] if best else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _MacStatfs(ctypes.Structure):
|
|
210
|
+
# <sys/mount.h> 의 64비트 inode struct statfs (macOS).
|
|
211
|
+
_fields_ = [
|
|
212
|
+
("f_bsize", ctypes.c_uint32),
|
|
213
|
+
("f_iosize", ctypes.c_int32),
|
|
214
|
+
("f_blocks", ctypes.c_uint64),
|
|
215
|
+
("f_bfree", ctypes.c_uint64),
|
|
216
|
+
("f_bavail", ctypes.c_uint64),
|
|
217
|
+
("f_files", ctypes.c_uint64),
|
|
218
|
+
("f_ffree", ctypes.c_uint64),
|
|
219
|
+
("f_fsid", ctypes.c_int32 * 2),
|
|
220
|
+
("f_owner", ctypes.c_uint32),
|
|
221
|
+
("f_type", ctypes.c_uint32),
|
|
222
|
+
("f_flags", ctypes.c_uint32),
|
|
223
|
+
("f_fssubtype", ctypes.c_uint32),
|
|
224
|
+
("f_fstypename", ctypes.c_char * 16),
|
|
225
|
+
("f_mntonname", ctypes.c_char * 1024),
|
|
226
|
+
("f_mntfromname", ctypes.c_char * 1024),
|
|
227
|
+
("f_flags_ext", ctypes.c_uint32),
|
|
228
|
+
("f_reserved", ctypes.c_uint32 * 7),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _mac_statfs(path: str) -> str | None:
|
|
233
|
+
try:
|
|
234
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
235
|
+
# x86_64 는 64비트 inode 판이 $INODE64 접미사를 단다. arm64 는 접미사가 없다.
|
|
236
|
+
func = getattr(libc, "statfs$INODE64", None) or libc.statfs
|
|
237
|
+
func.argtypes = [ctypes.c_char_p, ctypes.POINTER(_MacStatfs)]
|
|
238
|
+
buf = _MacStatfs()
|
|
239
|
+
if func(os.fsencode(path), ctypes.byref(buf)) != 0:
|
|
240
|
+
return None
|
|
241
|
+
fstype = buf.f_fstypename.decode("utf-8", errors="replace")
|
|
242
|
+
mount_point = buf.f_mntonname.decode("utf-8", errors="replace")
|
|
243
|
+
except (OSError, AttributeError, ValueError):
|
|
244
|
+
return None
|
|
245
|
+
# 구조체 배치가 어긋났으면 지점이 경로의 앞부분이 아니다. 그럴 때는 믿지 않는다.
|
|
246
|
+
if not fstype or not mount_point or not _under(path, mount_point):
|
|
247
|
+
return None
|
|
248
|
+
return fstype
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _existing(path: str) -> str:
|
|
252
|
+
"""``path`` 또는 그 가장 가까운 존재하는 상위 디렉터리의 실제 경로."""
|
|
253
|
+
current = os.path.abspath(path)
|
|
254
|
+
while not os.path.exists(current):
|
|
255
|
+
parent = os.path.dirname(current)
|
|
256
|
+
if parent == current:
|
|
257
|
+
break
|
|
258
|
+
current = parent
|
|
259
|
+
return os.path.realpath(current)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def filesystem_type(path: str) -> str | None:
|
|
263
|
+
"""``path``(없으면 존재하는 상위 디렉터리)의 파일시스템 종류. 모르면 ``None``."""
|
|
264
|
+
target = _existing(path)
|
|
265
|
+
if sys.platform.startswith("linux"):
|
|
266
|
+
try:
|
|
267
|
+
with open("/proc/self/mountinfo", encoding="utf-8", errors="replace") as f:
|
|
268
|
+
return mountinfo_fstype(f.read(), target)
|
|
269
|
+
except OSError:
|
|
270
|
+
return None
|
|
271
|
+
if sys.platform == "darwin":
|
|
272
|
+
fstype = _mac_statfs(target)
|
|
273
|
+
if fstype:
|
|
274
|
+
return fstype
|
|
275
|
+
if os.name != "posix":
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
run = subprocess.run(["mount"], capture_output=True, text=True, timeout=10, check=False)
|
|
279
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
280
|
+
return None
|
|
281
|
+
if run.returncode != 0:
|
|
282
|
+
return None
|
|
283
|
+
return mount_output_fstype(run.stdout, target)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --- DB 별칭 ----------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _db_file(name: Any) -> str | None:
|
|
290
|
+
"""별칭 ``NAME`` 의 파일 경로(URI 면 디코딩한 파일명). 판정할 수 없으면 ``None``."""
|
|
291
|
+
if isinstance(name, os.PathLike):
|
|
292
|
+
name = os.fspath(name)
|
|
293
|
+
if not isinstance(name, str) or not name:
|
|
294
|
+
return None
|
|
295
|
+
uri = _uri(name)
|
|
296
|
+
path = uri[0] if uri is not None else name
|
|
297
|
+
return path or None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _alias_role(name: Any) -> tuple[str, str | None]:
|
|
301
|
+
"""``checks._role()`` 과 같되, ``vfs=litestream`` 이면 ``mode=ro`` 가 함께 있어도 VFS 다.
|
|
302
|
+
|
|
303
|
+
정적 체크는 읽기 전용과 VFS 를 똑같이 건너뛰므로 순서가 상관없지만, doctor 는 VFS 에
|
|
304
|
+
연결하지 않아야 한다(확장이 필요하다).
|
|
305
|
+
"""
|
|
306
|
+
role, reason = _role(name)
|
|
307
|
+
if role == "read-only" and _is_litestream_vfs(name):
|
|
308
|
+
return "vfs", None
|
|
309
|
+
return role, reason
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True, slots=True)
|
|
313
|
+
class _Target:
|
|
314
|
+
"""별칭이 실제로 여는 대상. 모든 섹션이 이 값 하나를 쓴다.
|
|
315
|
+
|
|
316
|
+
``name`` 은 Django 가 ``sqlite3.connect()`` 에 넘기는 ``database`` 다
|
|
317
|
+
(``get_connection_params()`` — 연결을 열지 않는다. Django 5.2·6.1 은 ``NAME`` 뒤에
|
|
318
|
+
``OPTIONS`` 를 병합하므로 ``OPTIONS['database']`` 가 ``NAME`` 을 덮는다).
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
name: Any
|
|
322
|
+
role: str
|
|
323
|
+
reason: str | None
|
|
324
|
+
path: str | None
|
|
325
|
+
overridden: bool = False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _target(alias: str, config: Mapping[str, Any]) -> _Target:
|
|
329
|
+
try:
|
|
330
|
+
params = connections[alias].get_connection_params()
|
|
331
|
+
except Exception as exc: # noqa: BLE001 - ImproperlyConfigured 등을 한 줄로 보고한다
|
|
332
|
+
return _Target(None, "error", _one_line(f"{type(exc).__name__}: {exc}"), None)
|
|
333
|
+
name = params.get("database")
|
|
334
|
+
if isinstance(name, os.PathLike):
|
|
335
|
+
name = os.fspath(name)
|
|
336
|
+
overridden = "database" in _options(config)
|
|
337
|
+
if not isinstance(name, str):
|
|
338
|
+
return _Target(name, "unknown", "the database parameter is not a string", None, overridden)
|
|
339
|
+
role, reason = _alias_role(name)
|
|
340
|
+
path = _db_file(name) if role in ("write", "read-only") else None
|
|
341
|
+
return _Target(name, role, reason, path, overridden)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _immutable(name: str) -> bool:
|
|
345
|
+
uri = _uri(name)
|
|
346
|
+
return uri is not None and any(v.lower() in _TRUE for v in uri[1].get("immutable", []))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _wal_header(path: str) -> bool | None:
|
|
350
|
+
"""DB 헤더의 쓰기·읽기 버전(오프셋 18·19)이 2(WAL)인지. 읽지 못하면 ``None``.
|
|
351
|
+
|
|
352
|
+
https://www.sqlite.org/fileformat2.html#file_format_version_numbers. 읽기 전용으로 연다.
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
|
|
356
|
+
try:
|
|
357
|
+
header = os.read(fd, 20)
|
|
358
|
+
finally:
|
|
359
|
+
os.close(fd)
|
|
360
|
+
except OSError:
|
|
361
|
+
return None
|
|
362
|
+
if len(header) < 20:
|
|
363
|
+
return None # 빈 파일 등. 연결해도 헤더가 아직 없다
|
|
364
|
+
return header[18] == 2 or header[19] == 2
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _canon(key: str, value: Any) -> Any:
|
|
368
|
+
"""비교용 정규화. 실제 값과 권장값을 같은 표기로 맞춘다."""
|
|
369
|
+
if key == "synchronous":
|
|
370
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
371
|
+
return _SYNCHRONOUS.get(value, value)
|
|
372
|
+
return str(value).upper()
|
|
373
|
+
if key in ("journal_mode", "transaction_mode"):
|
|
374
|
+
return None if value is None else str(value).upper()
|
|
375
|
+
if isinstance(value, bool):
|
|
376
|
+
return int(value)
|
|
377
|
+
return value
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _file_size(path: str) -> int | None:
|
|
381
|
+
try:
|
|
382
|
+
return os.path.getsize(path)
|
|
383
|
+
except OSError:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _read_pragmas(alias: str, keys: Iterable[str]) -> dict[str, Any]:
|
|
388
|
+
conn = connections[alias]
|
|
389
|
+
try:
|
|
390
|
+
conn.ensure_connection() # init_command 가 여기서 실행된다
|
|
391
|
+
values: dict[str, Any] = {"transaction_mode": getattr(conn, "transaction_mode", None)}
|
|
392
|
+
with conn.cursor() as cursor:
|
|
393
|
+
for key in keys:
|
|
394
|
+
cursor.execute(f"PRAGMA {key}")
|
|
395
|
+
row = cursor.fetchone()
|
|
396
|
+
values[key] = row[0] if row else None
|
|
397
|
+
cursor.execute("SELECT sqlite_version()")
|
|
398
|
+
values["sqlite_version"] = cursor.fetchone()[0]
|
|
399
|
+
return values
|
|
400
|
+
finally:
|
|
401
|
+
conn.close()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _diagnose_alias(alias: str, target: _Target, rec: dict[str, Any]) -> list[Item]:
|
|
405
|
+
section = "database"
|
|
406
|
+
role = target.role
|
|
407
|
+
if role == "error":
|
|
408
|
+
return [Item(section, alias, "connect", "error", None, None, target.reason or "")]
|
|
409
|
+
note = "OPTIONS['database'] overrides NAME; checking that path" if target.overridden else ""
|
|
410
|
+
items = [Item(section, alias, "role", "ok", role, None, note)]
|
|
411
|
+
if role == "unknown":
|
|
412
|
+
return [
|
|
413
|
+
Item(section, alias, "role", "unknown", role, None, f"not connected: {target.reason}")
|
|
414
|
+
]
|
|
415
|
+
if role == "vfs":
|
|
416
|
+
return [
|
|
417
|
+
Item(
|
|
418
|
+
section,
|
|
419
|
+
alias,
|
|
420
|
+
"role",
|
|
421
|
+
"unknown",
|
|
422
|
+
role,
|
|
423
|
+
None,
|
|
424
|
+
"not connected: the Litestream VFS needs its extension loaded",
|
|
425
|
+
)
|
|
426
|
+
]
|
|
427
|
+
path = None
|
|
428
|
+
if role != "memory":
|
|
429
|
+
path = target.path
|
|
430
|
+
if path is None:
|
|
431
|
+
return [Item(section, alias, "file", "error", None, None, "database path is empty")]
|
|
432
|
+
if not os.path.exists(path):
|
|
433
|
+
return items + [
|
|
434
|
+
Item(
|
|
435
|
+
section,
|
|
436
|
+
alias,
|
|
437
|
+
"file",
|
|
438
|
+
"error",
|
|
439
|
+
os.path.abspath(path),
|
|
440
|
+
None,
|
|
441
|
+
"database file does not exist; not connected (connecting would create "
|
|
442
|
+
"an empty database)",
|
|
443
|
+
)
|
|
444
|
+
]
|
|
445
|
+
if not os.path.isfile(path):
|
|
446
|
+
return items + [
|
|
447
|
+
Item(
|
|
448
|
+
section,
|
|
449
|
+
alias,
|
|
450
|
+
"file",
|
|
451
|
+
"error",
|
|
452
|
+
os.path.abspath(path),
|
|
453
|
+
None,
|
|
454
|
+
"not a regular file; not connected",
|
|
455
|
+
)
|
|
456
|
+
]
|
|
457
|
+
items.append(Item(section, alias, "file", "ok", _file_size(path), None, "bytes"))
|
|
458
|
+
wal = _file_size(path + "-wal")
|
|
459
|
+
items.append(
|
|
460
|
+
Item(
|
|
461
|
+
section,
|
|
462
|
+
alias,
|
|
463
|
+
"wal",
|
|
464
|
+
"ok",
|
|
465
|
+
wal,
|
|
466
|
+
None,
|
|
467
|
+
"bytes" if wal is not None else "no -wal file",
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
# 읽기 전용 별칭은 진단이 파일을 바꾸면 안 된다. WAL 읽기는 SQLite 가 본래 -shm 에 쓰고
|
|
471
|
+
# -wal 을 만들 수 있으므로 -shm 이 있어도 무변경 근거가 아니다(재현함: 빈 -shm 확장,
|
|
472
|
+
# 남은·열린 -shm 의 해시 변경). immutable 참값 URI 는 WAL 을 무시하고 아무것도 만들지
|
|
473
|
+
# 않는다(실측).
|
|
474
|
+
if role == "read-only" and not _immutable(target.name) and _wal_header(path):
|
|
475
|
+
return items + [
|
|
476
|
+
Item(
|
|
477
|
+
section,
|
|
478
|
+
alias,
|
|
479
|
+
"connect",
|
|
480
|
+
"unknown",
|
|
481
|
+
None,
|
|
482
|
+
None,
|
|
483
|
+
"not connected: read-only alias of a WAL database; reading WAL writes "
|
|
484
|
+
"-shm and may create -wal. Diagnose it through the writer's alias or an "
|
|
485
|
+
"immutable=1 URI",
|
|
486
|
+
)
|
|
487
|
+
]
|
|
488
|
+
pragmas = rec["pragmas"]
|
|
489
|
+
keys = list(dict.fromkeys([*pragmas, "foreign_keys"]))
|
|
490
|
+
try:
|
|
491
|
+
values = _read_pragmas(alias, keys)
|
|
492
|
+
except Exception as exc: # noqa: BLE001 - 어떤 연결 오류든 한 줄로 보고한다
|
|
493
|
+
return items + [
|
|
494
|
+
Item(
|
|
495
|
+
section,
|
|
496
|
+
alias,
|
|
497
|
+
"connect",
|
|
498
|
+
"error",
|
|
499
|
+
None,
|
|
500
|
+
None,
|
|
501
|
+
_one_line(f"{type(exc).__name__}: {exc}"),
|
|
502
|
+
)
|
|
503
|
+
]
|
|
504
|
+
if role == "memory":
|
|
505
|
+
return [Item(section, alias, "role", "ok", role, None, "in-memory database")]
|
|
506
|
+
expected = {"transaction_mode": rec["transaction_mode"], **pragmas}
|
|
507
|
+
for key in ["transaction_mode", *keys]:
|
|
508
|
+
actual = _canon(key, values.get(key))
|
|
509
|
+
if key not in expected:
|
|
510
|
+
items.append(Item(section, alias, key, "ok", actual))
|
|
511
|
+
elif role == "read-only" and key in _WRITE_KEYS:
|
|
512
|
+
items.append(Item(section, alias, key, "ok", actual, None, "read-only: not compared"))
|
|
513
|
+
else:
|
|
514
|
+
want = _canon(key, expected[key])
|
|
515
|
+
level = "ok" if actual == want else "warn"
|
|
516
|
+
message = "" if level == "ok" else "differs from the recommended value (DESIGN §6-0)"
|
|
517
|
+
items.append(Item(section, alias, key, level, actual, want, message))
|
|
518
|
+
items.append(Item(section, alias, "sqlite_version", "ok", values["sqlite_version"]))
|
|
519
|
+
return items
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _diagnose_mount(alias: str, path: str) -> Item:
|
|
523
|
+
fstype = filesystem_type(path)
|
|
524
|
+
level, message = classify_fstype(fstype)
|
|
525
|
+
return Item("mount", alias, "filesystem", level, fstype, None, message)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# --- Litestream --------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _real(path: str) -> str:
|
|
532
|
+
return os.path.realpath(os.path.abspath(path))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _caller_path(path: str) -> str:
|
|
536
|
+
"""호출자 작업 디렉터리 기준 절대 경로. Litestream 은 임시 cwd 에서 돌기 때문이다."""
|
|
537
|
+
return os.path.abspath(path)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _binary(binary: str) -> str:
|
|
541
|
+
"""경로 구분자가 든 바이너리는 절대 경로로, PATH 로 찾는 이름은 그대로 둔다."""
|
|
542
|
+
if os.sep in binary or (os.altsep and os.altsep in binary):
|
|
543
|
+
return os.path.abspath(binary)
|
|
544
|
+
return binary
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _litestream_items(
|
|
548
|
+
config: str,
|
|
549
|
+
binary: str,
|
|
550
|
+
db_paths: Mapping[str, str],
|
|
551
|
+
write_aliases: Iterable[str],
|
|
552
|
+
) -> tuple[list[Item], set[str] | None]:
|
|
553
|
+
"""설정의 DB 목록을 ``DATABASES`` 와 대조한다.
|
|
554
|
+
|
|
555
|
+
``(항목, 설정 DB 의 실제 경로 집합 또는 None)`` 을 돌려준다.
|
|
556
|
+
"""
|
|
557
|
+
section = "litestream"
|
|
558
|
+
# 상대 경로·dir: 항목은 Litestream 작업 디렉터리 기준으로 풀린다. 새 임시 디렉터리에서 돌려
|
|
559
|
+
# 그 아래로 풀린 항목을 골라낸다(사이드카의 작업 디렉터리를 doctor 는 알 수 없다).
|
|
560
|
+
with tempfile.TemporaryDirectory(prefix="sqlite-doctor-") as cwd:
|
|
561
|
+
result = litestream.config_databases(config, binary=binary, cwd=cwd)
|
|
562
|
+
cwd_forms = {cwd, os.path.realpath(cwd)}
|
|
563
|
+
if isinstance(result, str):
|
|
564
|
+
return [Item(section, None, "config", "error", config, None, result)], None
|
|
565
|
+
items = [Item(section, None, "config", "ok", config, None, f"{len(result)} database(s)")]
|
|
566
|
+
by_real: dict[str, str] = {}
|
|
567
|
+
for path in result:
|
|
568
|
+
if any(path == c or path.startswith(c + os.sep) for c in cwd_forms):
|
|
569
|
+
items.append(
|
|
570
|
+
Item(
|
|
571
|
+
section,
|
|
572
|
+
None,
|
|
573
|
+
"config_path",
|
|
574
|
+
"warn",
|
|
575
|
+
path,
|
|
576
|
+
None,
|
|
577
|
+
"relative path or dir: entry; it resolves against litestream's working "
|
|
578
|
+
"directory and cannot be matched to DATABASES",
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
continue
|
|
582
|
+
real = os.path.realpath(path)
|
|
583
|
+
if real != path:
|
|
584
|
+
items.append(
|
|
585
|
+
Item(
|
|
586
|
+
section,
|
|
587
|
+
None,
|
|
588
|
+
"config_path",
|
|
589
|
+
"warn",
|
|
590
|
+
path,
|
|
591
|
+
real,
|
|
592
|
+
"not a real path (symlink or '..'); boot refuses it — use the real path (D-15)",
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
by_real[real] = path
|
|
596
|
+
alias_by_real: dict[str, str] = {}
|
|
597
|
+
for alias, path in db_paths.items():
|
|
598
|
+
alias_by_real.setdefault(_real(path), alias)
|
|
599
|
+
for alias in write_aliases:
|
|
600
|
+
real = _real(db_paths[alias])
|
|
601
|
+
if real in by_real:
|
|
602
|
+
items.append(
|
|
603
|
+
Item(section, alias, "replicated", "ok", by_real[real], None, "in the config")
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
items.append(
|
|
607
|
+
Item(
|
|
608
|
+
section,
|
|
609
|
+
alias,
|
|
610
|
+
"replicated",
|
|
611
|
+
"warn",
|
|
612
|
+
None,
|
|
613
|
+
real,
|
|
614
|
+
"write database is not in the litestream config; it is not replicated",
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
for real, path in by_real.items():
|
|
618
|
+
if real not in alias_by_real:
|
|
619
|
+
items.append(
|
|
620
|
+
Item(section, None, "extra", "ok", path, None, "in the config but not in DATABASES")
|
|
621
|
+
)
|
|
622
|
+
return items, set(by_real)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# --- 채널 레이어 -------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
_INMEMORY = "channels.layers.InMemoryChannelLayer"
|
|
628
|
+
_NATS = ("channels_nats.NatsChannelLayer", "channels_nats.layer.NatsChannelLayer")
|
|
629
|
+
_REDIS = "channels_redis.core.RedisChannelLayer"
|
|
630
|
+
_REDIS_PUBSUB = "channels_redis.pubsub.RedisPubSubChannelLayer"
|
|
631
|
+
_LITE_AIO = "channels_lite.layers.aio.AIOSQLiteChannelLayer"
|
|
632
|
+
_LITE = ("channels_lite.layers.core.SQLiteChannelLayer", _LITE_AIO)
|
|
633
|
+
|
|
634
|
+
# DESIGN §8 표의 요약.
|
|
635
|
+
_SUMMARY = {
|
|
636
|
+
_INMEMORY: "in-memory: one process only; messages do not reach other processes or workers",
|
|
637
|
+
_NATS: (
|
|
638
|
+
"channels-nats: messages sent before a receiver exists are dropped; ordering only "
|
|
639
|
+
"within one subscription; no ChannelFull; no disk writes"
|
|
640
|
+
),
|
|
641
|
+
_REDIS: (
|
|
642
|
+
"channels_redis: messages are kept until expiry; per-channel ordering; ChannelFull "
|
|
643
|
+
"can be raised; no SQLite writes"
|
|
644
|
+
),
|
|
645
|
+
_LITE: (
|
|
646
|
+
"channels-lite: not recommended — polling trades latency for idle CPU and every "
|
|
647
|
+
"message costs 2 DB writes; keep it in a separate file excluded from Litestream"
|
|
648
|
+
),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _channel_items(
|
|
653
|
+
sqlite_files: Mapping[str, str],
|
|
654
|
+
replicated: set[str] | None,
|
|
655
|
+
profile: str,
|
|
656
|
+
) -> list[Item]:
|
|
657
|
+
section = "channels"
|
|
658
|
+
layers = getattr(settings, "CHANNEL_LAYERS", None)
|
|
659
|
+
if layers is None:
|
|
660
|
+
return [Item(section, None, "backend", "ok", None, None, "CHANNEL_LAYERS is not set")]
|
|
661
|
+
if not isinstance(layers, Mapping):
|
|
662
|
+
return [
|
|
663
|
+
Item(section, None, "backend", "unknown", None, None, "CHANNEL_LAYERS is not a dict")
|
|
664
|
+
]
|
|
665
|
+
items = []
|
|
666
|
+
for name, layer in layers.items():
|
|
667
|
+
backend = layer.get("BACKEND") if isinstance(layer, Mapping) else None
|
|
668
|
+
if not isinstance(backend, str):
|
|
669
|
+
items.append(Item(section, name, "backend", "unknown", None, None, "no BACKEND"))
|
|
670
|
+
continue
|
|
671
|
+
if backend == _INMEMORY:
|
|
672
|
+
if profile == "single-server-multiproc":
|
|
673
|
+
items.append(
|
|
674
|
+
Item(
|
|
675
|
+
section,
|
|
676
|
+
name,
|
|
677
|
+
"backend",
|
|
678
|
+
"warn",
|
|
679
|
+
backend,
|
|
680
|
+
None,
|
|
681
|
+
_SUMMARY[_INMEMORY]
|
|
682
|
+
+ "; profile single-server-multiproc runs several processes (DESIGN §8)",
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
items.append(
|
|
687
|
+
Item(section, name, "backend", "ok", backend, None, _SUMMARY[_INMEMORY])
|
|
688
|
+
)
|
|
689
|
+
elif backend in _NATS:
|
|
690
|
+
items.append(Item(section, name, "backend", "ok", backend, None, _SUMMARY[_NATS]))
|
|
691
|
+
elif backend == _REDIS:
|
|
692
|
+
items.append(Item(section, name, "backend", "ok", backend, None, _SUMMARY[_REDIS]))
|
|
693
|
+
elif backend == _REDIS_PUBSUB:
|
|
694
|
+
items.append(
|
|
695
|
+
Item(
|
|
696
|
+
section,
|
|
697
|
+
name,
|
|
698
|
+
"backend",
|
|
699
|
+
"unknown",
|
|
700
|
+
backend,
|
|
701
|
+
None,
|
|
702
|
+
"channels_redis pub/sub: semantics not measured (DESIGN §8 covers "
|
|
703
|
+
"RedisChannelLayer)",
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
elif backend in _LITE:
|
|
707
|
+
items.append(Item(section, name, "backend", "ok", backend, None, _SUMMARY[_LITE]))
|
|
708
|
+
items.extend(_lite_items(name, layer, sqlite_files, replicated))
|
|
709
|
+
else:
|
|
710
|
+
items.append(
|
|
711
|
+
Item(section, name, "backend", "unknown", backend, None, "unknown channel layer")
|
|
712
|
+
)
|
|
713
|
+
return items
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _sharing(alias: str, sqlite_files: Mapping[str, str]) -> list[str]:
|
|
717
|
+
"""``alias`` 와 같은 실제 파일을 쓰는 다른 별칭."""
|
|
718
|
+
real = _real(sqlite_files[alias])
|
|
719
|
+
return sorted(
|
|
720
|
+
other for other, path in sqlite_files.items() if other != alias and _real(path) == real
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _dedicated_channel_aliases(sqlite_files: Mapping[str, str]) -> set[str]:
|
|
725
|
+
"""channels-lite 가 쓰는 전용 채널 DB 별칭(앱 DB 와 다른 파일). 복제 대상이 아니다(§8)."""
|
|
726
|
+
layers = getattr(settings, "CHANNEL_LAYERS", None)
|
|
727
|
+
if not isinstance(layers, Mapping):
|
|
728
|
+
return set()
|
|
729
|
+
found = set()
|
|
730
|
+
for layer in layers.values():
|
|
731
|
+
if not isinstance(layer, Mapping) or layer.get("BACKEND") not in _LITE:
|
|
732
|
+
continue
|
|
733
|
+
config = layer.get("CONFIG")
|
|
734
|
+
alias = config.get("database") if isinstance(config, Mapping) else None
|
|
735
|
+
if (
|
|
736
|
+
isinstance(alias, str)
|
|
737
|
+
and alias in sqlite_files
|
|
738
|
+
and alias != "default"
|
|
739
|
+
and not _sharing(alias, sqlite_files)
|
|
740
|
+
):
|
|
741
|
+
found.add(alias)
|
|
742
|
+
return found
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _lite_items(
|
|
746
|
+
name: str,
|
|
747
|
+
layer: Mapping[str, Any],
|
|
748
|
+
sqlite_files: Mapping[str, str],
|
|
749
|
+
replicated: set[str] | None,
|
|
750
|
+
) -> list[Item]:
|
|
751
|
+
section = "channels"
|
|
752
|
+
config = layer.get("CONFIG")
|
|
753
|
+
alias = config.get("database") if isinstance(config, Mapping) else None
|
|
754
|
+
if not isinstance(alias, str):
|
|
755
|
+
return [
|
|
756
|
+
Item(section, name, "database", "unknown", None, None, "CONFIG['database'] is not set")
|
|
757
|
+
]
|
|
758
|
+
if alias not in sqlite_files:
|
|
759
|
+
return [
|
|
760
|
+
Item(
|
|
761
|
+
section,
|
|
762
|
+
name,
|
|
763
|
+
"database",
|
|
764
|
+
"unknown",
|
|
765
|
+
alias,
|
|
766
|
+
None,
|
|
767
|
+
"not a SQLite file database alias in DATABASES",
|
|
768
|
+
)
|
|
769
|
+
]
|
|
770
|
+
real = _real(sqlite_files[alias])
|
|
771
|
+
shared = _sharing(alias, sqlite_files)
|
|
772
|
+
items = []
|
|
773
|
+
if alias == "default" or shared:
|
|
774
|
+
if alias == "default":
|
|
775
|
+
what = "is the app database (default)"
|
|
776
|
+
else:
|
|
777
|
+
what = "shares a file with " + ", ".join(shared)
|
|
778
|
+
items.append(
|
|
779
|
+
Item(
|
|
780
|
+
section,
|
|
781
|
+
name,
|
|
782
|
+
"database",
|
|
783
|
+
"warn",
|
|
784
|
+
alias,
|
|
785
|
+
None,
|
|
786
|
+
f"channel database {what}; use a separate file (DESIGN §8)",
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
items.append(Item(section, name, "database", "ok", alias, None, "separate file"))
|
|
791
|
+
if replicated is not None:
|
|
792
|
+
if real in replicated:
|
|
793
|
+
items.append(
|
|
794
|
+
Item(
|
|
795
|
+
section,
|
|
796
|
+
name,
|
|
797
|
+
"replicated",
|
|
798
|
+
"warn",
|
|
799
|
+
alias,
|
|
800
|
+
None,
|
|
801
|
+
"channel database is in the litestream config; exclude it from "
|
|
802
|
+
"replication (DESIGN §8)",
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
else:
|
|
806
|
+
items.append(Item(section, name, "replicated", "ok", alias, None, "not replicated"))
|
|
807
|
+
return items
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _patch_items() -> list[Item]:
|
|
811
|
+
"""channels-lite aio 중복 배달 패치(DESIGN §9)의 상태. aio 레이어도 설정도 없으면 안 본다."""
|
|
812
|
+
section = "channels"
|
|
813
|
+
layers = getattr(settings, "CHANNEL_LAYERS", None)
|
|
814
|
+
aio = []
|
|
815
|
+
if isinstance(layers, Mapping):
|
|
816
|
+
aio = [
|
|
817
|
+
name
|
|
818
|
+
for name, layer in layers.items()
|
|
819
|
+
if isinstance(layer, Mapping) and layer.get("BACKEND") == _LITE_AIO
|
|
820
|
+
]
|
|
821
|
+
on = channels_lite.enabled(getattr(settings, "SQLITE_OPS", {}))
|
|
822
|
+
if not aio and not on:
|
|
823
|
+
return []
|
|
824
|
+
st = channels_lite.status()
|
|
825
|
+
setting = f"SQLITE_OPS['{channels_lite.SETTING}']"
|
|
826
|
+
if st.applied:
|
|
827
|
+
level, message = "ok", f"duplicate-delivery patch applied (channels-lite {st.version})"
|
|
828
|
+
elif on:
|
|
829
|
+
level, message = "warn", f"{setting} is True but the patch is not applied: {st.reason}"
|
|
830
|
+
elif st.applicable:
|
|
831
|
+
level, message = (
|
|
832
|
+
"warn",
|
|
833
|
+
f"channels-lite {st.version} aio layer can deliver one message on a general channel "
|
|
834
|
+
f"to two receivers; set {setting} = True (DESIGN §9)",
|
|
835
|
+
)
|
|
836
|
+
else:
|
|
837
|
+
level, message = "warn", f"duplicate-delivery patch is not available: {st.reason}"
|
|
838
|
+
expected = f"channels-lite=={channels_lite.VERIFIED_VERSION}"
|
|
839
|
+
if not aio:
|
|
840
|
+
if st.applied:
|
|
841
|
+
message += f"; no {_LITE_AIO} in CHANNEL_LAYERS"
|
|
842
|
+
return [Item(section, None, "patch", level, st.version, expected, message)]
|
|
843
|
+
return [Item(section, name, "patch", level, st.version, expected, message) for name in aio]
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# --- 프로세스 ----------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
_FD_DOC = "docs/profiles/fd-limits.md"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _limit(value: int) -> int | str:
|
|
852
|
+
return "unlimited" if value == resource.RLIM_INFINITY else value
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def nofile_items() -> list[Item]:
|
|
856
|
+
"""이 프로세스의 ``RLIMIT_NOFILE`` soft·hard. 보고만 하고 판정하지 않는다(DESIGN §6-2).
|
|
857
|
+
|
|
858
|
+
필요한 fd 수는 동시 연결 수에 달려 있어 근거 있는 경고 기준이 없다. 그래서 값을 읽지
|
|
859
|
+
못해도 ``unknown`` 이 아니라 ``ok`` 로 사유만 적는다(종료 코드를 바꾸지 않는다).
|
|
860
|
+
"""
|
|
861
|
+
section = "process"
|
|
862
|
+
if resource is None:
|
|
863
|
+
return [
|
|
864
|
+
Item(
|
|
865
|
+
section,
|
|
866
|
+
None,
|
|
867
|
+
"nofile",
|
|
868
|
+
"ok",
|
|
869
|
+
None,
|
|
870
|
+
None,
|
|
871
|
+
"unavailable: the resource module is not available on this platform",
|
|
872
|
+
)
|
|
873
|
+
]
|
|
874
|
+
try:
|
|
875
|
+
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
876
|
+
except (OSError, ValueError) as exc: # resource.error 는 OSError 다
|
|
877
|
+
return [
|
|
878
|
+
Item(
|
|
879
|
+
section,
|
|
880
|
+
None,
|
|
881
|
+
"nofile",
|
|
882
|
+
"ok",
|
|
883
|
+
None,
|
|
884
|
+
None,
|
|
885
|
+
_one_line(f"unavailable: {type(exc).__name__}: {exc}"),
|
|
886
|
+
)
|
|
887
|
+
]
|
|
888
|
+
note = f"RLIMIT_NOFILE of this process; reported, not judged ({_FD_DOC})"
|
|
889
|
+
return [
|
|
890
|
+
Item(section, None, "nofile_soft", "ok", _limit(soft), None, note),
|
|
891
|
+
Item(section, None, "nofile_hard", "ok", _limit(hard), None, note),
|
|
892
|
+
]
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# --- 조립 ------------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def select_aliases(
|
|
899
|
+
aliases: Iterable[str] | None, section: str
|
|
900
|
+
) -> tuple[dict[str, Mapping[str, Any]], list[str], list[Item]]:
|
|
901
|
+
"""``(sqlite3 별칭 설정 전부, 고른 별칭, 고르지 못한 별칭의 항목)``. 없으면 sqlite3 전부."""
|
|
902
|
+
databases = settings.DATABASES
|
|
903
|
+
sqlite = {
|
|
904
|
+
alias: config
|
|
905
|
+
for alias, config in databases.items()
|
|
906
|
+
if isinstance(config, Mapping) and config.get("ENGINE") == ENGINE
|
|
907
|
+
}
|
|
908
|
+
if aliases is None:
|
|
909
|
+
return sqlite, list(sqlite), []
|
|
910
|
+
selected: list[str] = []
|
|
911
|
+
items: list[Item] = []
|
|
912
|
+
for alias in dict.fromkeys(aliases):
|
|
913
|
+
if alias not in databases:
|
|
914
|
+
items.append(Item(section, alias, "alias", "error", None, None, "not in DATABASES"))
|
|
915
|
+
elif alias not in sqlite:
|
|
916
|
+
items.append(
|
|
917
|
+
Item(
|
|
918
|
+
section,
|
|
919
|
+
alias,
|
|
920
|
+
"alias",
|
|
921
|
+
"unknown",
|
|
922
|
+
databases[alias].get("ENGINE"),
|
|
923
|
+
ENGINE,
|
|
924
|
+
"not a sqlite3 database; not diagnosed",
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
else:
|
|
928
|
+
selected.append(alias)
|
|
929
|
+
return sqlite, selected, items
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def diagnose(
|
|
933
|
+
aliases: Iterable[str] | None = None,
|
|
934
|
+
*,
|
|
935
|
+
litestream_config: str | None = None,
|
|
936
|
+
litestream_binary: str = "litestream",
|
|
937
|
+
) -> list[Item]:
|
|
938
|
+
"""진단 항목을 모은다. ``aliases`` 가 없으면 sqlite3 엔진 별칭 전부를 본다."""
|
|
939
|
+
items: list[Item] = []
|
|
940
|
+
profile, error = _profile()
|
|
941
|
+
if error is not None:
|
|
942
|
+
items.append(Item("settings", None, "profile", "error", None, None, error.msg))
|
|
943
|
+
profile = DEFAULT_PROFILE
|
|
944
|
+
else:
|
|
945
|
+
items.append(Item("settings", None, "profile", "ok", profile))
|
|
946
|
+
rec = recommended(profile)
|
|
947
|
+
|
|
948
|
+
sqlite, selected, alias_items = select_aliases(aliases, "database")
|
|
949
|
+
items.extend(alias_items)
|
|
950
|
+
|
|
951
|
+
# 별칭마다 실제로 여는 대상을 한 번 정하고 모든 섹션이 그것을 쓴다(연결하지 않는다).
|
|
952
|
+
targets = {alias: _target(alias, config) for alias, config in sqlite.items()}
|
|
953
|
+
# 파일 DB 별칭(쓰기·읽기 전용)의 경로. 메모리·VFS·판정 불가는 빼고 본다.
|
|
954
|
+
files = {
|
|
955
|
+
alias: t.path
|
|
956
|
+
for alias, t in targets.items()
|
|
957
|
+
if t.role in ("write", "read-only") and t.path is not None
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for alias in selected:
|
|
961
|
+
items.extend(_diagnose_alias(alias, targets[alias], rec))
|
|
962
|
+
for alias in selected:
|
|
963
|
+
if alias in files:
|
|
964
|
+
items.append(_diagnose_mount(alias, files[alias]))
|
|
965
|
+
|
|
966
|
+
replicated: set[str] | None = None
|
|
967
|
+
if litestream_config is None:
|
|
968
|
+
items.append(
|
|
969
|
+
Item(
|
|
970
|
+
"litestream",
|
|
971
|
+
None,
|
|
972
|
+
"config",
|
|
973
|
+
"ok",
|
|
974
|
+
None,
|
|
975
|
+
None,
|
|
976
|
+
"skipped: --litestream-config not given",
|
|
977
|
+
)
|
|
978
|
+
)
|
|
979
|
+
else:
|
|
980
|
+
# 전용 채널 DB 는 복제에서 빼는 것이 규칙이므로 "복제되지 않음" 대상이 아니다(§8)
|
|
981
|
+
channel_only = _dedicated_channel_aliases(files)
|
|
982
|
+
write_aliases = [
|
|
983
|
+
a
|
|
984
|
+
for a in selected
|
|
985
|
+
if targets[a].role == "write" and a in files and a not in channel_only
|
|
986
|
+
]
|
|
987
|
+
ls_items, replicated = _litestream_items(
|
|
988
|
+
_caller_path(litestream_config), _binary(litestream_binary), files, write_aliases
|
|
989
|
+
)
|
|
990
|
+
items.extend(ls_items)
|
|
991
|
+
|
|
992
|
+
items.extend(_channel_items(files, replicated, profile))
|
|
993
|
+
items.extend(_patch_items())
|
|
994
|
+
items.extend(nofile_items())
|
|
995
|
+
return items
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def exit_code(items: Iterable[Item]) -> int:
|
|
999
|
+
"""0 문제 없음 · 1 경고(판정 불가 포함) · 2 오류."""
|
|
1000
|
+
levels = {item.level for item in items}
|
|
1001
|
+
if "error" in levels:
|
|
1002
|
+
return 2
|
|
1003
|
+
if levels & {"warn", "unknown"}:
|
|
1004
|
+
return 1
|
|
1005
|
+
return 0
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _counts(items: list[Item]) -> dict[str, int]:
|
|
1009
|
+
return {level: sum(1 for item in items if item.level == level) for level in LEVELS}
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def to_json(items: list[Item]) -> str:
|
|
1013
|
+
"""``--json`` 출력(스키마 ``version: 1``, DESIGN §6-2)."""
|
|
1014
|
+
data = {
|
|
1015
|
+
"version": SCHEMA_VERSION,
|
|
1016
|
+
"items": [asdict(item) for item in items],
|
|
1017
|
+
"summary": _counts(items),
|
|
1018
|
+
"exit_code": exit_code(items),
|
|
1019
|
+
}
|
|
1020
|
+
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _show(value: Any) -> str:
|
|
1024
|
+
return "-" if value is None else str(value)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def format_text(items: list[Item]) -> str:
|
|
1028
|
+
"""사람용 출력. 섹션별 표, 줄 앞에 수준, 마지막 줄에 요약과 종료 코드."""
|
|
1029
|
+
lines = []
|
|
1030
|
+
for section in SECTIONS:
|
|
1031
|
+
rows = [item for item in items if item.section == section]
|
|
1032
|
+
if not rows:
|
|
1033
|
+
continue
|
|
1034
|
+
lines.append(f"[{section}]")
|
|
1035
|
+
alias_w = max(len(_show(r.alias)) for r in rows)
|
|
1036
|
+
key_w = max(len(r.key) for r in rows)
|
|
1037
|
+
for r in rows:
|
|
1038
|
+
value = _show(r.value)
|
|
1039
|
+
if r.expected is not None:
|
|
1040
|
+
value += f" (expected {r.expected})"
|
|
1041
|
+
text = f" {r.level.upper():<7} {_show(r.alias):<{alias_w}} {r.key:<{key_w}} {value}"
|
|
1042
|
+
if r.message:
|
|
1043
|
+
text += f" — {r.message}"
|
|
1044
|
+
lines.append(text.rstrip())
|
|
1045
|
+
lines.append("")
|
|
1046
|
+
counts = _counts(items)
|
|
1047
|
+
lines.append(
|
|
1048
|
+
f"summary: {counts['warn']} warning(s), {counts['error']} error(s), "
|
|
1049
|
+
f"{counts['unknown']} unknown -> exit {exit_code(items)}"
|
|
1050
|
+
)
|
|
1051
|
+
return "\n".join(lines)
|