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.
Files changed (30) hide show
  1. snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
  2. snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
  3. snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
  4. snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
  5. snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
  6. snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
  7. snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
  8. snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
  9. snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
  10. snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
  11. snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
  12. snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
  13. snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
  14. snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
  15. snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
  16. snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
  17. snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
  18. snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
  19. snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
  20. snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
  21. snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
  22. snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
  23. snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
  24. snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
  25. snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
  26. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
  27. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
  28. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
  29. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
  30. 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