fix(dev): ensure_pg 智能定位 Docker Desktop(装 D 盘等非默认盘符也能自动拉起)

原先把 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) <noreply@anthropic.com>
This commit is contained in:
guke
2026-07-28 14:13:11 +08:00
parent c9ef4464f6
commit 3f449d3600
3 changed files with 147 additions and 22 deletions
+6
View File
@@ -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))"
+89 -12
View File
@@ -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.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:
+52 -10
View File
@@ -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):