outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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.
- outerloop/__init__.py +7 -7
- outerloop/appauth.py +3 -3
- outerloop/appmanifest.py +16 -11
- outerloop/attempt.py +80 -52
- outerloop/cli.py +170 -86
- outerloop/climbboard.py +209 -4
- outerloop/compute.py +84 -7
- outerloop/contract.py +3 -2
- outerloop/followup.py +37 -23
- outerloop/github.py +4 -4
- outerloop/harness.py +18 -7
- outerloop/image.py +372 -0
- outerloop/init.py +417 -29
- outerloop/limits.py +1 -1
- outerloop/measure.py +2 -2
- outerloop/orchestrator.py +1 -1
- outerloop/paths.py +14 -1
- outerloop/review.py +1 -1
- outerloop/steward.py +3 -3
- outerloop/syscall.py +1 -0
- outerloop/tick.py +400 -105
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/METADATA +14 -10
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/RECORD +27 -26
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/NOTICE +0 -0
outerloop/harness.py
CHANGED
|
@@ -142,7 +142,7 @@ def _rmtree_at(dir_fd: int, name: str) -> None:
|
|
|
142
142
|
class VertexConfig:
|
|
143
143
|
"""Claude-on-Vertex auth (ADC): set to bill Anthropic sessions to a GCP
|
|
144
144
|
project instead of an Anthropic API key. The single owner of the
|
|
145
|
-
|
|
145
|
+
OUTERLOOP_VERTEX_* env contract is `vertex_from_env`."""
|
|
146
146
|
|
|
147
147
|
project: str
|
|
148
148
|
region: str = "global"
|
|
@@ -166,12 +166,12 @@ class VertexConfig:
|
|
|
166
166
|
|
|
167
167
|
def vertex_from_env() -> VertexConfig | None:
|
|
168
168
|
"""The deployment's Vertex coordinates, or None (Anthropic-direct). A live
|
|
169
|
-
flip needs only the env: set
|
|
169
|
+
flip needs only the env: set OUTERLOOP_VERTEX_PROJECT to route every
|
|
170
170
|
claude session through Vertex; unset it to fall back to the API key."""
|
|
171
|
-
project = os.environ.get("
|
|
171
|
+
project = os.environ.get("OUTERLOOP_VERTEX_PROJECT", "").strip()
|
|
172
172
|
if not project:
|
|
173
173
|
return None
|
|
174
|
-
adc = os.path.expanduser(os.environ.get("
|
|
174
|
+
adc = os.path.expanduser(os.environ.get("OUTERLOOP_VERTEX_ADC", "").strip())
|
|
175
175
|
if not adc:
|
|
176
176
|
# resolve the gcloud default NOW, under the real HOME — the session's
|
|
177
177
|
# HOME is a scrubbed per-run directory where ambient discovery would
|
|
@@ -181,7 +181,7 @@ def vertex_from_env() -> VertexConfig | None:
|
|
|
181
181
|
adc = default
|
|
182
182
|
return VertexConfig(
|
|
183
183
|
project=project,
|
|
184
|
-
region=os.environ.get("
|
|
184
|
+
region=os.environ.get("OUTERLOOP_VERTEX_REGION", "global").strip() or "global",
|
|
185
185
|
adc_file=adc,
|
|
186
186
|
)
|
|
187
187
|
|
|
@@ -199,6 +199,17 @@ def redact(text: str, secrets: tuple[str, ...]) -> str:
|
|
|
199
199
|
return text
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
def default_binary(backend: str) -> str:
|
|
203
|
+
"""The host CLI a job spawns for `backend`: the path init recorded
|
|
204
|
+
(`OUTERLOOP_<BACKEND>_BIN`), else `~/.local/bin/<backend>` where the native
|
|
205
|
+
installers put it. One rule for the climb, the follow-up and the steward,
|
|
206
|
+
so a CLI installed anywhere else works in every lane."""
|
|
207
|
+
name = backend.strip().lower() or "claude"
|
|
208
|
+
return os.path.expanduser(
|
|
209
|
+
os.environ.get(f"OUTERLOOP_{name.upper()}_BIN") or f"~/.local/bin/{name}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
202
213
|
def _error_result(stop_reason: str, transcript_path: str = "", detail: str = "") -> SessionResult:
|
|
203
214
|
return SessionResult(
|
|
204
215
|
stop_reason=stop_reason,
|
|
@@ -609,7 +620,7 @@ class ClaudeCodeHarness:
|
|
|
609
620
|
)
|
|
610
621
|
except OSError as exc:
|
|
611
622
|
log.warning("could not spawn %s: %s", self.binary, exc)
|
|
612
|
-
return _error_result("spawn-error")
|
|
623
|
+
return _error_result("spawn-error", detail=f"could not spawn {self.binary}: {exc}")
|
|
613
624
|
|
|
614
625
|
try:
|
|
615
626
|
stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
|
|
@@ -1061,7 +1072,7 @@ class CodexHarness:
|
|
|
1061
1072
|
except OSError as exc:
|
|
1062
1073
|
log.warning("could not spawn %s: %s", self.binary, exc)
|
|
1063
1074
|
self._purge_auth(session_home)
|
|
1064
|
-
return _error_result("spawn-error")
|
|
1075
|
+
return _error_result("spawn-error", detail=f"could not spawn {self.binary}: {exc}")
|
|
1065
1076
|
try:
|
|
1066
1077
|
stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
|
|
1067
1078
|
except subprocess.TimeoutExpired:
|
outerloop/image.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""The agent image on an adopter's machine: where it lives, and fetching the
|
|
2
|
+
published one. The tick reads `~/outerloop-images/agent-py312.sif` unless
|
|
3
|
+
`OUTERLOOP_IMAGE` names another file (tick._default_image); `init` fills that
|
|
4
|
+
default on Linux when Apptainer is installed, so local mode runs contained
|
|
5
|
+
without a hand step."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
IMAGE_NAME = "agent-py312.sif"
|
|
24
|
+
IMAGE_URL = f"https://huggingface.co/outerloop-science/agent-image/resolve/main/{IMAGE_NAME}"
|
|
25
|
+
IMAGE_DIR_NAME = "outerloop-images"
|
|
26
|
+
LEGACY_IMAGE_DIR_NAME = "autoresearch-images" # pre-rename; honored until the release after 0.1
|
|
27
|
+
_CHUNK = 1 << 20
|
|
28
|
+
_PROBE_TIMEOUT_S = 60
|
|
29
|
+
APPTAINER_RELEASES = "https://github.com/apptainer/apptainer/releases"
|
|
30
|
+
APPTAINER_VERSION = "1.5.3"
|
|
31
|
+
# the GitHub release ships Debian packages for x86-64 only; the project's PPA
|
|
32
|
+
# builds amd64 and arm64 for every current Ubuntu
|
|
33
|
+
APPTAINER_DEB = f"apptainer_{APPTAINER_VERSION}_amd64.deb"
|
|
34
|
+
APPTAINER_DEB_URL = f"{APPTAINER_RELEASES}/download/v{APPTAINER_VERSION}/{APPTAINER_DEB}"
|
|
35
|
+
APPTAINER_PPA = "ppa:apptainer/ppa"
|
|
36
|
+
# the unprivileged installer, pinned to the release tag (never a moving branch)
|
|
37
|
+
APPTAINER_UNPRIV_URL = (
|
|
38
|
+
"https://raw.githubusercontent.com/apptainer/apptainer/"
|
|
39
|
+
f"v{APPTAINER_VERSION}/tools/install-unprivileged.sh"
|
|
40
|
+
)
|
|
41
|
+
PROBE_CMD = "apptainer exec docker://alpine:3.20 cat /etc/alpine-release"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def image_dir(home: Path | None = None) -> Path:
|
|
45
|
+
return (home or Path.home()) / IMAGE_DIR_NAME
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_image(home: Path | None = None) -> str:
|
|
49
|
+
"""An existing image on this machine, new location first, or ""."""
|
|
50
|
+
home = home or Path.home()
|
|
51
|
+
for d in (IMAGE_DIR_NAME, LEGACY_IMAGE_DIR_NAME):
|
|
52
|
+
p = home / d / IMAGE_NAME
|
|
53
|
+
if p.is_file():
|
|
54
|
+
return str(p)
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _linux_flavor() -> tuple[str, str]:
|
|
59
|
+
"""(distribution id, version) from os-release, or ("", "")."""
|
|
60
|
+
try:
|
|
61
|
+
info = platform.freedesktop_os_release()
|
|
62
|
+
except OSError:
|
|
63
|
+
return "", ""
|
|
64
|
+
return info.get("ID", ""), info.get("VERSION_ID", "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install_hint() -> str:
|
|
68
|
+
"""The exact steps for THIS machine, written for someone installing Apptainer
|
|
69
|
+
for the first time. Every path is a root operation or an admin request, which
|
|
70
|
+
is why init never runs it."""
|
|
71
|
+
if sys.platform == "darwin":
|
|
72
|
+
return (
|
|
73
|
+
"macOS has no Apptainer; runs stay uncontained here (a macOS containment is on the\n"
|
|
74
|
+
" roadmap). A Linux machine, or Slurm, runs contained."
|
|
75
|
+
)
|
|
76
|
+
dist, _version = _linux_flavor()
|
|
77
|
+
apparmor_note = (
|
|
78
|
+
" Ubuntu 23.10+ blocks unprivileged user namespaces unless an AppArmor profile allows\n"
|
|
79
|
+
" them; the official package carries that profile (the unprivileged installer does\n"
|
|
80
|
+
" not, and fails with 'Could not write info to setgroups').\n"
|
|
81
|
+
)
|
|
82
|
+
check = (
|
|
83
|
+
f" 3. {PROBE_CMD}\n"
|
|
84
|
+
" (prints a version number)\n"
|
|
85
|
+
" then run `outerloop init --force` again to download the image."
|
|
86
|
+
)
|
|
87
|
+
if dist in ("ubuntu", "linuxmint", "pop"):
|
|
88
|
+
return (
|
|
89
|
+
"Install Apptainer from the project's Ubuntu PPA (amd64 and arm64):\n"
|
|
90
|
+
+ apparmor_note
|
|
91
|
+
+ f" 1. sudo add-apt-repository -y {APPTAINER_PPA}\n"
|
|
92
|
+
" 2. sudo apt-get update && sudo apt-get install -y apptainer\n" + check
|
|
93
|
+
)
|
|
94
|
+
if dist == "debian":
|
|
95
|
+
if platform.machine() == "x86_64":
|
|
96
|
+
return (
|
|
97
|
+
"Install the official Apptainer package (Debian, x86-64):\n"
|
|
98
|
+
+ apparmor_note
|
|
99
|
+
+ f" 1. curl -fsSLO {APPTAINER_DEB_URL}\n"
|
|
100
|
+
f" 2. sudo apt-get install -y ./{APPTAINER_DEB}\n" + check
|
|
101
|
+
)
|
|
102
|
+
return (
|
|
103
|
+
f"No Apptainer Debian package is published for this machine ({platform.machine()}).\n"
|
|
104
|
+
" Build from source (https://apptainer.org/docs/admin/main/installation.html) or use\n"
|
|
105
|
+
" the unprivileged installer. Download it, read it, then run it:\n"
|
|
106
|
+
f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
|
|
107
|
+
" less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
|
|
108
|
+
" export PATH=$HOME/apptainer/bin:$PATH\n"
|
|
109
|
+
f" Check with `{PROBE_CMD}`, then run `outerloop init --force` again."
|
|
110
|
+
)
|
|
111
|
+
if dist == "fedora":
|
|
112
|
+
return (
|
|
113
|
+
"Install Apptainer from the Fedora repositories:\n"
|
|
114
|
+
" sudo dnf install -y apptainer\n"
|
|
115
|
+
f" {PROBE_CMD} # prints a version number\n"
|
|
116
|
+
" then run `outerloop init --force` again to download the image."
|
|
117
|
+
)
|
|
118
|
+
if dist in ("rhel", "centos", "rocky", "almalinux"):
|
|
119
|
+
return (
|
|
120
|
+
"Install Apptainer from EPEL:\n"
|
|
121
|
+
" sudo dnf install -y epel-release\n"
|
|
122
|
+
" sudo dnf install -y apptainer\n"
|
|
123
|
+
f" {PROBE_CMD} # prints a version number\n"
|
|
124
|
+
" then run `outerloop init --force` again to download the image."
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
"Install Apptainer for your distribution:\n"
|
|
128
|
+
" https://apptainer.org/docs/admin/main/installation.html\n"
|
|
129
|
+
" Without root, the project's unprivileged installer works on most systems.\n"
|
|
130
|
+
" Download it, read it, then run it:\n"
|
|
131
|
+
f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
|
|
132
|
+
" less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
|
|
133
|
+
" export PATH=$HOME/apptainer/bin:$PATH\n"
|
|
134
|
+
" On a Slurm cluster, ask the administrators; most already provide it.\n"
|
|
135
|
+
f" Check with `{PROBE_CMD}`, then run\n"
|
|
136
|
+
" `outerloop init --force` again to download the image."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def containment_check(*, runner: Callable[..., Any] = subprocess.run) -> str:
|
|
141
|
+
"""Why contained runs are NOT possible here, or "" when they are. Finds
|
|
142
|
+
apptainer and actually runs a container: being on PATH is not enough
|
|
143
|
+
(Ubuntu 24.04 lets any program create a user namespace but strips its
|
|
144
|
+
capabilities unless an AppArmor profile allows it, so a hand-installed
|
|
145
|
+
apptainer fails at exec time with `Could not write info to setgroups`).
|
|
146
|
+
The probe needs no network: a throwaway sandbox with the standard mount
|
|
147
|
+
points, the host's /bin, /lib and /usr bound in, running /bin/true —
|
|
148
|
+
about 0.1 s on a working install."""
|
|
149
|
+
if sys.platform == "darwin":
|
|
150
|
+
return "apptainer does not exist on macOS"
|
|
151
|
+
if not sys.platform.startswith("linux"):
|
|
152
|
+
return f"apptainer is not available on {sys.platform}"
|
|
153
|
+
binary = shutil.which("apptainer")
|
|
154
|
+
if binary is None:
|
|
155
|
+
return "apptainer is not installed (not on PATH)"
|
|
156
|
+
binds = ",".join(d for d in ("/bin", "/lib", "/lib64", "/usr") if os.path.exists(d))
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="outerloop-probe-") as sandbox:
|
|
158
|
+
for d in (
|
|
159
|
+
"dev",
|
|
160
|
+
"proc",
|
|
161
|
+
"sys",
|
|
162
|
+
"tmp",
|
|
163
|
+
"etc",
|
|
164
|
+
"bin",
|
|
165
|
+
"lib",
|
|
166
|
+
"lib64",
|
|
167
|
+
"usr",
|
|
168
|
+
"root",
|
|
169
|
+
"home",
|
|
170
|
+
"var/tmp",
|
|
171
|
+
):
|
|
172
|
+
os.makedirs(os.path.join(sandbox, d), exist_ok=True)
|
|
173
|
+
for f in ("etc/passwd", "etc/group"):
|
|
174
|
+
open(os.path.join(sandbox, f), "a").close()
|
|
175
|
+
argv = [binary, "exec", "--containall", "--cleanenv", "--no-home", "--pwd", "/"]
|
|
176
|
+
if binds:
|
|
177
|
+
argv += ["-B", binds]
|
|
178
|
+
argv += [sandbox, "/bin/true"]
|
|
179
|
+
try:
|
|
180
|
+
proc = runner(argv, capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S)
|
|
181
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
182
|
+
return f"apptainer failed to start: {exc}"
|
|
183
|
+
err = (proc.stderr or "").strip()
|
|
184
|
+
if "setgroups" in err or "user namespace" in err.lower() or "unshare" in err.lower():
|
|
185
|
+
last = err.splitlines()[-1] if err else "user namespace refused"
|
|
186
|
+
return f"apptainer cannot create containers here ({last})"
|
|
187
|
+
if proc.returncode != 0:
|
|
188
|
+
last = err.splitlines()[-1] if err else f"exit {proc.returncode}"
|
|
189
|
+
return f"apptainer exec failed ({last})"
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def containment_available() -> bool:
|
|
194
|
+
return containment_check() == ""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _fmt_mb(n: int) -> str:
|
|
198
|
+
return f"{n / (1 << 20):,.0f} MB"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Progress:
|
|
202
|
+
"""Download progress: a redrawn bar with size, speed and ETA when stdout is
|
|
203
|
+
a terminal; one line per 10% when it is not (a log, a CI step). `report`
|
|
204
|
+
receives the non-tty lines and the final summary."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, url: str, report: Callable[[str], None]) -> None:
|
|
207
|
+
self.url = url
|
|
208
|
+
self.report = report
|
|
209
|
+
self.total = 0
|
|
210
|
+
self.t0 = 0.0
|
|
211
|
+
self.tty = sys.stdout.isatty()
|
|
212
|
+
self.next_mark = 10
|
|
213
|
+
self.last_draw = 0.0
|
|
214
|
+
|
|
215
|
+
def start(self, total: int) -> None:
|
|
216
|
+
self.total = total
|
|
217
|
+
self.t0 = time.monotonic()
|
|
218
|
+
size = f" ({_fmt_mb(total)})" if total else ""
|
|
219
|
+
self.report(f"downloading {self.url}{size}")
|
|
220
|
+
|
|
221
|
+
def update(self, done: int) -> None:
|
|
222
|
+
now = time.monotonic()
|
|
223
|
+
if self.tty:
|
|
224
|
+
if now - self.last_draw < 0.2:
|
|
225
|
+
return
|
|
226
|
+
self.last_draw = now
|
|
227
|
+
sys.stdout.write("\r" + self._line(done, now) + "\033[K")
|
|
228
|
+
sys.stdout.flush()
|
|
229
|
+
elif self.total:
|
|
230
|
+
pct = done * 100 // self.total
|
|
231
|
+
while pct >= self.next_mark and self.next_mark <= 100:
|
|
232
|
+
self.report(f" {self.next_mark:3d}% {_fmt_mb(done)} of {_fmt_mb(self.total)}")
|
|
233
|
+
self.next_mark += 10
|
|
234
|
+
|
|
235
|
+
def _line(self, done: int, now: float) -> str:
|
|
236
|
+
elapsed = max(now - self.t0, 1e-6)
|
|
237
|
+
speed = done / elapsed
|
|
238
|
+
if self.total:
|
|
239
|
+
frac = min(done / self.total, 1.0)
|
|
240
|
+
width = 30
|
|
241
|
+
filled = int(frac * width)
|
|
242
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
243
|
+
eta = (self.total - done) / speed if speed > 0 else 0
|
|
244
|
+
return (
|
|
245
|
+
f" [{bar}] {frac * 100:3.0f}% {_fmt_mb(done)} of {_fmt_mb(self.total)}"
|
|
246
|
+
f" {speed / (1 << 20):.1f} MB/s ETA {int(eta) // 60}:{int(eta) % 60:02d}"
|
|
247
|
+
)
|
|
248
|
+
return f" {_fmt_mb(done)} {speed / (1 << 20):.1f} MB/s"
|
|
249
|
+
|
|
250
|
+
def finish(self, done: int) -> None:
|
|
251
|
+
if self.tty:
|
|
252
|
+
sys.stdout.write("\r" + self._line(done, time.monotonic()) + "\033[K\n")
|
|
253
|
+
sys.stdout.flush()
|
|
254
|
+
elapsed = time.monotonic() - self.t0
|
|
255
|
+
self.report(f" downloaded {_fmt_mb(done)} in {int(elapsed) // 60}:{int(elapsed) % 60:02d}")
|
|
256
|
+
|
|
257
|
+
def abort(self) -> None:
|
|
258
|
+
if self.tty:
|
|
259
|
+
sys.stdout.write("\n")
|
|
260
|
+
sys.stdout.flush()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def download_image(
|
|
264
|
+
url: str = IMAGE_URL,
|
|
265
|
+
dest: Path | None = None,
|
|
266
|
+
*,
|
|
267
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
268
|
+
report: Callable[[str], None] = print,
|
|
269
|
+
checksum_url: str = "",
|
|
270
|
+
) -> Path:
|
|
271
|
+
"""Stream `url` to `dest` (default: the image dir), through a `.part`
|
|
272
|
+
file renamed into place only after its sha256 matches the checksum the
|
|
273
|
+
build published beside it (`<url>.sha256`); any failure, mismatch or
|
|
274
|
+
interruption removes the part file. Progress: a live bar on a terminal,
|
|
275
|
+
one line per 10% otherwise."""
|
|
276
|
+
dest = dest or image_dir() / IMAGE_NAME
|
|
277
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
part = dest.with_name(dest.name + ".part")
|
|
279
|
+
digest = hashlib.sha256()
|
|
280
|
+
bar = _Progress(url, report)
|
|
281
|
+
try:
|
|
282
|
+
with opener(url, timeout=60) as resp:
|
|
283
|
+
total = int(resp.headers.get("Content-Length") or 0)
|
|
284
|
+
bar.start(total)
|
|
285
|
+
done = 0
|
|
286
|
+
with part.open("wb") as out:
|
|
287
|
+
while True:
|
|
288
|
+
chunk = resp.read(_CHUNK)
|
|
289
|
+
if not chunk:
|
|
290
|
+
break
|
|
291
|
+
out.write(chunk)
|
|
292
|
+
digest.update(chunk)
|
|
293
|
+
done += len(chunk)
|
|
294
|
+
bar.update(done)
|
|
295
|
+
bar.finish(done)
|
|
296
|
+
if total and done != total:
|
|
297
|
+
raise OSError(f"short download: {done} of {total} bytes")
|
|
298
|
+
# the checksum the build published beside the image: an image that does
|
|
299
|
+
# not match it is never installed (it would run with the bound harness
|
|
300
|
+
# binary and the run's credentials)
|
|
301
|
+
expected = _published_checksum(checksum_url or url + ".sha256", opener)
|
|
302
|
+
if digest.hexdigest() != expected:
|
|
303
|
+
raise OSError(
|
|
304
|
+
f"checksum mismatch for {url}: expected {expected}, got {digest.hexdigest()}"
|
|
305
|
+
)
|
|
306
|
+
report(" checksum verified")
|
|
307
|
+
except BaseException:
|
|
308
|
+
# a failed or interrupted download never leaves a partial image where
|
|
309
|
+
# a later run would find it
|
|
310
|
+
bar.abort()
|
|
311
|
+
part.unlink(missing_ok=True)
|
|
312
|
+
raise
|
|
313
|
+
os.replace(part, dest)
|
|
314
|
+
report(f" saved {dest}")
|
|
315
|
+
return dest
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _published_checksum(url: str, opener: Callable[..., Any]) -> str:
|
|
319
|
+
"""The sha256 hex the build published at `url` (`<hex> <file>` as sha256sum
|
|
320
|
+
writes it). No readable checksum means no image."""
|
|
321
|
+
try:
|
|
322
|
+
with opener(url, timeout=60) as resp:
|
|
323
|
+
text = resp.read(4096).decode("ascii", "replace")
|
|
324
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
325
|
+
raise OSError(f"no checksum published at {url}: {exc}") from exc
|
|
326
|
+
token = text.split()[0].strip().lower() if text.split() else ""
|
|
327
|
+
if len(token) != 64 or any(c not in "0123456789abcdef" for c in token):
|
|
328
|
+
raise OSError(f"unreadable checksum at {url}")
|
|
329
|
+
return token
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def ensure_image(
|
|
333
|
+
*,
|
|
334
|
+
interactive: bool,
|
|
335
|
+
want: bool = True,
|
|
336
|
+
probe: bool = True,
|
|
337
|
+
home: Path | None = None,
|
|
338
|
+
ask: Callable[[str], str] = input,
|
|
339
|
+
report: Callable[[str], None] = print,
|
|
340
|
+
fetch: Callable[..., Path] = download_image,
|
|
341
|
+
) -> str:
|
|
342
|
+
"""The image path `init` records: an existing image, else the published
|
|
343
|
+
one downloaded to the image dir, when this machine can run it and the
|
|
344
|
+
adopter did not opt out. "" means uncontained (the loop says so at start);
|
|
345
|
+
when that is because Apptainer is missing or cannot run containers, the
|
|
346
|
+
exact install steps for this machine are printed, and an image already on
|
|
347
|
+
disk is NOT recorded (a contained run would only fail later). `probe=False`
|
|
348
|
+
skips the container check: on Slurm the image runs on compute nodes, which
|
|
349
|
+
the login node cannot speak for. A failed download is a warning, never a
|
|
350
|
+
failed setup."""
|
|
351
|
+
if not want:
|
|
352
|
+
return ""
|
|
353
|
+
problem = containment_check() if probe else ""
|
|
354
|
+
found = find_image(home)
|
|
355
|
+
if problem:
|
|
356
|
+
report(f"Runs will be UNCONTAINED on this machine: {problem}.")
|
|
357
|
+
report(" " + install_hint())
|
|
358
|
+
if found:
|
|
359
|
+
report(f" (the image at {found} will be used once apptainer works)")
|
|
360
|
+
return ""
|
|
361
|
+
if found:
|
|
362
|
+
return found
|
|
363
|
+
dest = image_dir(home) / IMAGE_NAME
|
|
364
|
+
if interactive:
|
|
365
|
+
answer = ask(f"Download the agent image to {dest} so runs are contained? [Y/n] ")
|
|
366
|
+
if answer.strip().lower().startswith("n"):
|
|
367
|
+
return ""
|
|
368
|
+
try:
|
|
369
|
+
return str(fetch(IMAGE_URL, dest, report=report))
|
|
370
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
371
|
+
report(f" image download failed ({exc}); continuing without one, pass --image later")
|
|
372
|
+
return ""
|