release: bump version to 0.74.3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
This commit is contained in:
@@ -76,6 +76,8 @@ main() {
|
||||
check_file docs/harness-audit.md
|
||||
check_file docs/documentation-coverage-rules.md
|
||||
check_file planet.sh
|
||||
check_file scripts/lib/docker-bootstrap.zsh
|
||||
check_file scripts/harness/test_docker_bootstrap.py
|
||||
check_file pyproject.toml
|
||||
check_file frontend/package.json
|
||||
check_file scripts/harness/security-check.sh
|
||||
|
||||
@@ -13,6 +13,9 @@ main() {
|
||||
harness_run scripts/harness/doctor.sh
|
||||
harness_run git diff --check
|
||||
harness_run zsh -n planet.sh
|
||||
harness_run zsh -n scripts/lib/docker-bootstrap.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_database_startup.py
|
||||
harness_run bash -n scripts/bootstrap-dev.sh
|
||||
harness_run bash -n scripts/harness/lib.sh
|
||||
harness_run bash -n scripts/harness/doctor.sh
|
||||
|
||||
274
scripts/harness/test_database_startup.py
Normal file
274
scripts/harness/test_database_startup.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Database lifecycle regressions without starting or changing host containers."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from asyncpg import InvalidCatalogNameError, InvalidPasswordError
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import check_database_connection as probe # noqa: E402
|
||||
|
||||
TEST_URL = make_url("postgresql+asyncpg://postgres:test-secret@localhost:5432/planet_db")
|
||||
|
||||
|
||||
def shell_function(name: str) -> str:
|
||||
source = (ROOT / "planet.sh").read_text()
|
||||
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", source, re.MULTILINE | re.DOTALL)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing shell function: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
def run_shell(functions: list[str], setup: str, action: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["zsh", "-f"],
|
||||
input="\n".join(["set -e", *(shell_function(name) for name in functions), setup, action]),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseLifecycleTests(unittest.TestCase):
|
||||
def test_existing_container_gets_current_compose_configuration(self) -> None:
|
||||
for function in ("start_database_services", "start_postgres_service"):
|
||||
with self.subTest(function=function):
|
||||
result = run_shell(
|
||||
[function],
|
||||
"""
|
||||
mapped=0
|
||||
docker() { return 0; }
|
||||
compose_up() { mapped=1; }
|
||||
""",
|
||||
f"{function}\n[ $mapped -eq 1 ]",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_port_conflict_is_visible_and_cannot_fall_back_to_old_container(self) -> None:
|
||||
result = run_shell(
|
||||
["start_database_services"],
|
||||
"""
|
||||
docker() { return 0; }
|
||||
compose_up() { echo 'port is already allocated' >&2; return 1; }
|
||||
""",
|
||||
"start_database_services",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("port is already allocated", result.stderr)
|
||||
|
||||
def test_installed_compose_failure_is_not_reported_as_missing_compose(self) -> None:
|
||||
result = run_shell(
|
||||
["compose_up"],
|
||||
"""
|
||||
compose_available() { return 0; }
|
||||
compose_v1_available() { return 1; }
|
||||
docker() { echo 'address already in use' >&2; return 1; }
|
||||
set_wait_detail() { :; }
|
||||
log_warn() { echo "$*"; }
|
||||
log_error() { echo "$*"; }
|
||||
report_missing_compose() { echo MISSING_COMPOSE; return 1; }
|
||||
""",
|
||||
"compose_up up -d postgres",
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("address already in use", result.stderr)
|
||||
self.assertNotIn("MISSING_COMPOSE", result.stdout)
|
||||
|
||||
def run_init(self, connection_status: int) -> subprocess.CompletedProcess[str]:
|
||||
return run_shell(
|
||||
["init"],
|
||||
f"""
|
||||
for fn in parse_service_args guard_init_when_services_running print_splash \
|
||||
start_wait_session stop_wait_session ensure_docker_runtime ensure_python_runtime \
|
||||
sync_python_deps sync_frontend_deps ensure_planet_env_files \
|
||||
prepare_motion_agent_host_dependencies set_wait_detail; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
log_success() {{ echo "$*"; }}
|
||||
log_error() {{ echo "$*"; }}
|
||||
log_step() {{ :; }}
|
||||
log_note() {{ :; }}
|
||||
ensure_database_services_healthy() {{ echo CONTAINERS_HEALTHY; }}
|
||||
verify_backend_database_connection() {{ echo CONNECTION_CHECK; return {connection_status}; }}
|
||||
run_command_with_spinner() {{ shift; "$@"; }}
|
||||
initialize_backend_database() {{ echo SCHEMA_INITIALIZED; }}
|
||||
""",
|
||||
"init --non-motion-agent",
|
||||
)
|
||||
|
||||
def test_healthy_containers_do_not_allow_schema_changes_on_bad_connection(self) -> None:
|
||||
result = self.run_init(1)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("CONNECTION_CHECK", result.stdout)
|
||||
self.assertNotIn("SCHEMA_INITIALIZED", result.stdout)
|
||||
self.assertNotIn("数据库服务已就绪", result.stdout)
|
||||
|
||||
def test_real_connection_is_checked_before_reporting_ready_and_creating_tables(self) -> None:
|
||||
result = self.run_init(0)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("CONNECTION_CHECK", result.stdout)
|
||||
self.assertLess(
|
||||
result.stdout.index("CONNECTION_CHECK"), result.stdout.index("数据库服务已就绪")
|
||||
)
|
||||
self.assertLess(
|
||||
result.stdout.index("数据库服务已就绪"), result.stdout.index("SCHEMA_INITIALIZED")
|
||||
)
|
||||
|
||||
def test_only_missing_ports_trigger_one_volume_preserving_recreation(self) -> None:
|
||||
for first_status, retry_status, expected_status, expected_recreates in (
|
||||
(0, 0, 0, 0),
|
||||
(1, 0, 1, 0),
|
||||
(2, 0, 0, 1),
|
||||
(2, 2, 2, 1),
|
||||
):
|
||||
with self.subTest(first_status=first_status, retry_status=retry_status):
|
||||
result = run_shell(
|
||||
["verify_backend_database_connection"],
|
||||
f"""
|
||||
checks=0
|
||||
set_wait_detail() {{ :; }}
|
||||
log_warn() {{ :; }}
|
||||
compose_up() {{ echo "COMPOSE $*"; }}
|
||||
wait_for_postgres_health() {{ return 0; }}
|
||||
run_command_with_spinner() {{
|
||||
checks=$((checks + 1))
|
||||
if [ $checks -eq 1 ]; then return {first_status}; fi
|
||||
return {retry_status}
|
||||
}}
|
||||
""",
|
||||
"verify_backend_database_connection",
|
||||
)
|
||||
self.assertEqual(result.returncode, expected_status, result.stderr)
|
||||
self.assertEqual(result.stdout.count("COMPOSE"), expected_recreates)
|
||||
if expected_recreates:
|
||||
self.assertIn("up -d --no-deps --force-recreate postgres", result.stdout)
|
||||
self.assertNotIn("--renew-anon-volumes", result.stdout)
|
||||
|
||||
|
||||
class DatabaseProbeTests(unittest.TestCase):
|
||||
def docker_result(
|
||||
self, ports: dict | None, mode: str = "bridge"
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.CompletedProcess([], 0, f"{mode}\n{json.dumps(ports)}\n", "")
|
||||
|
||||
def test_missing_port_blocks_sql_even_if_container_is_healthy(self) -> None:
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", return_value=TEST_URL),
|
||||
patch.object(probe.subprocess, "run", return_value=self.docker_result({})),
|
||||
patch.object(probe, "check_connection", new_callable=AsyncMock) as connect,
|
||||
redirect_stderr(output),
|
||||
redirect_stdout(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 2)
|
||||
connect.assert_not_called()
|
||||
self.assertIn("未向宿主机发布", output.getvalue())
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
|
||||
def test_actual_backend_port_must_match_published_port(self) -> None:
|
||||
result = self.docker_result({"5432/tcp": [{"HostIp": "0.0.0.0", "HostPort": "15432"}]})
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError):
|
||||
probe.check_published_port(TEST_URL)
|
||||
probe.check_published_port(TEST_URL.set(port=15432))
|
||||
|
||||
def test_host_networking_does_not_require_a_port_mapping(self) -> None:
|
||||
with patch.object(probe.subprocess, "run", return_value=self.docker_result(None, "host")):
|
||||
probe.check_published_port(TEST_URL)
|
||||
|
||||
def test_external_database_does_not_require_a_local_mapping(self) -> None:
|
||||
with patch.object(probe.subprocess, "run") as inspect:
|
||||
probe.check_published_port(TEST_URL.set(host="configured-db.example"))
|
||||
inspect.assert_not_called()
|
||||
|
||||
def test_docker_failure_does_not_echo_raw_output(self) -> None:
|
||||
result = subprocess.CompletedProcess([], 1, "", "sensitive test-secret")
|
||||
with patch.object(probe.subprocess, "run", return_value=result):
|
||||
with self.assertRaises(probe.DatabaseReadinessError) as error:
|
||||
probe.check_published_port(TEST_URL)
|
||||
self.assertIn("Docker context", str(error.exception))
|
||||
self.assertNotIn("test-secret", str(error.exception))
|
||||
|
||||
def test_authentication_error_is_actionable_without_leaking_dsn_or_query_secrets(self) -> None:
|
||||
output = io.StringIO()
|
||||
url = TEST_URL.update_query_dict({"sslpassword": "query-secret"})
|
||||
error = DBAPIError(
|
||||
None, None, InvalidPasswordError(url.render_as_string(hide_password=False))
|
||||
)
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", return_value=url),
|
||||
patch.object(probe, "check_published_port"),
|
||||
patch.object(probe, "check_connection", new_callable=AsyncMock, side_effect=error),
|
||||
redirect_stderr(output),
|
||||
redirect_stdout(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 1)
|
||||
self.assertIn("认证失败", output.getvalue())
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
self.assertNotIn("query-secret", output.getvalue())
|
||||
self.assertNotIn("Traceback", output.getvalue())
|
||||
|
||||
def test_missing_database_and_network_errors_have_distinct_diagnostics(self) -> None:
|
||||
self.assertIn("数据库不存在", probe.connection_diagnostic(InvalidCatalogNameError()))
|
||||
for error in (ConnectionRefusedError(), TimeoutError()):
|
||||
self.assertIn("地址不可达", probe.connection_diagnostic(error))
|
||||
|
||||
def test_invalid_config_never_echoes_validation_input(self) -> None:
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.object(probe, "backend_database_url", side_effect=ValueError("test-secret")),
|
||||
redirect_stderr(output),
|
||||
):
|
||||
self.assertEqual(probe.main(), 1)
|
||||
self.assertNotIn("test-secret", output.getvalue())
|
||||
|
||||
|
||||
class DatabaseConnectionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_connection_deadline_stops_a_stalled_probe(self) -> None:
|
||||
async def stall() -> None:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.dispose = AsyncMock()
|
||||
engine.connect.return_value.__aenter__.side_effect = stall
|
||||
with (
|
||||
patch.object(probe, "create_async_engine", return_value=engine),
|
||||
patch.object(probe, "CONNECT_TIMEOUT_SECONDS", 0.001),
|
||||
):
|
||||
with self.assertRaises(TimeoutError):
|
||||
await probe.check_connection(TEST_URL)
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
async def test_probe_only_selects_and_always_disposes_the_engine(self) -> None:
|
||||
for error in (None, InvalidPasswordError("test-secret")):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
engine = MagicMock()
|
||||
engine.dispose = AsyncMock()
|
||||
connection = AsyncMock()
|
||||
engine.connect.return_value.__aenter__.return_value = connection
|
||||
connection.execute.side_effect = error
|
||||
with patch.object(probe, "create_async_engine", return_value=engine):
|
||||
if error:
|
||||
with self.assertRaises(InvalidPasswordError):
|
||||
await probe.check_connection(TEST_URL)
|
||||
else:
|
||||
await probe.check_connection(TEST_URL)
|
||||
self.assertEqual(str(connection.execute.call_args.args[0]), "SELECT 1")
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
332
scripts/harness/test_docker_bootstrap.py
Normal file
332
scripts/harness/test_docker_bootstrap.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Exercise Docker bootstrap with isolated command stubs, never the host daemon."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE = ROOT / "scripts/lib/docker-bootstrap.zsh"
|
||||
|
||||
COMMAND_STUB = r"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
state_path = Path(os.environ["DOCKER_TEST_STATE"])
|
||||
state = json.loads(state_path.read_text())
|
||||
name = Path(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
state["calls"].append([name, args])
|
||||
|
||||
def save() -> None:
|
||||
state_path.write_text(json.dumps(state))
|
||||
|
||||
def finish(code: int = 0, output: str = "") -> None:
|
||||
save()
|
||||
if output:
|
||||
print(output)
|
||||
sys.exit(code)
|
||||
|
||||
def install_command(name: str) -> None:
|
||||
target = state_path.parent / "bin" / name
|
||||
if not target.exists():
|
||||
target.symlink_to("mock-command")
|
||||
|
||||
if name == "id":
|
||||
if args == ["-u"]:
|
||||
finish(output=str(state["uid"]))
|
||||
if args == ["-un"]:
|
||||
finish(output="planet-test")
|
||||
finish(output="planet-test" + (" docker" if state["member"] else ""))
|
||||
if name == "sudo":
|
||||
if state.get("sudo_denied"):
|
||||
finish(1)
|
||||
if args == ["-v"]:
|
||||
finish()
|
||||
if "-E" in args:
|
||||
finish() # Record re-exec argv without executing planet.sh on this machine.
|
||||
save()
|
||||
sys.exit(subprocess.call(args[1:] if args[0] == "--" else args))
|
||||
if name == "apt-get":
|
||||
if state.get("apt_fail"):
|
||||
finish(1, "simulated apt failure")
|
||||
if args[0] == "install":
|
||||
for package in args[2:]:
|
||||
if package in ("docker.io", "docker-ce"):
|
||||
state["engine"] = True
|
||||
install_command("docker")
|
||||
install_command("dockerd")
|
||||
if package in ("docker-compose-v2", "docker-compose-plugin"):
|
||||
state["compose"] = True
|
||||
if package in ("docker-buildx", "docker-buildx-plugin"):
|
||||
state["buildx"] = "0.20.0"
|
||||
if package == "passwd":
|
||||
install_command("usermod")
|
||||
finish()
|
||||
if name == "dpkg-query":
|
||||
finish(0 if state.get("ce") else 1, "install ok installed" if state.get("ce") else "")
|
||||
if name == "systemctl":
|
||||
if args[0] == "show":
|
||||
unit_exists = state["engine"] and (args[-1] != "docker.socket" or state["socket_unit"])
|
||||
finish(output="loaded" if unit_exists else "not-found")
|
||||
if args[0] == "is-active":
|
||||
finish(output="active" if state["running"] else "inactive")
|
||||
if state.get("service_fail"):
|
||||
finish(1, "simulated service failure")
|
||||
state["running"] = True
|
||||
finish()
|
||||
if name == "usermod":
|
||||
state["member"] = True
|
||||
finish()
|
||||
if name == "mock-socket-access":
|
||||
finish(0 if state["running"] and state["uid"] != 0 and not state["access"] else 1)
|
||||
if name == "docker":
|
||||
if args[0] == "info":
|
||||
finish(0 if state["running"] and (state["uid"] == 0 or state["access"]) else 1)
|
||||
if args[:2] == ["context", "inspect"]:
|
||||
finish(output=state["endpoint"])
|
||||
if args[:2] == ["compose", "version"]:
|
||||
finish(0 if state["compose"] else 1)
|
||||
if args[:2] == ["buildx", "version"]:
|
||||
finish(0 if state["buildx"] else 1, "github.com/docker/buildx v" + state["buildx"])
|
||||
finish(1)
|
||||
"""
|
||||
|
||||
|
||||
class DockerBootstrapTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="planet-docker-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.folder = Path(self.temporary.name)
|
||||
self.bin = self.folder / "bin"
|
||||
self.bin.mkdir()
|
||||
self.state_file = self.folder / "state.json"
|
||||
self.state = {
|
||||
"calls": [],
|
||||
"uid": 1000,
|
||||
"member": False,
|
||||
"access": False,
|
||||
"engine": False,
|
||||
"running": False,
|
||||
"compose": False,
|
||||
"buildx": "",
|
||||
"endpoint": "unix:///var/run/docker.sock",
|
||||
"socket_unit": True,
|
||||
}
|
||||
stub = self.bin / "mock-command"
|
||||
stub.write_text(f"#!{sys.executable}\n" + COMMAND_STUB)
|
||||
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"):
|
||||
executable = shutil.which(name)
|
||||
self.assertIsNotNone(executable, f"test prerequisite missing: {name}")
|
||||
(self.bin / name).symlink_to(executable)
|
||||
|
||||
def existing_engine(self, *, running: bool = True, access: bool = True) -> None:
|
||||
for name in ("docker", "dockerd"):
|
||||
(self.bin / name).symlink_to("mock-command")
|
||||
self.state.update(
|
||||
engine=True, running=running, access=access, compose=True, buildx="0.20.0"
|
||||
)
|
||||
|
||||
def run_bootstrap(
|
||||
self,
|
||||
action: str = "ensure_docker_runtime",
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
arguments: list[str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.state_file.write_text(json.dumps(self.state))
|
||||
env = os.environ.copy()
|
||||
for name in ("DOCKER_HOST", "DOCKER_CONTEXT", "PLANET_DOCKER_GROUP_REEXEC"):
|
||||
env.pop(name, None)
|
||||
env.update(
|
||||
PATH=str(self.bin),
|
||||
DOCKER_TEST_STATE=str(self.state_file),
|
||||
TEST_OS="ubuntu",
|
||||
TEST_DESKTOP="0",
|
||||
PLANET_STATE_DIR=str(self.folder),
|
||||
)
|
||||
env.update(extra_env or {})
|
||||
arguments = arguments or ["init", "--non-motion-agent"]
|
||||
prelude = f"""
|
||||
set -e
|
||||
SCRIPT_DIR={shlex.quote(str(self.folder / "repo with ' quotes"))}
|
||||
source {shlex.quote(str(MODULE))}
|
||||
log_note() {{ print -r -- "$*"; }}
|
||||
log_error() {{ print -r -- "$*"; }}
|
||||
log_step() {{ print -r -- "$*"; }}
|
||||
docker_bootstrap_os_id() {{ print -r -- "$TEST_OS"; }}
|
||||
docker_desktop_present() {{ [[ "$TEST_DESKTOP" == 1 ]]; }}
|
||||
docker_socket_needs_group_access() {{ mock-socket-access; }}
|
||||
run_command_quiet_unless_verbose() {{ local output="$1"; shift; "$@" > "$output" 2>&1; }}
|
||||
ensure_system_command() {{ command -v "$1" >/dev/null || docker_as_root apt-get install -y "$2"; }}
|
||||
{action} {shlex.join(arguments)}
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[str(self.bin / "zsh"), "-f", "-c", prelude],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
self.state = json.loads(self.state_file.read_text())
|
||||
return result
|
||||
|
||||
def calls(self, name: str) -> list[list[str]]:
|
||||
return [args for command, args in self.state["calls"] if command == name]
|
||||
|
||||
def test_working_environment_is_reused_without_privilege_or_installation(self) -> None:
|
||||
self.existing_engine()
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
|
||||
def test_fresh_root_install_starts_and_validates_engine(self) -> None:
|
||||
self.state["uid"] = 0
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get"),
|
||||
[
|
||||
["update", "--error-on=any"],
|
||||
["install", "-y", "docker.io", "docker-compose-v2", "docker-buildx"],
|
||||
],
|
||||
)
|
||||
self.assertIn(["enable", "--now", "docker.service"], self.calls("systemctl"))
|
||||
self.assertTrue(self.state["running"])
|
||||
|
||||
def test_fresh_user_installs_missing_usermod_and_reexecutes_without_sg(self) -> None:
|
||||
args = ["--verbose", "init", "--non-motion-agent", "a 'quoted' $(argument)"]
|
||||
result = self.run_bootstrap(arguments=args)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn(["install", "-y", "passwd"], self.calls("apt-get"))
|
||||
self.assertEqual(self.calls("usermod"), [["-aG", "docker", "planet-test"]])
|
||||
reexec = self.calls("sudo")[-1]
|
||||
self.assertEqual(reexec[:7], ["-E", "-u", "planet-test", "-g", "docker", "--", "env"])
|
||||
self.assertEqual(reexec[7], "PLANET_DOCKER_GROUP_REEXEC=1")
|
||||
self.assertEqual(reexec[8], f"PATH={self.bin}")
|
||||
self.assertEqual(reexec[10], str(self.folder / "repo with ' quotes/planet.sh"))
|
||||
self.assertEqual(reexec[11:], args)
|
||||
self.assertFalse((self.bin / "sg").exists())
|
||||
|
||||
def test_existing_group_membership_refreshes_start_without_installing(self) -> None:
|
||||
self.existing_engine(access=False)
|
||||
self.state["member"] = True
|
||||
result = self.run_bootstrap("refresh_docker_group", arguments=["start", "--allow-lan"])
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("sudo")[-1][-2:], ["start", "--allow-lan"])
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_failed_group_refresh_does_not_loop(self) -> None:
|
||||
self.existing_engine(access=False)
|
||||
self.state["member"] = True
|
||||
result = self.run_bootstrap(
|
||||
"refresh_docker_group", extra_env={"PLANET_DOCKER_GROUP_REEXEC": "1"}
|
||||
)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
|
||||
def test_stopped_existing_engine_is_started_without_installation(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
self.assertTrue(self.state["running"])
|
||||
|
||||
def test_missing_or_outdated_plugins_are_installed_without_replacing_engine(self) -> None:
|
||||
self.existing_engine()
|
||||
self.state.update(compose=False, buildx="0.16.0")
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get")[-1], ["install", "-y", "docker-compose-v2", "docker-buildx"]
|
||||
)
|
||||
|
||||
def test_existing_docker_ce_keeps_its_package_family(self) -> None:
|
||||
self.existing_engine()
|
||||
self.state.update(compose=False, buildx="", ce=True)
|
||||
result = self.run_bootstrap()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(
|
||||
self.calls("apt-get")[-1],
|
||||
["install", "-y", "docker-compose-plugin", "docker-buildx-plugin"],
|
||||
)
|
||||
|
||||
def test_install_failure_stops_before_daemon_start(self) -> None:
|
||||
self.state["apt_fail"] = True
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
self.assertIn("simulated apt failure", result.stdout)
|
||||
|
||||
def test_missing_sudo_reports_requirement_without_attempting_install(self) -> None:
|
||||
(self.bin / "sudo").unlink()
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("缺少 sudo", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_desktop_and_nonlocal_contexts_are_not_replaced(self) -> None:
|
||||
result = self.run_bootstrap(extra_env={"TEST_DESKTOP": "1"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Docker Desktop", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_working_remote_environment_is_reused(self) -> None:
|
||||
self.existing_engine()
|
||||
result = self.run_bootstrap(extra_env={"DOCKER_HOST": "ssh://remote-docker"})
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(self.calls("sudo"), [])
|
||||
|
||||
def test_unsupported_os_has_an_explicit_error(self) -> None:
|
||||
result = self.run_bootstrap(extra_env={"TEST_OS": "debian"})
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("自动安装目前支持 Ubuntu", result.stdout)
|
||||
self.assertEqual(self.calls("apt-get"), [])
|
||||
|
||||
def test_missing_cli_diagnostic_never_suggests_a_socket_unit(self) -> None:
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("未找到 Docker CLI", result.stdout)
|
||||
self.assertNotIn("systemctl", result.stdout)
|
||||
self.assertEqual(self.calls("systemctl"), [])
|
||||
|
||||
def test_missing_service_diagnostic_never_suggests_a_socket_unit(self) -> None:
|
||||
(self.bin / "docker").symlink_to("mock-command")
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertIn("未检测到可用的 docker.service", result.stdout)
|
||||
self.assertNotIn("enable --now docker.socket", result.stdout)
|
||||
|
||||
def test_service_without_socket_unit_gets_service_only_hint(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
self.state["socket_unit"] = False
|
||||
result = self.run_bootstrap("log_docker_daemon_diagnostics")
|
||||
self.assertIn("enable --now docker.service", result.stdout)
|
||||
self.assertNotIn("enable --now docker.socket", result.stdout)
|
||||
|
||||
def test_daemon_start_failure_is_not_reported_as_database_failure(self) -> None:
|
||||
self.existing_engine(running=False)
|
||||
self.state["service_fail"] = True
|
||||
result = self.run_bootstrap()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Docker Engine 启动失败", result.stdout)
|
||||
self.assertNotIn("数据库启动失败", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user