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,51 @@
|
|
|
1
|
+
"""Jobs left running by an earlier run of the same workflow (Snakemake killed, laptop lost its
|
|
2
|
+
connection, ...). Snakemake would submit them again, so they'd run twice."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
ACTIVE = ("SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def workflow_id(workdir: str, snakefile: str, storage_prefix: str) -> str:
|
|
11
|
+
key = "|".join([os.path.abspath(workdir), os.path.abspath(snakefile), storage_prefix or ""])
|
|
12
|
+
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_jobs(batch, **kw) -> list:
|
|
16
|
+
"""The job summaries list_jobs(**kw) gives, from every page."""
|
|
17
|
+
out, token = [], None
|
|
18
|
+
while True:
|
|
19
|
+
resp = batch.list_jobs(**kw, **({"nextToken": token} if token else {}))
|
|
20
|
+
out += resp.get("jobSummaryList", [])
|
|
21
|
+
token = resp.get("nextToken")
|
|
22
|
+
if not token:
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def describe_jobs(batch, ids) -> list:
|
|
27
|
+
"""The jobs described (with their tags and job definition), 100 at a time as Batch takes them."""
|
|
28
|
+
ids, jobs = list(ids), []
|
|
29
|
+
for i in range(0, len(ids), 100):
|
|
30
|
+
jobs += batch.describe_jobs(jobs=ids[i : i + 100])["jobs"]
|
|
31
|
+
return jobs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def active_jobs(batch, queue: str) -> list:
|
|
35
|
+
"""Every active job in `queue`, described. The summaries have no job definition or tags
|
|
36
|
+
(despite the API model), so every one is described."""
|
|
37
|
+
return describe_jobs(batch, [j["jobId"] for status in ACTIVE
|
|
38
|
+
for j in list_jobs(batch, jobQueue=queue, jobStatus=status, maxResults=1000)])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_orphans(batch, queue: str, jobdef_prefix: str, wf_id: str, run_id: str) -> list:
|
|
42
|
+
"""[(job id, job name, status)] of active jobs in `queue` from this workflow but another run."""
|
|
43
|
+
found = []
|
|
44
|
+
for d in active_jobs(batch, queue):
|
|
45
|
+
tags = d.get("tags") or {}
|
|
46
|
+
jobdef = d.get("jobDefinition", "").rsplit("/", 1)[-1].split(":")[0]
|
|
47
|
+
if (jobdef.startswith(jobdef_prefix + "-")
|
|
48
|
+
and tags.get("snakemake-workflow") == wf_id
|
|
49
|
+
and tags.get("snakemake-run-id") != run_id):
|
|
50
|
+
found.append((d["jobId"], d.get("jobName", ""), d.get("status", "")))
|
|
51
|
+
return found
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Workarounds for bugs in Snakemake and the S3 storage plugin, each limited to what this
|
|
2
|
+
plugin needs. They can go once the bugs are fixed upstream.
|
|
3
|
+
|
|
4
|
+
Each one first checks that the code it patches is still what it was written for (the
|
|
5
|
+
parameters it takes); if a new version changed it, the workaround isn't applied and the
|
|
6
|
+
executor says so at start (NOT_APPLIED), instead of failing quietly or calling it wrongly.
|
|
7
|
+
tests/test_units.py fails as soon as one doesn't apply, and CI runs the newest Snakemake weekly."""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
14
|
+
|
|
15
|
+
from .settings import MAX_SOURCES_MB, PLUGIN_NAME
|
|
16
|
+
from .sources import too_big, workflow_files
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SOURCES = ("sending the workflow folder to the jobs (Snakemake's DAG.get_sources): jobs get only "
|
|
20
|
+
"what git knows about")
|
|
21
|
+
MISSING = "the hint for inputs that are only on this machine (Snakemake's MissingInputException)"
|
|
22
|
+
S3_DIRS = ("directory() outputs on S3 (the S3 storage plugin's StorageObject.exists): they can be "
|
|
23
|
+
"reported missing after the job made them")
|
|
24
|
+
# Workarounds this Snakemake (or S3 plugin) doesn't let us apply; the executor warns about them
|
|
25
|
+
NOT_APPLIED: list = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fits(func, params, what) -> bool:
|
|
29
|
+
"""Does func take these parameters first, as the code a workaround was written for did?
|
|
30
|
+
If not, `what` goes into NOT_APPLIED."""
|
|
31
|
+
try:
|
|
32
|
+
ok = list(inspect.signature(func).parameters)[: len(params)] == list(params)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
ok = False
|
|
35
|
+
if not ok and what not in NOT_APPLIED:
|
|
36
|
+
NOT_APPLIED.append(what)
|
|
37
|
+
return ok
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def skip_remote_module_scripts():
|
|
41
|
+
"""Two fixes to what Snakemake packs for the jobs. (1) Work around Snakemake (9.27): packing the workflow's sources for the jobs crashes with
|
|
42
|
+
"TypeError: ... not GithubFile" when a rule comes from a module fetched from GitHub (as
|
|
43
|
+
snakedeploy sets up), because it treats the rule's script as a local file. Those scripts
|
|
44
|
+
don't need packing: each job fetches them from GitHub itself. So they're hidden while the
|
|
45
|
+
sources are collected. (2) The whole workflow folder is packed, see below."""
|
|
46
|
+
try:
|
|
47
|
+
from snakemake.dag import DAG
|
|
48
|
+
from snakemake.sourcecache import LocalSourceFile, SourceFile
|
|
49
|
+
except ImportError:
|
|
50
|
+
NOT_APPLIED.append(SOURCES)
|
|
51
|
+
return
|
|
52
|
+
orig = getattr(DAG, "get_sources", None)
|
|
53
|
+
if getattr(orig, "_skips_remote", False) or not fits(orig, ["self"], SOURCES):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
def get_sources(self):
|
|
57
|
+
# Snakemake imports every installed executor plugin, so this runs for all of them:
|
|
58
|
+
# leave other executors' runs as they are
|
|
59
|
+
plugin = getattr(getattr(self, "workflow", None), "executor_plugin", None)
|
|
60
|
+
if getattr(plugin, "name", None) != PLUGIN_NAME:
|
|
61
|
+
return get_sources.__wrapped__(self)
|
|
62
|
+
# Jobs only get files below the folder Snakemake works in; Snakemake leaves the rest out
|
|
63
|
+
# with a warning, and every job then fails for want of the Snakefile
|
|
64
|
+
main = str(getattr(self.workflow, "main_snakefile", "") or "")
|
|
65
|
+
if main and os.path.exists(main) and os.path.relpath(main).startswith(".."):
|
|
66
|
+
raise WorkflowError(
|
|
67
|
+
f"The Snakefile ({os.path.relpath(main)}) is outside the folder Snakemake works in "
|
|
68
|
+
f"({os.getcwd()}), set by a workdir: directive or --directory, so the jobs couldn't get it. "
|
|
69
|
+
"Remove workdir: and start Snakemake in the workflow's folder: outputs go under the "
|
|
70
|
+
"storage prefix anyway (a project of its own: smkflex init in its own folder)."
|
|
71
|
+
)
|
|
72
|
+
hidden = []
|
|
73
|
+
for rule in getattr(self.workflow, "rules", []):
|
|
74
|
+
base = getattr(rule, "basedir", None)
|
|
75
|
+
if isinstance(base, SourceFile) and not isinstance(base, LocalSourceFile):
|
|
76
|
+
for attr in ("script", "notebook"):
|
|
77
|
+
value = getattr(rule, attr, None)
|
|
78
|
+
if value:
|
|
79
|
+
hidden.append((rule, attr, value))
|
|
80
|
+
setattr(rule, attr, None)
|
|
81
|
+
try:
|
|
82
|
+
files = get_sources.__wrapped__(self)
|
|
83
|
+
finally:
|
|
84
|
+
for rule, attr, value in hidden:
|
|
85
|
+
setattr(rule, attr, value)
|
|
86
|
+
# Snakemake sends jobs only what git knows about, besides the Snakefile and the files
|
|
87
|
+
# named in script:/conda:. Scripts a shell: command runs, modules the Snakefile imports,
|
|
88
|
+
# sample sheets and the like are missing in a folder that isn't a git checkout (such as
|
|
89
|
+
# one made by snakedeploy), or when never `git add`ed. Send the folder as smkflex run
|
|
90
|
+
# does: what git would track, or everything .gitignore doesn't exclude, and config/.
|
|
91
|
+
if isinstance(files, set):
|
|
92
|
+
files.update(workflow_files("."))
|
|
93
|
+
settings = getattr(self.workflow, "executor_settings", None)
|
|
94
|
+
limit = getattr(settings, "max_sources_mb", None) or MAX_SOURCES_MB
|
|
95
|
+
problem = too_big(files, limit)
|
|
96
|
+
if problem:
|
|
97
|
+
raise WorkflowError(problem)
|
|
98
|
+
return files
|
|
99
|
+
|
|
100
|
+
get_sources._skips_remote = True
|
|
101
|
+
get_sources.__wrapped__ = orig
|
|
102
|
+
DAG.get_sources = get_sources
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Snakemake packs the sources before it creates the executor, so this can't wait for it.
|
|
106
|
+
skip_remote_module_scripts()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def explain_local_only_inputs():
|
|
110
|
+
"""Snakemake only says "Missing input files ... (in storage)" for an input that is on this
|
|
111
|
+
machine but was never uploaded (data/, or an absolute path): jobs read their inputs from
|
|
112
|
+
storage. Say so, with the smkflex push command that uploads it."""
|
|
113
|
+
try:
|
|
114
|
+
from snakemake.exceptions import MissingInputException
|
|
115
|
+
except ImportError:
|
|
116
|
+
NOT_APPLIED.append(MISSING)
|
|
117
|
+
return
|
|
118
|
+
orig = MissingInputException.__init__
|
|
119
|
+
if getattr(orig, "_smkflex", False) or not fits(orig, ["self", "job", "files"], MISSING):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
def __init__(self, job, files, *args, **kwargs):
|
|
123
|
+
orig(self, job, files, *args, **kwargs)
|
|
124
|
+
try:
|
|
125
|
+
hint = local_only_hint(job.rule.workflow, files)
|
|
126
|
+
except Exception: # only an extra line: never let it break Snakemake's own error
|
|
127
|
+
hint = None
|
|
128
|
+
if hint and self.args:
|
|
129
|
+
self.args = (self.args[0] + hint, *self.args[1:])
|
|
130
|
+
|
|
131
|
+
__init__._smkflex = True
|
|
132
|
+
MissingInputException.__init__ = __init__
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def local_only_hint(workflow, files):
|
|
136
|
+
if getattr(getattr(workflow, "executor_plugin", None), "name", None) != PLUGIN_NAME:
|
|
137
|
+
return None
|
|
138
|
+
prefix = str(getattr(workflow.storage_settings, "default_storage_prefix", "") or "").rstrip("/")
|
|
139
|
+
here, stored = [], False
|
|
140
|
+
for f in files:
|
|
141
|
+
query = getattr(getattr(f, "storage_object", None), "query", None) if getattr(f, "is_storage", False) else None
|
|
142
|
+
if not prefix or not query or not str(query).startswith(prefix + "/"):
|
|
143
|
+
continue
|
|
144
|
+
stored = True
|
|
145
|
+
rel = str(query)[len(prefix) + 1:]
|
|
146
|
+
# a relative path goes to prefix/path, an absolute one to prefix/path-without-its-/
|
|
147
|
+
for local in (rel, "/" + rel):
|
|
148
|
+
if os.path.exists(local):
|
|
149
|
+
here.append(local)
|
|
150
|
+
break
|
|
151
|
+
if not here:
|
|
152
|
+
return (f"\n Jobs read their inputs from {prefix}/, not from this machine: upload them there, "
|
|
153
|
+
"at the paths your rules and sample sheets use, with: smkflex push <files or folders>"
|
|
154
|
+
if stored else None)
|
|
155
|
+
push = sorted({os.path.dirname(p) if os.path.isabs(p) else p.split("/")[0] for p in here})
|
|
156
|
+
files_text = ", ".join(here[:5]) + (f" and {len(here) - 5} more" if len(here) > 5 else "")
|
|
157
|
+
return (f"\n On this machine but not in storage, where the jobs read their inputs: {files_text}. "
|
|
158
|
+
f"Upload with: smkflex push {' '.join(push)}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
explain_local_only_inputs()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def recheck_s3_directories():
|
|
165
|
+
"""Work around snakemake-storage-plugin-s3 (0.3.6): a storage object remembers is_dir() =
|
|
166
|
+
False from before the job ran, so a directory() output the job then uploads is reported
|
|
167
|
+
"missing in storage". exists() looks a remembered False up again (it only asks is_dir()
|
|
168
|
+
when there's no plain object of that name). Only there: mtime() and size() of every file
|
|
169
|
+
ask is_dir() too, and keep the remembered answer, as without the patch."""
|
|
170
|
+
try:
|
|
171
|
+
from snakemake_storage_plugin_s3 import StorageObject
|
|
172
|
+
except ImportError: # not installed: nothing to work around
|
|
173
|
+
return
|
|
174
|
+
orig = getattr(StorageObject, "exists", None)
|
|
175
|
+
if getattr(orig, "_rechecks", False) or not fits(orig, ["self"], S3_DIRS):
|
|
176
|
+
return
|
|
177
|
+
# what it works around: the remembered is_dir() answer, in _is_dir
|
|
178
|
+
if "_is_dir" not in inspect.getsource(StorageObject):
|
|
179
|
+
NOT_APPLIED.append(S3_DIRS)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
def exists(self):
|
|
183
|
+
if getattr(self, "_is_dir", None) is False:
|
|
184
|
+
self._is_dir = None
|
|
185
|
+
return orig(self)
|
|
186
|
+
|
|
187
|
+
exists._rechecks = True
|
|
188
|
+
StorageObject.exists = exists
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def keep_unneeded_temp(cmd: str) -> str:
|
|
192
|
+
"""Drop --unneeded-temp-files from a job's command. Snakemake (9.x) tells the job to delete
|
|
193
|
+
temp() outputs that no other job needs, but then waits for every output in storage after
|
|
194
|
+
the job, and fails with "Missing files". Without the option the job uploads them, and
|
|
195
|
+
Snakemake removes them from storage itself as with any other temp file."""
|
|
196
|
+
return re.sub(r" --unneeded-temp-files(?: (?!-)(?:'[^']*'|\S+))+", "", cmd)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Startup checks on the queue, its compute environments and the job role.
|
|
2
|
+
|
|
3
|
+
A missing permission to look only gives a warning. Skip with --aws-batch-flex-skip-preflight."""
|
|
4
|
+
|
|
5
|
+
import weakref
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def access_denied(e: ClientError) -> bool:
|
|
12
|
+
code = e.response.get("Error", {}).get("Code", "")
|
|
13
|
+
return "AccessDenied" in code or "UnauthorizedOperation" in code or code == "AccessDeniedException"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# What describe_queue found, per Batch client (one region and login), for as long as it lives
|
|
17
|
+
_QUEUES: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def describe_queue(batch, queue: str):
|
|
21
|
+
"""(queue, its compute environments) as Batch describes them, looked up once per client:
|
|
22
|
+
the startup checks, Spot and priority all need them. (None, None) if there's no such
|
|
23
|
+
queue; the environments are None if they couldn't be described. Batch's errors for the
|
|
24
|
+
queue itself are raised."""
|
|
25
|
+
try:
|
|
26
|
+
cache = _QUEUES.setdefault(batch, {})
|
|
27
|
+
except TypeError: # a stand-in client that can't be weakly referenced (tests): no cache
|
|
28
|
+
cache = {}
|
|
29
|
+
if queue in cache:
|
|
30
|
+
return cache[queue]
|
|
31
|
+
found = batch.describe_job_queues(jobQueues=[queue])["jobQueues"]
|
|
32
|
+
if not found:
|
|
33
|
+
return None, None
|
|
34
|
+
q = found[0]
|
|
35
|
+
ces = [o["computeEnvironment"] for o in q.get("computeEnvironmentOrder", [])]
|
|
36
|
+
envs = []
|
|
37
|
+
if ces:
|
|
38
|
+
try:
|
|
39
|
+
envs = batch.describe_compute_environments(computeEnvironments=ces)["computeEnvironments"]
|
|
40
|
+
except (ClientError, BotoCoreError):
|
|
41
|
+
envs = None
|
|
42
|
+
cache[queue] = (q, envs)
|
|
43
|
+
return q, envs
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_queue(batch, queue: str, platform: Optional[str] = None):
|
|
47
|
+
"""Returns (errors, warnings, detected platform or None) for the job queue and the compute
|
|
48
|
+
environments behind it. A mismatch with `platform` is only an error when `platform` is given."""
|
|
49
|
+
errors, warnings, detected = [], [], None
|
|
50
|
+
try:
|
|
51
|
+
q, envs = describe_queue(batch, queue)
|
|
52
|
+
except ClientError as e:
|
|
53
|
+
if access_denied(e):
|
|
54
|
+
return [], [f"cannot verify queue '{queue}' (no batch:DescribeJobQueues permission); continuing"], None
|
|
55
|
+
return [f"AWS Batch refused the queue lookup: {e}"], [], None
|
|
56
|
+
except BotoCoreError as e:
|
|
57
|
+
return [], [f"cannot verify queue '{queue}': {e}; continuing"], None
|
|
58
|
+
if q is None:
|
|
59
|
+
hint = ""
|
|
60
|
+
try:
|
|
61
|
+
names = sorted(q["jobQueueName"] for q in batch.describe_job_queues()["jobQueues"])
|
|
62
|
+
hint = f" Queues in this region: {', '.join(names[:10]) or 'none'}."
|
|
63
|
+
except (ClientError, BotoCoreError):
|
|
64
|
+
pass
|
|
65
|
+
return [f"Job queue '{queue}' not found in this region (check the queue name and the region)." + hint], [], None
|
|
66
|
+
if q.get("state") != "ENABLED":
|
|
67
|
+
errors.append(f"Job queue '{queue}' is {q.get('state')}, not ENABLED.")
|
|
68
|
+
if q.get("status") != "VALID":
|
|
69
|
+
errors.append(f"Job queue '{queue}' has status {q.get('status')}: {q.get('statusReason', 'no reason given')}")
|
|
70
|
+
if not q.get("computeEnvironmentOrder"):
|
|
71
|
+
return errors + [f"Job queue '{queue}' has no compute environment attached."], warnings, None
|
|
72
|
+
if envs is None:
|
|
73
|
+
return errors, warnings + [f"cannot inspect the compute environments of '{queue}'; continuing"], None
|
|
74
|
+
for ce in envs:
|
|
75
|
+
name, kind = ce.get("computeEnvironmentName"), ce.get("computeResources", {}).get("type", "")
|
|
76
|
+
if ce.get("status") not in (None, "VALID"):
|
|
77
|
+
errors.append(f"Compute environment '{name}' has status {ce.get('status')}: {ce.get('statusReason', '')}")
|
|
78
|
+
if kind:
|
|
79
|
+
actual = "fargate" if kind.startswith("FARGATE") else "ec2"
|
|
80
|
+
detected = detected or actual
|
|
81
|
+
if platform and actual != platform:
|
|
82
|
+
errors.append(
|
|
83
|
+
f"Queue '{queue}' runs on {actual.upper()} (compute environment '{name}') but "
|
|
84
|
+
f"--aws-batch-flex-platform is '{platform}'. Set --aws-batch-flex-platform {actual} "
|
|
85
|
+
"(or leave it unset: the platform is then detected per queue)."
|
|
86
|
+
)
|
|
87
|
+
return errors, warnings, detected
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def queue_uses_spot(batch, queue: str) -> bool:
|
|
91
|
+
"""Best effort: is any compute environment behind the queue Spot (EC2 or Fargate)?"""
|
|
92
|
+
try:
|
|
93
|
+
_q, envs = describe_queue(batch, queue)
|
|
94
|
+
except (ClientError, BotoCoreError):
|
|
95
|
+
return False
|
|
96
|
+
return any(ce.get("computeResources", {}).get("type", "").endswith("SPOT") for ce in envs or [])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_role(iam, role_arn: str, label: str):
|
|
100
|
+
"""Best effort: does the role exist? Only a definite 'no such role' is an error."""
|
|
101
|
+
name = role_arn.rsplit("/", 1)[-1]
|
|
102
|
+
try:
|
|
103
|
+
iam.get_role(RoleName=name)
|
|
104
|
+
except ClientError as e:
|
|
105
|
+
if e.response.get("Error", {}).get("Code") == "NoSuchEntity":
|
|
106
|
+
return [f"{label} '{role_arn}' does not exist."], []
|
|
107
|
+
return [], [] # cannot look (no iam:GetRole): not our business
|
|
108
|
+
except BotoCoreError:
|
|
109
|
+
return [], []
|
|
110
|
+
return [], []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
SUBMIT_HINT = (
|
|
114
|
+
"The identity running snakemake needs batch:SubmitJob (on the queue and the snakemake-* job definitions), "
|
|
115
|
+
"batch:RegisterJobDefinition, batch:DescribeJobDefinitions, batch:DescribeJobs, batch:ListJobs, batch:TerminateJob, "
|
|
116
|
+
"batch:TagResource (jobs are tagged), logs:GetLogEvents, and iam:PassRole on the job role."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ami_snakemake_version(batch, ec2, queue: str) -> Optional[str]:
|
|
121
|
+
"""The SnakemakeVersion tag (set by bake-ami.sh) of the AMI the queue's instances start from.
|
|
122
|
+
None when it can't be found out: no tag (older AMIs), no permission, or several AMIs."""
|
|
123
|
+
try:
|
|
124
|
+
_q, envs = describe_queue(batch, queue)
|
|
125
|
+
images = set()
|
|
126
|
+
for ce in envs or []:
|
|
127
|
+
cr = ce.get("computeResources") or {}
|
|
128
|
+
# Batch uses an image override first, then imageId, then the launch template's image
|
|
129
|
+
found = {c.get("imageIdOverride") for c in cr.get("ec2Configuration") or []} - {None}
|
|
130
|
+
if not found and cr.get("imageId"):
|
|
131
|
+
found = {cr["imageId"]}
|
|
132
|
+
lt = cr.get("launchTemplate") or {}
|
|
133
|
+
if not found and (lt.get("launchTemplateId") or lt.get("launchTemplateName")):
|
|
134
|
+
which = ({"LaunchTemplateId": lt["launchTemplateId"]} if lt.get("launchTemplateId")
|
|
135
|
+
else {"LaunchTemplateName": lt["launchTemplateName"]})
|
|
136
|
+
for v in ec2.describe_launch_template_versions(
|
|
137
|
+
Versions=[lt.get("version") or "$Default"], **which)["LaunchTemplateVersions"]:
|
|
138
|
+
found |= {v.get("LaunchTemplateData", {}).get("ImageId")} - {None}
|
|
139
|
+
images |= found
|
|
140
|
+
if not images:
|
|
141
|
+
return None
|
|
142
|
+
versions = {t["Value"] for img in ec2.describe_images(ImageIds=sorted(images))["Images"]
|
|
143
|
+
for t in img.get("Tags", []) if t["Key"] == "SnakemakeVersion"}
|
|
144
|
+
return versions.pop() if len(versions) == 1 else None
|
|
145
|
+
except (ClientError, BotoCoreError, KeyError, IndexError):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _version_tuple(v: str):
|
|
150
|
+
try:
|
|
151
|
+
return tuple(int(x) for x in v.split(".")[:3])
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def version_note(ami: Optional[str], local: str):
|
|
157
|
+
"""(level, message) comparing the AMI's Snakemake with this machine's, or (None, None)."""
|
|
158
|
+
if not ami or ami == "unknown" or ami == local:
|
|
159
|
+
return None, None
|
|
160
|
+
a, l = _version_tuple(ami), _version_tuple(local)
|
|
161
|
+
msg = f"the AMI has Snakemake {ami}, this machine has {local}."
|
|
162
|
+
if a and l and a[0] == l[0] and min(a, l) >= (9, 5):
|
|
163
|
+
return "info", "Note: " + msg + " Versions from 9.5 on are known to work together."
|
|
164
|
+
return "warning", ("Snakemake version gap: " + msg + " Jobs may fail with 'unrecognized "
|
|
165
|
+
"arguments'; install the AMI's version here or bake a new AMI.")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""smkflex: the profile (as Snakemake finds it) and the AWS clients its commands use."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
import yaml
|
|
8
|
+
from botocore.config import Config as BotoConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Fail(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class _Log:
|
|
15
|
+
def info(self, msg):
|
|
16
|
+
print(msg, file=sys.stderr)
|
|
17
|
+
|
|
18
|
+
warning = info
|
|
19
|
+
|
|
20
|
+
def workflow_profile_file(create=False):
|
|
21
|
+
"""The project's workflow profile, as Snakemake finds it when started here: the first of
|
|
22
|
+
default/, profiles/default/ and (next to workflow/Snakefile) workflow/profiles/default/ that
|
|
23
|
+
exists. With create, profiles/default/config.yaml when there's none (Snakemake reads that
|
|
24
|
+
for a Snakefile at the top or in workflow/)."""
|
|
25
|
+
candidates = ["default", os.path.join("profiles", "default")]
|
|
26
|
+
if os.path.isfile(os.path.join("workflow", "Snakefile")):
|
|
27
|
+
candidates.append(os.path.join("workflow", "profiles", "default"))
|
|
28
|
+
for d in candidates:
|
|
29
|
+
if os.path.isdir(d):
|
|
30
|
+
for name in ("profile.yaml", "config.yaml"):
|
|
31
|
+
if os.path.isfile(os.path.join(d, name)):
|
|
32
|
+
return os.path.join(d, name)
|
|
33
|
+
return os.path.join(d, "config.yaml")
|
|
34
|
+
return os.path.join("profiles", "default", "config.yaml") if create else None
|
|
35
|
+
|
|
36
|
+
def _only_installed_profile():
|
|
37
|
+
"""The name of the one profile in ~/.config/snakemake that uses this plugin, if there is
|
|
38
|
+
exactly one (setup.sh installs it there)."""
|
|
39
|
+
base = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "snakemake")
|
|
40
|
+
names = []
|
|
41
|
+
try:
|
|
42
|
+
entries = sorted(os.listdir(base))
|
|
43
|
+
except OSError:
|
|
44
|
+
return None
|
|
45
|
+
for n in entries:
|
|
46
|
+
for f in ("profile.yaml", "config.yaml"):
|
|
47
|
+
cfg = os.path.join(base, n, f)
|
|
48
|
+
if os.path.isfile(cfg):
|
|
49
|
+
try:
|
|
50
|
+
with open(cfg) as fh:
|
|
51
|
+
if (yaml.safe_load(fh) or {}).get("executor") == "aws-batch-flex":
|
|
52
|
+
names.append(n)
|
|
53
|
+
except (OSError, yaml.YAMLError):
|
|
54
|
+
pass
|
|
55
|
+
break
|
|
56
|
+
if len(names) == 1:
|
|
57
|
+
print(f"(using profile {names[0]} from {base})", file=sys.stderr)
|
|
58
|
+
return names[0]
|
|
59
|
+
if names:
|
|
60
|
+
raise Fail(f"there are {len(names)} profiles for this plugin in {base} ({', '.join(names)}): "
|
|
61
|
+
f"say which with --profile {names[0]} (or export SNAKEMAKE_PROFILE={names[0]})")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def _load_profile(path):
|
|
65
|
+
"""(profile folder, settings) for --profile: a folder, or a name that Snakemake looks up in
|
|
66
|
+
~/.config/snakemake/ (as `snakemake --profile NAME` does). Default: ./profile, then
|
|
67
|
+
$SNAKEMAKE_PROFILE."""
|
|
68
|
+
if path is None:
|
|
69
|
+
path = "profile" if os.path.isdir("profile") else os.environ.get("SNAKEMAKE_PROFILE")
|
|
70
|
+
if not path:
|
|
71
|
+
path = _only_installed_profile()
|
|
72
|
+
if not path:
|
|
73
|
+
raise Fail("no profile: pass --profile (a folder with config.yaml, or a profile name)")
|
|
74
|
+
found = None
|
|
75
|
+
try:
|
|
76
|
+
from snakemake.cli import get_profile_dir
|
|
77
|
+
|
|
78
|
+
found = get_profile_dir(path)
|
|
79
|
+
except ImportError:
|
|
80
|
+
cfg = os.path.join(path, "config.yaml")
|
|
81
|
+
found = (path, cfg) if os.path.isfile(cfg) else None
|
|
82
|
+
if not found:
|
|
83
|
+
raise Fail(f"profile {path} not found (looked for a folder with config.yaml or profile.yaml "
|
|
84
|
+
"here and in ~/.config/snakemake/)")
|
|
85
|
+
folder, cfg = found
|
|
86
|
+
with open(cfg) as f:
|
|
87
|
+
settings = yaml.safe_load(f) or {}
|
|
88
|
+
# The project's own workflow profile, which Snakemake loads on top (e.g. its storage prefix)
|
|
89
|
+
wp = workflow_profile_file()
|
|
90
|
+
if wp and os.path.isfile(wp):
|
|
91
|
+
with open(wp) as f:
|
|
92
|
+
settings.update(yaml.safe_load(f) or {})
|
|
93
|
+
return str(folder), settings
|
|
94
|
+
|
|
95
|
+
def _clients(profile):
|
|
96
|
+
cfg = BotoConfig(region_name=profile.get("aws-batch-flex-region"),
|
|
97
|
+
retries={"max_attempts": 10, "mode": "adaptive"})
|
|
98
|
+
return boto3.client("batch", config=cfg), boto3.client("logs", config=cfg), cfg.region_name
|