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,8 @@
|
|
|
1
|
+
"""AWS Batch executor for Snakemake that runs each rule in the image from its
|
|
2
|
+
`container:` directive. Images don't need Snakemake installed."""
|
|
3
|
+
|
|
4
|
+
from . import patches # noqa: F401 (Snakemake packs the sources before it creates the executor)
|
|
5
|
+
from .executor import Executor
|
|
6
|
+
from .settings import ExecutorSettings, common_settings
|
|
7
|
+
|
|
8
|
+
__all__ = ["Executor", "ExecutorSettings", "common_settings"]
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Shell snippets that run before the Snakemake job command inside the container.
|
|
2
|
+
|
|
3
|
+
"host" mode uses the env mounted from the instance; the other modes install Snakemake into a
|
|
4
|
+
venv under $TMPDIR with uv. Either way Snakemake is only reachable through a shell function
|
|
5
|
+
called `python`, so the rule's own commands still see the image's python and PATH."""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import importlib.metadata as md
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import textwrap
|
|
12
|
+
import zlib
|
|
13
|
+
from typing import Iterable, List, Optional
|
|
14
|
+
|
|
15
|
+
MODES = ("auto", "always", "never", "host")
|
|
16
|
+
# uv for jobs that install Snakemake at start, and for the AMI's env; its installer checks the
|
|
17
|
+
# download's sha256
|
|
18
|
+
UV_VERSION = "0.13.0"
|
|
19
|
+
|
|
20
|
+
_TEMPLATE = r"""
|
|
21
|
+
set -eu
|
|
22
|
+
@@WORKDIR@@
|
|
23
|
+
SMK_BOOT="${TMPDIR:-/tmp}/.snakemake-batch-bootstrap"
|
|
24
|
+
SMK_NEED=1
|
|
25
|
+
@@CHECK@@
|
|
26
|
+
# the image's own matching Snakemake: still run it through the launcher (cache, usage figures)
|
|
27
|
+
if [ "$SMK_NEED" = 0 ]; then SMK_PY=$(command -v python); fi
|
|
28
|
+
if [ "$SMK_NEED" = 1 ]; then
|
|
29
|
+
echo "[snakemake-batch] bootstrapping Snakemake into $SMK_BOOT" >&2
|
|
30
|
+
mkdir -p "$SMK_BOOT"
|
|
31
|
+
export UV_CACHE_DIR="$SMK_BOOT/cache" UV_PYTHON_INSTALL_DIR="$SMK_BOOT/python"
|
|
32
|
+
@@PREREQS@@
|
|
33
|
+
@@UV@@
|
|
34
|
+
if [ -z "$UV" ]; then
|
|
35
|
+
echo "[snakemake-batch] cannot bootstrap: image needs curl, wget, or python3+pip (and internet access)" >&2
|
|
36
|
+
exit 127
|
|
37
|
+
fi
|
|
38
|
+
$UV venv --clear --quiet --python @@PYVER@@ --system-site-packages "$SMK_BOOT/venv" >&2
|
|
39
|
+
$UV pip install --quiet --python "$SMK_BOOT/venv/bin/python" @@NEWER@@@@REQS@@ >&2
|
|
40
|
+
SMK_PY="$SMK_BOOT/venv/bin/python"
|
|
41
|
+
python() { "$SMK_PY" "$@"; }
|
|
42
|
+
fi
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def uv_snippet(bin_dir: str, lib_dir: str) -> str:
|
|
47
|
+
"""Shell that sets UV to a uv to run: the image's own, else version UV_VERSION from its
|
|
48
|
+
installer (curl or wget; it checks the download's sha256) into bin_dir, else from pip into
|
|
49
|
+
lib_dir. UV stays empty if none of them worked. For jobs that install Snakemake at start
|
|
50
|
+
and for the AMI's env (hostenv.py)."""
|
|
51
|
+
return f"""UV=""
|
|
52
|
+
if command -v uv >/dev/null 2>&1; then
|
|
53
|
+
UV=uv
|
|
54
|
+
else
|
|
55
|
+
if command -v curl >/dev/null 2>&1; then
|
|
56
|
+
curl -fsSL https://astral.sh/uv/{UV_VERSION}/install.sh | UV_UNMANAGED_INSTALL="{bin_dir}" sh >&2 || true
|
|
57
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
58
|
+
wget -qO- https://astral.sh/uv/{UV_VERSION}/install.sh | UV_UNMANAGED_INSTALL="{bin_dir}" sh >&2 || true
|
|
59
|
+
fi
|
|
60
|
+
if [ -x "{bin_dir}/uv" ]; then
|
|
61
|
+
UV="{bin_dir}/uv"
|
|
62
|
+
else
|
|
63
|
+
for p in python3 python; do
|
|
64
|
+
if command -v "$p" >/dev/null 2>&1 && "$p" -m pip install -q --target "{lib_dir}" uv=={UV_VERSION} >&2; then
|
|
65
|
+
UV="env PYTHONPATH={lib_dir} $p -m uv"
|
|
66
|
+
break
|
|
67
|
+
fi
|
|
68
|
+
done
|
|
69
|
+
fi
|
|
70
|
+
fi"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Installs curl (for uv) in an image without curl, wget or pip: only with install_prereqs
|
|
74
|
+
_PREREQS = r'''
|
|
75
|
+
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 \
|
|
76
|
+
&& ! { command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; }; then
|
|
77
|
+
echo "[snakemake-batch] no curl/wget/pip in the image: installing curl, ca-certificates and bash" >&2
|
|
78
|
+
if [ "$(id -u)" != 0 ]; then
|
|
79
|
+
echo "[snakemake-batch] cannot install prerequisites: the image runs as a non-root user" >&2
|
|
80
|
+
elif command -v apt-get >/dev/null 2>&1; then
|
|
81
|
+
apt-get update -qq >&2 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates bash >&2 || true
|
|
82
|
+
elif command -v apk >/dev/null 2>&1; then apk add -q --no-cache curl ca-certificates bash >&2 || true
|
|
83
|
+
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl ca-certificates bash >&2 || true
|
|
84
|
+
elif command -v microdnf >/dev/null 2>&1; then microdnf install -y curl ca-certificates bash >&2 || true
|
|
85
|
+
elif command -v yum >/dev/null 2>&1; then yum install -y -q curl ca-certificates bash >&2 || true
|
|
86
|
+
else echo "[snakemake-batch] no known package manager in the image" >&2
|
|
87
|
+
fi
|
|
88
|
+
fi
|
|
89
|
+
'''
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
WORKDIR = (
|
|
93
|
+
'SMK_WORK="${TMPDIR:-/tmp}/snakemake-job-$$"; mkdir -p "$SMK_WORK"; cd "$SMK_WORK"'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_HOST_TEMPLATE = r"""
|
|
97
|
+
set -eu
|
|
98
|
+
@@WORKDIR@@
|
|
99
|
+
SMK_ENV=@@ENV@@
|
|
100
|
+
SMK_WAITED=0
|
|
101
|
+
while [ ! -f "$SMK_ENV/.ready" ]; do
|
|
102
|
+
if [ "$SMK_WAITED" -ge @@WAIT@@ ]; then
|
|
103
|
+
echo "[snakemake-batch] host env $SMK_ENV is missing or not ready. Install it on the" >&2
|
|
104
|
+
echo " EC2 host/AMI (snakemake-batch-flex-host-env) and check the job definition mount." >&2
|
|
105
|
+
exit 127
|
|
106
|
+
fi
|
|
107
|
+
sleep 2; SMK_WAITED=$((SMK_WAITED + 2))
|
|
108
|
+
done
|
|
109
|
+
SMK_PY="$SMK_ENV/venv/bin/python"
|
|
110
|
+
python() { "$SMK_PY" "$@"; }
|
|
111
|
+
if ! python -c "import snakemake" >/dev/null 2>&1; then
|
|
112
|
+
echo "[snakemake-batch] the host env cannot run in this image (musl/Alpine? exotic libc?)." >&2
|
|
113
|
+
exit 127
|
|
114
|
+
fi
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Python `script:` rules normally run with Snakemake's own interpreter. This launcher makes them
|
|
118
|
+
# use the image's python (>= 3.10, which Snakemake's script header needs) when it has one, the
|
|
119
|
+
# same way Snakemake treats conda envs. Anything else falls through to the original code.
|
|
120
|
+
_LAUNCHER = r"""
|
|
121
|
+
import os, runpy, shutil, subprocess, sys
|
|
122
|
+
a = sys.argv[1:]
|
|
123
|
+
if a[:2] != ["-m", "snakemake"]:
|
|
124
|
+
os.execv(sys.executable, [sys.executable] + a)
|
|
125
|
+
sys.argv = ["snakemake"] + a[2:]
|
|
126
|
+
if "monitor" in globals() and "--deploy-sources" not in sys.argv:
|
|
127
|
+
# the job tools came with this launcher, in the same file (job_tools_source)
|
|
128
|
+
try:
|
|
129
|
+
make_numpy_loadable()
|
|
130
|
+
monitor()
|
|
131
|
+
_cc = os.environ.get("SMK_FLEX_CONDA_CACHE", "none")
|
|
132
|
+
if _cc != "none":
|
|
133
|
+
sys.argv = ["snakemake"] + install(sys.argv[1:], _cc)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"[snakemake-batch] job tools off for this job (conda env cache, usage figures): {e!r}",
|
|
136
|
+
file=sys.stderr)
|
|
137
|
+
if os.environ.get("SMK_FLEX_SCRIPT_PYTHON") == "host":
|
|
138
|
+
runpy.run_module("snakemake", run_name="__main__", alter_sys=True)
|
|
139
|
+
sys.exit()
|
|
140
|
+
found = []
|
|
141
|
+
def image_python():
|
|
142
|
+
if not found:
|
|
143
|
+
found.append(None)
|
|
144
|
+
p = shutil.which("python") or shutil.which("python3")
|
|
145
|
+
if p and os.path.realpath(p) != os.path.realpath(sys.executable):
|
|
146
|
+
try:
|
|
147
|
+
if subprocess.run([p, "-c", "import sys; sys.exit(sys.version_info < (3, 10))"]).returncode == 0:
|
|
148
|
+
found[0] = p
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
return found[0]
|
|
152
|
+
def own_packages():
|
|
153
|
+
# Links to just this Snakemake's packages, put first on the script's path so that a
|
|
154
|
+
# Snakemake installed in the image can't shadow them. Everything else: image first.
|
|
155
|
+
import snakemake
|
|
156
|
+
site = os.path.dirname(os.path.dirname(snakemake.__file__))
|
|
157
|
+
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".smk-pkgs")
|
|
158
|
+
if not os.path.isdir(d):
|
|
159
|
+
os.makedirs(d)
|
|
160
|
+
for n in os.listdir(site):
|
|
161
|
+
if n.startswith("snakemake") and os.path.isdir(os.path.join(site, n)) and "." not in n:
|
|
162
|
+
os.symlink(os.path.join(site, n), os.path.join(d, n))
|
|
163
|
+
return d
|
|
164
|
+
try:
|
|
165
|
+
import inspect
|
|
166
|
+
from snakemake import script as sm_script
|
|
167
|
+
orig = sm_script.PythonScript.execute_script
|
|
168
|
+
if list(inspect.signature(orig).parameters) != ["self", "fname", "edit"]:
|
|
169
|
+
raise RuntimeError("this Snakemake's PythonScript.execute_script changed")
|
|
170
|
+
def execute_script(self, fname, edit=False):
|
|
171
|
+
if self.container_img is None and self.conda_env is None and self.env_modules is None and image_python():
|
|
172
|
+
return self._execute_cmd(
|
|
173
|
+
"PYTHONPATH={pkgs:q}${{PYTHONPATH:+:$PYTHONPATH}} {py_exec:q} {fname:q}",
|
|
174
|
+
pkgs=own_packages(), py_exec=image_python(), fname=fname, is_python_script=True)
|
|
175
|
+
return orig(self, fname, edit)
|
|
176
|
+
sm_script.PythonScript.execute_script = execute_script
|
|
177
|
+
except Exception as e:
|
|
178
|
+
print(f"[snakemake-batch] script: rules will use Snakemake's python ({e!r})", file=sys.stderr)
|
|
179
|
+
runpy.run_module("snakemake", run_name="__main__", alter_sys=True)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def job_tools_source() -> str:
|
|
184
|
+
"""The file every job downloads and runs (launcher_snippet): condacache.py, jobmonitor.py and
|
|
185
|
+
jobfixes.py, then the launcher. Too long to send with each job; uploaded once per run."""
|
|
186
|
+
from pathlib import Path
|
|
187
|
+
|
|
188
|
+
here = Path(__file__).parent
|
|
189
|
+
tools = [(here / name).read_text() for name in ("condacache.py", "jobmonitor.py", "jobfixes.py")]
|
|
190
|
+
# the launcher runs when the job's loader runs the file, not when the tools are only loaded
|
|
191
|
+
launcher = 'if __name__ == "smkflex_launcher":\n' + textwrap.indent(_LAUNCHER, " ")
|
|
192
|
+
return "\n\n".join(tools + [launcher])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Fetches the job tools (with the launcher) named in SMK_FLEX_JOB_CODE, checks them against the
|
|
196
|
+
# sha256 in their name, and runs them; else plain Snakemake. In the job's command, so short.
|
|
197
|
+
_LOADER = (
|
|
198
|
+
"import hashlib,os,runpy,sys\n"
|
|
199
|
+
"u=os.environ.get(\"SMK_FLEX_JOB_CODE\");a=sys.argv[1:];d=None\n"
|
|
200
|
+
"if a[:2]!=[\"-m\",\"snakemake\"]:os.execv(sys.executable,[sys.executable]+a)\n"
|
|
201
|
+
"if u and \"--deploy-sources\" not in a:\n"
|
|
202
|
+
" try:\n"
|
|
203
|
+
" if u.startswith(\"s3://\"):\n"
|
|
204
|
+
" import boto3;b,_,k=u[5:].partition(\"/\");d=boto3.client(\"s3\").get_object(Bucket=b,Key=k)[\"Body\"].read()\n"
|
|
205
|
+
" else:d=open(u,\"rb\").read()\n"
|
|
206
|
+
" if not u.endswith(\"jobtools-%s.py\"%hashlib.sha256(d).hexdigest()):raise ValueError(u+\" does not match its checksum\")\n"
|
|
207
|
+
" except Exception as e:\n"
|
|
208
|
+
" d=None;print(\"[snakemake-batch] job tools off for this job (conda env cache, usage figures, image python for script:): %r\"%e,file=sys.stderr)\n"
|
|
209
|
+
"if d:exec(compile(d,\"smkflex-jobtools.py\",\"exec\"),{\"__name__\":\"smkflex_launcher\",\"__file__\":os.path.join(os.getcwd(),\".smk-launch.py\")})\n"
|
|
210
|
+
"else:sys.argv=[\"snakemake\"]+a[2:];runpy.run_module(\"snakemake\",run_name=\"__main__\",alter_sys=True)\n"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def launcher_snippet(job_code: bool) -> str:
|
|
215
|
+
"""Point the `python` function (if the prelude defined one) at the launcher: the short loader
|
|
216
|
+
above when the job tools were uploaded (job_code), else the launcher itself, compressed (ECS
|
|
217
|
+
allows only 8192 characters of overrides per job)."""
|
|
218
|
+
if job_code:
|
|
219
|
+
return 'if [ -n "${SMK_PY:-}" ]; then\n' f' python() {{ "$SMK_PY" -c {shlex.quote(_LOADER)} "$@"; }}\n' "fi\n"
|
|
220
|
+
return image_python_snippet()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def image_python_snippet() -> str:
|
|
224
|
+
"""Point the `python` function at the launcher above (only if the prelude defined one).
|
|
225
|
+
The launcher is sent compressed: ECS allows only 8192 characters of overrides per job."""
|
|
226
|
+
packed = base64.b64encode(zlib.compress(_LAUNCHER.encode(), 9)).decode()
|
|
227
|
+
unpack = "import base64,sys,zlib;open(sys.argv[1],'wb').write(zlib.decompress(base64.b64decode(sys.argv[2])))"
|
|
228
|
+
return (
|
|
229
|
+
'if [ -n "${SMK_PY:-}" ]; then\n'
|
|
230
|
+
f' "$SMK_PY" -c "{unpack}" "$SMK_WORK/.smk-launch.py" {packed}\n'
|
|
231
|
+
' python() { "$SMK_PY" "$SMK_WORK/.smk-launch.py" "$@"; }\n'
|
|
232
|
+
"fi\n"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _py_check(snakemake_version: str, package_names: Iterable[str]) -> str:
|
|
237
|
+
code = (
|
|
238
|
+
"import importlib.metadata as m\n"
|
|
239
|
+
f"assert m.version('snakemake') == {snakemake_version!r}\n"
|
|
240
|
+
+ "".join(f"m.version({n!r})\n" for n in package_names)
|
|
241
|
+
)
|
|
242
|
+
return shlex.quote(code)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _compact(script: str) -> str:
|
|
246
|
+
"""The script without indentation and blank lines: the job's command has to fit in
|
|
247
|
+
ECS's 8192 characters, and sh doesn't need them."""
|
|
248
|
+
return "\n".join(line.strip() for line in script.splitlines() if line.strip()) + "\n"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_prelude(
|
|
252
|
+
mode: str,
|
|
253
|
+
snakemake_version: str,
|
|
254
|
+
requirements: list[str],
|
|
255
|
+
python_version: str,
|
|
256
|
+
install_prereqs: bool = False,
|
|
257
|
+
resolved_at: Optional[str] = None,
|
|
258
|
+
) -> str:
|
|
259
|
+
"""Return a POSIX-sh snippet; after it runs, `python -m snakemake` works. With resolved_at
|
|
260
|
+
(an RFC 3339 time), packages are installed as they were then: the same versions in every
|
|
261
|
+
job of a run, however long it takes."""
|
|
262
|
+
if mode not in MODES:
|
|
263
|
+
raise ValueError(f"bootstrap mode must be one of {MODES}, got {mode!r}")
|
|
264
|
+
if mode == "never":
|
|
265
|
+
return f"set -eu\n{WORKDIR}"
|
|
266
|
+
|
|
267
|
+
names = [r.split("==")[0] for r in requirements]
|
|
268
|
+
check = ""
|
|
269
|
+
if mode == "auto": # use the image's own Snakemake if it's this version
|
|
270
|
+
check = (
|
|
271
|
+
"if command -v python >/dev/null 2>&1 && "
|
|
272
|
+
f"python -c {_py_check(snakemake_version, names)} >/dev/null 2>&1; "
|
|
273
|
+
"then SMK_NEED=0; fi"
|
|
274
|
+
)
|
|
275
|
+
reqs = " ".join(shlex.quote(r) for r in requirements)
|
|
276
|
+
return _compact(
|
|
277
|
+
_TEMPLATE.replace("@@WORKDIR@@", WORKDIR)
|
|
278
|
+
.replace("@@CHECK@@", check)
|
|
279
|
+
.replace("@@PYVER@@", shlex.quote(python_version))
|
|
280
|
+
.replace("@@PREREQS@@", _PREREQS if install_prereqs else "")
|
|
281
|
+
.replace("@@REQS@@", reqs)
|
|
282
|
+
.replace("@@NEWER@@", f"--exclude-newer {shlex.quote(resolved_at)} " if resolved_at else "")
|
|
283
|
+
.replace("@@UV@@", uv_snippet("$SMK_BOOT/bin", "$SMK_BOOT/uvlib"))
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_host_prelude(env_path: str, wait_seconds: int = 120) -> str:
|
|
288
|
+
"""Prelude for the host-mounted env. Waits for `.ready` because an instance that is
|
|
289
|
+
still installing the env (user-data) may already have been given a job."""
|
|
290
|
+
return _compact(
|
|
291
|
+
_HOST_TEMPLATE.replace("@@WORKDIR@@", WORKDIR)
|
|
292
|
+
.replace("@@ENV@@", shlex.quote(env_path))
|
|
293
|
+
.replace("@@WAIT@@", str(int(wait_seconds)))
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def conda_snippet(conda_path: str) -> str:
|
|
298
|
+
"""Make a host-mounted conda usable in any image: only `condabin` (conda itself, no python)
|
|
299
|
+
goes at the END of PATH, so an image's own conda wins; packages cache to a writable dir.
|
|
300
|
+
The AMI's git (smkflex-bin, see hostenv) goes there too, for images without one."""
|
|
301
|
+
return (
|
|
302
|
+
f'export PATH="$PATH:{conda_path}/condabin:{conda_path}/smkflex-bin"\n'
|
|
303
|
+
'export CONDA_PKGS_DIRS="$SMK_WORK/conda-pkgs"\n'
|
|
304
|
+
'[ -w "${HOME:-/nonexistent}" ] || export HOME="$SMK_WORK"\n'
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def pinned(package: str) -> str:
|
|
309
|
+
"""A pip requirement for the version installed here (unpinned if it isn't installed)."""
|
|
310
|
+
try:
|
|
311
|
+
return f"{package}=={md.version(package)}"
|
|
312
|
+
except md.PackageNotFoundError:
|
|
313
|
+
return package
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def locked(packages: Iterable[str]):
|
|
317
|
+
"""(pins, skipped): name==version of everything installed here that `packages` need, all
|
|
318
|
+
the way down, so the AMI's env has exactly this machine's versions; for a constraints file,
|
|
319
|
+
so pins the env's Python doesn't need are left alone. A version that doesn't fit what the
|
|
320
|
+
packages ask for (an environment mixed by hand) isn't pinned: it's in `skipped`."""
|
|
321
|
+
from packaging.requirements import Requirement
|
|
322
|
+
from packaging.specifiers import SpecifierSet
|
|
323
|
+
|
|
324
|
+
def key(name):
|
|
325
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
326
|
+
|
|
327
|
+
found, wanted, todo = {}, {}, list(packages)
|
|
328
|
+
while todo:
|
|
329
|
+
name = todo.pop()
|
|
330
|
+
if key(name) in found:
|
|
331
|
+
continue
|
|
332
|
+
try:
|
|
333
|
+
dist = md.distribution(name)
|
|
334
|
+
except md.PackageNotFoundError:
|
|
335
|
+
continue
|
|
336
|
+
found[key(name)] = dist.version
|
|
337
|
+
for line in dist.requires or []:
|
|
338
|
+
req = Requirement(line)
|
|
339
|
+
if req.marker is None or req.marker.evaluate({"extra": ""}):
|
|
340
|
+
wanted[key(req.name)] = wanted.get(key(req.name), SpecifierSet()) & req.specifier
|
|
341
|
+
todo.append(req.name)
|
|
342
|
+
pins, skipped = [], []
|
|
343
|
+
for k, v in sorted(found.items()):
|
|
344
|
+
# a local build ("+...") isn't on PyPI
|
|
345
|
+
fits = "+" not in v and wanted.get(k, SpecifierSet()).contains(v, prereleases=True)
|
|
346
|
+
(pins if fits else skipped).append(f"{k}=={v}")
|
|
347
|
+
return pins, skipped
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def storage_requirements(default_provider: Optional[str] = None) -> List[str]:
|
|
351
|
+
"""pip requirements for the storage plugins installed here, pinned to these versions."""
|
|
352
|
+
packages = set()
|
|
353
|
+
try:
|
|
354
|
+
from snakemake_interface_storage_plugins.registry import StoragePluginRegistry
|
|
355
|
+
|
|
356
|
+
reg = StoragePluginRegistry()
|
|
357
|
+
packages = {reg.get_plugin_package_name(n) for n in reg.get_registered_plugins()}
|
|
358
|
+
except Exception: # registry API drift: fall back to the default provider
|
|
359
|
+
if default_provider:
|
|
360
|
+
packages = {f"snakemake-storage-plugin-{default_provider}"}
|
|
361
|
+
return [pinned(p) for p in sorted(packages)]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def snakemake_prelude(s, mode: str, default_storage_provider: Optional[str] = None,
|
|
365
|
+
resolved_at: Optional[str] = None) -> str:
|
|
366
|
+
"""The prelude that gives a job Snakemake, for settings `s` (settings.ExecutorSettings):
|
|
367
|
+
from the host env (mode "host") or installed at start, the same version as here, with the
|
|
368
|
+
other packages as they were at resolved_at."""
|
|
369
|
+
if mode == "host":
|
|
370
|
+
return build_host_prelude(s.host_env_path, s.host_env_wait_seconds)
|
|
371
|
+
import sys
|
|
372
|
+
|
|
373
|
+
version = md.version("snakemake")
|
|
374
|
+
reqs = [f"snakemake=={version}"] + storage_requirements(default_storage_provider) + list(s.extra_packages or [])
|
|
375
|
+
pyver = str(s.python_version or f"{sys.version_info[0]}.{sys.version_info[1]}")
|
|
376
|
+
return build_prelude(mode, version, reqs, pyver, s.install_prereqs, resolved_at)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""smkflex run: the workflow folder packed for the head job (with --add files, or a --git clone),
|
|
2
|
+
the commit it records, and the head job's command."""
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tarfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from . import headjob
|
|
14
|
+
from .bootstrap import conda_snippet, snakemake_prelude
|
|
15
|
+
from .profiles import Fail
|
|
16
|
+
from .settings import from_profile
|
|
17
|
+
from .sources import SKIP_DIRS, workflow_files
|
|
18
|
+
|
|
19
|
+
# Runs in the head job before anything else: fetch and unpack the bundle. Kept small, since
|
|
20
|
+
# everything sent with a job must fit in 8192 characters.
|
|
21
|
+
_FETCH = (
|
|
22
|
+
"import io,sys,tarfile\n"
|
|
23
|
+
"u,d=sys.argv[1],sys.argv[2]\n"
|
|
24
|
+
"if u.startswith('s3://'):\n"
|
|
25
|
+
" import boto3;b,_,k=u[5:].partition('/')\n"
|
|
26
|
+
" data=boto3.client('s3').get_object(Bucket=b,Key=k)['Body'].read()\n"
|
|
27
|
+
"else:\n"
|
|
28
|
+
" data=open(u,'rb').read()\n"
|
|
29
|
+
"t=tarfile.open(fileobj=io.BytesIO(data))\n"
|
|
30
|
+
"t.extractall(d,**({'filter':'data'} if hasattr(tarfile,'data_filter') else {}))\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def added_files(paths, base="."):
|
|
34
|
+
"""{path in the bundle: local file} for --add: files, or every file in a folder, given
|
|
35
|
+
relative to `base` (the current folder) and sent to the same place."""
|
|
36
|
+
out = {}
|
|
37
|
+
for p in paths:
|
|
38
|
+
norm = os.path.normpath(p)
|
|
39
|
+
if os.path.isabs(p) or norm == ".." or norm.startswith(".." + os.sep):
|
|
40
|
+
raise Fail(f"--add {p}: give a path inside this folder (it's sent to the same place)")
|
|
41
|
+
full = os.path.join(base, norm)
|
|
42
|
+
if os.path.isfile(full):
|
|
43
|
+
out[norm] = full
|
|
44
|
+
elif os.path.isdir(full):
|
|
45
|
+
for d, dirs, names in os.walk(full):
|
|
46
|
+
dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
|
|
47
|
+
for n in names:
|
|
48
|
+
f = os.path.join(d, n)
|
|
49
|
+
out[os.path.relpath(f, base)] = f
|
|
50
|
+
else:
|
|
51
|
+
raise Fail(f"--add {p}: no such file or folder here")
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
def make_bundle(root, profile_dir, max_mb, extra=None):
|
|
55
|
+
"""The workflow folder `root` as a tar.gz, with `extra` ({path in the bundle: local file})
|
|
56
|
+
laid over it, plus the profile, this plugin and the head job's script."""
|
|
57
|
+
buf = io.BytesIO()
|
|
58
|
+
total = 0
|
|
59
|
+
sources = {f: os.path.join(root, f) for f in workflow_files(root)}
|
|
60
|
+
sources.update(extra or {})
|
|
61
|
+
files = sorted(sources)
|
|
62
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as t:
|
|
63
|
+
for f in files:
|
|
64
|
+
total += os.path.getsize(sources[f])
|
|
65
|
+
if total > max_mb * 1024 * 1024:
|
|
66
|
+
raise Fail(f"the workflow folder has more than {max_mb} MB of files (at {f}); data "
|
|
67
|
+
"belongs in storage. Add big files to .gitignore, or raise --max-bundle-mb.")
|
|
68
|
+
t.add(sources[f], arcname=f)
|
|
69
|
+
# what `git ls-files` would say, for the head job's stand-in git
|
|
70
|
+
listing = "".join(f + "\n" for f in files).encode()
|
|
71
|
+
info = tarfile.TarInfo(".smkflex/files.txt")
|
|
72
|
+
info.size = len(listing)
|
|
73
|
+
t.addfile(info, io.BytesIO(listing))
|
|
74
|
+
for d, dirs, names in os.walk(profile_dir):
|
|
75
|
+
dirs[:] = [x for x in dirs if x != "__pycache__"]
|
|
76
|
+
for n in names:
|
|
77
|
+
p = os.path.join(d, n)
|
|
78
|
+
t.add(p, arcname=os.path.join(".smkflex", "profile", os.path.relpath(p, profile_dir)))
|
|
79
|
+
# this plugin as installed here, so the head job runs the same version
|
|
80
|
+
pkg = os.path.dirname(os.path.abspath(__file__))
|
|
81
|
+
for n in sorted(os.listdir(pkg)):
|
|
82
|
+
if n.endswith(".py"):
|
|
83
|
+
t.add(os.path.join(pkg, n), arcname=f".smkflex/site/{os.path.basename(pkg)}/{n}")
|
|
84
|
+
t.add(headjob.__file__, arcname=".smkflex/head.py")
|
|
85
|
+
return buf.getvalue()
|
|
86
|
+
|
|
87
|
+
def head_command(profile, platform, bundle_url, state_url, snakemake_args):
|
|
88
|
+
s = from_profile(profile)
|
|
89
|
+
mode = "auto" if s.bootstrap == "host" and platform == "fargate" else s.bootstrap
|
|
90
|
+
resolved_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
91
|
+
prelude = snakemake_prelude(s, mode, profile.get("default-storage-provider"), resolved_at)
|
|
92
|
+
if s.conda_path and platform == "ec2":
|
|
93
|
+
prelude += "\n" + conda_snippet(s.conda_path)
|
|
94
|
+
args = " ".join(shlex.quote(a) for a in snakemake_args)
|
|
95
|
+
return (
|
|
96
|
+
f"{prelude}\n"
|
|
97
|
+
'SMKFLEX_DIR="${TMPDIR:-/tmp}/smkflex-workflow"; rm -rf "$SMKFLEX_DIR"; mkdir -p "$SMKFLEX_DIR"\n'
|
|
98
|
+
'SMKFLEX_PY="${SMK_PY:-python}"\n'
|
|
99
|
+
f'"$SMKFLEX_PY" -c {shlex.quote(_FETCH)} {shlex.quote(bundle_url)} "$SMKFLEX_DIR"\n'
|
|
100
|
+
'cd "$SMKFLEX_DIR"\n'
|
|
101
|
+
f'exec "$SMKFLEX_PY" .smkflex/head.py {shlex.quote(state_url)} {args}\n'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _git(*args, cwd=None):
|
|
105
|
+
try:
|
|
106
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
raise Fail("git isn't installed here; it's needed for --git") from None
|
|
109
|
+
except subprocess.CalledProcessError as e:
|
|
110
|
+
raise Fail(f"git {' '.join(args[:2])} failed: {(e.stderr or e.stdout).strip()}") from None
|
|
111
|
+
|
|
112
|
+
def clone(url, rev, dest):
|
|
113
|
+
"""Shallow clone of `rev` (branch, tag; default: the default branch), or a full clone
|
|
114
|
+
when it's a commit. Returns the commit."""
|
|
115
|
+
opts = ["--quiet", "--recurse-submodules", "--shallow-submodules"]
|
|
116
|
+
try:
|
|
117
|
+
_git("clone", *opts, "--depth", "1", *(["--branch", rev] if rev else []), url, dest)
|
|
118
|
+
except Fail:
|
|
119
|
+
if not rev:
|
|
120
|
+
raise
|
|
121
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
122
|
+
_git("clone", "--quiet", "--recurse-submodules", url, dest)
|
|
123
|
+
try:
|
|
124
|
+
_git("checkout", "--quiet", rev, cwd=dest)
|
|
125
|
+
except Fail:
|
|
126
|
+
raise Fail(f"{url} has no branch, tag or commit '{rev}'") from None
|
|
127
|
+
_git("submodule", "update", "--quiet", "--init", "--recursive", cwd=dest)
|
|
128
|
+
return _git("rev-parse", "HEAD", cwd=dest)
|
|
129
|
+
|
|
130
|
+
def local_commit(root):
|
|
131
|
+
"""The commit of a local git checkout, with -modified if it has uncommitted changes,
|
|
132
|
+
including new files that aren't git-ignored (the bundle sends those too)."""
|
|
133
|
+
try:
|
|
134
|
+
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True, check=True).stdout.strip()
|
|
135
|
+
dirty = subprocess.run(["git", "status", "--porcelain", "."], cwd=root,
|
|
136
|
+
capture_output=True, text=True, check=True).stdout.strip()
|
|
137
|
+
except (OSError, subprocess.CalledProcessError):
|
|
138
|
+
return None
|
|
139
|
+
return sha[:12] + ("-modified" if dirty else "")
|
|
140
|
+
|
|
141
|
+
def _tag(value):
|
|
142
|
+
return re.sub(r"[^A-Za-z0-9 _.:/=+\-@]", "_", str(value))[:256]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""smkflex check: the setup, checked from this machine, one line per check."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
from botocore.config import Config as BotoConfig
|
|
7
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
8
|
+
|
|
9
|
+
from . import headjob
|
|
10
|
+
from .preflight import ami_snakemake_version, check_queue, check_role, queue_uses_spot, version_note
|
|
11
|
+
from .profiles import Fail, _load_profile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_check(a):
|
|
15
|
+
"""Check the setup from this machine, one line per check, with what to do about failures."""
|
|
16
|
+
results = []
|
|
17
|
+
|
|
18
|
+
def report(level, what, fix=""):
|
|
19
|
+
results.append(level)
|
|
20
|
+
mark = {"ok": " ok ", "warn": " warn ", "fail": " FAIL "}[level]
|
|
21
|
+
print(f"[{mark}] {what}" + (f"\n -> {fix}" if fix else ""))
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
profile_dir, profile = _load_profile(a.profile)
|
|
25
|
+
except Fail as e:
|
|
26
|
+
report("fail", f"profile: {e}", "run this in your workflow folder, or pass --profile DIR")
|
|
27
|
+
return 1
|
|
28
|
+
report("ok", f"profile {profile_dir}")
|
|
29
|
+
if profile.get("executor") != "aws-batch-flex":
|
|
30
|
+
report("warn", f"the profile's executor is {profile.get('executor')!r}, not aws-batch-flex",
|
|
31
|
+
"add `executor: aws-batch-flex`, or pass --executor aws-batch-flex to snakemake")
|
|
32
|
+
|
|
33
|
+
import importlib.metadata as md
|
|
34
|
+
try:
|
|
35
|
+
local = md.version("snakemake")
|
|
36
|
+
report("ok", f"Snakemake {local} installed here")
|
|
37
|
+
except md.PackageNotFoundError:
|
|
38
|
+
local = None
|
|
39
|
+
report("fail", "Snakemake isn't installed here", "pip install snakemake")
|
|
40
|
+
provider = profile.get("default-storage-provider")
|
|
41
|
+
prefix = str(profile.get("default-storage-prefix") or "").rstrip("/")
|
|
42
|
+
if not provider or provider == "local" or not prefix:
|
|
43
|
+
report("fail", "no remote storage in the profile",
|
|
44
|
+
"set default-storage-provider: s3 and default-storage-prefix: s3://BUCKET/PREFIX")
|
|
45
|
+
else:
|
|
46
|
+
try:
|
|
47
|
+
md.version(f"snakemake-storage-plugin-{provider}")
|
|
48
|
+
report("ok", f"storage plugin {provider} installed here")
|
|
49
|
+
except md.PackageNotFoundError:
|
|
50
|
+
report("fail", f"snakemake-storage-plugin-{provider} isn't installed here",
|
|
51
|
+
f"pip install snakemake-storage-plugin-{provider}")
|
|
52
|
+
|
|
53
|
+
region = profile.get("aws-batch-flex-region")
|
|
54
|
+
cfg = BotoConfig(region_name=region, retries={"max_attempts": 3})
|
|
55
|
+
try:
|
|
56
|
+
who = boto3.client("sts", config=cfg).get_caller_identity()["Arn"]
|
|
57
|
+
report("ok", f"AWS login: {who}")
|
|
58
|
+
except (ClientError, BotoCoreError) as e:
|
|
59
|
+
report("fail", f"AWS login: {e}", "run `aws configure` (or `aws sso login`), or set AWS_PROFILE")
|
|
60
|
+
return 1
|
|
61
|
+
batch = boto3.client("batch", config=cfg)
|
|
62
|
+
region = batch.meta.region_name if hasattr(batch, "meta") else region
|
|
63
|
+
if not region:
|
|
64
|
+
report("fail", "no AWS region", "set aws-batch-flex-region in the profile")
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
queue = profile.get("aws-batch-flex-queue")
|
|
68
|
+
platform, queue_ok = None, False
|
|
69
|
+
if not queue:
|
|
70
|
+
report("fail", "no queue in the profile", "set aws-batch-flex-queue (Terraform prints it as queue_name)")
|
|
71
|
+
else:
|
|
72
|
+
errors, warnings, detected = check_queue(batch, queue, profile.get("aws-batch-flex-platform"))
|
|
73
|
+
platform = profile.get("aws-batch-flex-platform") or detected or "ec2"
|
|
74
|
+
for e in errors:
|
|
75
|
+
report("fail", f"queue: {e}")
|
|
76
|
+
for w in warnings:
|
|
77
|
+
report("warn", f"queue: {w}")
|
|
78
|
+
queue_ok = not errors
|
|
79
|
+
if not errors:
|
|
80
|
+
spot = " (Spot)" if queue_uses_spot(batch, queue) else ""
|
|
81
|
+
report("ok", f"queue {queue} in {region}: enabled, {platform.upper()}{spot}")
|
|
82
|
+
|
|
83
|
+
iam = boto3.client("iam", config=cfg)
|
|
84
|
+
for key, label in (("aws-batch-flex-job-role-arn", "job role"), ("aws-batch-flex-head-role-arn", "head role"),
|
|
85
|
+
("aws-batch-flex-execution-role-arn", "execution role")):
|
|
86
|
+
arn = profile.get(key)
|
|
87
|
+
if arn:
|
|
88
|
+
errors, _w = check_role(iam, arn, label.capitalize())
|
|
89
|
+
if errors:
|
|
90
|
+
report("fail", errors[0], f"check {key} in the profile (Terraform prints the right one)")
|
|
91
|
+
else:
|
|
92
|
+
report("ok", f"{label} {arn.rsplit('/', 1)[-1]}")
|
|
93
|
+
if not profile.get("aws-batch-flex-job-role-arn") and platform == "fargate":
|
|
94
|
+
report("fail", "Fargate jobs need a job role (there's no instance role to reach storage with)",
|
|
95
|
+
"set aws-batch-flex-job-role-arn")
|
|
96
|
+
if platform == "fargate" and not profile.get("aws-batch-flex-execution-role-arn"):
|
|
97
|
+
report("fail", "Fargate needs an execution role", "set aws-batch-flex-execution-role-arn")
|
|
98
|
+
if not profile.get("aws-batch-flex-head-role-arn"):
|
|
99
|
+
report("warn", "no aws-batch-flex-head-role-arn: fine for snakemake, but smkflex run needs it",
|
|
100
|
+
"add the head_role_arn that Terraform prints")
|
|
101
|
+
|
|
102
|
+
if prefix and provider and provider != "local":
|
|
103
|
+
probe = f"{prefix}/.smkflex/check-{os.getpid()}"
|
|
104
|
+
try:
|
|
105
|
+
headjob.put(probe, b"smkflex check")
|
|
106
|
+
ok = headjob.get(probe) == b"smkflex check"
|
|
107
|
+
if probe.startswith("s3://"):
|
|
108
|
+
s3, bucket, key = headjob._s3(probe)
|
|
109
|
+
s3.delete_object(Bucket=bucket, Key=key)
|
|
110
|
+
else:
|
|
111
|
+
os.remove(probe)
|
|
112
|
+
report("ok" if ok else "fail", f"storage {prefix}: write, read, delete")
|
|
113
|
+
except (ClientError, BotoCoreError, OSError) as e:
|
|
114
|
+
report("fail", f"storage {prefix}: {e}",
|
|
115
|
+
"check the bucket name and that your AWS login may read and write under the prefix")
|
|
116
|
+
|
|
117
|
+
if queue_ok and platform == "ec2" and profile.get("aws-batch-flex-bootstrap") == "host" and local:
|
|
118
|
+
ami = ami_snakemake_version(batch, boto3.client("ec2", config=cfg), queue)
|
|
119
|
+
level, note = version_note(ami, local)
|
|
120
|
+
if not ami:
|
|
121
|
+
report("warn", "couldn't find the AMI's Snakemake version (an older AMI, or no ec2:Describe* permission)")
|
|
122
|
+
elif level == "warning":
|
|
123
|
+
report("warn", note, f"pip install snakemake=={ami}, or bake a new AMI")
|
|
124
|
+
else:
|
|
125
|
+
report("ok", f"the AMI has Snakemake {ami}" + (f" (here: {local}; known to work together)" if level else ""))
|
|
126
|
+
|
|
127
|
+
fails, warns = results.count("fail"), results.count("warn")
|
|
128
|
+
print(f"\n{fails} problem(s), {warns} warning(s)." if fails or warns else "\nAll good.")
|
|
129
|
+
return 1 if fails else 0
|