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,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()