diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-3-container-gateway.sh b/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-3-container-gateway.sh index a70830b0b..6e7614d2c 100755 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-3-container-gateway.sh +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-3-container-gateway.sh @@ -52,7 +52,7 @@ _probe_timeout() { # seconds cmd...; portable across GNU timeout, macOS Homebre else "$@" & local cmd_pid=$! - (sleep "$secs" && kill "$cmd_pid" 2> /dev/null) & + (sleep "$secs" && kill "$cmd_pid" 2> /dev/null) > /dev/null 2>&1 & local watchdog_pid=$! wait "$cmd_pid" 2> /dev/null local rc=$? @@ -100,11 +100,12 @@ for rt in podman docker; do echo "PROBE: ${rt}-runtime → ✗ (gateway socket missing at $sock — container gateway not running)" continue fi - if _probe_timeout 15 "$rt" info > /dev/null 2>"${TMPDIR:-/tmp}/$rt-probe.err"; then + # Held in a variable, not a file: /tmp is read-only inside the sandbox. + out=$(_probe_timeout 15 "$rt" info 2>&1 > /dev/null); rc=$? + if [ $rc -eq 0 ]; then echo "PROBE: ${rt}-runtime → ✓ ($rt reaches the container gateway at $sock)" else - rc=$? - err=$(head -1 "${TMPDIR:-/tmp}/$rt-probe.err") + err=$(printf '%s\n' "$out" | head -1) case "$rc" in 137|143) # The shell-fallback branch of _probe_timeout kills the child with diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-5-signing-key.sh b/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-5-signing-key.sh index 747a6f0d4..b7cedd8ff 100755 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-5-signing-key.sh +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/scripts/probe-5-signing-key.sh @@ -25,10 +25,12 @@ else ssh-*) echo "PROBE: signing-key → ✓ (user.signingkey is a literal key, nothing to read)" ;; *) key="${key/#\~/$HOME}" - if head -c 1 "$key" >/dev/null 2>"${TMPDIR:-/tmp}/signing-key.err"; then + # Held in a variable, not a file: /tmp is read-only inside the sandbox. + out=$(head -c 1 "$key" 2>&1); rc=$? + if [ $rc -eq 0 ]; then echo "PROBE: signing-key → ✓ ($key readable inside sandbox)" else - echo "PROBE: signing-key → ✗ ($key not readable inside sandbox: $(head -1 "${TMPDIR:-/tmp}/signing-key.err"))" + echo "PROBE: signing-key → ✗ ($key not readable inside sandbox: $(printf '%s\n' "$out" | head -1))" fi ;; esac fi diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/tests/helpers.py b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/helpers.py new file mode 100644 index 000000000..3e65509cf --- /dev/null +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/helpers.py @@ -0,0 +1,94 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Shared test helpers for isolated-setup-doctor probe tests.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def find_bash() -> str | None: + """Finds a functional bash executable, preferring Git Bash over WSL on Windows.""" + if os.name == "nt": + for candidate in [ + Path("C:/Program Files/Git/bin/bash.exe"), + Path("C:/Program Files/Git/usr/bin/bash.exe"), + Path("C:/Program Files (x86)/Git/bin/bash.exe"), + ]: + if candidate.is_file(): + return str(candidate) + w = shutil.which("bash") + if w and "system32" not in w.lower(): + return w + return None + return shutil.which("bash") or "bash" + + +def to_posix(path: Path | str) -> str: + """Converts a Path or path string to POSIX format, converting Windows drive letters.""" + s = Path(path).as_posix() + if os.name == "nt" and len(s) >= 2 and s[1] == ":": + return f"/{s[0].lower()}{s[2:]}" + return s + + +def run_probe_script( + probe_path: Path, + env_vars: dict[str, str], + bin_dir: Path | None = None, + cwd: Path | None = None, +) -> str: + """Runs a probe script with proper PATH, POSIX paths, and UTF-8 encoding.""" + bash = find_bash() + if not bash: + raise RuntimeError("bash not found") + + # A minimal base, not the host environment: the host's CONTAINER_HOST / + # DOCKER_HOST, or a gateway running on this machine, must not decide a test. + keep = ["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR"] + if os.name == "nt": + keep += ["SYSTEMROOT", "COMSPEC", "PATHEXT", "TEMP", "TMP", "USERPROFILE"] + env = {name: os.environ[name] for name in keep if name in os.environ} + env.update(env_vars) + + if bin_dir is not None: + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + + if os.name == "nt": + probe_posix = to_posix(probe_path) + if bin_dir is not None: + bin_posix = to_posix(bin_dir) + cmd = f'export PATH="{bin_posix}:$PATH"; bash "{probe_posix}"' + else: + cmd = f'bash "{probe_posix}"' + args = [bash, "-c", cmd] + else: + args = [bash, str(probe_path)] + + res = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=cwd, + ) + return res.stdout diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_1_ssh_agent.py b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_1_ssh_agent.py index b96889b19..18d5fdfe1 100644 --- a/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_1_ssh_agent.py +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_1_ssh_agent.py @@ -48,15 +48,21 @@ def setUp(self) -> None: # ssh-add never connects to it. A sandbox may refuse bind(2), so fall # back to the session's own agent socket. self.sock_path = self.tmp / "agent.sock" - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self.addCleanup(sock.close) - try: - sock.bind(str(self.sock_path)) - except OSError: + if not hasattr(socket, "AF_UNIX"): existing = os.environ.get("SSH_AUTH_SOCK", "") if not (existing and Path(existing).is_socket()): - self.skipTest("cannot bind a unix socket and no SSH_AUTH_SOCK to reuse") + self.skipTest("AF_UNIX is not supported on this platform and no SSH_AUTH_SOCK to reuse") self.sock_path = Path(existing) + else: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(sock.close) + try: + sock.bind(str(self.sock_path)) + except OSError: + existing = os.environ.get("SSH_AUTH_SOCK", "") + if not (existing and Path(existing).is_socket()): + self.skipTest("cannot bind a unix socket and no SSH_AUTH_SOCK to reuse") + self.sock_path = Path(existing) def fake_ssh_add(self, output: str, rc: int) -> None: script = self.bin / "ssh-add" @@ -69,7 +75,14 @@ def run_probe(self) -> str: "SSH_AUTH_SOCK": str(self.sock_path), "TMPDIR": str(self.tmp), } - result = subprocess.run(["bash", str(PROBE)], env=env, capture_output=True, text=True, check=True) + result = subprocess.run( + ["bash", str(PROBE)], + env=env, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) return result.stdout.strip() def test_counts_listed_identities(self) -> None: diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_3_container_gateway.py b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_3_container_gateway.py new file mode 100644 index 000000000..5f443758f --- /dev/null +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_3_container_gateway.py @@ -0,0 +1,175 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Probe 3 reports container runtime status, and writes no file. + +Inside the sandbox /tmp is read-only. The probe holds any error output in a +variable rather than redirecting to a file under /tmp. +""" + +from __future__ import annotations + +import json +import socket +import stat +import sys +import tempfile +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from helpers import find_bash, run_probe_script, to_posix + +PROBE = Path(__file__).resolve().parents[1] / "scripts" / "probe-3-container-gateway.sh" +OLD_PODMAN_ERR = Path("/tmp/podman-probe.err") +OLD_DOCKER_ERR = Path("/tmp/docker-probe.err") + + +class ProbeThreeContainerGateway(unittest.TestCase): + def setUp(self) -> None: + self.bash = find_bash() + if not self.bash: + self.skipTest("bash is not available on this platform") + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.bin = self.tmp / "bin" + self.bin.mkdir() + self.addCleanup(self._tmp.cleanup) + + def fake_cmd(self, name: str, body: str = "exit 0\n") -> Path: + script = self.bin / name + script.write_text(f"#!/bin/sh\n{body}") + script.chmod(script.stat().st_mode | stat.S_IXUSR) + return script + + def setup_stub_gateway(self, serving: list[str]) -> None: + gw_pkg = self.tmp / "tools" / "container-gateway" / "src" / "container_gateway" + gw_pkg.mkdir(parents=True, exist_ok=True) + (gw_pkg / "__init__.py").write_text("", encoding="utf-8") + status_payload = json.dumps({"running": True, "serving": serving}) + (gw_pkg / "__main__.py").write_text( + f"import json\nprint({status_payload!r})\n", + encoding="utf-8", + ) + + def bind_unix_socket(self, name: str) -> Path: + if not hasattr(socket, "AF_UNIX"): + self.skipTest("AF_UNIX is not supported on this platform") + sock_path = self.tmp / name + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(sock.close) + try: + sock.bind(str(sock_path)) + except OSError as e: + self.skipTest(f"cannot bind unix socket: {e}") + return sock_path + + def run_probe( + self, + env_overrides: dict[str, str] | None = None, + cwd: Path | None = None, + ) -> str: + env = { + "TMPDIR": str(self.tmp), + "HOME": str(self.tmp), + **(env_overrides or {}), + } + return run_probe_script(PROBE, env, bin_dir=self.bin, cwd=cwd).strip() + + def test_runtimes_not_on_path(self) -> None: + out = self.run_probe( + { + "PATH": str(self.bin), + "CONTAINER_HOST": "", + "DOCKER_HOST": "", + } + ) + self.assertIn("PROBE: podman-runtime → ⊘ (podman not on PATH)", out) + self.assertIn("PROBE: docker-runtime → ⊘ (docker not on PATH)", out) + + def test_host_env_unset(self) -> None: + self.fake_cmd("podman") + out = self.run_probe({"CONTAINER_HOST": ""}) + self.assertIn( + "PROBE: podman-runtime → ✗ (CONTAINER_HOST unset — gateway not wired into settings)", + out, + ) + + def test_relative_socket_url(self) -> None: + self.fake_cmd("podman") + out = self.run_probe({"CONTAINER_HOST": "unix://run/podman.sock"}) + self.assertIn( + "PROBE: podman-runtime → ✗ (CONTAINER_HOST=unix://run/podman.sock is not absolute", + out, + ) + + def test_gateway_not_running(self) -> None: + self.fake_cmd("podman") + sock = "/var/run/podman.sock" + out = self.run_probe({"CONTAINER_HOST": f"unix://{sock}"}) + self.assertIn( + f"PROBE: podman-runtime → ✗ (gateway socket missing at {sock} — container gateway not running)", + out, + ) + + def test_writes_nothing_under_tmp(self) -> None: + self.setup_stub_gateway(["podman"]) + sock_path = self.bind_unix_socket("podman.sock") + posix_sock = to_posix(sock_path) + self.fake_cmd( + "podman", + 'printf "connect: operation not permitted\\n" >&2\nexit 1\n', + ) + started = time.time() + out = self.run_probe( + {"CONTAINER_HOST": f"unix://{posix_sock}"}, + cwd=self.tmp, + ) + self.assertIn( + f"PROBE: podman-runtime → ✗ (connect to {posix_sock} denied — add it to sandbox.network.allowUnixSockets)", + out, + ) + if OLD_PODMAN_ERR.exists(): + self.assertLess(OLD_PODMAN_ERR.stat().st_mtime, started) + if OLD_DOCKER_ERR.exists(): + self.assertLess(OLD_DOCKER_ERR.stat().st_mtime, started) + self.assertFalse((self.tmp / "podman-probe.err").exists()) + self.assertFalse((self.tmp / "docker-probe.err").exists()) + + def test_docker_stdout_does_not_mask_stderr_error(self) -> None: + self.setup_stub_gateway(["docker"]) + sock_path = self.bind_unix_socket("docker.sock") + posix_sock = to_posix(sock_path) + self.fake_cmd( + "docker", + 'printf "Client: Docker Engine - Community\\n Version: 25.0.0\\n"\n' + 'printf "connect: 502 Bad Gateway\\n" >&2\n' + "exit 1\n", + ) + out = self.run_probe( + {"DOCKER_HOST": f"unix://{posix_sock}"}, + cwd=self.tmp, + ) + self.assertIn( + "PROBE: docker-runtime → ✗ (gateway up, backend down: connect: 502 Bad Gateway)", + out, + ) + self.assertNotIn("Client:", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_5_signing_key.py b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_5_signing_key.py new file mode 100644 index 000000000..a6326f0f8 --- /dev/null +++ b/plugins/magpie-setup/skills/isolated-setup-doctor/tests/test_probe_5_signing_key.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Probe 5 reports whether user.signingkey is readable, and writes no file. + +Inside the sandbox /tmp is read-only. The probe holds any error output in a +variable rather than redirecting to a file under /tmp. +""" + +from __future__ import annotations + +import os +import stat +import sys +import tempfile +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from helpers import find_bash, run_probe_script, to_posix + +PROBE = Path(__file__).resolve().parents[1] / "scripts" / "probe-5-signing-key.sh" +OLD_OUTPUT = Path("/tmp/signing-key.err") + + +class ProbeFiveSigningKey(unittest.TestCase): + def setUp(self) -> None: + self.bash = find_bash() + if not self.bash: + self.skipTest("bash is not available on this platform") + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.bin = self.tmp / "bin" + self.bin.mkdir() + self.addCleanup(self._tmp.cleanup) + + def fake_git(self, answers: dict[str, str]) -> None: + script = self.bin / "git" + lines = ["#!/bin/sh"] + for key, val in answers.items(): + lines.append(f'if [ "$*" = "config --get {key}" ]; then printf "%s\\n" "{val}"; exit 0; fi') + lines.append("exit 1\n") + script.write_text("\n".join(lines)) + script.chmod(script.stat().st_mode | stat.S_IXUSR) + + def run_probe(self, env_overrides: dict[str, str] | None = None) -> str: + env = { + "TMPDIR": str(self.tmp), + "HOME": str(self.tmp), + **(env_overrides or {}), + } + return run_probe_script(PROBE, env, bin_dir=self.bin).strip() + + def test_gpg_format_not_ssh(self) -> None: + self.fake_git({"gpg.format": "openpgp"}) + self.assertEqual(self.run_probe(), "PROBE: signing-key → ⊘ (gpg.format is not ssh)") + + def test_signingkey_unset(self) -> None: + self.fake_git({"gpg.format": "ssh", "user.signingkey": ""}) + self.assertEqual( + self.run_probe(), "PROBE: signing-key → ⊘ (gpg.format=ssh but user.signingkey unset)" + ) + + def test_literal_key(self) -> None: + self.fake_git({"gpg.format": "ssh", "user.signingkey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5..."}) + self.assertEqual( + self.run_probe(), + "PROBE: signing-key → ✓ (user.signingkey is a literal key, nothing to read)", + ) + + def test_readable_key_file(self) -> None: + key_file = self.tmp / "id_ed25519.pub" + key_file.write_text("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5...") + key_arg = to_posix(key_file) if os.name == "nt" else str(key_file) + self.fake_git({"gpg.format": "ssh", "user.signingkey": key_arg}) + self.assertEqual( + self.run_probe(), + f"PROBE: signing-key → ✓ ({key_arg} readable inside sandbox)", + ) + + def test_unreadable_key_file(self) -> None: + key_file = self.tmp / "missing_key.pub" + key_arg = to_posix(key_file) if os.name == "nt" else str(key_file) + self.fake_git({"gpg.format": "ssh", "user.signingkey": key_arg}) + out = self.run_probe() + self.assertTrue(out.startswith(f"PROBE: signing-key → ✗ ({key_arg} not readable inside sandbox:")) + + def test_writes_nothing_under_tmp(self) -> None: + key_file = self.tmp / "missing_key.pub" + key_arg = to_posix(key_file) if os.name == "nt" else str(key_file) + self.fake_git({"gpg.format": "ssh", "user.signingkey": key_arg}) + started = time.time() + self.run_probe() + if OLD_OUTPUT.exists(): + self.assertLess(OLD_OUTPUT.stat().st_mtime, started) + self.assertFalse((self.tmp / "signing-key.err").exists()) + + +if __name__ == "__main__": + unittest.main()