From 3f449d360040d2ee979490b3dcc4e1a9d381f846 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 28 Jul 2026 14:13:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(dev):=20ensure=5Fpg=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=20Docker=20Desktop(=E8=A3=85=20D=20=E7=9B=98?= =?UTF-8?q?=E7=AD=89=E9=9D=9E=E9=BB=98=E8=AE=A4=E7=9B=98=E7=AC=A6=E4=B9=9F?= =?UTF-8?q?=E8=83=BD=E8=87=AA=E5=8A=A8=E6=8B=89=E8=B5=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先把 Docker Desktop.exe 写死在 %ProgramFiles%\Docker\Docker\,装在 D 盘就自动拉起 失败(明明 docker CLI 已在 PATH 上可用、只是 daemon 没启)。改为按可靠性排序的多路探测: DOCKER_DESKTOP_EXE 显式指定 → 从 PATH 上 docker CLI 反推安装目录(跟随实际盘符)→ 注册表 → 各 Program Files 变体兜底;找不到时提示 DOCKER_DESKTOP_EXE / sqlite 逃生舱。 .env.example 记一笔用法。 顺带修一个先前就红、与现行"sqlite→降级返 True"契约矛盾的测试 (test_ensure_rejects_sqlite → test_ensure_sqlite_escape_hatch)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 6 +++ scripts/ensure_pg.py | 101 +++++++++++++++++++++++++++++++++++----- tests/test_ensure_pg.py | 62 ++++++++++++++++++++---- 3 files changed, 147 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 2c55492..a8f943d 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ APP_DEBUG=true # 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。 # ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。 DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia +# Docker 自动定位:run.bat/run.sh 会自动找 Docker Desktop 并启动——优先从 PATH 上的 docker CLI 反推 +# 安装目录(装在 D 盘等非默认盘符也能找到),再退到注册表 / 常见目录。仅当你的安装位置极特殊、自动 +# 探测失败时,才需下面这行显式指到 exe(值可含空格,直接写到行尾即可,无需引号): +# DOCKER_DESKTOP_EXE=D:\Program Files\Docker\Docker\Docker Desktop.exe +# 实在不想装/启 Docker → 把上面 DATABASE_URL 改成 sqlite 可降级跑(仅救急,PG 专有 SQL/严格性不被验证): +# DATABASE_URL=sqlite:///./data/app.db # ===== JWT ===== # 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))" diff --git a/scripts/ensure_pg.py b/scripts/ensure_pg.py index 30aa618..26eb84d 100644 --- a/scripts/ensure_pg.py +++ b/scripts/ensure_pg.py @@ -16,11 +16,13 @@ from __future__ import annotations import os +import shutil import socket import subprocess import sys import time -from pathlib import Path +from collections.abc import Mapping +from pathlib import Path, PureWindowsPath from urllib.parse import urlsplit ROOT = Path(__file__).resolve().parent.parent @@ -79,10 +81,83 @@ def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: return False -def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None: - """按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。""" +def _win_docker_desktop_candidates( + env: Mapping[str, str], docker_cli: str | None +) -> list[PureWindowsPath]: + """Windows 上 Docker Desktop.exe 的候选路径(按优先级)。纯函数:不碰文件系统、不读注册表。 + + 用 PureWindowsPath 解析,故在任意 OS 上跑单测都按 Windows 语义(反斜杠分隔),行为确定。 + 优先级: + ① 环境变量 DOCKER_DESKTOP_EXE 显式指定(终极逃生舱,盘符随你); + ② 从 PATH 上的 docker CLI 反推——Docker Desktop 的 CLI 在 + <安装目录>\\resources\\bin\\docker.exe,往上几级即安装目录,天然跟随实际盘符 + (装在 D 盘就反推出 D 盘,不再写死 C 盘);多取几级容忍未来目录布局微调; + ③ 各 Program Files 变体下的标准安装路径兜底(覆盖常规 C 盘装)。 + 调用方按序取第一个真实存在的。 + """ + out: list[PureWindowsPath] = [] + override = (env.get("DOCKER_DESKTOP_EXE") or "").strip().strip('"') + if override: + out.append(PureWindowsPath(override)) + if docker_cli: + for parent in list(PureWindowsPath(docker_cli).parents)[:4]: + out.append(parent / "Docker Desktop.exe") + for var in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"): + root = env.get(var) + if root: + out.append(PureWindowsPath(root) / "Docker" / "Docker" / "Docker Desktop.exe") + return out + + +def _docker_desktop_from_registry() -> Path | None: + """从注册表尽力取 Docker Desktop.exe 位置(best-effort;非 Windows / 任何异常都当没找到)。 + + 比路径猜测更权威且完全跟随实际盘符。探两处: + - App Paths\\Docker Desktop.exe 的默认值(通常就是 exe 全路径); + - Uninstall\\Docker Desktop 的 InstallLocation(安装目录,需再拼 exe 名)。 + """ + try: + import winreg + except ImportError: # 非 Windows + return None + probes = ( + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Docker Desktop.exe", "", False), + (winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Docker Desktop", + "InstallLocation", True), + ) + for hive, subkey, value_name, join_exe in probes: + try: + with winreg.OpenKey(hive, subkey) as key: + val, _ = winreg.QueryValueEx(key, value_name) + except OSError: + continue # 键不存在/无权限 → 下一个 + if not val: + continue + exe = Path(val) / "Docker Desktop.exe" if join_exe else Path(val) + if exe.exists(): + return exe + return None + + +def _find_docker_desktop_exe() -> Path | None: + """Windows 上尽力定位【真实存在】的 Docker Desktop.exe;遍历候选 + 注册表兜底,找不到返回 None。""" + for cand in _win_docker_desktop_candidates(os.environ, shutil.which("docker")): + if Path(cand).exists(): + return Path(cand) + return _docker_desktop_from_registry() + + +def _docker_desktop_cmd(platform: str) -> list[str] | None: + """按平台给出启动 Docker Desktop 的命令。 + + Windows:智能定位 exe(见 _find_docker_desktop_exe),找不到 → None。 + macOS:交给 `open -a Docker`。Linux:None(daemon 需 sudo,让用户手动)。 + """ if platform.startswith("win"): - return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")] + exe = _find_docker_desktop_exe() + return [str(exe)] if exe else None if platform == "darwin": return ["open", "-a", "Docker"] return None @@ -119,16 +194,18 @@ def _start_docker_daemon() -> bool: """守护进程没起时按平台拉起,轮询到就绪。返回是否成功。""" if _docker_daemon_ok(): return True - cmd = _docker_desktop_cmd( - sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files") - ) + cmd = _docker_desktop_cmd(sys.platform) if cmd is None: - _log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。") + if sys.platform.startswith("win"): + # docker CLI 在 PATH 上(否则走不到这)、却定位不到 Docker Desktop.exe:多为非标准安装位置 + _log("找不到 Docker Desktop.exe(已试:PATH 上 docker CLI 反推、注册表、常见安装目录)。") + _log(" 确已安装 → 设环境变量 DOCKER_DESKTOP_EXE= 再重试," + "或先手动启动 Docker Desktop。") + _log(f" 不想折腾 → 把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑(仅救急)。") + else: + _log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。") return False - if sys.platform.startswith("win") and not Path(cmd[0]).exists(): - _log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。") - return False - _log("启动 Docker Desktop(首次冷启可能 30-60s)…") + _log(f"启动 Docker Desktop(首次冷启可能 30-60s):{cmd[0]}") try: subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as e: diff --git a/tests/test_ensure_pg.py b/tests/test_ensure_pg.py index ee6e686..d9596ee 100644 --- a/tests/test_ensure_pg.py +++ b/tests/test_ensure_pg.py @@ -5,9 +5,11 @@ import socket from scripts.ensure_pg import ( _docker_desktop_cmd, + _docker_desktop_from_registry, _is_sqlite, _parse_host_port, _port_open, + _win_docker_desktop_candidates, ensure, ) @@ -56,24 +58,64 @@ def test_port_open_false(): assert not _port_open("127.0.0.1", port, timeout=0.3) -def test_docker_desktop_cmd_windows(): - cmd = _docker_desktop_cmd("win32", r"C:\Program Files") - assert cmd is not None - assert cmd[0].endswith("Docker Desktop.exe") - assert "Docker" in cmd[0] +def test_win_candidates_override_wins(): + env = { + "DOCKER_DESKTOP_EXE": r"X:\custom\Docker Desktop.exe", + "ProgramFiles": r"C:\Program Files", + } + cands = [str(p) for p in _win_docker_desktop_candidates(env, None)] + assert cands[0] == r"X:\custom\Docker Desktop.exe" + + +def test_win_candidates_follow_cli_drive(): + # 核心:docker CLI 在 D 盘 → 反推出 D 盘的 Docker Desktop.exe(不再写死 C 盘) + env = {"ProgramFiles": r"C:\Program Files"} + cli = r"D:\Docker\Docker\resources\bin\docker.exe" + cands = [str(p) for p in _win_docker_desktop_candidates(env, cli)] + assert r"D:\Docker\Docker\Docker Desktop.exe" in cands + # 兜底的 C 盘常见路径也仍在 + assert r"C:\Program Files\Docker\Docker\Docker Desktop.exe" in cands + + +def test_win_candidates_no_cli_uses_program_files(): + env = {"ProgramFiles": r"C:\Program Files"} + cands = [str(p) for p in _win_docker_desktop_candidates(env, None)] + assert cands == [r"C:\Program Files\Docker\Docker\Docker Desktop.exe"] + + +def test_docker_desktop_cmd_windows_found(monkeypatch, tmp_path): + exe = tmp_path / "Docker Desktop.exe" + exe.write_text("") # 真实存在 + monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: exe) + assert _docker_desktop_cmd("win32") == [str(exe)] + + +def test_docker_desktop_cmd_windows_not_found(monkeypatch): + monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: None) + assert _docker_desktop_cmd("win32") is None def test_docker_desktop_cmd_darwin(): - assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"] + assert _docker_desktop_cmd("darwin") == ["open", "-a", "Docker"] def test_docker_desktop_cmd_linux(): - assert _docker_desktop_cmd("linux", "") is None + assert _docker_desktop_cmd("linux") is None -def test_ensure_rejects_sqlite(): - # dev 守卫:sqlite 直接 False(不碰 Docker) - assert ensure("sqlite:///./data/app.db") is False +def test_registry_probe_never_raises(): + # best-effort:无论平台/有无键,只返回 Path 或 None,绝不抛 + r = _docker_desktop_from_registry() + assert r is None or hasattr(r, "exists") + + +def test_ensure_sqlite_escape_hatch(monkeypatch): + # sqlite 是【显式降级逃生舱】:打印横幅、返回 True,且绝不触碰 docker + def _boom(): + raise AssertionError("sqlite 分支不应调用 docker") + + monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom) + assert ensure("sqlite:///./data/app.db") is True def test_ensure_shortcircuits_when_pg_up(monkeypatch):