patchground 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Engine probing for `doctor`: reachable, version. Never builds, pulls or runs anything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import socket
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import docker
|
|
12
|
+
import docker.errors
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
DEFAULT_PROBE_TIMEOUT = 5.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EngineProblem(StrEnum):
|
|
19
|
+
"""Why the engine could not be used; a category, never the raw engine error."""
|
|
20
|
+
|
|
21
|
+
NOT_RUNNING = "not_running" # no socket, connection refused: engine absent or stopped
|
|
22
|
+
PERMISSION = "permission" # socket exists but this user may not use it
|
|
23
|
+
TIMEOUT = "timeout" # engine did not answer within the probe budget
|
|
24
|
+
MISCONFIGURED = "misconfigured" # DOCKER_HOST / context could not be parsed or used
|
|
25
|
+
UNHEALTHY = "unhealthy" # engine answered, but with an error
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class EngineProbe:
|
|
30
|
+
"""Structured engine readiness. ``problem`` is None exactly when ``reachable``."""
|
|
31
|
+
|
|
32
|
+
reachable: bool
|
|
33
|
+
problem: EngineProblem | None = None
|
|
34
|
+
engine: str | None = None # "Docker" or "Podman"
|
|
35
|
+
version: str | None = None
|
|
36
|
+
api_version: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def probe_engine(*, timeout: float = DEFAULT_PROBE_TIMEOUT) -> EngineProbe:
|
|
40
|
+
"""Ping the configured engine and read its version, within ``timeout`` seconds per request.
|
|
41
|
+
|
|
42
|
+
Never raises for an unusable engine; the client is always closed.
|
|
43
|
+
"""
|
|
44
|
+
if timeout <= 0:
|
|
45
|
+
raise ValueError("timeout must be positive")
|
|
46
|
+
client: docker.DockerClient | None = None
|
|
47
|
+
try:
|
|
48
|
+
client = docker.from_env(timeout=max(1, round(timeout)))
|
|
49
|
+
client.ping()
|
|
50
|
+
info: dict[str, Any] = client.version()
|
|
51
|
+
except (docker.errors.DockerException, requests.exceptions.RequestException, OSError) as exc:
|
|
52
|
+
return EngineProbe(reachable=False, problem=_classify(exc))
|
|
53
|
+
finally:
|
|
54
|
+
if client is not None:
|
|
55
|
+
client.close()
|
|
56
|
+
return EngineProbe(
|
|
57
|
+
reachable=True,
|
|
58
|
+
engine=_engine_name(info),
|
|
59
|
+
version=_text(info.get("Version")),
|
|
60
|
+
api_version=_text(info.get("ApiVersion")),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _text(value: object) -> str | None:
|
|
65
|
+
return value[:64] if isinstance(value, str) and value else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _engine_name(info: dict[str, Any]) -> str:
|
|
69
|
+
components = info.get("Components")
|
|
70
|
+
if isinstance(components, list):
|
|
71
|
+
for component in components:
|
|
72
|
+
name = component.get("Name") if isinstance(component, dict) else None
|
|
73
|
+
if isinstance(name, str) and "podman" in name.lower():
|
|
74
|
+
return "Podman"
|
|
75
|
+
return "Docker"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _chain(exc: BaseException) -> list[BaseException]:
|
|
79
|
+
"""The exception, its causes/contexts and exceptions nested in args (bounded)."""
|
|
80
|
+
seen: list[BaseException] = []
|
|
81
|
+
pending: list[BaseException] = [exc]
|
|
82
|
+
while pending and len(seen) < 32:
|
|
83
|
+
current = pending.pop()
|
|
84
|
+
if any(current is known for known in seen):
|
|
85
|
+
continue
|
|
86
|
+
seen.append(current)
|
|
87
|
+
for linked in (current.__cause__, current.__context__):
|
|
88
|
+
if linked is not None:
|
|
89
|
+
pending.append(linked)
|
|
90
|
+
for arg in current.args:
|
|
91
|
+
nested = arg if isinstance(arg, tuple) else (arg,)
|
|
92
|
+
pending.extend(item for item in nested if isinstance(item, BaseException))
|
|
93
|
+
return seen
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _classify(exc: BaseException) -> EngineProblem:
|
|
97
|
+
chain = _chain(exc)
|
|
98
|
+
for item in chain:
|
|
99
|
+
if isinstance(item, PermissionError) or (
|
|
100
|
+
isinstance(item, OSError) and item.errno in (errno.EACCES, errno.EPERM)
|
|
101
|
+
):
|
|
102
|
+
return EngineProblem.PERMISSION
|
|
103
|
+
for item in chain:
|
|
104
|
+
if isinstance(item, (requests.exceptions.Timeout, socket.timeout, TimeoutError)):
|
|
105
|
+
return EngineProblem.TIMEOUT
|
|
106
|
+
for item in chain:
|
|
107
|
+
if isinstance(item, (FileNotFoundError, ConnectionRefusedError, ConnectionResetError)):
|
|
108
|
+
return EngineProblem.NOT_RUNNING
|
|
109
|
+
if isinstance(item, requests.exceptions.ConnectionError):
|
|
110
|
+
return EngineProblem.NOT_RUNNING
|
|
111
|
+
if isinstance(exc, docker.errors.APIError):
|
|
112
|
+
return EngineProblem.UNHEALTHY
|
|
113
|
+
if (
|
|
114
|
+
isinstance(exc, docker.errors.DockerException)
|
|
115
|
+
and "fetching server api version" in str(exc).lower()
|
|
116
|
+
):
|
|
117
|
+
return EngineProblem.NOT_RUNNING
|
|
118
|
+
return EngineProblem.MISCONFIGURED
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Run phase: execute a command against a copy of some files inside a hardened container.
|
|
2
|
+
|
|
3
|
+
Every run gets a fresh container on a fresh ``internal`` bridge network (no route out), a
|
|
4
|
+
read-only root filesystem, and two fresh volumes, ``/workspace`` and ``/results``, which are the
|
|
5
|
+
only writable paths besides a small ``/tmp`` tmpfs. Files are copied into the volumes with
|
|
6
|
+
``put_archive`` through a never-started "seed" container before the run container is created
|
|
7
|
+
(see :func:`_create_seed`). Results come from ``/results/junit.xml`` only; the exit code is
|
|
8
|
+
informative. Containers, volumes and network are removed in a ``finally``, also on timeout,
|
|
9
|
+
errors and ``KeyboardInterrupt``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import io
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import stat
|
|
19
|
+
import tarfile
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import docker.errors
|
|
28
|
+
import requests
|
|
29
|
+
import urllib3.exceptions
|
|
30
|
+
from docker import DockerClient
|
|
31
|
+
from docker.models.containers import Container
|
|
32
|
+
from docker.models.networks import Network
|
|
33
|
+
from docker.models.volumes import Volume
|
|
34
|
+
from docker.types import LogConfig, Mount
|
|
35
|
+
|
|
36
|
+
from patchground.sandbox._engine import connect
|
|
37
|
+
from patchground.sandbox.errors import JUnitError, SandboxError
|
|
38
|
+
from patchground.sandbox.junit import TestResult, TestStatus, parse_junit
|
|
39
|
+
|
|
40
|
+
RUN_UID = 1000
|
|
41
|
+
RUN_GID = 1000
|
|
42
|
+
WORKSPACE = "/workspace"
|
|
43
|
+
RESULTS = "/results"
|
|
44
|
+
JUNIT_PATH = f"{RESULTS}/junit.xml"
|
|
45
|
+
#: Largest junit.xml accepted from a container.
|
|
46
|
+
MAX_JUNIT_BYTES = 50 * 1024**2
|
|
47
|
+
LOG_TAIL_LINES = 200
|
|
48
|
+
#: Most bytes of sanitized combined output ``run_command`` returns (the tail of the output).
|
|
49
|
+
MAX_OUTPUT_TAIL_BYTES = 64 * 1024
|
|
50
|
+
#: Raw log bytes kept while streaming; sanitizing only shrinks them, so the tail still fills.
|
|
51
|
+
_RAW_TAIL_BYTES = 4 * MAX_OUTPUT_TAIL_BYTES
|
|
52
|
+
#: The engine keeps at most ``max-size * max-file`` of a container's output, whatever it prints.
|
|
53
|
+
LOG_CONFIG = LogConfig(type="json-file", config={"max-size": "1m", "max-file": "2"})
|
|
54
|
+
#: Exit codes the OCI runtime uses for "found but not executable" / "not found".
|
|
55
|
+
_EXEC_FAILURE_CODES = (126, 127)
|
|
56
|
+
#: A connection error this close to the wall-clock limit counts as the limit being hit.
|
|
57
|
+
TIMEOUT_TOLERANCE_SECONDS = 1.0
|
|
58
|
+
LABELS = {"patchground": "1"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Limits:
|
|
63
|
+
"""Per-run resource limits (defaults from ``patchground-contract-execution``)."""
|
|
64
|
+
|
|
65
|
+
cpus: float = 2.0
|
|
66
|
+
memory_bytes: int = 2 * 1024**3
|
|
67
|
+
pids: int = 512
|
|
68
|
+
timeout_seconds: float = 600.0
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
if self.cpus <= 0 or self.memory_bytes <= 0 or self.pids <= 0 or self.timeout_seconds <= 0:
|
|
72
|
+
raise ValueError(f"limits must be positive: {self}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT_LIMITS = Limits()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RunOutcome(StrEnum):
|
|
79
|
+
"""Overall outcome of a run, derived from JUnit results (never from the exit code)."""
|
|
80
|
+
|
|
81
|
+
PASSED = "passed"
|
|
82
|
+
FAILED = "failed"
|
|
83
|
+
ERRORED = "errored"
|
|
84
|
+
MISSING_RESULTS = "missing_results"
|
|
85
|
+
LIMIT_EXCEEDED = "limit_exceeded"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class RunResult:
|
|
90
|
+
"""Result of :func:`run_check`.
|
|
91
|
+
|
|
92
|
+
``limit`` is ``"timeout"`` or ``"memory"`` when ``outcome`` is ``LIMIT_EXCEEDED``.
|
|
93
|
+
``log_tail`` is the last ~200 lines of combined stdout/stderr, followed by any
|
|
94
|
+
``[patchground]`` notes explaining the outcome.
|
|
95
|
+
|
|
96
|
+
``log_tail`` is raw test-runner output and may contain test sources (assertion rewrites,
|
|
97
|
+
tracebacks), including those of hidden checks. Callers must not display it for runs that
|
|
98
|
+
included hidden layers. Prefixed note lines are also untrusted; tests can spoof them.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
outcome: RunOutcome
|
|
102
|
+
tests: tuple[TestResult, ...]
|
|
103
|
+
exit_code: int | None
|
|
104
|
+
limit: str | None
|
|
105
|
+
log_tail: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class CommandResult:
|
|
110
|
+
"""Result of :func:`run_command`.
|
|
111
|
+
|
|
112
|
+
``output_tail`` is the sanitized tail (at most :data:`MAX_OUTPUT_TAIL_BYTES` bytes of UTF-8)
|
|
113
|
+
of the combined stdout/stderr of the command; earlier output may have been dropped, which
|
|
114
|
+
``output_tail_only`` (always true) states. It is arbitrary command output: untrusted text with
|
|
115
|
+
terminal control sequences removed, never engine error text. ``limit`` is ``"timeout"`` or
|
|
116
|
+
``"memory"`` when a resource limit ended the run; ``exit_code`` is ``None`` on timeout.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
exit_code: int | None
|
|
120
|
+
limit: str | None
|
|
121
|
+
output_tail: str
|
|
122
|
+
output_tail_only: bool = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------- tar building
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tarinfo(name: str, mode: int, mtime: float, *, size: int = 0, is_dir: bool) -> tarfile.TarInfo:
|
|
129
|
+
info = tarfile.TarInfo(name)
|
|
130
|
+
info.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
|
|
131
|
+
info.mode = mode
|
|
132
|
+
info.size = size
|
|
133
|
+
info.mtime = int(mtime)
|
|
134
|
+
info.uid = RUN_UID
|
|
135
|
+
info.gid = RUN_GID
|
|
136
|
+
info.uname = ""
|
|
137
|
+
info.gname = ""
|
|
138
|
+
return info
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _collect(layer: Path) -> dict[str, tuple[Path, bool]]:
|
|
142
|
+
"""Map relative POSIX path -> (path, is_dir) for one layer; reject non-regular files."""
|
|
143
|
+
if not layer.is_dir() or layer.is_symlink():
|
|
144
|
+
raise SandboxError(f"layer is not a directory: {layer}")
|
|
145
|
+
entries: dict[str, tuple[Path, bool]] = {}
|
|
146
|
+
for dirpath, dirnames, filenames in os.walk(layer):
|
|
147
|
+
base = Path(dirpath)
|
|
148
|
+
for name in [*dirnames, *filenames]:
|
|
149
|
+
path = base / name
|
|
150
|
+
st = path.lstat()
|
|
151
|
+
rel = path.relative_to(layer).as_posix()
|
|
152
|
+
if stat.S_ISDIR(st.st_mode):
|
|
153
|
+
entries[rel] = (path, True)
|
|
154
|
+
elif stat.S_ISREG(st.st_mode):
|
|
155
|
+
entries[rel] = (path, False)
|
|
156
|
+
else:
|
|
157
|
+
kind = "symlink" if stat.S_ISLNK(st.st_mode) else "special file"
|
|
158
|
+
raise SandboxError(f"refusing to copy {kind} into the sandbox: {path}")
|
|
159
|
+
return entries
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_seed_tar(layers: Sequence[Path]) -> bytes:
|
|
163
|
+
"""Build the in-memory tar that seeds the ``workspace`` and ``results`` volumes.
|
|
164
|
+
|
|
165
|
+
It is extracted at ``/`` of the seed container, so it holds a ``workspace/`` directory with
|
|
166
|
+
``layers`` (directories, applied in order) and an empty ``results/`` directory. Every entry is
|
|
167
|
+
owned by uid/gid 1000 and keeps its permission bits (setuid/setgid/sticky stripped); the two
|
|
168
|
+
top-level entries make the volume roots themselves owned by 1000 and mode 0755.
|
|
169
|
+
|
|
170
|
+
Directories may appear in several layers; any other path clash between layers is a caller
|
|
171
|
+
bug and raises :class:`SandboxError`, as do symlinks and special files anywhere in a layer
|
|
172
|
+
and files that cannot be read.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
return _build_seed_tar(layers)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
raise SandboxError(f"cannot read files to copy into the sandbox: {exc}") from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_seed_tar(layers: Sequence[Path]) -> bytes:
|
|
181
|
+
merged: dict[str, tuple[Path, bool]] = {}
|
|
182
|
+
for layer in layers:
|
|
183
|
+
for rel, (path, is_dir) in _collect(layer).items():
|
|
184
|
+
previous = merged.get(rel)
|
|
185
|
+
if previous is not None and not (previous[1] and is_dir):
|
|
186
|
+
raise SandboxError(f"path {rel!r} is provided by more than one layer")
|
|
187
|
+
merged.setdefault(rel, (path, is_dir))
|
|
188
|
+
now = time.time()
|
|
189
|
+
ws = WORKSPACE.lstrip("/")
|
|
190
|
+
buf = io.BytesIO()
|
|
191
|
+
with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
|
192
|
+
tar.addfile(_tarinfo(f"{ws}/", 0o755, now, is_dir=True))
|
|
193
|
+
for rel in sorted(merged):
|
|
194
|
+
path, is_dir = merged[rel]
|
|
195
|
+
st = path.stat()
|
|
196
|
+
mode = stat.S_IMODE(st.st_mode) & 0o777
|
|
197
|
+
if is_dir:
|
|
198
|
+
tar.addfile(_tarinfo(f"{ws}/{rel}/", mode, st.st_mtime, is_dir=True))
|
|
199
|
+
else:
|
|
200
|
+
with path.open("rb") as fh:
|
|
201
|
+
info = _tarinfo(f"{ws}/{rel}", mode, st.st_mtime, size=st.st_size, is_dir=False)
|
|
202
|
+
tar.addfile(info, fh)
|
|
203
|
+
tar.addfile(_tarinfo(f"{RESULTS.lstrip('/')}/", 0o755, now, is_dir=True))
|
|
204
|
+
return buf.getvalue()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_single_file(chunks: Iterable[bytes], limit: int) -> bytes:
|
|
208
|
+
"""Return the bytes of the one regular file in a ``get_archive`` tar stream."""
|
|
209
|
+
buf = io.BytesIO()
|
|
210
|
+
for chunk in chunks:
|
|
211
|
+
buf.write(chunk)
|
|
212
|
+
if buf.tell() > limit + 64 * 1024: # file plus tar headers/padding
|
|
213
|
+
raise JUnitError(f"junit.xml is larger than {limit} bytes")
|
|
214
|
+
buf.seek(0)
|
|
215
|
+
try:
|
|
216
|
+
with tarfile.open(fileobj=buf, mode="r:") as tar:
|
|
217
|
+
members = tar.getmembers()
|
|
218
|
+
if len(members) != 1 or not members[0].isreg():
|
|
219
|
+
raise JUnitError("junit.xml is not a regular file")
|
|
220
|
+
if members[0].size > limit:
|
|
221
|
+
raise JUnitError(f"junit.xml is larger than {limit} bytes")
|
|
222
|
+
fh = tar.extractfile(members[0])
|
|
223
|
+
if fh is None:
|
|
224
|
+
raise JUnitError("junit.xml could not be read")
|
|
225
|
+
return fh.read()
|
|
226
|
+
except tarfile.TarError as exc:
|
|
227
|
+
raise JUnitError(f"could not read junit.xml archive: {exc}") from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------- outcome rules
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def outcome_for(tests: Sequence[TestResult]) -> tuple[RunOutcome, str | None]:
|
|
234
|
+
"""Derive the run outcome from parsed tests; returns (outcome, explanatory note or None)."""
|
|
235
|
+
if not tests:
|
|
236
|
+
return RunOutcome.ERRORED, "junit.xml contains no test cases"
|
|
237
|
+
statuses = {t.status for t in tests}
|
|
238
|
+
if TestStatus.ERRORED in statuses:
|
|
239
|
+
return RunOutcome.ERRORED, None
|
|
240
|
+
if TestStatus.FAILED in statuses:
|
|
241
|
+
return RunOutcome.FAILED, None
|
|
242
|
+
return RunOutcome.PASSED, None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _with_notes(log: str, notes: Sequence[str]) -> str:
|
|
246
|
+
if not notes:
|
|
247
|
+
return log
|
|
248
|
+
suffix = "\n".join(f"[patchground] {note}" for note in notes)
|
|
249
|
+
if log and not log.endswith("\n"):
|
|
250
|
+
log += "\n"
|
|
251
|
+
return log + suffix + "\n"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --------------------------------------------------------------------------- run
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@contextlib.contextmanager
|
|
258
|
+
def _hardened_container(
|
|
259
|
+
image_id: str,
|
|
260
|
+
layers: Sequence[Path],
|
|
261
|
+
*,
|
|
262
|
+
entrypoint: list[str],
|
|
263
|
+
command: list[str],
|
|
264
|
+
limits: Limits,
|
|
265
|
+
) -> Iterator[Container]:
|
|
266
|
+
"""Yield a created, not yet started hardened container seeded with a copy of ``layers``.
|
|
267
|
+
|
|
268
|
+
This is the single run lifecycle of the module: fresh internal network and volumes, seed
|
|
269
|
+
archive, the hardened container, and teardown of all of it on every exit path, including
|
|
270
|
+
errors and ``KeyboardInterrupt``. Engine failures become :class:`SandboxError`.
|
|
271
|
+
"""
|
|
272
|
+
seed_tar = build_seed_tar(layers)
|
|
273
|
+
run_id = f"patchground-{uuid.uuid4().hex}"
|
|
274
|
+
client = connect()
|
|
275
|
+
network: Network | None = None
|
|
276
|
+
volumes: list[Volume] = []
|
|
277
|
+
seed: Container | None = None
|
|
278
|
+
container: Container | None = None
|
|
279
|
+
try:
|
|
280
|
+
network = client.networks.create(run_id, driver="bridge", internal=True, labels=LABELS)
|
|
281
|
+
for suffix in ("workspace", "results"):
|
|
282
|
+
volumes.append(client.volumes.create(name=f"{run_id}-{suffix}", labels=LABELS))
|
|
283
|
+
mounts = [
|
|
284
|
+
Mount(WORKSPACE, _name(volumes[0]), type="volume", no_copy=True),
|
|
285
|
+
Mount(RESULTS, _name(volumes[1]), type="volume", no_copy=True),
|
|
286
|
+
]
|
|
287
|
+
seed = _create_seed(client, image_id, mounts)
|
|
288
|
+
if not seed.put_archive("/", seed_tar):
|
|
289
|
+
raise SandboxError("could not copy files into the sandbox")
|
|
290
|
+
_remove_container(seed)
|
|
291
|
+
seed = None
|
|
292
|
+
container = _create_container(
|
|
293
|
+
client, image_id, entrypoint, command, mounts, _name(network), limits
|
|
294
|
+
)
|
|
295
|
+
yield container
|
|
296
|
+
except (
|
|
297
|
+
docker.errors.DockerException,
|
|
298
|
+
requests.exceptions.RequestException,
|
|
299
|
+
urllib3.exceptions.HTTPError,
|
|
300
|
+
) as exc:
|
|
301
|
+
raise SandboxError(f"sandbox run failed: {exc}") from exc
|
|
302
|
+
finally:
|
|
303
|
+
_teardown([c for c in (seed, container) if c is not None], volumes, network)
|
|
304
|
+
client.close()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run_check(
|
|
308
|
+
image_id: str,
|
|
309
|
+
layers: Sequence[Path],
|
|
310
|
+
command: str,
|
|
311
|
+
*,
|
|
312
|
+
limits: Limits = DEFAULT_LIMITS,
|
|
313
|
+
) -> RunResult:
|
|
314
|
+
"""Run ``command`` (via ``/bin/sh -c``) in ``image_id`` against a copy of ``layers``.
|
|
315
|
+
|
|
316
|
+
``layers`` are directories merged into ``/workspace`` in order (see :func:`build_seed_tar`).
|
|
317
|
+
The command runs as uid/gid 1000 with ``PG_RESULTS=/results`` and must write
|
|
318
|
+
``/results/junit.xml``. The image's ENTRYPOINT is overridden.
|
|
319
|
+
|
|
320
|
+
Raises :class:`EngineUnavailable` if the engine is unreachable and :class:`SandboxError`
|
|
321
|
+
for invalid layers or engine failures; test outcomes are reported in the result.
|
|
322
|
+
"""
|
|
323
|
+
with _hardened_container(
|
|
324
|
+
image_id, layers, entrypoint=["/bin/sh", "-c"], command=[command], limits=limits
|
|
325
|
+
) as container:
|
|
326
|
+
container.start()
|
|
327
|
+
return _wait_and_collect(container, limits)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def run_command(
|
|
331
|
+
image_id: str,
|
|
332
|
+
layers: Sequence[Path],
|
|
333
|
+
argv: Sequence[str],
|
|
334
|
+
*,
|
|
335
|
+
limits: Limits = DEFAULT_LIMITS,
|
|
336
|
+
) -> CommandResult:
|
|
337
|
+
"""Run ``argv`` literally (no shell unless ``argv`` names one) against a copy of ``layers``.
|
|
338
|
+
|
|
339
|
+
Same container, hardening, limits and teardown as :func:`run_check`; the image's ENTRYPOINT is
|
|
340
|
+
replaced by ``argv[0]``. Nothing from the host environment is passed in, and no JUnit is read.
|
|
341
|
+
A command that cannot be started (missing or non-executable program) reports exit code 126 or
|
|
342
|
+
127 with empty output. Raises :class:`SandboxError` for an empty or NUL-containing ``argv``,
|
|
343
|
+
invalid layers or engine failures.
|
|
344
|
+
"""
|
|
345
|
+
if not argv or not argv[0]:
|
|
346
|
+
raise SandboxError("command is empty")
|
|
347
|
+
if any("\0" in arg for arg in argv):
|
|
348
|
+
raise SandboxError("command arguments cannot contain NUL bytes")
|
|
349
|
+
with _hardened_container(
|
|
350
|
+
image_id, layers, entrypoint=[argv[0]], command=list(argv[1:]), limits=limits
|
|
351
|
+
) as container:
|
|
352
|
+
try:
|
|
353
|
+
container.start()
|
|
354
|
+
except docker.errors.APIError:
|
|
355
|
+
# An unrunnable program is the command's outcome, not an infrastructure failure;
|
|
356
|
+
# the engine's message is deliberately dropped.
|
|
357
|
+
container.reload()
|
|
358
|
+
state = container.attrs.get("State", {})
|
|
359
|
+
code = state.get("ExitCode")
|
|
360
|
+
if code not in _EXEC_FAILURE_CODES or state.get("Running"):
|
|
361
|
+
raise
|
|
362
|
+
return CommandResult(code, None, "")
|
|
363
|
+
exit_code = _wait(container, limits)
|
|
364
|
+
container.reload()
|
|
365
|
+
oom_killed = bool(container.attrs.get("State", {}).get("OOMKilled", False))
|
|
366
|
+
output = _output_tail(container)
|
|
367
|
+
if exit_code is None:
|
|
368
|
+
return CommandResult(None, "timeout", output)
|
|
369
|
+
return CommandResult(exit_code, "memory" if oom_killed else None, output)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_ANSI = re.compile(
|
|
373
|
+
r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (titles, hyperlinks), also unterminated
|
|
374
|
+
r"|\x1b[PX^_][^\x1b]*(?:\x1b\\)?" # DCS / SOS / PM / APC strings
|
|
375
|
+
r"|\x1b\[[0-?]*[ -/]*[@-~]" # CSI
|
|
376
|
+
r"|\x1b[ -/]*[0-~]?" # other escapes, or a lone/truncated ESC
|
|
377
|
+
)
|
|
378
|
+
_CONTROLS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def sanitize_output(raw: bytes) -> str:
|
|
382
|
+
"""Decode command output and drop terminal control sequences, keeping ``\\n`` and ``\\t``.
|
|
383
|
+
|
|
384
|
+
Carriage returns are removed (``\\r\\n`` becomes ``\\n``), so output cannot overwrite what
|
|
385
|
+
precedes it on a terminal line. The result is at most :data:`MAX_OUTPUT_TAIL_BYTES` bytes of
|
|
386
|
+
UTF-8, keeping the end of the output.
|
|
387
|
+
"""
|
|
388
|
+
text = raw.decode("utf-8", errors="replace")
|
|
389
|
+
text = _CONTROLS.sub("", _ANSI.sub("", text))
|
|
390
|
+
encoded = text.encode("utf-8")
|
|
391
|
+
if len(encoded) <= MAX_OUTPUT_TAIL_BYTES:
|
|
392
|
+
return text
|
|
393
|
+
return encoded[-MAX_OUTPUT_TAIL_BYTES:].decode("utf-8", errors="ignore")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _output_tail(container: Container) -> str:
|
|
397
|
+
"""Stream the container's (engine-bounded) log, keeping only a rolling raw tail in memory."""
|
|
398
|
+
raw = bytearray()
|
|
399
|
+
for chunk in container.logs(stdout=True, stderr=True, stream=True, follow=False):
|
|
400
|
+
raw += chunk
|
|
401
|
+
if len(raw) > 2 * _RAW_TAIL_BYTES:
|
|
402
|
+
del raw[:-_RAW_TAIL_BYTES]
|
|
403
|
+
return sanitize_output(bytes(raw[-_RAW_TAIL_BYTES:]))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _name(resource: Network | Volume) -> str:
|
|
407
|
+
name = resource.name
|
|
408
|
+
if name is None:
|
|
409
|
+
raise SandboxError("engine returned a resource without a name")
|
|
410
|
+
return name
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _create_seed(client: DockerClient, image_id: str, mounts: list[Mount]) -> Container:
|
|
414
|
+
"""Create (never start) a container whose only job is to receive the seed archive.
|
|
415
|
+
|
|
416
|
+
The engine ignores metadata of a tar's root entry (``.``) and refuses archives into a
|
|
417
|
+
read-only rootfs, so the volume roots cannot be chowned through the run container. This
|
|
418
|
+
writable, never-started container mounts the same volumes and takes the archive at ``/``,
|
|
419
|
+
where ``workspace/`` and ``results/`` are ordinary entries whose ownership is applied.
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
return client.containers.create(
|
|
423
|
+
image_id,
|
|
424
|
+
["true"],
|
|
425
|
+
entrypoint=["/bin/true"],
|
|
426
|
+
network_mode="none",
|
|
427
|
+
mounts=mounts,
|
|
428
|
+
labels=LABELS,
|
|
429
|
+
)
|
|
430
|
+
except docker.errors.ImageNotFound as exc:
|
|
431
|
+
raise SandboxError(f"image not found: {image_id}") from exc
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _create_container(
|
|
435
|
+
client: DockerClient,
|
|
436
|
+
image_id: str,
|
|
437
|
+
entrypoint: list[str],
|
|
438
|
+
command: list[str],
|
|
439
|
+
mounts: list[Mount],
|
|
440
|
+
network_name: str,
|
|
441
|
+
limits: Limits,
|
|
442
|
+
) -> Container:
|
|
443
|
+
return client.containers.create(
|
|
444
|
+
image_id,
|
|
445
|
+
command,
|
|
446
|
+
entrypoint=entrypoint,
|
|
447
|
+
user=f"{RUN_UID}:{RUN_GID}",
|
|
448
|
+
working_dir=WORKSPACE,
|
|
449
|
+
environment={"PG_RESULTS": RESULTS, "HOME": "/tmp"}, # noqa: S108 - container path
|
|
450
|
+
read_only=True,
|
|
451
|
+
mounts=mounts,
|
|
452
|
+
tmpfs={"/tmp": "rw,exec,size=256m,mode=1777"}, # noqa: S108 - container path
|
|
453
|
+
network=network_name,
|
|
454
|
+
cap_drop=["ALL"],
|
|
455
|
+
security_opt=["no-new-privileges"],
|
|
456
|
+
nano_cpus=int(limits.cpus * 1e9),
|
|
457
|
+
mem_limit=limits.memory_bytes,
|
|
458
|
+
memswap_limit=limits.memory_bytes,
|
|
459
|
+
pids_limit=limits.pids,
|
|
460
|
+
log_config=LOG_CONFIG,
|
|
461
|
+
labels=LABELS,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _wait(container: Container, limits: Limits) -> int | None:
|
|
466
|
+
"""Wait for ``container`` to exit; its exit code, or ``None`` if it hit the wall clock.
|
|
467
|
+
|
|
468
|
+
On timeout the container is killed. A connection error well before the deadline means the
|
|
469
|
+
engine went away mid-run and raises :class:`SandboxError` (the caller still tears down).
|
|
470
|
+
"""
|
|
471
|
+
started = time.monotonic()
|
|
472
|
+
try:
|
|
473
|
+
status = container.wait(timeout=limits.timeout_seconds)
|
|
474
|
+
except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as exc:
|
|
475
|
+
# docker-py surfaces a wait() timeout as ReadTimeout or, depending on the transport,
|
|
476
|
+
# as ConnectionError (docker/docker-py#1966); only near the deadline is it a timeout.
|
|
477
|
+
elapsed = time.monotonic() - started
|
|
478
|
+
if elapsed < limits.timeout_seconds - TIMEOUT_TOLERANCE_SECONDS:
|
|
479
|
+
raise SandboxError(f"engine connection lost during run: {exc}") from exc
|
|
480
|
+
with contextlib.suppress(docker.errors.DockerException):
|
|
481
|
+
container.kill()
|
|
482
|
+
return None
|
|
483
|
+
return int(status["StatusCode"])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _wait_and_collect(container: Container, limits: Limits) -> RunResult:
|
|
487
|
+
exit_code = _wait(container, limits)
|
|
488
|
+
timed_out = exit_code is None
|
|
489
|
+
container.reload()
|
|
490
|
+
oom_killed = bool(container.attrs.get("State", {}).get("OOMKilled", False))
|
|
491
|
+
log = container.logs(stdout=True, stderr=True, tail=LOG_TAIL_LINES).decode(
|
|
492
|
+
"utf-8", errors="replace"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if timed_out:
|
|
496
|
+
note = f"wall-clock limit of {limits.timeout_seconds:g}s exceeded; container killed"
|
|
497
|
+
return RunResult(RunOutcome.LIMIT_EXCEEDED, (), None, "timeout", _with_notes(log, [note]))
|
|
498
|
+
if oom_killed:
|
|
499
|
+
note = f"memory limit of {limits.memory_bytes} bytes exceeded (OOM killed)"
|
|
500
|
+
return RunResult(
|
|
501
|
+
RunOutcome.LIMIT_EXCEEDED, (), exit_code, "memory", _with_notes(log, [note])
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
chunks, _ = container.get_archive(JUNIT_PATH)
|
|
506
|
+
tests = parse_junit(_extract_single_file(chunks, MAX_JUNIT_BYTES))
|
|
507
|
+
except docker.errors.NotFound:
|
|
508
|
+
note = f"{JUNIT_PATH} was not written by the test command"
|
|
509
|
+
return RunResult(RunOutcome.MISSING_RESULTS, (), exit_code, None, _with_notes(log, [note]))
|
|
510
|
+
except JUnitError as exc:
|
|
511
|
+
note = f"{JUNIT_PATH} is unusable: {exc}"
|
|
512
|
+
return RunResult(RunOutcome.MISSING_RESULTS, (), exit_code, None, _with_notes(log, [note]))
|
|
513
|
+
|
|
514
|
+
outcome, explanation = outcome_for(tests)
|
|
515
|
+
notes = [explanation] if explanation else []
|
|
516
|
+
return RunResult(outcome, tests, exit_code, None, _with_notes(log, notes))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _remove_container(container: Container) -> None:
|
|
520
|
+
container.remove(v=True, force=True)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _teardown(
|
|
524
|
+
containers: Sequence[Container], volumes: Sequence[Volume], network: Network | None
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Remove containers, then volumes, then the network; each step guarded, never raises."""
|
|
527
|
+
for container in containers:
|
|
528
|
+
with contextlib.suppress(Exception):
|
|
529
|
+
_remove_container(container)
|
|
530
|
+
for volume in volumes:
|
|
531
|
+
with contextlib.suppress(Exception):
|
|
532
|
+
volume.remove(force=True)
|
|
533
|
+
if network is not None:
|
|
534
|
+
with contextlib.suppress(Exception):
|
|
535
|
+
network.remove()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Local authored challenge sessions: no CLI, verification or pack dependencies."""
|
|
2
|
+
|
|
3
|
+
from patchground.sessions.history import history_for_session
|
|
4
|
+
from patchground.sessions.models import (
|
|
5
|
+
CheckResult,
|
|
6
|
+
HistoryEntry,
|
|
7
|
+
HistoryResult,
|
|
8
|
+
ResetResult,
|
|
9
|
+
SessionError,
|
|
10
|
+
SessionStatus,
|
|
11
|
+
SnapshotStatus,
|
|
12
|
+
StartedSession,
|
|
13
|
+
)
|
|
14
|
+
from patchground.sessions.reset import reset_session
|
|
15
|
+
from patchground.sessions.submission import (
|
|
16
|
+
SubmissionSnapshot,
|
|
17
|
+
check_submission,
|
|
18
|
+
commit_submission,
|
|
19
|
+
prepare_submission,
|
|
20
|
+
read_submission,
|
|
21
|
+
)
|
|
22
|
+
from patchground.sessions.workflow import Sandbox, check_session, session_status, start_session
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CheckResult",
|
|
26
|
+
"HistoryEntry",
|
|
27
|
+
"HistoryResult",
|
|
28
|
+
"ResetResult",
|
|
29
|
+
"Sandbox",
|
|
30
|
+
"SessionError",
|
|
31
|
+
"SessionStatus",
|
|
32
|
+
"SnapshotStatus",
|
|
33
|
+
"StartedSession",
|
|
34
|
+
"SubmissionSnapshot",
|
|
35
|
+
"check_session",
|
|
36
|
+
"check_submission",
|
|
37
|
+
"commit_submission",
|
|
38
|
+
"history_for_session",
|
|
39
|
+
"prepare_submission",
|
|
40
|
+
"read_submission",
|
|
41
|
+
"reset_session",
|
|
42
|
+
"session_status",
|
|
43
|
+
"start_session",
|
|
44
|
+
]
|