snakemake-executor-plugin-aws-batch-flex 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.
- snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
- snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
- snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
- snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
- snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
- snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
- snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
- snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
- snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
- snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
- snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
- snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
- snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
- snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
- snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
- snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
- snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
- snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
- snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
- snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
- snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
- snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
- snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
- snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
- snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
- snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Runs inside the head job that `smkflex run` submits: restores Snakemake's .snakemake state,
|
|
2
|
+
runs Snakemake on the unpacked workflow, then saves the state for the next run.
|
|
3
|
+
|
|
4
|
+
`smkflex run` copies this file into the workflow bundle, so it only uses the standard library
|
|
5
|
+
(and boto3, which comes with the S3 storage plugin)."""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sqlite3
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
# The parts of .snakemake that make a rerun behave like one on the same machine: what was
|
|
18
|
+
# made with which code and parameters, and which jobs didn't finish. Conda envs, local
|
|
19
|
+
# copies of storage files and the like are left out.
|
|
20
|
+
STATE_DIRS = ("metadata", "incomplete", "log")
|
|
21
|
+
# The same records with Snakemake's --persistence-backend db (SQLite at its default place)
|
|
22
|
+
DB = os.path.join(".snakemake", "metadata.db")
|
|
23
|
+
SAVE_EVERY = 300 # seconds between saves of the state while Snakemake runs (unchanged: skipped)
|
|
24
|
+
WRAPPER_PREFIX = "https://github.com/snakemake/snakemake-wrappers/raw/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _s3(url):
|
|
28
|
+
import boto3
|
|
29
|
+
|
|
30
|
+
bucket, _, key = url[len("s3://"):].partition("/")
|
|
31
|
+
return boto3.client("s3"), bucket, key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get(url):
|
|
35
|
+
"""The file at an s3:// URL or local path, or None if there is none."""
|
|
36
|
+
if url.startswith("s3://"):
|
|
37
|
+
s3, bucket, key = _s3(url)
|
|
38
|
+
try:
|
|
39
|
+
return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
40
|
+
except s3.exceptions.NoSuchKey:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open(url, "rb") as f:
|
|
44
|
+
return f.read()
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def put(url, data):
|
|
50
|
+
if url.startswith("s3://"):
|
|
51
|
+
s3, bucket, key = _s3(url)
|
|
52
|
+
s3.put_object(Bucket=bucket, Key=key, Body=data)
|
|
53
|
+
else:
|
|
54
|
+
os.makedirs(os.path.dirname(url) or ".", exist_ok=True)
|
|
55
|
+
with open(url, "wb") as f:
|
|
56
|
+
f.write(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def delete(url):
|
|
60
|
+
if url.startswith("s3://"):
|
|
61
|
+
s3, bucket, key = _s3(url)
|
|
62
|
+
s3.delete_object(Bucket=bucket, Key=key)
|
|
63
|
+
elif os.path.exists(url):
|
|
64
|
+
os.remove(url)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unpack(data, dest):
|
|
68
|
+
with tarfile.open(fileobj=io.BytesIO(data)) as t:
|
|
69
|
+
if hasattr(tarfile, "data_filter"):
|
|
70
|
+
t.extractall(dest, filter="data")
|
|
71
|
+
else:
|
|
72
|
+
t.extractall(dest)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _state_files():
|
|
76
|
+
"""[(path, stat)] of the state files now; files removed meanwhile (a job ended) are left out.
|
|
77
|
+
The database's write-ahead log counts too: recent changes can be only there."""
|
|
78
|
+
out = []
|
|
79
|
+
for p in (DB, DB + "-wal"):
|
|
80
|
+
try:
|
|
81
|
+
out.append((p, os.stat(p)))
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
for d in STATE_DIRS:
|
|
85
|
+
for root, _dirs, names in os.walk(os.path.join(".snakemake", d)):
|
|
86
|
+
for n in names:
|
|
87
|
+
p = os.path.join(root, n)
|
|
88
|
+
try:
|
|
89
|
+
out.append((p, os.stat(p)))
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
return sorted(out, key=lambda f: f[0])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save_state(url, last=None):
|
|
96
|
+
"""Upload the state. Snakemake may still be running, so files can change or go while they're
|
|
97
|
+
read; those are left for the next save. Returns what was saved: pass it back as `last` and
|
|
98
|
+
an unchanged state isn't uploaded again."""
|
|
99
|
+
files = _state_files()
|
|
100
|
+
saved = [(p, st.st_size, st.st_mtime_ns) for p, st in files]
|
|
101
|
+
if saved == last:
|
|
102
|
+
return last
|
|
103
|
+
buf = io.BytesIO()
|
|
104
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as t:
|
|
105
|
+
for p, st in files:
|
|
106
|
+
if p == DB + "-wal":
|
|
107
|
+
continue # in the snapshot of the database
|
|
108
|
+
try:
|
|
109
|
+
if p == DB:
|
|
110
|
+
data = _db_snapshot()
|
|
111
|
+
else:
|
|
112
|
+
with open(p, "rb") as f:
|
|
113
|
+
data = f.read()
|
|
114
|
+
except (OSError, sqlite3.Error):
|
|
115
|
+
continue
|
|
116
|
+
info = tarfile.TarInfo(p)
|
|
117
|
+
info.size, info.mtime, info.mode = len(data), int(st.st_mtime), st.st_mode & 0o777
|
|
118
|
+
t.addfile(info, io.BytesIO(data))
|
|
119
|
+
put(url, buf.getvalue())
|
|
120
|
+
return saved
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _db_snapshot():
|
|
124
|
+
"""The database as one consistent file, with what's in its write-ahead log, although
|
|
125
|
+
Snakemake may be writing to it: copying the file itself could catch it halfway."""
|
|
126
|
+
fd, tmp = tempfile.mkstemp(suffix=".db")
|
|
127
|
+
os.close(fd)
|
|
128
|
+
try:
|
|
129
|
+
src, dst = sqlite3.connect(DB), sqlite3.connect(tmp)
|
|
130
|
+
try:
|
|
131
|
+
src.backup(dst)
|
|
132
|
+
finally:
|
|
133
|
+
src.close()
|
|
134
|
+
dst.close()
|
|
135
|
+
with open(tmp, "rb") as f:
|
|
136
|
+
return f.read()
|
|
137
|
+
finally:
|
|
138
|
+
os.remove(tmp)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class StateSaver:
|
|
142
|
+
"""Saves the state every `every` seconds while Snakemake runs, and at once after wake.set(),
|
|
143
|
+
so a head job that dies (out of memory, instance lost) or is killed while stopping keeps
|
|
144
|
+
most of it."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, url, every):
|
|
147
|
+
self.url, self.every, self.last = url, every, None
|
|
148
|
+
self.wake, self.done = threading.Event(), threading.Event()
|
|
149
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
150
|
+
self.thread.start()
|
|
151
|
+
|
|
152
|
+
def _run(self):
|
|
153
|
+
while True:
|
|
154
|
+
self.wake.wait(self.every)
|
|
155
|
+
self.wake.clear()
|
|
156
|
+
if self.done.is_set():
|
|
157
|
+
return
|
|
158
|
+
self._save()
|
|
159
|
+
|
|
160
|
+
def _save(self):
|
|
161
|
+
try:
|
|
162
|
+
self.last = save_state(self.url, self.last)
|
|
163
|
+
return True
|
|
164
|
+
except Exception as e:
|
|
165
|
+
print(f"[smkflex] could not save .snakemake state: {e!r}", file=sys.stderr)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def finish(self):
|
|
169
|
+
"""Stop the saves in the background, then save the final state. True if it was saved."""
|
|
170
|
+
self.done.set()
|
|
171
|
+
self.wake.set()
|
|
172
|
+
self.thread.join()
|
|
173
|
+
return self._save()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
GIT_SHIM = """#!{python}
|
|
177
|
+
# Stand-in for git, which the image doesn't have: Snakemake runs `git ls-files` to find the
|
|
178
|
+
# workflow's source files, and these are exactly the files smkflex sent.
|
|
179
|
+
import sys
|
|
180
|
+
if sys.argv[1:2] == ["ls-files"]:
|
|
181
|
+
sys.stdout.write(open({files!r}).read())
|
|
182
|
+
else:
|
|
183
|
+
sys.exit("git is not installed in the head job's image (smkflex only provides git ls-files)")
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def git_shim():
|
|
188
|
+
"""Directory with a stand-in `git`, or None when the image has a real one."""
|
|
189
|
+
import shutil
|
|
190
|
+
|
|
191
|
+
if shutil.which("git"):
|
|
192
|
+
return None
|
|
193
|
+
d = os.path.abspath(os.path.join(".smkflex", "bin"))
|
|
194
|
+
os.makedirs(d, exist_ok=True)
|
|
195
|
+
path = os.path.join(d, "git")
|
|
196
|
+
with open(path, "w") as f:
|
|
197
|
+
f.write(GIT_SHIM.format(python=sys.executable, files=os.path.abspath(".smkflex/files.txt")))
|
|
198
|
+
os.chmod(path, 0o755)
|
|
199
|
+
return d
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv):
|
|
203
|
+
state_url, args = argv[0], argv[1:]
|
|
204
|
+
state = get(state_url)
|
|
205
|
+
if state:
|
|
206
|
+
unpack(state, ".")
|
|
207
|
+
print("[smkflex] restored .snakemake state from the previous run", file=sys.stderr)
|
|
208
|
+
site = os.path.abspath(os.path.join(".smkflex", "site"))
|
|
209
|
+
env = dict(os.environ)
|
|
210
|
+
env["PYTHONPATH"] = site + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
|
|
211
|
+
shim = git_shim()
|
|
212
|
+
if shim:
|
|
213
|
+
env["PATH"] = shim + os.pathsep + env.get("PATH", "")
|
|
214
|
+
cmd = [sys.executable, "-m", "snakemake", "--profile", ".smkflex/profile", *args]
|
|
215
|
+
if not any(a == "--wrapper-prefix" or a.startswith("--wrapper-prefix=") for a in args):
|
|
216
|
+
# Without a prefix, Snakemake fetches version-tagged wrappers with git clone, and the
|
|
217
|
+
# head job's image usually has no git; the same files are on GitHub over plain HTTPS.
|
|
218
|
+
cmd += ["--wrapper-prefix", WRAPPER_PREFIX]
|
|
219
|
+
print("[smkflex] " + " ".join(cmd), file=sys.stderr, flush=True)
|
|
220
|
+
proc = subprocess.Popen(cmd, env=env)
|
|
221
|
+
saver = StateSaver(state_url, SAVE_EVERY)
|
|
222
|
+
|
|
223
|
+
# `smkflex cancel` (Batch's terminate) sends SIGTERM; Snakemake cancels its jobs on SIGINT.
|
|
224
|
+
# The state is saved at once too, in case the head job is killed before Snakemake is done.
|
|
225
|
+
def stop(_sig, _frame):
|
|
226
|
+
proc.send_signal(signal.SIGINT)
|
|
227
|
+
saver.wake.set()
|
|
228
|
+
|
|
229
|
+
signal.signal(signal.SIGTERM, stop)
|
|
230
|
+
rc = proc.wait()
|
|
231
|
+
if saver.finish():
|
|
232
|
+
print("[smkflex] saved .snakemake state", file=sys.stderr)
|
|
233
|
+
bundle = os.environ.get("SMKFLEX_BUNDLE_URL")
|
|
234
|
+
if bundle: # the uploaded workflow isn't needed any more
|
|
235
|
+
try:
|
|
236
|
+
delete(bundle)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"[smkflex] could not remove {bundle}: {e!r}", file=sys.stderr)
|
|
239
|
+
print(f"[smkflex] snakemake exited with {rc}", file=sys.stderr)
|
|
240
|
+
return rc
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Plain-language hints for job failures with a known cause, from the end of the job's log."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def known_problem_hints(log_lines: List[str]) -> List[str]:
|
|
8
|
+
"""Plain-language explanations for failures with a known cause, from the job's log."""
|
|
9
|
+
text = "\n".join(log_lines)
|
|
10
|
+
hints = []
|
|
11
|
+
bad = " ".join(re.findall(r"unrecognized arguments:(.*)", text))
|
|
12
|
+
plugins = sorted(set(re.findall(r"--storage-([a-z0-9]+)-", bad)))
|
|
13
|
+
for name in plugins:
|
|
14
|
+
hints.append(
|
|
15
|
+
f" hint: this machine has snakemake-storage-plugin-{name} installed but the job's "
|
|
16
|
+
f"Snakemake (from the AMI, or installed at job start) doesn't, and Snakemake passes "
|
|
17
|
+
f"every installed storage "
|
|
18
|
+
f"plugin's options to its jobs. Uninstall it here (pip uninstall "
|
|
19
|
+
f"snakemake-storage-plugin-{name}) or bake an AMI with --storage-plugin {name}."
|
|
20
|
+
)
|
|
21
|
+
if "pandas of Snakemake's env can't load in this image" in text and "pandas" in text.split("can't load in this image", 1)[1]:
|
|
22
|
+
hints.append(
|
|
23
|
+
" hint: the image lacks a system library (named above) that the numpy and pandas of the AMI's "
|
|
24
|
+
"Snakemake need, and no conda from the AMI was there to lend one (aws-batch-flex-conda-path, EC2). "
|
|
25
|
+
"Many bioconda images keep theirs inside their own environment. If the Snakefile imports pandas, "
|
|
26
|
+
"set aws-batch-flex-conda-path, or use a Debian- or Ubuntu-based image for this rule."
|
|
27
|
+
)
|
|
28
|
+
missing = sorted(set(re.findall(r"ModuleNotFoundError: No module named '([A-Za-z0-9_.]+)'", text)))
|
|
29
|
+
for mod in missing:
|
|
30
|
+
hints.append(
|
|
31
|
+
f" hint: Python couldn't import {mod}. If the Snakefile (or a file it includes) imports "
|
|
32
|
+
f"it, it's needed in every job too, because Snakemake reads the Snakefile there as well: "
|
|
33
|
+
f"add it to the AMI with scripts/update-ami.sh --extra-package {mod.split('.')[0]} (the pip "
|
|
34
|
+
f"name can differ), or to --aws-batch-flex-extra-packages for jobs that install Snakemake "
|
|
35
|
+
f"at start. If a script: imports it, it belongs in the rule's image or conda env."
|
|
36
|
+
)
|
|
37
|
+
if re.search(r"\(403\).*(HeadObject|GetObject|PutObject|ListObjects).*Forbidden|AccessDenied.*(GetObject|PutObject)", text):
|
|
38
|
+
hints.append(
|
|
39
|
+
" hint: the job wasn't allowed to read or write the bucket. Jobs use the job role "
|
|
40
|
+
"(aws-batch-flex-job-role-arn), not your own login, and the Terraform setup only lets "
|
|
41
|
+
"it use the s3_prefix folder (s3://BUCKET/snakemake/...). Keep default-storage-prefix "
|
|
42
|
+
"inside that folder, or give the role access to the other one."
|
|
43
|
+
)
|
|
44
|
+
if "No space left on device" in text:
|
|
45
|
+
hints.append(
|
|
46
|
+
" hint: the job ran out of disk. On EC2, all jobs on an instance share its disk "
|
|
47
|
+
"(root_volume_gib in Terraform), and disk_mb isn't enforced there: use a bigger "
|
|
48
|
+
"volume, a queue with bigger disks for this rule (batch_queue), or fewer jobs per "
|
|
49
|
+
"instance. On Fargate, raise the rule's disk_mb (up to 200 GiB)."
|
|
50
|
+
)
|
|
51
|
+
if re.search(r"SyntaxError: .*(in file /bin/sh\b|source code cannot contain null bytes)", text):
|
|
52
|
+
hints.append(
|
|
53
|
+
" hint: the image has a Python ENTRYPOINT (such as [\"python3\"]) that the plugin "
|
|
54
|
+
"couldn't read from its registry (see the warning at the start), so Python was handed "
|
|
55
|
+
"/bin/sh as a script to run. Give the rule the entrypoint: "
|
|
56
|
+
"resources: batch_entrypoint='[\"python3\"]'."
|
|
57
|
+
)
|
|
58
|
+
# a script a shell: command runs that wasn't sent: python, Rscript, sh/bash
|
|
59
|
+
scripts = re.findall(r"can't open file '(?:/tmp/snakemake-job-\d+/)?([^']+)'", text)
|
|
60
|
+
scripts += re.findall(r"cannot open file '([^']+\.[Rr])': No such file", text)
|
|
61
|
+
scripts += re.findall(r"(?:^|\s)(\S+\.(?:sh|py|R|pl|rb|jl)): No such file", text, re.M)
|
|
62
|
+
scripts += re.findall(r"Can't open (\S+\.(?:sh|py|R|pl|rb|jl))", text) # older dash
|
|
63
|
+
if scripts:
|
|
64
|
+
hints.append(
|
|
65
|
+
f" hint: {', '.join(sorted(set(scripts)))} wasn't sent to the job. Jobs get the workflow folder "
|
|
66
|
+
"snakemake runs in, except what .gitignore excludes and single files over 10 MB. Is it "
|
|
67
|
+
"git-ignored, outside that folder, or made by an earlier rule without being its output?"
|
|
68
|
+
)
|
|
69
|
+
if not plugins and "unrecognized arguments" in text:
|
|
70
|
+
hints.append(
|
|
71
|
+
" hint: the job's Snakemake didn't understand an option from this machine's "
|
|
72
|
+
"Snakemake; the two versions are probably too far apart (compare "
|
|
73
|
+
"`snakemake --version` here with the job's; 9.5 to 9.27 are known to mix fine)."
|
|
74
|
+
)
|
|
75
|
+
return hints
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Builds the Snakemake env that gets baked into the AMI and mounted into jobs.
|
|
2
|
+
|
|
3
|
+
Python comes from uv's standalone builds and lives inside the env directory, so the env works
|
|
4
|
+
in any glibc image as long as it's mounted at the same path. `.ready` is written last and jobs
|
|
5
|
+
wait for it."""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import email
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
import textwrap
|
|
14
|
+
from email.mime.multipart import MIMEMultipart
|
|
15
|
+
from email.mime.text import MIMEText
|
|
16
|
+
|
|
17
|
+
DEFAULT_PATH = "/opt/snakemake-env"
|
|
18
|
+
|
|
19
|
+
_SCRIPT = r"""#!/bin/bash
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
ENV=@@ENV@@
|
|
22
|
+
mkdir -p "$ENV"
|
|
23
|
+
if [ -f "$ENV/.ready" ] && [ "$(cat "$ENV/.ready")" = @@STAMP@@ ]; then
|
|
24
|
+
echo "snakemake host env already up to date at $ENV"; exit 0
|
|
25
|
+
fi
|
|
26
|
+
rm -f "$ENV/.ready"
|
|
27
|
+
export UV_CACHE_DIR="$ENV/.cache" UV_PYTHON_INSTALL_DIR="$ENV/python" UV_PYTHON_PREFERENCE=only-managed
|
|
28
|
+
@@UV@@
|
|
29
|
+
[ -n "$UV" ] || { echo "need curl, wget or python3+pip to install uv" >&2; exit 1; }
|
|
30
|
+
$UV python install @@PYVER@@
|
|
31
|
+
$UV venv --clear --python @@PYVER@@ "$ENV/venv"
|
|
32
|
+
# exactly the versions on the machine that made this script (see bootstrap.locked)
|
|
33
|
+
cat > "$ENV/constraints.txt" <<'SMK_PINS_EOF'
|
|
34
|
+
@@PINS@@
|
|
35
|
+
SMK_PINS_EOF
|
|
36
|
+
$UV pip install --python "$ENV/venv/bin/python" -c "$ENV/constraints.txt" @@REQS@@
|
|
37
|
+
"$ENV/venv/bin/python" -c "import snakemake; print('snakemake', snakemake.__version__)"
|
|
38
|
+
rm -rf "$ENV/.cache"
|
|
39
|
+
chmod -R a+rX "$ENV"
|
|
40
|
+
echo @@STAMP@@ > "$ENV/.ready"
|
|
41
|
+
echo "snakemake host env ready at $ENV"
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _packages(storage_plugins: list[str]) -> list[str]:
|
|
46
|
+
return ["snakemake"] + [f"snakemake-storage-plugin-{p}" for p in storage_plugins]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_requirements(storage_plugins: list[str], extra: list[str]) -> list[str]:
|
|
50
|
+
from .bootstrap import pinned
|
|
51
|
+
|
|
52
|
+
return [pinned(p) for p in _packages(storage_plugins)] + list(extra)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_constraints(storage_plugins: list[str], extra: list[str] = ()) -> list[str]:
|
|
56
|
+
"""Pins for the env: this machine's versions of Snakemake, the storage plugins and the extra
|
|
57
|
+
packages (e.g. pandas), and of everything they need."""
|
|
58
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
59
|
+
from packaging.utils import canonicalize_name
|
|
60
|
+
|
|
61
|
+
from .bootstrap import locked
|
|
62
|
+
|
|
63
|
+
asked = {}
|
|
64
|
+
for r in extra:
|
|
65
|
+
try:
|
|
66
|
+
req = Requirement(r)
|
|
67
|
+
except InvalidRequirement: # a URL or a path: installed as given, not pinned
|
|
68
|
+
continue
|
|
69
|
+
asked[canonicalize_name(req.name)] = req.specifier
|
|
70
|
+
pins, skipped = locked(_packages(storage_plugins) + list(asked))
|
|
71
|
+
# a version here that doesn't fit what's asked for (pandas<3 with pandas 3 here) isn't pinned
|
|
72
|
+
fits = [p for p in pins if asked.get(p.split("==")[0], None) is None
|
|
73
|
+
or asked[p.split("==")[0]].contains(p.split("==")[1], prereleases=True)]
|
|
74
|
+
skipped += [p for p in pins if p not in fits]
|
|
75
|
+
pins = fits
|
|
76
|
+
if skipped:
|
|
77
|
+
print(f"note: not pinned, since this machine's version doesn't fit what the packages ask for: "
|
|
78
|
+
f"{', '.join(skipped)}", file=sys.stderr)
|
|
79
|
+
return pins
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def install_script(env_path: str, python_version: str, requirements: list[str],
|
|
83
|
+
constraints: list[str] = ()) -> str:
|
|
84
|
+
from .bootstrap import uv_snippet
|
|
85
|
+
|
|
86
|
+
stamp = shlex.quote("|".join([python_version, *sorted(requirements), *sorted(constraints)]))
|
|
87
|
+
return (
|
|
88
|
+
_SCRIPT.replace("@@ENV@@", shlex.quote(env_path))
|
|
89
|
+
.replace("@@STAMP@@", stamp)
|
|
90
|
+
.replace("@@PYVER@@", shlex.quote(python_version))
|
|
91
|
+
.replace("@@PINS@@", "\n".join(constraints))
|
|
92
|
+
.replace("@@REQS@@", " ".join(shlex.quote(r) for r in requirements))
|
|
93
|
+
.replace("@@UV@@", uv_snippet("$ENV/.uv", "$ENV/.uvlib"))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_CONDA = r"""#!/bin/bash
|
|
98
|
+
set -euo pipefail
|
|
99
|
+
ROOT=@ROOT@
|
|
100
|
+
if [ ! -x "$ROOT/bin/conda" ]; then
|
|
101
|
+
mkdir -p "$(dirname "$ROOT")"
|
|
102
|
+
case "$(uname -m)" in
|
|
103
|
+
x86_64) SUM=@X86_64@ ;;
|
|
104
|
+
aarch64) SUM=@AARCH64@ ;;
|
|
105
|
+
*) echo "no Miniforge checksum for $(uname -m)" >&2; exit 1 ;;
|
|
106
|
+
esac
|
|
107
|
+
curl -fsSL -o /tmp/miniforge.sh "https://github.com/conda-forge/miniforge/releases/download/@MINIFORGE@/Miniforge3-@MINIFORGE@-Linux-$(uname -m).sh"
|
|
108
|
+
echo "$SUM /tmp/miniforge.sh" | sha256sum -c -
|
|
109
|
+
bash /tmp/miniforge.sh -b -p "$ROOT"
|
|
110
|
+
rm -f /tmp/miniforge.sh
|
|
111
|
+
fi
|
|
112
|
+
@ENVS@
|
|
113
|
+
# git for jobs whose image has none: Snakemake needs it for pipelines loaded from GitHub
|
|
114
|
+
# (module ... github(...), what snakedeploy sets up). conda-forge's git brings its own libraries,
|
|
115
|
+
# so it works in any image; smkflex-bin holds only a link to it, for the end of jobs' PATH.
|
|
116
|
+
if [ ! -x "$ROOT/envs/smkflex-git/bin/git" ]; then
|
|
117
|
+
"$ROOT/bin/conda" create -y -q -p "$ROOT/envs/smkflex-git" -c conda-forge git
|
|
118
|
+
fi
|
|
119
|
+
mkdir -p "$ROOT/smkflex-bin" && ln -sf ../envs/smkflex-git/bin/git "$ROOT/smkflex-bin/git"
|
|
120
|
+
"$ROOT/bin/conda" clean -a -y -q || true
|
|
121
|
+
chmod -R a+rX "$ROOT"
|
|
122
|
+
echo "conda ready at $ROOT"
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
# Miniforge for the AMI, with the sha256 of its installers (from its GitHub release)
|
|
126
|
+
MINIFORGE = "26.7.2-0"
|
|
127
|
+
MINIFORGE_SHA256 = {
|
|
128
|
+
"X86_64": "281b0ac7d550802efc81af633225a5e6116d29ae72f3ab4eae7168c3931a4c05",
|
|
129
|
+
"AARCH64": "89b786c8d2c8b0fda7553914c1314ae4ddaa094503802f279377b19ac4463cb2",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_CONDA_ENV = r"""cat > /tmp/env-@NAME@.yaml <<'SMK_ENV_EOF'
|
|
133
|
+
@YAML@
|
|
134
|
+
SMK_ENV_EOF
|
|
135
|
+
if [ -d "$ROOT/envs/@NAME@" ]; then "$ROOT/bin/conda" env update -n @NAME@ -f /tmp/env-@NAME@.yaml --prune -q
|
|
136
|
+
else "$ROOT/bin/conda" env create -n @NAME@ -f /tmp/env-@NAME@.yaml -q; fi"""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def conda_script(conda_path: str, envs: list) -> str:
|
|
140
|
+
"""Install Miniforge at conda_path and create the named environments (name, yaml text).
|
|
141
|
+
Named environments land in <conda_path>/envs/<name>, so `conda: "<name>"` finds them."""
|
|
142
|
+
blocks = []
|
|
143
|
+
for name, yaml_text in envs:
|
|
144
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", name):
|
|
145
|
+
raise ValueError(f"invalid environment name {name!r}")
|
|
146
|
+
blocks.append(_CONDA_ENV.replace("@NAME@", name).replace("@YAML@", yaml_text.rstrip()))
|
|
147
|
+
script = _CONDA.replace("@ROOT@", shlex.quote(conda_path)).replace("@MINIFORGE@", MINIFORGE)
|
|
148
|
+
for arch, sha in MINIFORGE_SHA256.items():
|
|
149
|
+
script = script.replace(f"@{arch}@", sha)
|
|
150
|
+
return script.replace("@ENVS@", "\n".join(blocks))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def launch_template_userdata(script: str, includes: tuple = ()) -> str:
|
|
154
|
+
"""MIME multipart user-data, the format AWS Batch launch templates require.
|
|
155
|
+
|
|
156
|
+
`includes` are existing user-data documents (e.g. the one in your current launch
|
|
157
|
+
template): multipart documents are merged part by part, untouched; a plain script is
|
|
158
|
+
wrapped as a shell-script part. Our installer is appended as the LAST part.
|
|
159
|
+
"""
|
|
160
|
+
msg = MIMEMultipart("mixed", boundary="==SMKBOUNDARY==")
|
|
161
|
+
for text in includes:
|
|
162
|
+
parsed = email.message_from_string(text)
|
|
163
|
+
if parsed.is_multipart():
|
|
164
|
+
for part in parsed.get_payload():
|
|
165
|
+
msg.attach(part)
|
|
166
|
+
elif text.lstrip().startswith("#cloud-config"):
|
|
167
|
+
msg.attach(MIMEText(text, "cloud-config", "us-ascii"))
|
|
168
|
+
else:
|
|
169
|
+
msg.attach(MIMEText(text, "x-shellscript", "us-ascii"))
|
|
170
|
+
msg.attach(MIMEText(script, "x-shellscript", "us-ascii"))
|
|
171
|
+
del msg["MIME-Version"] # re-added first, matching AWS's documented layout
|
|
172
|
+
return "MIME-Version: 1.0\n" + msg.as_string()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv=None):
|
|
176
|
+
ap = argparse.ArgumentParser(
|
|
177
|
+
prog="snakemake-batch-flex-host-env",
|
|
178
|
+
description=textwrap.dedent(__doc__).split("\n\n")[0],
|
|
179
|
+
)
|
|
180
|
+
ap.add_argument("what", choices=["script", "userdata", "conda"],
|
|
181
|
+
help="script: shell script to run on an instance/AMI build; "
|
|
182
|
+
"userdata: same, wrapped for an EC2 launch template; "
|
|
183
|
+
"conda: script that installs Miniforge and creates --conda-env environments")
|
|
184
|
+
ap.add_argument("--path", default=DEFAULT_PATH, help=f"install dir (default {DEFAULT_PATH}); "
|
|
185
|
+
"must match --aws-batch-flex-host-env-path")
|
|
186
|
+
ap.add_argument("--storage-plugin", action="append", default=[], metavar="NAME",
|
|
187
|
+
help="storage plugin to include, e.g. s3 (repeatable)")
|
|
188
|
+
ap.add_argument("--extra-package", action="append", default=[], metavar="REQ",
|
|
189
|
+
help="additional pip requirement (repeatable)")
|
|
190
|
+
ap.add_argument("--include", action="append", default=[], metavar="FILE",
|
|
191
|
+
help="userdata only: existing user-data file to keep (repeatable), e.g. the "
|
|
192
|
+
"decoded user-data of your current launch template. Our installer is "
|
|
193
|
+
"appended after it.")
|
|
194
|
+
ap.add_argument("--conda-path", default="/opt/miniforge3", help="conda: install dir (mounted into jobs)")
|
|
195
|
+
ap.add_argument("--conda-env", action="append", default=[], metavar="FILE",
|
|
196
|
+
help="conda: environment YAML to create (repeatable); the name is the file name")
|
|
197
|
+
ap.add_argument("--python-version", default=f"{sys.version_info[0]}.{sys.version_info[1]}")
|
|
198
|
+
a = ap.parse_args(argv)
|
|
199
|
+
if a.what == "conda":
|
|
200
|
+
envs = [(os.path.splitext(os.path.basename(f))[0], open(f).read()) for f in a.conda_env]
|
|
201
|
+
sys.stdout.write(conda_script(a.conda_path, envs))
|
|
202
|
+
return
|
|
203
|
+
if a.include and a.what != "userdata":
|
|
204
|
+
ap.error("--include only applies to 'userdata'")
|
|
205
|
+
includes = []
|
|
206
|
+
for f in a.include:
|
|
207
|
+
try:
|
|
208
|
+
includes.append(open(f, encoding="ascii").read())
|
|
209
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
210
|
+
ap.error(f"cannot read --include {f}: {e} (user-data must be plain ASCII)")
|
|
211
|
+
script = install_script(
|
|
212
|
+
a.path, a.python_version, default_requirements(a.storage_plugin, a.extra_package),
|
|
213
|
+
default_constraints(a.storage_plugin, a.extra_package),
|
|
214
|
+
)
|
|
215
|
+
sys.stdout.write(
|
|
216
|
+
launch_template_userdata(script, tuple(includes)) if a.what == "userdata" else script
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
main()
|