release: bump version to 0.74.7
This commit is contained in:
@@ -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
156
scripts/docker_command.py
Normal 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
128
scripts/docker_mirror.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
93
scripts/harness/test_docker_command.py
Normal file
93
scripts/harness/test_docker_command.py
Normal 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()
|
||||
134
scripts/harness/test_docker_mirror.py
Normal file
134
scripts/harness/test_docker_mirror.py
Normal 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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
44
scripts/lib/docker-mirror.zsh
Normal file
44
scripts/lib/docker-mirror.zsh
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user