You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

454 lines
18 KiB

"""
db_manager.py Configuration Storage Layer (Multi-Backend)
Storage priority:
1. sqlite3 Python module (preferred, full dpworldapp compat)
2. sqlite3 CLI via subprocess (dpworldapp compat on minimal Python)
3. JSON file fallback (no dpworldapp compat, temp only)
"""
import os
import sys
import json
import subprocess
import shutil
import threading
import time
from datetime import datetime, timezone
try:
import sqlite3
HAS_SQLITE = True
except ImportError:
HAS_SQLITE = False
DB_PATH_DEFAULT = os.path.join(os.path.expanduser("~"), "db", "dynamic_data.db")
# save_config retries transient SQLite "database is locked" errors: a competing
# writer can briefly hold the lock even with PRAGMA busy_timeout set. Total
# attempts, and the pause between them.
_WRITE_MAX_ATTEMPTS = 3
_WRITE_RETRY_DELAY_S = 0.1
def _has_sqlite3_cli():
"""Check if sqlite3 command-line tool is available."""
return shutil.which("sqlite3") is not None
class DBManager:
"""Configuration storage manager with 3-tier backend."""
# Whitelist of allowed config keys (SQL injection defense for CLI backend)
ALLOWED_KEYS = frozenset({'device_config', 'protocol_config', 'log_config', 'net_config',
'ap_config', 'uplink_config'}) # uplink: Telemetry Uplink (web-owned)
def __init__(self, db_path=None, backend=None):
self.db_path = db_path or os.environ.get("DB_PATH", DB_PATH_DEFAULT)
self._lock = threading.Lock()
if backend is not None:
# Explicit backend override (used in tests and special deployments)
self._backend = backend
if backend == "json":
self._json_dir = os.path.dirname(self.db_path)
os.makedirs(self._json_dir, exist_ok=True)
print(f"[DB] Backend override: {backend} ({self.db_path})")
elif HAS_SQLITE:
self._backend = "python"
print(f"[DB] Using Python sqlite3: {self.db_path}")
elif _has_sqlite3_cli():
self._backend = "cli"
print(f"[DB] Using sqlite3 CLI: {self.db_path}")
else:
self._backend = "json"
self._json_dir = os.path.dirname(self.db_path)
os.makedirs(self._json_dir, exist_ok=True)
print(f"[DB] Fallback to JSON files: {self._json_dir}")
# ─── Python sqlite3 module ───────────────────────────────────
def _connect(self):
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA journal_mode = WAL")
conn.row_factory = sqlite3.Row
return conn
def _validate_key(self, key):
"""Validate config key against whitelist to prevent SQL injection."""
if not isinstance(key, str):
raise ValueError(f"Invalid config key type: {type(key)}")
if key not in self.ALLOWED_KEYS:
raise ValueError(f"Invalid config key: {key}")
# ─── sqlite3 CLI (subprocess) ────────────────────────────────
def _cli_exec(self, sql):
"""Execute SQL via sqlite3 CLI and return stdout."""
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
result = subprocess.run(
["sqlite3", self.db_path],
input=sql,
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
raise RuntimeError(f"sqlite3 CLI error: {result.stderr.strip()}")
return result.stdout.strip()
def _cli_query(self, sql):
"""Execute query via CLI and return result string."""
return self._cli_exec(sql)
# ─── JSON file helpers ───────────────────────────────────────
def _json_path(self, key):
return os.path.join(self._json_dir, f"{key}.json")
def _read_json_file(self, key):
path = self._json_path(key)
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def _write_json_file(self, key, data):
path = self._json_path(key)
tmp_path = path + ".tmp"
json_str = json.dumps(data, ensure_ascii=False, indent=2)
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(json_str)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
# ─── Public API ──────────────────────────────────────────────
def ensure_tables(self):
if self._backend == "python":
with self._lock:
conn = self._connect()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS board_config (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS event_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL
)
""")
conn.commit()
finally:
conn.close()
elif self._backend == "cli":
with self._lock:
self._cli_exec(
"CREATE TABLE IF NOT EXISTS board_config "
"(key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL);\n"
"CREATE TABLE IF NOT EXISTS event_history "
"(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL);"
)
else:
os.makedirs(self._json_dir, exist_ok=True)
def get_config(self, key):
self._validate_key(key)
if self._backend == "python":
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"SELECT value FROM board_config WHERE key = ?", (key,)
)
row = cursor.fetchone()
if row is None:
return None
try:
return json.loads(row["value"])
except json.JSONDecodeError:
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; returning None", file=sys.stderr)
return None
finally:
conn.close()
elif self._backend == "cli":
with self._lock:
raw = self._cli_query(
f"SELECT value FROM board_config WHERE key = '{key}';"
)
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; returning None", file=sys.stderr)
return None
else:
with self._lock:
return self._read_json_file(key)
def save_config(self, key, data):
self._validate_key(key)
json_str = json.dumps(data, ensure_ascii=False, indent=2)
if self._backend == "python":
last_error = None
for attempt in range(_WRITE_MAX_ATTEMPTS):
# #37 fix: acquire the lock only around the actual DB op; the retry
# backoff sleep below runs OUTSIDE the lock so a contended write
# does not stall every other DB thread for the backoff window.
with self._lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO board_config (key, value) VALUES (?, ?)",
(key, json_str)
)
conn.commit()
return
except sqlite3.OperationalError as e:
# Retry only the transient lock; re-raise anything else.
if "database is locked" not in str(e).lower():
raise
last_error = e
finally:
conn.close()
if attempt < _WRITE_MAX_ATTEMPTS - 1:
time.sleep(_WRITE_RETRY_DELAY_S) # outside the lock
raise last_error
elif self._backend == "cli":
# Escape single quotes in JSON for SQL
escaped = json_str.replace("'", "''")
with self._lock:
self._cli_exec(
f"INSERT OR REPLACE INTO board_config (key, value) "
f"VALUES ('{key}', '{escaped}');"
)
else:
with self._lock:
self._write_json_file(key, data)
def update_config(self, key, mutator, default=None):
"""
Atomic read-modify-write of board_config entry.
Args:
key: board_config key (must be in ALLOWED_KEYS)
mutator: callable(current_value) -> new_value. current_value is
the JSON-deserialized current value (or `default` if absent).
Must NOT mutate input return a new dict.
default: value to pass to mutator when key is missing (default: None)
Returns:
The new value (after mutator) i.e., what was actually saved.
If mutator returns None the key is deleted and None is returned.
Atomic guarantees:
- python backend: BEGIN IMMEDIATE + SELECT + UPDATE + COMMIT within
one connection. Other writers block; lock retries with exponential
backoff (same _WRITE_MAX_ATTEMPTS pattern).
- cli backend: serialized by _lock (cli is sequential).
- json backend: lock + read + mutator + atomic write (tempfile+rename).
"""
self._validate_key(key)
if self._backend == "python":
last_error = None
for attempt in range(_WRITE_MAX_ATTEMPTS):
# #37 fix: lock only around the atomic RMW; the retry backoff sleep
# below runs OUTSIDE the lock so a contended write does not stall
# every other DB thread for the backoff window.
with self._lock:
conn = self._connect()
try:
# BEGIN IMMEDIATE — acquires write lock immediately,
# blocking other writers but allowing concurrent reads.
conn.execute("BEGIN IMMEDIATE")
cursor = conn.execute(
"SELECT value FROM board_config WHERE key = ?", (key,)
)
row = cursor.fetchone()
if row is None:
current = default
else:
try:
current = json.loads(row["value"])
except json.JSONDecodeError:
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; using default", file=sys.stderr)
current = default
new_value = mutator(current)
if new_value is None:
# mutator returned None → delete key
conn.execute(
"DELETE FROM board_config WHERE key = ?", (key,)
)
else:
json_str = json.dumps(new_value, ensure_ascii=False, indent=2)
conn.execute(
"INSERT OR REPLACE INTO board_config (key, value) VALUES (?, ?)",
(key, json_str)
)
conn.commit()
return new_value
except sqlite3.OperationalError as e:
if "database is locked" not in str(e).lower():
raise
last_error = e
try:
conn.rollback()
except Exception:
pass
finally:
conn.close()
if attempt < _WRITE_MAX_ATTEMPTS - 1:
time.sleep(_WRITE_RETRY_DELAY_S) # outside the lock
raise last_error
elif self._backend == "cli":
with self._lock:
raw = self._cli_query(
f"SELECT value FROM board_config WHERE key = '{key}';"
)
if not raw:
current = default
else:
try:
current = json.loads(raw)
except json.JSONDecodeError:
print(f"[DB] WARNING: malformed JSON in board_config key={key!r}; using default", file=sys.stderr)
current = default
new_value = mutator(current)
if new_value is None:
self._cli_exec(f"DELETE FROM board_config WHERE key = '{key}';")
else:
json_str = json.dumps(new_value, ensure_ascii=False, indent=2)
escaped = json_str.replace("'", "''")
self._cli_exec(
f"INSERT OR REPLACE INTO board_config (key, value) "
f"VALUES ('{key}', '{escaped}');"
)
return new_value
else: # json backend
with self._lock:
current = self._read_json_file(key)
if current is None:
current = default
new_value = mutator(current)
if new_value is None:
path = self._json_path(key)
if os.path.exists(path):
os.remove(path)
else:
self._write_json_file(key, new_value)
return new_value
def config_exists(self, key):
self._validate_key(key)
if self._backend == "python":
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"SELECT 1 FROM board_config WHERE key = ? LIMIT 1", (key,)
)
return cursor.fetchone() is not None
finally:
conn.close()
elif self._backend == "cli":
with self._lock:
raw = self._cli_query(
f"SELECT 1 FROM board_config WHERE key = '{key}' LIMIT 1;"
)
return bool(raw)
else:
with self._lock:
return os.path.exists(self._json_path(key))
Release v1.12.3 — 빈 디바이스 DB-init 안정화(fail-soft) + Wi-Fi AP DFS 채널 선택 v1.12.1 전달 이후의 v1.12.2·v1.12.3 변경을 함께 반영합니다. dpworldapp 공유 DB(device/protocol)·계약·원자적 쓰기 계약은 무변경. ■ v1.12.2 — 신규/빈 디바이스 DB-init 안정화 (fail-soft) 펌웨어 업데이트 직후 동기화 문제로 device_config/protocol_config 가 비어 있을 때, 웹이 그 값을 생성·덮어써 dpworldapp 의 시딩을 선점하는 문제를 차단. 협력사 DB-init fix(2ca0ac1)를 포함하되, 미초기화 시 예외를 던져 fresh device 를 중단시키던 부분을 fail-soft 로 대체(렌더는 적용, DB 쓰기만 skip). - db_manager: config_initialized(key) 단일 판정 신규 (존재 AND 비어있지 않은 dict; 빈 {} = 미초기화). - apply_engine: 미초기화 시 raise 제거 → 렌더/networkctl 은 적용하되 DB 미러 쓰기만 skip(journal warn). _write_db / _write_db_restore 동일 적용. _lkg_bookkeeping: all-absent baseline 의 LKG 승격 방지(fresh device). - server: 미초기화 응답 통일 — GET 204, POST 409 not_initialized(code 필드). - netmodel(F3): 비-dict / malformed WIFI_SSID 내성 — crash 대신 안전 degrade. - migrations: contract_canonical 을 device / protocol 로 분리, 각 키가 시딩될 때까지 독립 defer(seed-ordering). 기존 통합 플래그 하위호환 유지. - frontend(F2): device/protocolConfigAbsent 가드 — 로드 시 부재(204)였던 키를 Save All 로 생성하지 않도록 차단하고 안내 토스트 표시. ■ v1.12.3 — Wi-Fi AP 채널 선택 + 네트워크 즉시적용 검증 - ap_engine: DFS 인지 채널 선택 — STA 가 DFS/no-IR 5GHz(52–64·100–144)에 있어도 AP 가 맹목 추종하지 않고 비-DFS 채널 선택(2.4GHz STA 는 ch1–13 만 추종). parse_iw_link_beacon_int 추가 — MCC(AP≠STA 채널) 시 hostapd beacon_int 을 STA BSS 와 일치(QCA6490 &#34;STA/AP BI must match&#34;). AP 상태에 라디오-다운 사유(country_unset / channel_dfs / mcc_bi) 추가. - ap_renderer: render_hostapd 에 beacon_int 파라미터 추가(기본 100). - 즉시적용: 기존 systemctl start 방식 유지 — 디바이스 실측으로 재확인, 관련 내부 재작업 시도는 검토 후 원복(순변화 없음). ■ 기타 - api.js: 저장 실패 시 서버 errors 메시지를 사용자에게 노출. - 문서/버전: VERSION·constants.js → v1.12.3, RELEASE-NOTES §0, DELIVERABLE-MANIFEST, BSP-INTEGRATION 갱신. - MemoryMax 48M→128M(v1.11.16) 주석 정합화(fw_routes / watchdog / server).
1 month ago
def config_initialized(self, key):
"""True iff the key exists AND holds a non-empty dict. An empty {} counts as
NOT initialized it is the residual state the seed-ordering bug produces
(web/old-code created an empty/partial row before dpworldapp seeded it).
Single source of truth for 'absent' across migrations / GET 204 / POST 409 /
apply DB-write guard (2026-06-29 strengthened merge)."""
v = self.get_config(key)
return isinstance(v, dict) and bool(v)
# ─── Schema meta (migration flags) ──────────────────────────
def _ensure_schema_meta_table(self) -> None:
# _lock is acquired here independently; callers must NOT hold the
# lock when calling this method (sequential acquisition only).
with self._lock:
conn = self._connect()
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
applied_at TEXT NOT NULL
)
""")
conn.commit()
finally:
conn.close()
def get_schema_meta(self, key: str):
self._ensure_schema_meta_table()
with self._lock:
conn = self._connect()
try:
row = conn.execute(
"SELECT value FROM schema_meta WHERE key = ?", (key,)
).fetchone()
return row[0] if row else None
finally:
conn.close()
def set_schema_meta(self, key: str, value: str) -> None:
self._ensure_schema_meta_table()
now = datetime.now(timezone.utc).isoformat()
with self._lock:
conn = self._connect()
try:
conn.execute("""
INSERT INTO schema_meta(key, value, applied_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value,
applied_at=excluded.applied_at
""", (key, value, now))
conn.commit()
finally:
conn.close()
def get_raw_json(self, key):
self._validate_key(key)
if self._backend == "python":
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"SELECT value FROM board_config WHERE key = ?", (key,)
)
row = cursor.fetchone()
return row["value"] if row else None
finally:
conn.close()
elif self._backend == "cli":
with self._lock:
raw = self._cli_query(
f"SELECT value FROM board_config WHERE key = '{key}';"
)
return raw if raw else None
else:
with self._lock:
data = self._read_json_file(key)
return json.dumps(data, ensure_ascii=False, indent=2) if data else None