diff --git a/scripts/ci/activity_acceptance_buildx.py b/scripts/ci/activity_acceptance_buildx.py new file mode 100644 index 0000000..af03bac --- /dev/null +++ b/scripts/ci/activity_acceptance_buildx.py @@ -0,0 +1,50 @@ +"""Fresh private Buildx tool state; Docker credential authority stays separate.""" + +from __future__ import annotations + +import os +import shutil +import stat +import tempfile +from pathlib import Path + + +class BuildxState: + def __init__(self, environment, parent=None): + self.path = Path(tempfile.mkdtemp(prefix="published-activity-buildx-", dir=parent)) + self.directory = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + self.identity = self.shape(os.fstat(self.directory)) + self.environment = {**environment, "BUILDX_CONFIG": str(self.path)} + + @staticmethod + def shape(info): + return info.st_dev, info.st_ino, info.st_uid, info.st_gid, info.st_mode + + def check(self): + current = self.path.lstat() + held = os.fstat(self.directory) + if ( + self.shape(current) != self.identity + or self.shape(held) != self.identity + or not stat.S_ISDIR(current.st_mode) + or current.st_uid != os.getuid() + or stat.S_IMODE(current.st_mode) != 0o700 + ): + raise ValueError("private Buildx state directory changed") + + def __enter__(self): + self.check() + return self.environment + + def __exit__(self, _error_type, error, _traceback): + try: + self.check() + shutil.rmtree(self.path) + except Exception as state_error: + if error is None: + raise + # Preserve the command's captured streams when both operations fail. + error.buildx_state_failure = type(state_error).__name__ + finally: + os.close(self.directory) + return False diff --git a/scripts/ci/published-activity-acceptance.md b/scripts/ci/published-activity-acceptance.md index 21f6d4c..fb275ac 100644 --- a/scripts/ci/published-activity-acceptance.md +++ b/scripts/ci/published-activity-acceptance.md @@ -41,6 +41,14 @@ six-minute execution bound, followed by independent cleanup of each captured ID and then the network. Label-bound reconciliation covers uncertain create responses. A foreign replacement is preserved. A reserved result descriptor retains a terminal failure even if the output path refuses further use. +Failed subprocesses retain up to 64 KiB of stdout and stderr, the full stream +lengths and hashes, and the exit code or timeout. Losing an optional diagnostic +file cannot disable cleanup or turn the terminal failure into a pass. +Buildx receives fresh owner-only tool state for each manifest lookup, checked +against a held directory identity and removed afterward. Its writable state is +separate from Docker's empty credential configuration and never enters a +container or artifact. Other Docker operations and cleanup keep their original +credential and engine authority. The seven-day artifact contains the selected tuple, complete wheel hashes, dependency versions, SDK inventory, before/after container and network facts, diff --git a/scripts/ci/published-activity-acceptance.py b/scripts/ci/published-activity-acceptance.py index 7e7bb2b..0931ac5 100644 --- a/scripts/ci/published-activity-acceptance.py +++ b/scripts/ci/published-activity-acceptance.py @@ -19,6 +19,7 @@ try: from scripts.ci import activity_acceptance_output as output_files from scripts.ci import activity_acceptance_runtime as runtime + from scripts.ci.activity_acceptance_buildx import BuildxState from scripts.ci.activity_acceptance_session import Session except ModuleNotFoundError as error: if error.name != "scripts": @@ -26,6 +27,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import activity_acceptance_output as output_files from scripts.ci import activity_acceptance_runtime as runtime + from scripts.ci.activity_acceptance_buildx import BuildxState from scripts.ci.activity_acceptance_session import Session ROOT = Path(__file__).resolve().parents[2] @@ -35,6 +37,7 @@ OWNER_VALUE = "=sdk-python" LABEL_FILTER = "label=" ID_FORMAT = "{{.ID}}" +DIAGNOSTIC_LIMIT = 64 * 1024 # No inherited context, registry credentials, proxy, SSH or remote Docker endpoint. # This kernel directory is private to each CLI process and contains no config.json. DOCKER_ENVIRONMENT = { @@ -47,6 +50,10 @@ def call(*arguments, check=True, **kwargs): if arguments[0] == "docker": kwargs["env"] = DOCKER_ENVIRONMENT + if arguments[:2] == ("docker", "buildx"): + with BuildxState(DOCKER_ENVIRONMENT) as environment: + kwargs["env"] = environment + return subprocess.run(arguments, check=check, capture_output=True, text=True, timeout=180, **kwargs) return subprocess.run(arguments, check=check, capture_output=True, text=True, timeout=180, **kwargs) @@ -54,6 +61,37 @@ def cleanup_call(*arguments, check=True): return subprocess.run(arguments, check=check, capture_output=True, text=True, timeout=10, env=DOCKER_ENVIRONMENT) +def record_failure(files, report, error): + report["failure"] = type(error).__name__ + ": " + str(error)[:1000] + report["complete"] = False + if not isinstance(error, subprocess.CalledProcessError | subprocess.TimeoutExpired): + return + arguments = list(error.cmd) if isinstance(error.cmd, tuple | list) else [error.cmd] + report["command_failure"] = { + "program": arguments[0], + "operation": arguments[1:4], + "argv_sha256": hashlib.sha256(json.dumps(arguments).encode()).hexdigest(), + "exit_code": getattr(error, "returncode", None), + "timeout_seconds": getattr(error, "timeout", None), + "streams": {}, + } + if hasattr(error, "buildx_state_failure"): + report["command_failure"]["buildx_state_failure"] = error.buildx_state_failure + for name, value in (("stdout", error.stdout), ("stderr", error.stderr)): + data = value.encode() if isinstance(value, str) else value or b"" + retained = data[:DIAGNOSTIC_LIMIT] + report["command_failure"]["streams"][name] = { + "bytes": len(data), + "retained_bytes": len(retained), + "truncated": len(data) > len(retained), + "sha256": hashlib.sha256(data).hexdigest(), + } + try: + files.write("command-" + name + ".txt", retained) + except (OSError, ValueError) as retention_error: + report.setdefault("diagnostic_retention_failures", {})[name] = type(retention_error).__name__ + + def deadline(_signum, _frame): raise TimeoutError("published cell reached its six-minute execution bound") @@ -463,8 +501,7 @@ def main(): if engine() != report["engine"]: raise ValueError("Docker engine authority changed") except Exception as error: - report["failure"] = type(error).__name__ + ": " + str(error)[:1000] - report["complete"] = False + record_failure(files, report, error) finally: signal.alarm(0) owned.cleanup() diff --git a/tests/test_published_activity_acceptance.py b/tests/test_published_activity_acceptance.py index 8407b9f..31078ab 100644 --- a/tests/test_published_activity_acceptance.py +++ b/tests/test_published_activity_acceptance.py @@ -541,3 +541,136 @@ def test_platform_image_binding_refuses_an_ambiguous_or_foreign_local_object(mut del platform["Descriptor"] with pytest.raises(ValueError): runtime.bind_local_image(index, platform, index_digest, child_digest, config_digest) + + +def test_failed_command_retains_diagnostics_and_cleanup_in_the_terminal_failure(monkeypatch, tmp_path): + output = tmp_path / "result" + monkeypatch.setattr(runner, "OUTPUT", output) + error = runner.subprocess.CalledProcessError( + 1, ["docker", "buildx", "imagetools", "inspect"], output="captured stdout\n", stderr="metadata reader failed\n" + ) + + def fail_source(_report): + raise error + + cleaned = [] + monkeypatch.setattr(runner, "capture_source", fail_source) + monkeypatch.setattr(runner.OwnedDocker, "cleanup", lambda self: cleaned.append(self.nonce)) + with pytest.raises(RuntimeError, match="acceptance or cleanup is incomplete"): + runner.main() + result = json.loads((output / "result.json").read_text()) + assert result["complete"] is False + assert result["command_failure"]["exit_code"] == 1 + assert (output / "command-stdout.txt").read_text() == "captured stdout\n" + assert (output / "command-stderr.txt").read_text() == "metadata reader failed\n" + assert len(cleaned) == 1 + + +def test_failed_command_streams_are_bounded_and_keep_full_lengths_and_hashes(tmp_path): + files = runner.output_files.Output(tmp_path / "result") + data = b"x" * (runner.DIAGNOSTIC_LIMIT + 123) + error = runner.subprocess.TimeoutExpired(["docker", "buildx"], 5, output=data, stderr=b"timeout detail") + report = {"complete": True} + try: + runner.record_failure(files, report, error) + stream = report["command_failure"]["streams"]["stdout"] + assert stream["bytes"] == len(data) + assert stream["retained_bytes"] == runner.DIAGNOSTIC_LIMIT + assert stream["truncated"] is True + assert stream["sha256"] == runner.hashlib.sha256(data).hexdigest() + assert (files.path / "command-stdout.txt").stat().st_size == runner.DIAGNOSTIC_LIMIT + assert report["command_failure"]["timeout_seconds"] == 5 + assert report["complete"] is False + finally: + files.close() + + +def test_diagnostic_path_refusal_does_not_prevent_cleanup_or_the_held_failure_receipt(monkeypatch, tmp_path): + output = tmp_path / "result" + original = tmp_path / "original" + monkeypatch.setattr(runner, "OUTPUT", output) + + def fail_source(_report): + output.rename(original) + output.mkdir() + raise runner.subprocess.CalledProcessError(1, ["docker", "buildx"], stderr="retained failure") + + cleaned = [] + monkeypatch.setattr(runner, "capture_source", fail_source) + monkeypatch.setattr(runner.OwnedDocker, "cleanup", lambda self: cleaned.append(self.nonce)) + with pytest.raises(RuntimeError, match="acceptance or cleanup is incomplete"): + runner.main() + result = json.loads((original / "result.json").read_text()) + assert result["complete"] is False + assert result["output_failure"] == "identity_changed" + assert result["diagnostic_retention_failures"] == {"stdout": "ValueError", "stderr": "ValueError"} + assert list(output.iterdir()) == [] + assert len(cleaned) == 1 + + +def test_buildx_has_private_tool_state_without_changing_docker_credential_authority(monkeypatch, tmp_path): + original = runner.BuildxState + monkeypatch.setattr(runner, "BuildxState", lambda environment: original(environment, parent=tmp_path)) + monkeypatch.setenv("DOCKER_CONFIG", "/foreign/credentials") + observed = [] + + def run(arguments, **kwargs): + environment = kwargs["env"] + state = Path(environment["BUILDX_CONFIG"]) + assert environment["DOCKER_CONFIG"] == "/proc/self/fd" + assert environment["DOCKER_HOST"] == "unix:///var/run/docker.sock" + assert state.is_dir() + assert state.stat().st_mode & 0o777 == 0o700 + assert list(state.iterdir()) == [] + (state / "current").write_text("synthetic tool state") + observed.append(state) + return SimpleNamespace(stdout="manifest", returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", run) + result = runner.call("docker", "buildx", "imagetools", "inspect", "--raw", "public-image") + assert result.stdout == "manifest" + assert len(observed) == 1 + assert not observed[0].exists() + + +def test_regular_docker_and_cleanup_calls_do_not_inherit_buildx_state(monkeypatch): + observed = [] + monkeypatch.setattr(runner, "BuildxState", lambda *_args: pytest.fail("unexpected Buildx state")) + monkeypatch.setattr(runner.subprocess, "run", lambda _arguments, **kwargs: observed.append(kwargs["env"])) + runner.call("docker", "image", "inspect", "selected-image") + runner.cleanup_call("docker", "ps", "-a") + assert observed == [runner.DOCKER_ENVIRONMENT, runner.DOCKER_ENVIRONMENT] + assert "BUILDX_CONFIG" not in runner.DOCKER_ENVIRONMENT + + +def test_buildx_state_path_replacement_preserves_the_foreign_directory(tmp_path): + state = runner.BuildxState(runner.DOCKER_ENVIRONMENT, parent=tmp_path) + original = tmp_path / "original" + state.__enter__() + state.path.rename(original) + state.path.mkdir() + (state.path / "foreign").write_text("preserved") + with pytest.raises(ValueError, match="state directory changed"): + state.__exit__(None, None, None) + assert (state.path / "foreign").read_text() == "preserved" + assert original.is_dir() + + +def test_buildx_state_refusal_preserves_the_command_error_and_its_stderr(tmp_path): + state = runner.BuildxState(runner.DOCKER_ENVIRONMENT, parent=tmp_path) + original = tmp_path / "original" + error = runner.subprocess.CalledProcessError(1, ["docker", "buildx"], stderr="original metadata failure") + + def fail_with_replaced_state(): + with state: + state.path.rename(original) + state.path.mkdir() + (state.path / "foreign").write_text("preserved") + raise error + + with pytest.raises(runner.subprocess.CalledProcessError) as captured: + fail_with_replaced_state() + assert captured.value is error + assert captured.value.stderr == "original metadata failure" + assert captured.value.buildx_state_failure == "ValueError" + assert (state.path / "foreign").read_text() == "preserved"