release: bump version to 0.74.7
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
ci / delivery (push) Blocked by required conditions
release / images (push) Waiting to run

This commit is contained in:
linkong
2026-09-17 17:30:54 +08:00
parent 83a10a6c34
commit 60eefb19c9
25 changed files with 1079 additions and 116 deletions

View File

@@ -5,6 +5,8 @@ import asyncio
import json
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
@@ -16,6 +18,7 @@ from sqlalchemy.pool import NullPool
ROOT = Path(__file__).resolve().parents[1]
CONNECT_TIMEOUT_SECONDS = 10
DEFAULT_POSTGRES_PORT = 5432
DEFAULT_REDIS_PORT = 6379
POSTGRES_CONTAINER = "planet_postgres"
POSTGRES_CONTAINER_PORT = "5432/tcp"
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
@@ -36,6 +39,55 @@ def backend_database_url() -> str:
return settings.DATABASE_URL
def configured_ports() -> tuple[int, int]:
database = make_url(backend_database_url())
from app.core.config import settings
redis = make_url(settings.REDIS_URL)
postgres_port = database.port or DEFAULT_POSTGRES_PORT
redis_port = redis.port or DEFAULT_REDIS_PORT
targets = []
if database.host in LOOPBACK_HOSTS:
targets.append(("PostgreSQL", POSTGRES_CONTAINER, postgres_port))
if redis.host in LOOPBACK_HOSTS:
targets.append(("Redis", "planet_redis", redis_port))
check_port_conflicts(targets)
return postgres_port, redis_port
def check_port_conflicts(targets: list[tuple[str, str, int]]) -> None:
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"],
capture_output=True, text=True, timeout=CONNECT_TIMEOUT_SECONDS, check=False,
)
if result.returncode:
raise DatabaseReadinessError("无法读取 Docker 端口占用;请检查 daemon 和当前用户权限。")
for service, expected_container, port in targets:
own_binding = False
for line in result.stdout.splitlines():
name, _, bindings = line.partition("\t")
if not re.search(rf":{port}->", bindings):
continue
if name == expected_container:
own_binding = True
else:
raise DatabaseReadinessError(
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port}{name} 占用;"
"请调整 Planet 后端连接端口。"
)
if not own_binding:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
try:
# Docker can reuse recently closed TCP connections; TIME_WAIT is not a listener.
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", port))
except OSError as error:
raise DatabaseReadinessError(
f"PLANET_DB_PORT_OCCUPIED: {service} 端口 {port} 被宿主机服务占用;"
"请检查监听进程或调整 Planet 后端连接端口。"
) from error
def check_published_port(url: URL) -> None:
if url.host not in LOOPBACK_HOSTS:
return # An explicitly configured external database has no local container mapping.
@@ -106,6 +158,9 @@ def connection_diagnostic(error: BaseException) -> str:
def main() -> int:
try:
if sys.argv[1:] == ["--ports"]:
print(*configured_ports())
return 0
url = make_url(backend_database_url())
print(
f"后端数据库目标: host={url.host!r} port={url.port or DEFAULT_POSTGRES_PORT} database={url.database!r}"

156
scripts/docker_command.py Normal file
View File

@@ -0,0 +1,156 @@
"""Run Docker CLI work with live evidence, a deadline, and process-group cleanup."""
import argparse
from contextlib import nullcontext
import os
from pathlib import Path
import re
import selectors
import signal
import subprocess
import sys
import time
from typing import TextIO
def safe_line(value: str) -> str:
value = re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", value)
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1***@", value)
def concise_line(value: str) -> str:
value = safe_line(value).strip()
if "Bind for " in value:
return value[value.index("Bind for "):]
return re.sub(r"sha256:([0-9a-f]{12})[0-9a-f]{52}", r"sha256:\1…", value)
def write_status(path: Path | None, value: str) -> None:
if path is not None:
temporary = path.with_suffix(".pending")
temporary.write_text(value, encoding="utf-8")
temporary.replace(path)
def stop_group(process: subprocess.Popen[bytes]) -> None:
# A Compose plugin or credential helper can outlive the Docker CLI parent.
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(process.pid, sig)
except ProcessLookupError:
break
if sig == signal.SIGTERM:
time.sleep(0.2)
process.wait()
def monitor(
process: subprocess.Popen[bytes],
args: argparse.Namespace,
log: TextIO | None,
) -> int:
started = time.monotonic()
next_notice = started
latest = args.label
failure = ""
pending = b""
def emit(raw: bytes) -> None:
nonlocal latest, failure
line = safe_line(raw.decode("utf-8", errors="replace"))
if not line.strip():
return
latest = concise_line(line)
if re.search(r"error|failed|denied|timeout|refused|不存在|失败|被占用|未向宿主机", line, re.I):
failure = latest
print(line, flush=True)
if log is not None:
log.write(line + "\n")
log.flush()
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
while selector.get_map() or process.poll() is None:
now = time.monotonic()
if now - started >= args.timeout:
if pending:
emit(pending)
message = (
f"PLANET_DOCKER_COMMAND_TIMEOUT: {args.label}"
f"已等待 {now - started:.1f}s最近输出: {latest}"
)
write_status(args.status_file, f"{latest}(等待 {now - started:.0f} 秒后超时)")
if log is not None:
log.write(message + "\n")
log.flush()
return 124
if now >= next_notice:
detail = concise_line(pending.decode("utf-8", errors="replace")) or latest
write_status(args.status_file, f"{detail} · {now - started:.0f}s")
next_notice = now + args.heartbeat
for key, _ in selector.select(timeout=min(0.1, args.heartbeat)):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
continue
parts = re.split(b"[\r\n]", pending + chunk)
pending = parts.pop()
for part in parts:
emit(part)
write_status(args.status_file, concise_line(pending.decode("utf-8", errors="replace")) or latest)
if pending:
emit(pending)
code = process.wait()
write_status(args.status_file, (failure or latest) if code else "")
return code if code >= 0 else 128 - code
def positive_seconds(value: str) -> float:
seconds = float(value)
if not 0 < seconds < float("inf"):
raise argparse.ArgumentTypeError("时间必须为有限正数")
return seconds
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--label", required=True)
parser.add_argument("--timeout", type=positive_seconds, required=True)
parser.add_argument("--heartbeat", type=positive_seconds, default=5)
parser.add_argument("--log", type=Path)
parser.add_argument("--status-file", type=Path)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
parser.error("缺少命令")
log_context = nullcontext(None)
if args.log:
args.log.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(descriptor, 0o600)
log_context = os.fdopen(descriptor, "w", encoding="utf-8")
with log_context as log:
try:
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
except OSError as error:
write_status(args.status_file, safe_line(str(error)))
return 127
previous_handler = signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
try:
return monitor(process, args, log)
except KeyboardInterrupt:
return 130
finally:
stop_group(process)
signal.signal(signal.SIGTERM, previous_handler)
if __name__ == "__main__":
sys.exit(main())

128
scripts/docker_mirror.py Normal file
View File

@@ -0,0 +1,128 @@
"""Resolve verified public-image mirrors without changing Docker's global registry settings."""
import json
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
from docker_command import safe_line
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MIRROR_PREFIX = "m.daocloud.io"
MANIFEST_TIMEOUT = 30
PULL_TIMEOUT = 180
PUBLIC_REPOSITORIES = {
"docker.io/library/python", "docker.io/library/postgres", "docker.io/library/redis",
"ghcr.io/astral-sh/uv",
}
class MirrorError(RuntimeError):
pass
def build_images(environment: dict[str, str]) -> dict[str, str]:
defaults = dict(re.findall(
r"^ARG (PYTHON_IMAGE|UV_IMAGE)=(.+)$", (ROOT / "aiprovider/Dockerfile").read_text(), re.M
))
return {key: environment.get(key) or value for key, value in defaults.items()}
def mirror_prefix(environment: dict[str, str]) -> str:
prefix = environment.get("PLANET_DOCKER_MIRROR_PREFIX", DEFAULT_MIRROR_PREFIX).rstrip("/")
if not re.fullmatch(r"[a-zA-Z0-9.-]+(?::[0-9]+)?(?:/[a-zA-Z0-9._-]+)*", prefix):
raise MirrorError("备用镜像地址须为 registry 或 registry/path不得包含凭据或 URL 参数")
return prefix
def canonical_image(image: str) -> str:
first = image.split("/")[0]
if "/" not in image or not ("." in first or ":" in first or first == "localhost"):
image = "docker.io/" + ("library/" if "/" not in image else "") + image
if image.startswith("index.docker.io/"):
image = "docker.io/" + image.removeprefix("index.docker.io/")
if image.startswith("docker.io/") and image.count("/") == 1:
image = "docker.io/library/" + image.removeprefix("docker.io/")
return image
def mirror_image(image: str, environment: dict[str, str]) -> str:
canonical = canonical_image(image)
repository = canonical.split("@")[0].rsplit("/", 1)
repository = repository[0] + "/" + repository[1].split(":")[0]
if repository not in PUBLIC_REPOSITORIES:
raise MirrorError("仅为 Planet 的公开基础镜像回退;自定义或私有镜像须配置自身仓库")
return mirror_prefix(environment) + "/" + canonical
def mirror_registry_urls(environment: dict[str, str]) -> list[str]:
for image in build_images(environment).values():
mirror_image(image, environment)
return ["https://" + urlsplit("https://" + mirror_prefix(environment)).netloc + "/v2/"]
def run(command: list[str], timeout: int = MANIFEST_TIMEOUT) -> str:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
if result.returncode:
detail = safe_line(result.stderr.strip() or result.stdout.strip())
raise MirrorError(detail[-600:] or "备用镜像命令失败")
return result.stdout
def resolve_image(image: str, environment: dict[str, str]) -> str:
mirrored = mirror_image(image, environment)
manifest = json.loads(run([
"docker", "buildx", "imagetools", "inspect", mirrored, "--format", "{{json .Manifest}}"
]))
digest = manifest.get("digest") if isinstance(manifest, dict) else None
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
raise MirrorError("备用镜像未返回有效的 manifest digest")
if "@" in image and digest != image.rsplit("@", 1)[1]:
raise MirrorError("备用镜像 digest 与指定镜像不一致")
repository = mirrored.split("@")[0]
if ":" in repository.rsplit("/", 1)[1]:
repository = repository.rsplit(":", 1)[0]
return repository + "@" + digest
def pull_services(services: list[str], environment: dict[str, str]) -> None:
if not services or any(service not in {"postgres", "redis"} for service in services):
raise MirrorError("备用拉取只处理本次请求的 PostgreSQL / Redis 服务")
configuration = json.loads(run(["docker", "compose", "config", "--format", "json"]))
for service in services:
image = configuration["services"][service]["image"]
local = subprocess.run(
["docker", "image", "inspect", image], capture_output=True, timeout=MANIFEST_TIMEOUT
)
if local.returncode == 0:
continue
resolved = resolve_image(image, environment)
print(f"备用源拉取 {service}", flush=True)
# Docker verifies layer digests while pulling the immutable manifest reference.
run(["docker", "pull", resolved], PULL_TIMEOUT)
run(["docker", "tag", resolved, image])
def main() -> None:
environment = dict(os.environ)
if sys.argv[1:] == ["build"]:
resolved = {key: resolve_image(value, environment)
for key, value in build_images(environment).items()}
for key, value in resolved.items():
print(f"{key}={value}")
elif sys.argv[1:2] == ["pull"]:
pull_services(sys.argv[2:], environment)
else:
raise MirrorError("未知备用镜像操作")
if __name__ == "__main__":
try:
main()
except (MirrorError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
detail = str(error) if isinstance(error, MirrorError) else "镜像验证或拉取未完成"
sys.stderr.write("PLANET_DOCKER_MIRROR_FAILED: " + detail + "\n")
sys.exit(1)

View File

@@ -7,12 +7,13 @@ import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from urllib.parse import urlsplit
from docker_mirror import build_images, mirror_registry_urls
CONFIG = Path("/etc/docker/daemon.json")
STATE = Path("/etc/docker/planet-proxy-state.json")
PROXY_KEYS = ("http-proxy", "https-proxy", "no-proxy")
@@ -45,11 +46,8 @@ def daemon_proxies() -> dict[str, str]:
def registry_urls(environment: dict[str, str]) -> list[str]:
dockerfile = Path(__file__).resolve().parents[1] / "aiprovider/Dockerfile"
defaults = dict(re.findall(r"^ARG (PYTHON_IMAGE|UV_IMAGE)=(.+)$", dockerfile.read_text(), re.M))
registries = set()
for key in ("PYTHON_IMAGE", "UV_IMAGE"):
image = environment.get(key) or defaults[key]
for image in build_images(environment).values():
prefix = image.split("/")[0]
registry = (
prefix
@@ -118,6 +116,17 @@ def select_route(environment: dict[str, str], urls: list[str]) -> dict[str, obje
return {"desired": {}, "mode": "direct", "reachable": reachable(urls, "")}
def plan_routes(environment: dict[str, str], force_mirror: bool = False) -> dict[str, object]:
if not force_mirror:
plan = select_route(environment, registry_urls(environment))
plan["mirror"] = False
if plan["reachable"] or environment.get("PLANET_DOCKER_MIRROR_FALLBACK") == "0":
return plan
plan = select_route(environment, mirror_registry_urls(environment))
plan["mirror"] = True
return plan
def atomic_write(path: Path, contents: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
@@ -207,13 +216,13 @@ def rollback(
def main() -> None:
action = sys.argv[1]
if action == "plan":
plan = select_route(dict(os.environ), registry_urls(dict(os.environ)))
plan = plan_routes(dict(os.environ), sys.argv[2:] == ["mirror"])
plan["current"] = daemon_proxies()
json.dump(plan, sys.stdout)
elif action == "status":
plan = json.load(sys.stdin)
changed = int(plan["current"] != plan["desired"])
print(plan["mode"], changed, int(plan["reachable"]))
print(plan["mode"], changed, int(plan["reachable"]), int(plan.get("mirror", False)))
elif action == "apply":
if os.geteuid() != 0:
raise ProxyError(CONFIG_ERROR)

View File

@@ -15,9 +15,12 @@ main() {
harness_run zsh -n planet.sh
harness_run zsh -n scripts/lib/docker-bootstrap.zsh
harness_run zsh -n scripts/lib/docker-proxy.zsh
harness_run zsh -n scripts/lib/docker-mirror.zsh
harness_run zsh -n scripts/lib/error-diagnostics.zsh
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_bootstrap.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_command.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_proxy.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_docker_mirror.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_database_startup.py
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python scripts/harness/test_error_diagnostics.py
harness_run bash -n scripts/bootstrap-dev.sh

View File

@@ -6,6 +6,8 @@ import io
import json
from pathlib import Path
import re
import shlex
import socket
import subprocess
import sys
import tempfile
@@ -25,7 +27,7 @@ TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/pl
def shell_function(name: str) -> str:
source = (ROOT / "planet.sh").read_text()
source = (ROOT / "planet.sh").read_text() + "\n" + (ROOT / "scripts/lib/docker-bootstrap.zsh").read_text()
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
if match is None:
raise AssertionError(f"missing shell function: {name}")
@@ -44,6 +46,47 @@ def run_shell(functions: list[str], setup: str, action: str) -> subprocess.Compl
class ComposeSelectionTests(unittest.TestCase):
def test_probe_timeout_does_not_fall_back_or_claim_compose_is_missing(self) -> None:
result = run_shell(
["compose_up"],
"""
compose_available() { return 124; }
log_error() { echo "$*"; }
compose_v1_available() { echo UNEXPECTED_FALLBACK; return 0; }
run_compose_command() { echo UNEXPECTED_COMMAND; }
report_missing_compose() { echo UNEXPECTED_MISSING; }
""",
"compose_up up -d postgres",
)
self.assertEqual(result.returncode, 124)
self.assertNotIn("UNEXPECTED", result.stdout)
def test_compose_timeout_keeps_live_output_and_passes_log_to_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as folder:
binary = Path(folder) / "docker"
binary.write_text("#!/bin/sh\necho 'postgres Pulling'\nsleep 30\n")
binary.chmod(0o755)
result = run_shell(
["run_compose_command", "run_docker_observed"],
f"""
PLANET_STATE_DIR={shlex.quote(folder)}
DOCKER_COMMAND_RUNNER={shlex.quote(str(ROOT / 'scripts/docker_command.py'))}
PLANET_COMPOSE_TIMEOUT=0.3
clear_wait_spinner() {{ :; }}
docker_failure_allows_mirror() {{ return 1; }}
docker_daemon_available() {{ return 0; }}
set_wait_detail() {{ echo "$*" >&2; }}
log_error() {{ echo "DIAGNOSTIC $2"; }}
""",
f"run_compose_command {shlex.quote(str(binary))} compose up -d postgres",
)
self.assertEqual(result.returncode, 124, result.stderr)
self.assertIn("postgres Pulling", result.stderr)
logs = list(Path(folder).glob("compose.*.log"))
self.assertEqual(len(logs), 1)
self.assertIn(f"DIAGNOSTIC {logs[0]}", result.stdout)
self.assertIn("PLANET_DOCKER_COMMAND_TIMEOUT", logs[0].read_text())
def test_operations_use_v1_only_when_v2_is_unavailable(self) -> None:
operations = (
("compose_up", "up -d postgres"),
@@ -74,6 +117,8 @@ class ComposeSelectionTests(unittest.TestCase):
f"""
compose_available() {{ return {int(not v2_available)}; }}
compose_v1_available() {{ return {int(not v1_available)}; }}
run_compose_command() {{ "$@"; }}
docker_probe() {{ "$@"; }}
docker() {{
echo "docker $*" >> '{calls_file}'
echo ORIGINAL_ERROR >&2
@@ -112,6 +157,47 @@ class ComposeSelectionTests(unittest.TestCase):
class DatabaseLifecycleTests(unittest.TestCase):
def test_compose_error_exits_before_health_retry_or_restart(self) -> None:
result = run_shell(
["ensure_database_services_healthy"],
"""
DATABASE_START_MAX_RETRIES=3
start_database_services() { echo PORT_CONFLICT; return 1; }
wait_for_database_health() { echo UNEXPECTED_HEALTH; }
docker_probe() { echo UNEXPECTED_RESTART; }
animate_wait_spinner() { echo UNEXPECTED_RETRY; }
""",
"ensure_database_services_healthy",
)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout.strip(), "PORT_CONFLICT")
def test_failure_secondary_line_survives_outer_error_and_cleanup_notice(self) -> None:
result = run_shell(
["log_error", "cleanup_failed_start"],
"""
WAIT_SPINNER_MESSAGE='启动数据库'
LAST_COMMAND_ERROR='Bind for 127.0.0.1:5432 failed: port is already allocated'
VERBOSE=0
log_line() { echo "$1 $3"; }
stop_wait_session() { :; }
clear_wait_spinner() { echo UNEXPECTED_CLEAR; }
log_warn() { echo UNEXPECTED_WARNING; }
START_RUN_ACTIVE=1
START_RUN_COMPLETED=0
STARTED_MOTION_AGENT_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_BACKEND_THIS_RUN=0
""",
"log_error 'Docker Compose 执行失败'\n"
"log_error '后端数据库连接检查失败'\ncleanup_failed_start",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), [
"fail 启动数据库",
" Bind for 127.0.0.1:5432 failed: port is already allocated",
])
def test_ai_start_uses_host_readiness_without_waiting_for_docker_probe_schedule(self) -> None:
for recreate in (0, 1):
with self.subTest(recreate=recreate):
@@ -124,7 +210,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
AI_PROVIDER_HEALTH_CHECK_INTERVAL=2
AI_PROVIDER_CONTAINER_NAME=planet_aiprovider
for fn in set_wait_detail write_ai_provider_runtime_env_file \
ensure_ai_provider_image_current recreate_ai_provider_container docker; do
ensure_ai_provider_image_current recreate_ai_provider_container docker_probe; do
functions[$fn]='return 0'
done
wait_for_container_health() {{ echo WAIT_FOR_DOCKER_SCHEDULE; return 1; }}
@@ -195,7 +281,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
f"""
BACKEND_MAX_RETRIES=3
BACKEND_LOG_FILE=/dev/null
for fn in set_wait_detail log_success log_note log_error; do
for fn in set_wait_detail start_wait_session stop_wait_session log_success log_note log_error; do
functions[$fn]='return 0'
done
ensure_uv_backend_deps() {{ echo DEPS; }}
@@ -276,6 +362,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
[function],
"""
mapped=0
prepare_database_ports() { return 0; }
docker() { return 0; }
compose_up() { mapped=1; }
""",
@@ -288,6 +375,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
["start_database_services"],
"""
docker() { return 0; }
prepare_database_ports() { return 0; }
compose_up() { echo 'port is already allocated' >&2; return 1; }
""",
"start_database_services",
@@ -301,6 +389,7 @@ class DatabaseLifecycleTests(unittest.TestCase):
"""
compose_available() { return 0; }
compose_v1_available() { return 1; }
run_compose_command() { "$@"; }
docker() { echo 'address already in use' >&2; return 1; }
set_wait_detail() { :; }
log_warn() { echo "$*"; }
@@ -365,11 +454,12 @@ class DatabaseLifecycleTests(unittest.TestCase):
["verify_backend_database_connection"],
f"""
checks=0
mktemp() {{ echo /dev/null; }}
set_wait_detail() {{ :; }}
log_warn() {{ :; }}
compose_up() {{ echo "COMPOSE $*"; }}
wait_for_postgres_health() {{ return 0; }}
run_command_with_spinner() {{
run_docker_observed() {{
checks=$((checks + 1))
if [ $checks -eq 1 ]; then return {first_status}; fi
return {retry_status}
@@ -385,6 +475,54 @@ class DatabaseLifecycleTests(unittest.TestCase):
class DatabaseProbeTests(unittest.TestCase):
def test_recently_closed_connection_is_not_a_port_conflict(self) -> None:
with socket.socket() as server, socket.socket() as client:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
port = server.getsockname()[1]
server.listen()
client.connect(("127.0.0.1", port))
connection, _ = server.accept()
connection.close()
client.recv(1)
result = subprocess.CompletedProcess([], 0, "", "")
with patch.object(probe.subprocess, "run", return_value=result):
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", port)])
def test_actual_host_listener_is_still_a_conflict(self) -> None:
with socket.socket() as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
server.listen()
port = server.getsockname()[1]
result = subprocess.CompletedProcess([], 0, "", "")
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError):
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", port)])
def test_other_project_database_and_redis_ports_are_identified_before_recreate(self) -> None:
result = subprocess.CompletedProcess([], 0,
"happyrouter-db-1\t127.0.0.1:5432->5432/tcp\n"
"happyrouter-redis-1\t127.0.0.1:6379->6379/tcp\n", "")
for service, container, port, owner in (
("PostgreSQL", "planet_postgres", 5432, "happyrouter-db-1"),
("Redis", "planet_redis", 6379, "happyrouter-redis-1"),
):
with patch.object(probe.subprocess, "run", return_value=result):
with self.assertRaises(probe.DatabaseReadinessError) as error:
probe.check_port_conflicts([(service, container, port)])
self.assertIn(owner, str(error.exception))
self.assertIn("PLANET_DB_PORT_OCCUPIED", str(error.exception))
def test_own_published_port_is_reusable_and_other_port_is_not_a_conflict(self) -> None:
result = subprocess.CompletedProcess([], 0,
"planet_postgres\t127.0.0.1:15432->5432/tcp\n"
"happyrouter-db-1\t127.0.0.1:5432->5432/tcp\n", "")
with patch.object(probe.subprocess, "run", return_value=result), \
patch.object(probe.socket, "socket") as bind:
probe.check_port_conflicts([("PostgreSQL", "planet_postgres", 15432)])
bind.assert_not_called()
def docker_result(
self, ports: dict | None, mode: str = "bridge"
) -> subprocess.CompletedProcess:

View File

@@ -126,7 +126,8 @@ class DockerBootstrapTests(unittest.TestCase):
stub.chmod(0o755)
for name in ("id", "sudo", "apt-get", "systemctl", "dpkg-query", "mock-socket-access"):
(self.bin / name).symlink_to("mock-command")
for name in ("zsh", "env", "sort", "head", "tail", "readlink", "awk", "sed"):
for name in ("zsh", "env", "sort", "head", "tail", "readlink", "awk", "sed", "python3",
"mktemp", "sleep", "cat", "rm"):
executable = shutil.which(name)
self.assertIsNotNone(executable, f"test prerequisite missing: {name}")
(self.bin / name).symlink_to(executable)

View File

@@ -0,0 +1,93 @@
"""Exercise stalled Docker substitutes without contacting a daemon."""
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import unittest
RUNNER = Path(__file__).resolve().parents[1] / "docker_command.py"
class DockerCommandTests(unittest.TestCase):
def run_command(self, source: str) -> tuple[subprocess.CompletedProcess[str], str]:
with tempfile.TemporaryDirectory() as folder:
log = Path(folder) / "quoted ' $(literal).log"
status = Path(folder) / "status"
result = subprocess.run(
[sys.executable, str(RUNNER), "--label", "Compose up postgres redis",
"--timeout", "0.6", "--heartbeat", "0.15", "--log", str(log),
"--status-file", str(status), "--",
sys.executable, "-c", source],
capture_output=True, text=True, timeout=5,
)
self.assertEqual(log.stat().st_mode & 0o777, 0o600)
self.last_status = status.read_text()
return result, log.read_text()
def test_silent_hang_has_heartbeat_deadline_and_no_invented_cause(self) -> None:
result, log = self.run_command("import time; time.sleep(30)")
self.assertEqual(result.returncode, 124, result.stderr)
self.assertIn("Compose up postgres redis", self.last_status)
self.assertIn("超时", self.last_status)
self.assertEqual(result.stdout + result.stderr, "")
self.assertIn("PLANET_DOCKER_COMMAND_TIMEOUT", log)
self.assertNotIn("代理", log)
def test_partial_and_carriage_return_output_survives_timeout(self) -> None:
result, log = self.run_command(
"import time; print('postgres Pulling\\rredis Downloading', end='', flush=True); "
"time.sleep(30)"
)
self.assertEqual(result.returncode, 124)
self.assertIn("postgres Pulling", log)
self.assertIn("redis Downloading", self.last_status)
def test_failure_is_streamed_and_original_exit_code_preserved(self) -> None:
result, log = self.run_command(
"import sys; print('TLS handshake timeout', file=sys.stderr); sys.exit(17)"
)
self.assertEqual(result.returncode, 17)
self.assertIn("TLS handshake timeout", result.stdout)
self.assertEqual(log, "TLS handshake timeout\n")
self.assertEqual(self.last_status, "TLS handshake timeout")
def test_success_and_proxy_credentials(self) -> None:
result, log = self.run_command("print('https://user:secret@example.test/v2/ ready')")
self.assertEqual(result.returncode, 0)
self.assertEqual(self.last_status, "")
self.assertEqual(result.stderr, "")
self.assertIn("https://***@example.test/v2/ ready", log)
self.assertNotIn("secret", result.stdout + result.stderr + log)
def test_long_compose_network_error_keeps_the_port_in_secondary_line(self) -> None:
result, _ = self.run_command(
"import sys; print('Error response from daemon: failed to set up container networking: '"
" + 'x' * 160 + ': Bind for 127.0.0.1:5432 failed: port is already allocated'); sys.exit(1)"
)
self.assertEqual(result.returncode, 1)
self.assertEqual(self.last_status,
"Bind for 127.0.0.1:5432 failed: port is already allocated")
def test_timeout_kills_descendant_even_after_cli_parent_exits(self) -> None:
result, _ = self.run_command(
"import subprocess, sys; "
"p = subprocess.Popen([sys.executable, '-c', "
"'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); "
"time.sleep(30)']); print(p.pid, flush=True)"
)
self.assertEqual(result.returncode, 124)
child = int(result.stdout.strip())
state = Path(f"/proc/{child}/stat")
try:
self.assertTrue(not state.exists() or state.read_text().split()[2] == "Z")
finally:
if state.exists() and state.read_text().split()[2] != "Z":
os.kill(child, signal.SIGKILL)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
"""Mirror routing regressions without pulling images or editing daemon configuration."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from test_database_startup import run_shell
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts"))
import docker_mirror as mirror
import docker_proxy as proxy
DIGEST = "sha256:" + "a" * 64
class MirrorTests(unittest.TestCase):
def test_recoverable_uv_failure_does_not_freeze_status_before_tuna_fallback(self) -> None:
result = run_shell(["run_uv_sync_with_mirror_fallback", "run_with_retry"], """
DEPENDENCY_INSTALL_MAX_RETRIES=1
DEPENDENCY_INSTALL_RETRY_INTERVAL=0
WAIT_FAILURE_REPORTED=0
uv_lock_digest() { echo stable; }
assert_uv_lock_unchanged() { :; }
run_command_with_spinner() { shift; "$@"; }
run_command_quiet_unless_verbose() { return 1; }
configure_uv_tuna_index() { echo MIRROR_READY; }
run_uv_sync_with_tuna_config() { echo RECOVERED; }
set_wait_detail() { :; }
log_error() { echo FATAL; WAIT_FAILURE_REPORTED=1; }
""", "run_uv_sync_with_mirror_fallback /dev/null\n[ $WAIT_FAILURE_REPORTED -eq 0 ]")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.splitlines(), ["MIRROR_READY", "RECOVERED"])
def test_build_network_failure_retries_once_with_temporary_args(self) -> None:
with tempfile.TemporaryDirectory() as folder:
result = run_shell(["run_ai_provider_build_command"], f"""
AI_PROVIDER_BUILD_LOG_FILE={folder}/build.log
attempts=0
DOCKER_BUILD_USE_MIRROR=0
PYTHON_IMAGE=python:3.14-slim
run_docker_observed() {{
attempts=$((attempts + 1))
echo "$*" >&2
echo failure > "$3"
[ $attempts -eq 2 ]
}}
docker_failure_allows_mirror() {{ return 0; }}
prepare_docker_build_proxy() {{ DOCKER_BUILD_USE_MIRROR=1; }}
resolve_build_mirror_args() {{ DOCKER_MIRROR_BUILD_ARGS=(--build-arg PYTHON_IMAGE=mirror/python@{DIGEST}); }}
""", 'run_ai_provider_build_command "docker compose"\n'
'[ $attempts -eq 2 ] && [ "$PYTHON_IMAGE" = python:3.14-slim ]')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("--build-arg PYTHON_IMAGE=mirror/python@", result.stderr)
self.assertTrue((Path(folder) / "build.primary.log").exists())
def test_mapping_retains_original_repository_and_digest(self) -> None:
for image, expected in (
("python:3.14-slim", "docker.io/library/python:3.14-slim"),
("docker.io/postgres:15", "docker.io/library/postgres:15"),
("ghcr.io/astral-sh/uv:latest", "ghcr.io/astral-sh/uv:latest"),
("redis@" + DIGEST, "docker.io/library/redis@" + DIGEST),
):
self.assertEqual(mirror.mirror_image(image, {}), "m.daocloud.io/" + expected)
def test_private_repository_and_credentials_are_not_sent_to_public_mirror(self) -> None:
for image in ("private.example/team/python:3", "docker.io/private/app:latest"):
with self.assertRaises(mirror.MirrorError):
mirror.mirror_image(image, {})
with self.assertRaises(mirror.MirrorError):
mirror.mirror_prefix({"PLANET_DOCKER_MIRROR_PREFIX": "user:secret@mirror.test"})
def test_manifest_is_required_and_pinned_even_for_mutable_tags(self) -> None:
with patch.object(mirror, "run", return_value=json.dumps({"digest": DIGEST})):
self.assertEqual(mirror.resolve_image("python:3.14-slim", {}),
"m.daocloud.io/docker.io/library/python@" + DIGEST)
self.assertEqual(mirror.resolve_image("redis@" + DIGEST, {}),
"m.daocloud.io/docker.io/library/redis@" + DIGEST)
for manifest in ([], {}, {"digest": None}, {"digest": "invalid"}, {"digest": "sha256:" + "b" * 64}):
with patch.object(mirror, "run", return_value=json.dumps(manifest)):
with self.assertRaises(mirror.MirrorError):
mirror.resolve_image("redis@" + DIGEST, {})
def test_primary_route_is_preferred_and_fallback_can_be_disabled(self) -> None:
for reachable, disabled, calls in ((True, False, 1), (False, True, 1), (False, False, 2)):
with patch.object(proxy, "select_route", side_effect=[
{"reachable": reachable}, {"reachable": True}
]) as select:
plan = proxy.plan_routes({"PLANET_DOCKER_MIRROR_FALLBACK": "0"} if disabled else {})
self.assertEqual(select.call_count, calls)
self.assertEqual(plan["mirror"], calls == 2)
def test_database_pull_keeps_canonical_tag_and_does_not_replace_cached_images(self) -> None:
for cached in (True, False):
calls = []
def run(command, timeout=30):
calls.append(command)
return json.dumps({"services": {"postgres": {"image": "postgres:15"}}})
with patch.object(mirror, "run", side_effect=run), \
patch.object(mirror, "resolve_image", return_value="mirror/pg@" + DIGEST), \
patch.object(mirror.subprocess, "run", return_value=subprocess.CompletedProcess([], 0 if cached else 1)):
mirror.pull_services(["postgres"], {})
self.assertEqual(len(calls), 1 if cached else 3)
if not cached:
self.assertEqual(calls[-1], ["docker", "tag", "mirror/pg@" + DIGEST, "postgres:15"])
def test_only_registry_network_errors_allow_fallback(self) -> None:
cases = (
('Head "https://registry-1.docker.io/v2/python": i/o timeout', True),
('#3 [internal] load metadata for python\nPLANET_DOCKER_COMMAND_TIMEOUT', True),
('Head "https://registry-1.docker.io/v2/python": 429 Too Many Requests', False),
('Head "https://registry-1.docker.io/v2/python": unauthorized:', False),
('Head "https://registry-1.docker.io/v2/python": x509: unknown authority', False),
('#3 load metadata for python\nno such host\nprocess "/bin/sh -c uv sync" failed', False),
('Bind for 127.0.0.1:5432 failed: port is already allocated', False),
)
with tempfile.TemporaryDirectory() as folder:
log = Path(folder) / "failure.log"
for evidence, expected in cases:
log.write_text(evidence)
result = run_shell([], f"""
SCRIPT_DIR={ROOT}
source {ROOT}/scripts/lib/error-diagnostics.zsh
source {ROOT}/scripts/lib/docker-mirror.zsh
""", f"docker_failure_allows_mirror {log}")
self.assertEqual(result.returncode == 0, expected, evidence + result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -112,6 +112,8 @@ class ErrorDiagnosticsTests(unittest.TestCase):
source {shlex.quote(str(MODULE))}
log_line() {{ echo "$3"; }}
log_note() {{ echo "$1"; }}
stop_wait_session() {{ :; }}
VERBOSE=1
{shell_function('log_error')}
log_error 'new failure'
""",
@@ -133,19 +135,22 @@ class ErrorDiagnosticsTests(unittest.TestCase):
with self.subTest(verbose=verbose):
log = Path(folder) / "build ' quoted $(not-a-command).log"
result = run_shell(
["run_ai_provider_build_command"],
["run_ai_provider_build_command", "run_docker_observed"],
f"""
PATH={shlex.quote(folder + ':' + os.environ['PATH'])}
VERBOSE={verbose}
DOCKER_COMMAND_RUNNER={shlex.quote(str(ROOT / 'scripts/docker_command.py'))}
AI_PROVIDER_BUILD_LOG_FILE={shlex.quote(str(log))}
run_command_with_spinner() {{ shift; "$@"; }}
clear_wait_spinner() {{ :; }}
docker_failure_allows_mirror() {{ return 1; }}
""",
'if run_ai_provider_build_command "docker compose"; '
"then exit 0; else exit $?; fi",
)
self.assertEqual(result.returncode, 17, result.stderr)
self.assertEqual(log.read_text(), "TLS handshake timeout\n")
self.assertNotIn("not-a-command", result.stderr)
self.assertEqual(result.stdout + result.stderr, "")
self.assertNotIn("command not found", result.stderr)
if __name__ == "__main__":

View File

@@ -2,9 +2,56 @@
# Docker prerequisites for planet.sh; lifecycle and logging remain in planet.sh.
DOCKER_MIN_BUILDX_VERSION="0.17.0"
DOCKER_COMMAND_RUNNER="${${(%):-%N}:A:h:h}/docker_command.py"
docker_probe() {
local log_file="$(mktemp "$PLANET_STATE_DIR/docker-probe.XXXXXX.log")"
local label="检查 Docker"
case "$1 $2" in
'docker compose') label="检查 Docker Compose" ;;
'docker info') label="检查 Docker 服务" ;;
'docker buildx') label="检查 Docker Buildx" ;;
'docker context') label="检查 Docker context" ;;
'docker inspect') label="检查容器 ${@[-1]}" ;;
'docker start'|'docker restart') label="启动容器 ${@[-1]}" ;;
esac
run_docker_observed "$label" "${PLANET_DOCKER_PROBE_TIMEOUT:-15}" "$log_file" "$@"
}
run_docker_observed() {
setopt localtraps
local label="$1" deadline="$2" log_file="$3"
shift 3
local detail_file="${log_file}.status" command_status=0
LAST_COMMAND_ERROR=""
python3 "$DOCKER_COMMAND_RUNNER" --label "$label" --timeout "$deadline" \
--log "$log_file" --status-file "$detail_file" -- "$@" >/dev/null 2>&1 &
local command_pid=$!
trap 'kill -TERM "$command_pid" 2>/dev/null || true; wait "$command_pid" 2>/dev/null || true; return 130' INT
trap 'kill -TERM "$command_pid" 2>/dev/null || true; wait "$command_pid" 2>/dev/null || true; return 143' TERM
while kill -0 "$command_pid" 2>/dev/null; do
if (( $+functions[set_wait_detail] )); then
local detail="$label"
[ ! -f "$detail_file" ] || detail="$(<"$detail_file")"
set_wait_detail "$detail"
fi
sleep 0.125
done
wait "$command_pid" || command_status=$?
if [ "$command_status" -ne 0 ]; then
LAST_COMMAND_ERROR="$label"
[ ! -s "$detail_file" ] || LAST_COMMAND_ERROR="$(<"$detail_file")"
fi
rm -f "$detail_file"
if (( $+functions[set_wait_detail] )); then
set_wait_detail "${LAST_COMMAND_ERROR:-}"
fi
cat "$log_file"
return "$command_status"
}
compose_available() {
docker compose version >/dev/null 2>&1
docker_probe docker compose version >/dev/null
}
compose_v1_available() {
@@ -12,7 +59,7 @@ compose_v1_available() {
}
buildx_version() {
docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//'
docker_probe docker buildx version | awk '{print $2}' | sed 's/^v//'
}
buildx_meets_minimum() {
@@ -30,14 +77,14 @@ buildx_meets_minimum() {
}
docker_daemon_available() {
command -v docker >/dev/null 2>&1 && command docker info >/dev/null 2>&1
command -v docker >/dev/null 2>&1 && docker_probe docker info --format '{{.ServerVersion}}' >/dev/null
}
docker_uses_local_engine() {
local endpoint="${DOCKER_HOST:-}"
if [ -n "${DOCKER_CONTEXT:-}" ] || [ -z "$endpoint" ]; then
if command -v docker >/dev/null 2>&1; then
endpoint="$(command docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null)" || return 1
endpoint="$(docker_probe docker context inspect --format '{{.Endpoints.docker.Host}}')" || return 1
elif [ -n "${DOCKER_CONTEXT:-}" ]; then
return 1
else

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env zsh
docker_failure_allows_mirror() {
[ "${PLANET_DOCKER_MIRROR_FALLBACK:-1}" != 0 ] || return 1
local record="$(planet_error_record '' "$1")"
local code="${record%%$'\t'*}"
case "$code" in
P_DNS|P_NETWORK_TIMEOUT|P_CONNECTION_REFUSED|P_DOCKER_COMMAND_TIMEOUT) ;;
*) return 1 ;;
esac
grep -Eq 'process "/bin/sh -c|executor failed running' "$1" && return 1
# Package installation and daemon failures are not image-registry failures.
grep -Eiq 'load metadata|resolve source metadata|failed to resolve|fetch anonymous token|https?://[^ ]*/v2/|Pulling|Downloading' "$1"
}
resolve_build_mirror_args() {
local mirror_log="$(mktemp "$PLANET_STATE_DIR/docker-mirror.XXXXXX.log")"
DOCKER_MIRROR_BUILD_ARGS=()
if ! run_docker_observed "原镜像源不可达,验证备用镜像" \
"${PLANET_DOCKER_MIRROR_TIMEOUT:-120}" "$mirror_log" \
python3 "$SCRIPT_DIR/scripts/docker_mirror.py" build >/dev/null; then
cp "$mirror_log" "$AI_PROVIDER_BUILD_LOG_FILE"
return 1
fi
local assignment
while IFS= read -r assignment; do
DOCKER_MIRROR_BUILD_ARGS+=(--build-arg "$assignment")
done < "$mirror_log"
}
pull_database_mirrors() {
local -a services=()
local argument
for argument in "$@"; do
case "$argument" in postgres|redis) services+=("$argument") ;; esac
done
[ "${#services[@]}" -gt 0 ] || return 1
prepare_docker_build_proxy mirror || return 1
local mirror_log="$(mktemp "$PLANET_STATE_DIR/docker-mirror.XXXXXX.log")"
DOCKER_MIRROR_ERROR_LOG_FILE="$mirror_log"
run_docker_observed "原镜像源不可达,临时使用备用源" \
"${PLANET_COMPOSE_TIMEOUT:-180}" "$mirror_log" \
python3 "$SCRIPT_DIR/scripts/docker_mirror.py" pull "${services[@]}" >/dev/null
}

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env zsh
prepare_docker_build_proxy() {
DOCKER_BUILD_USE_MIRROR=0
# Desktop and remote/rootless daemons are managed in their own environment.
docker_uses_local_engine || return 0
docker_desktop_present && return 0
@@ -18,7 +19,8 @@ prepare_docker_build_proxy() {
: > "$error_file"
chmod 600 "$error_file"
{
if ! "$python_bin" "$helper" plan > "$plan_file" 2> "$error_file"; then
set_wait_detail "检查镜像源的代理、直连与备用路径"
if ! "$python_bin" "$helper" plan "${1:-auto}" > "$plan_file" 2> "$error_file"; then
log_error "Docker 构建代理检测失败" "$error_file"
return 1
fi
@@ -26,7 +28,11 @@ prepare_docker_build_proxy() {
log_error "Docker 构建代理检测失败" "$error_file"
return 1
fi
read -r mode changed connected <<< "$status_text"
read -r mode changed connected DOCKER_BUILD_USE_MIRROR <<< "$status_text"
if [ "$connected" -ne 1 ]; then
log_error "PLANET_PROXY_NO_ROUTE"
return 1
fi
if [ "$changed" -eq 1 ]; then
docker_require_sudo || return 1
log_note "更新 Docker 构建代理配置,重启后恢复原先运行的容器"
@@ -35,14 +41,10 @@ prepare_docker_build_proxy() {
return 1
fi
fi
if [ "$connected" -ne 1 ]; then
log_error "PLANET_PROXY_NO_ROUTE"
return 1
fi
if [ "$mode" = proxy ]; then
log_note "Docker 构建使用已验证可用的主机代理"
set_wait_detail "Docker 使用已验证可用的主机代理"
else
log_note "未发现可用主机代理Docker 构建使用直连"
set_wait_detail "Docker 使用已验证可用的直连路径"
fi
} always {
rm -f -- "$plan_file" "$error_file"