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:
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user