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,725 @@
|
|
|
1
|
+
"""The executor: submits each Snakemake job as an AWS Batch job in its rule's image, follows
|
|
2
|
+
it, and prints what the run used at the end."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import importlib.metadata as md
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import AsyncGenerator, List, Optional
|
|
14
|
+
|
|
15
|
+
import boto3
|
|
16
|
+
from botocore.config import Config as BotoConfig
|
|
17
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
18
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
19
|
+
from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
|
|
20
|
+
from snakemake_interface_executor_plugins.executors.remote import RemoteExecutor
|
|
21
|
+
from snakemake_interface_executor_plugins.jobs import JobExecutorInterface
|
|
22
|
+
|
|
23
|
+
from .bootstrap import conda_snippet, job_tools_source, launcher_snippet, snakemake_prelude
|
|
24
|
+
from .hints import known_problem_hints
|
|
25
|
+
from .imageinfo import Entrypoints
|
|
26
|
+
from .jobdefs import JobDefinitions, describe_existing, log_group_of, mutable_tag, normalize_image
|
|
27
|
+
from .naming import batch_job_name
|
|
28
|
+
from .orphans import ACTIVE, find_orphans, workflow_id
|
|
29
|
+
from .patches import NOT_APPLIED, keep_unneeded_temp, recheck_s3_directories
|
|
30
|
+
from .preflight import (
|
|
31
|
+
SUBMIT_HINT,
|
|
32
|
+
access_denied,
|
|
33
|
+
ami_snakemake_version,
|
|
34
|
+
check_queue,
|
|
35
|
+
check_role,
|
|
36
|
+
describe_queue,
|
|
37
|
+
version_note,
|
|
38
|
+
)
|
|
39
|
+
from .settings import (
|
|
40
|
+
ExecutorSettings,
|
|
41
|
+
host_mounts,
|
|
42
|
+
normalized,
|
|
43
|
+
registry_secrets,
|
|
44
|
+
validate,
|
|
45
|
+
volume_mounts,
|
|
46
|
+
)
|
|
47
|
+
from .sizing import fargate_size, job_requirements
|
|
48
|
+
from .tags import job_tags, parse_pairs, parse_resource_string
|
|
49
|
+
from .usage import Usage
|
|
50
|
+
|
|
51
|
+
FARGATE_MAX_DISK_GIB = 200
|
|
52
|
+
CONDA_CACHE_HOST_DIR = "/var/cache/smkflex-conda"
|
|
53
|
+
CONDA_CACHE_JOB_DIR = "/tmp/smkflex-conda" # condacache.ROOT
|
|
54
|
+
FARGATE_MAX_MEM_MIB = 122880
|
|
55
|
+
WRAPPER_HTTP_PREFIX = "https://github.com/snakemake/snakemake-wrappers/raw/"
|
|
56
|
+
ECS_OVERRIDES_MAX = 8192 # characters of a job's command and environment
|
|
57
|
+
CANCEL_THREADS = 16 # terminate_job calls at once when the run is cancelled
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _out_of_memory(d: dict) -> bool:
|
|
61
|
+
"""Batch/ECS says why a container stopped; out of memory is OutOfMemoryError."""
|
|
62
|
+
c = d.get("container") or {}
|
|
63
|
+
return "OutOfMemory" in (c.get("reason") or "") or "OutOfMemory" in (d.get("statusReason") or "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Submitted:
|
|
68
|
+
"""What the plugin keeps about a submitted job (in SubmittedJobInfo.aux["smkflex"])."""
|
|
69
|
+
image: str
|
|
70
|
+
queue: str
|
|
71
|
+
platform: str # ec2 | fargate
|
|
72
|
+
vcpu: float
|
|
73
|
+
mem: int # MiB the job got
|
|
74
|
+
asked: int # MiB its rule asked for (before a retry's extra)
|
|
75
|
+
warned: bool = False # about waiting long in RUNNABLE
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _sub(info: SubmittedJobInfo) -> Submitted:
|
|
79
|
+
return info.aux["smkflex"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _not_allowed(e) -> bool:
|
|
83
|
+
"""Was a Batch call refused for want of a permission? Batch reports a missing iam:PassRole
|
|
84
|
+
as a plain ClientException that says "not authorized"."""
|
|
85
|
+
return isinstance(e, ClientError) and (
|
|
86
|
+
access_denied(e) or "not authorized" in e.response.get("Error", {}).get("Message", ""))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Executor(RemoteExecutor):
|
|
90
|
+
def __post_init__(self):
|
|
91
|
+
s: ExecutorSettings = validate(normalized(self.workflow.executor_settings))
|
|
92
|
+
recheck_s3_directories()
|
|
93
|
+
for what in NOT_APPLIED:
|
|
94
|
+
self.logger.warning(
|
|
95
|
+
f"This Snakemake ({md.version('snakemake')}) changed code the plugin works around, so "
|
|
96
|
+
f"this workaround is off: {what}. Please report it to the plugin."
|
|
97
|
+
)
|
|
98
|
+
mounts = volume_mounts(s) + host_mounts(s)
|
|
99
|
+
cache = s.conda_cache.strip()
|
|
100
|
+
self.conda_cache = cache
|
|
101
|
+
if cache != "none":
|
|
102
|
+
# the instance's folder, shared by its jobs (EC2; Fargate jobs keep their own)
|
|
103
|
+
mounts.append((CONDA_CACHE_HOST_DIR, CONDA_CACHE_JOB_DIR, False))
|
|
104
|
+
self.mounts = mounts
|
|
105
|
+
registry_credentials = registry_secrets(s)
|
|
106
|
+
self.entrypoints = Entrypoints(registry_credentials, self.logger)
|
|
107
|
+
self.settings = s
|
|
108
|
+
# lock, default_storage_prefix, container_image and wrapper_prefix are Snakemake's but not
|
|
109
|
+
# declared by the executor interface, hence getattr with a default
|
|
110
|
+
lock = getattr(self.workflow.execution_settings, "lock", True)
|
|
111
|
+
self.orphan_mode = s.orphan_jobs or ("cancel" if lock else "warn")
|
|
112
|
+
self.workflow_id = workflow_id(
|
|
113
|
+
os.getcwd(),
|
|
114
|
+
self.workflow.main_snakefile or "",
|
|
115
|
+
getattr(self.workflow.storage_settings, "default_storage_prefix", "") or "",
|
|
116
|
+
)
|
|
117
|
+
self._orphans_checked: set = set()
|
|
118
|
+
tags = parse_pairs(s.tags or [], "--aws-batch-flex-tags")
|
|
119
|
+
self.run_id = uuid.uuid4().hex[:12]
|
|
120
|
+
# jobs that install Snakemake at start get the packages as they were now, all the same
|
|
121
|
+
self.resolved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
122
|
+
|
|
123
|
+
cfg = BotoConfig(
|
|
124
|
+
region_name=s.region, retries={"max_attempts": 10, "mode": "adaptive"}
|
|
125
|
+
)
|
|
126
|
+
self.batch = boto3.client("batch", config=cfg)
|
|
127
|
+
self.cwlogs = boto3.client("logs", config=cfg)
|
|
128
|
+
self.region = self.batch.meta.region_name or s.region
|
|
129
|
+
self.tags = tags
|
|
130
|
+
self.jobdefs = JobDefinitions(
|
|
131
|
+
self.batch,
|
|
132
|
+
self.logger,
|
|
133
|
+
prefix=s.job_definition_prefix,
|
|
134
|
+
platform=s.platform or "ec2",
|
|
135
|
+
job_role_arn=s.job_role_arn,
|
|
136
|
+
execution_role_arn=s.execution_role_arn,
|
|
137
|
+
log_group=s.log_group,
|
|
138
|
+
assign_public_ip=s.assign_public_ip,
|
|
139
|
+
ephemeral_storage_gib=s.ephemeral_storage_gib,
|
|
140
|
+
tags=tags,
|
|
141
|
+
mounts=mounts,
|
|
142
|
+
registry_credentials=registry_credentials,
|
|
143
|
+
)
|
|
144
|
+
self.job_code = self._upload_job_code()
|
|
145
|
+
self.default_image = s.default_image or getattr(
|
|
146
|
+
self.workflow.remote_execution_settings, "container_image", None
|
|
147
|
+
)
|
|
148
|
+
if not self.default_image:
|
|
149
|
+
raise WorkflowError(
|
|
150
|
+
"No default image: set --aws-batch-flex-default-image or give every "
|
|
151
|
+
"rule a `container:` directive."
|
|
152
|
+
)
|
|
153
|
+
self._platforms: dict = {} # queue -> "ec2" | "fargate"
|
|
154
|
+
self._preludes: dict = {} # platform -> job prelude
|
|
155
|
+
self._warned_fargate_mounts = False
|
|
156
|
+
self._warned_fargate_shm = False
|
|
157
|
+
self._said_resolved = False
|
|
158
|
+
self._warned_tags: set = set()
|
|
159
|
+
self._warned_disk: set = set()
|
|
160
|
+
self._existing_defs: dict = {} # ref -> ExistingJobDefinition
|
|
161
|
+
self._fair_share: dict = {} # queue -> has a scheduling policy
|
|
162
|
+
self._warned_existing: set = set()
|
|
163
|
+
self.usage = Usage()
|
|
164
|
+
self._oom: dict = {} # job id -> (times it ran out of memory, MiB its rule asked for then)
|
|
165
|
+
if not s.skip_preflight:
|
|
166
|
+
errors, warnings, detected = check_queue(self.batch, s.queue, s.platform)
|
|
167
|
+
self._platforms[s.queue] = s.platform or detected or "ec2"
|
|
168
|
+
if s.job_role_arn:
|
|
169
|
+
e2, w2 = check_role(boto3.client("iam", config=cfg), s.job_role_arn, "Job role")
|
|
170
|
+
errors, warnings = errors + e2, warnings + w2
|
|
171
|
+
self._report_checks(errors, warnings)
|
|
172
|
+
main_platform = self._queue_platform(s.queue)
|
|
173
|
+
self._handle_orphans(s.queue)
|
|
174
|
+
if s.bootstrap == "host" and main_platform == "ec2" and not s.skip_preflight:
|
|
175
|
+
ami = ami_snakemake_version(self.batch, boto3.client("ec2", config=cfg), s.queue)
|
|
176
|
+
level, note = version_note(ami, md.version("snakemake"))
|
|
177
|
+
if level == "warning":
|
|
178
|
+
self.logger.warning(note)
|
|
179
|
+
elif level == "info":
|
|
180
|
+
self.logger.info(note)
|
|
181
|
+
self._check_fargate_ok(main_platform, s.queue)
|
|
182
|
+
self.logger.info(
|
|
183
|
+
f"AWS Batch executor: queue={s.queue}, platform={main_platform}, "
|
|
184
|
+
f"default image={self.default_image}, bootstrap={s.bootstrap}"
|
|
185
|
+
)
|
|
186
|
+
if not s.no_auto_tags:
|
|
187
|
+
self.logger.info(f"Jobs are tagged snakemake-run-id={self.run_id}")
|
|
188
|
+
|
|
189
|
+
def _upload_job_code(self) -> Optional[str]:
|
|
190
|
+
"""Put the job tools (conda env cache, usage figures) next to the workflow's files: too
|
|
191
|
+
long to send with every job, the jobs' launcher loads them from there. None: jobs run
|
|
192
|
+
without them."""
|
|
193
|
+
import hashlib
|
|
194
|
+
|
|
195
|
+
from . import headjob
|
|
196
|
+
|
|
197
|
+
prefix = str(getattr(self.workflow.storage_settings, "default_storage_prefix", "") or "").rstrip("/")
|
|
198
|
+
if not prefix:
|
|
199
|
+
return None
|
|
200
|
+
data = job_tools_source().encode()
|
|
201
|
+
# the jobs check the content against the sha256 in the name before running it
|
|
202
|
+
url = f"{prefix}/.smkflex/code/jobtools-{hashlib.sha256(data).hexdigest()}.py"
|
|
203
|
+
try:
|
|
204
|
+
if headjob.get(url) != data:
|
|
205
|
+
headjob.put(url, data)
|
|
206
|
+
except (ClientError, BotoCoreError, OSError) as e:
|
|
207
|
+
self.logger.warning(f"jobs run without the conda env cache and usage figures: could not "
|
|
208
|
+
f"upload their code to {url} ({e})")
|
|
209
|
+
return None
|
|
210
|
+
return url
|
|
211
|
+
|
|
212
|
+
def _queue_platform(self, queue: str) -> str:
|
|
213
|
+
"""Platform of a queue: detected once from its compute environment (startup check),
|
|
214
|
+
else --aws-batch-flex-platform, else ec2."""
|
|
215
|
+
if queue not in self._platforms:
|
|
216
|
+
detected = None
|
|
217
|
+
if not self.settings.skip_preflight:
|
|
218
|
+
errors, warnings, detected = check_queue(self.batch, queue)
|
|
219
|
+
self._report_checks(errors, warnings)
|
|
220
|
+
self._platforms[queue] = detected or self.settings.platform or "ec2"
|
|
221
|
+
return self._platforms[queue]
|
|
222
|
+
|
|
223
|
+
def _report_checks(self, errors, warnings):
|
|
224
|
+
for w in warnings:
|
|
225
|
+
self.logger.warning(f"preflight: {w}")
|
|
226
|
+
if errors:
|
|
227
|
+
raise WorkflowError("Startup check failed:\n - " + "\n - ".join(errors) +
|
|
228
|
+
"\n(skip with --aws-batch-flex-skip-preflight)")
|
|
229
|
+
|
|
230
|
+
def _handle_orphans(self, queue: str):
|
|
231
|
+
if queue in self._orphans_checked or self.orphan_mode == "ignore" or self.settings.no_auto_tags:
|
|
232
|
+
return
|
|
233
|
+
self._orphans_checked.add(queue)
|
|
234
|
+
try:
|
|
235
|
+
found = find_orphans(
|
|
236
|
+
self.batch, queue, self.jobdefs.prefix, self.workflow_id, self.run_id
|
|
237
|
+
)
|
|
238
|
+
except (ClientError, BotoCoreError) as e:
|
|
239
|
+
self.logger.warning(f"Could not look for leftover jobs in queue {queue}: {e}")
|
|
240
|
+
return
|
|
241
|
+
if not found:
|
|
242
|
+
return
|
|
243
|
+
names = ", ".join(f"{name} ({status})" for _, name, status in found[:10])
|
|
244
|
+
more = f" and {len(found) - 10} more" if len(found) > 10 else ""
|
|
245
|
+
if self.orphan_mode == "warn":
|
|
246
|
+
self.logger.warning(
|
|
247
|
+
f"{len(found)} job(s) from an earlier run of this workflow are still active in "
|
|
248
|
+
f"queue {queue}: {names}{more}. They may run twice; cancel them or use "
|
|
249
|
+
"--aws-batch-flex-orphan-jobs cancel."
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
self.logger.info(
|
|
253
|
+
f"Cancelling {len(found)} job(s) left over from an earlier run of this workflow "
|
|
254
|
+
f"in queue {queue}: {names}{more}"
|
|
255
|
+
)
|
|
256
|
+
for jid, _, _ in found:
|
|
257
|
+
try:
|
|
258
|
+
self.batch.terminate_job(jobId=jid, reason="Left over from an earlier Snakemake run")
|
|
259
|
+
except (ClientError, BotoCoreError) as e:
|
|
260
|
+
self.logger.warning(f"Could not cancel leftover job {jid}: {e}")
|
|
261
|
+
|
|
262
|
+
def _platform_for(self, job: JobExecutorInterface, queue: str) -> str:
|
|
263
|
+
forced = job.resources.get("batch_platform")
|
|
264
|
+
if forced:
|
|
265
|
+
forced = str(forced).lower()
|
|
266
|
+
if forced not in ("ec2", "fargate"):
|
|
267
|
+
raise WorkflowError(
|
|
268
|
+
f"{job.name}: batch_platform must be 'ec2' or 'fargate', got '{forced}'."
|
|
269
|
+
)
|
|
270
|
+
return forced
|
|
271
|
+
return self._queue_platform(queue)
|
|
272
|
+
|
|
273
|
+
def _int_resource(self, job, names, low, high, default):
|
|
274
|
+
"""First of `names` set on the job (else `default`), as an int in [low, high]."""
|
|
275
|
+
for name in names:
|
|
276
|
+
value = job.resources.get(name)
|
|
277
|
+
if value is not None:
|
|
278
|
+
break
|
|
279
|
+
else:
|
|
280
|
+
name, value = "setting", default
|
|
281
|
+
if value is None:
|
|
282
|
+
return None
|
|
283
|
+
try:
|
|
284
|
+
n = int(value)
|
|
285
|
+
if not low <= n <= high:
|
|
286
|
+
raise ValueError
|
|
287
|
+
except (TypeError, ValueError):
|
|
288
|
+
raise WorkflowError(f"{job.name}: {name} must be a whole number from {low} to {high}, got {value!r}.") from None
|
|
289
|
+
return n
|
|
290
|
+
|
|
291
|
+
def _spot_retries_for(self, job: JobExecutorInterface) -> int:
|
|
292
|
+
attempts = job.resources.get("aws_batch_spot_attempts") # the aws-batch plugin's name: attempts
|
|
293
|
+
if attempts is not None and job.resources.get("batch_spot_retries") is None:
|
|
294
|
+
return self._int_resource(job, ["aws_batch_spot_attempts"], 1, 10, None) - 1
|
|
295
|
+
return self._int_resource(job, ["batch_spot_retries"], 0, 9, self.settings.spot_retries) or 0
|
|
296
|
+
|
|
297
|
+
def _priority_for(self, job: JobExecutorInterface, queue: str) -> Optional[int]:
|
|
298
|
+
priority = self._int_resource(
|
|
299
|
+
job, ["batch_priority", "aws_batch_scheduling_priority"], 0, 9999, self.settings.scheduling_priority
|
|
300
|
+
)
|
|
301
|
+
if priority is None:
|
|
302
|
+
return None
|
|
303
|
+
if queue not in self._fair_share:
|
|
304
|
+
try:
|
|
305
|
+
q, _envs = describe_queue(self.batch, queue)
|
|
306
|
+
self._fair_share[queue] = bool(q and q.get("schedulingPolicyArn"))
|
|
307
|
+
except (ClientError, BotoCoreError):
|
|
308
|
+
self._fair_share[queue] = True # can't tell: send it and let Batch decide
|
|
309
|
+
if not self._fair_share[queue]:
|
|
310
|
+
self.logger.warning(
|
|
311
|
+
f"Queue {queue} has no fair-share scheduling policy, so job priorities are "
|
|
312
|
+
"ignored for it (batch_priority / --aws-batch-flex-scheduling-priority)."
|
|
313
|
+
)
|
|
314
|
+
return priority if self._fair_share[queue] else None
|
|
315
|
+
|
|
316
|
+
def _shm_for(self, job: JobExecutorInterface, platform: str) -> Optional[int]:
|
|
317
|
+
# batch_shm_mb, or shared_memory_size_mb as in the aws-batch plugin
|
|
318
|
+
value = job.resources.get("batch_shm_mb") or job.resources.get("shared_memory_size_mb")
|
|
319
|
+
if not value:
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
shm = int(value)
|
|
323
|
+
if shm <= 0:
|
|
324
|
+
raise ValueError
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
raise WorkflowError(f"{job.name}: batch_shm_mb must be a positive number of MiB, got {value!r}.") from None
|
|
327
|
+
if platform == "fargate":
|
|
328
|
+
if not self._warned_fargate_shm:
|
|
329
|
+
self._warned_fargate_shm = True
|
|
330
|
+
self.logger.warning("Fargate doesn't support batch_shm_mb; /dev/shm stays at its default there.")
|
|
331
|
+
return None
|
|
332
|
+
return shm
|
|
333
|
+
|
|
334
|
+
def _more_memory_after_oom(self, job: JobExecutorInterface, req, platform: str):
|
|
335
|
+
"""A retry of a job that ran out of memory asks for oom_memory_factor times more,
|
|
336
|
+
for every time it ran out (Fargate: at most 120 GiB). Unless its rule already asks for
|
|
337
|
+
more on this attempt (mem_mb=lambda wildcards, attempt: ...): that is used as it is."""
|
|
338
|
+
times, asked = self._oom.get(job.jobid, (0, None))
|
|
339
|
+
factor = self.settings.oom_memory_factor
|
|
340
|
+
if not times or factor <= 1:
|
|
341
|
+
return
|
|
342
|
+
if asked is not None and req.memory_mib > asked:
|
|
343
|
+
self.logger.info(
|
|
344
|
+
f"{job.name} (attempt {job.attempt}) ran out of memory with {asked} MiB; its rule asks "
|
|
345
|
+
f"for {req.memory_mib} MiB this time, so that is used."
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
before = req.memory_mib
|
|
349
|
+
mem = int(before * factor**times)
|
|
350
|
+
if platform == "fargate":
|
|
351
|
+
mem = min(mem, FARGATE_MAX_MEM_MIB)
|
|
352
|
+
req.vcpu, mem = fargate_size(float(req.vcpu), mem)
|
|
353
|
+
req.memory_mib = mem
|
|
354
|
+
self.logger.info(
|
|
355
|
+
f"{job.name} (attempt {job.attempt}) ran out of memory with {before} MiB; "
|
|
356
|
+
f"retrying with {mem} MiB."
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _disk_for(self, job: JobExecutorInterface, platform: str) -> Optional[int]:
|
|
360
|
+
"""Fargate scratch disk in GiB for this job, from disk_mb; None keeps the default
|
|
361
|
+
(20 GiB, or ephemeral-storage-gib). EC2 jobs share the instance's disk."""
|
|
362
|
+
if platform != "fargate":
|
|
363
|
+
return None
|
|
364
|
+
value = job.resources.get("disk_mb")
|
|
365
|
+
if not value:
|
|
366
|
+
return None
|
|
367
|
+
# Snakemake's default for rules without inputs is a flat 50000; that isn't a request
|
|
368
|
+
if str(value) == "50000" and not list(job.input):
|
|
369
|
+
return None
|
|
370
|
+
try:
|
|
371
|
+
gib = math.ceil(int(job.resources.get("disk_mib") or value) / 1024)
|
|
372
|
+
except (TypeError, ValueError):
|
|
373
|
+
raise WorkflowError(f"{job.name}: disk_mb must be a number of MB, got {value!r}.") from None
|
|
374
|
+
if gib <= 20: # Fargate's default; 21 is the smallest size that can be set
|
|
375
|
+
return None
|
|
376
|
+
if gib > FARGATE_MAX_DISK_GIB:
|
|
377
|
+
if job.name not in self._warned_disk:
|
|
378
|
+
self._warned_disk.add(job.name)
|
|
379
|
+
self.logger.warning(
|
|
380
|
+
f"{job.name} asks for {gib} GiB of disk; Fargate allows at most "
|
|
381
|
+
f"{FARGATE_MAX_DISK_GIB}, so it gets {FARGATE_MAX_DISK_GIB}."
|
|
382
|
+
)
|
|
383
|
+
# The size is part of the job definition, so round up to 25 GiB steps; otherwise
|
|
384
|
+
# sizes derived from input files would register a new definition for nearly every job.
|
|
385
|
+
return min(FARGATE_MAX_DISK_GIB, math.ceil(gib / 25) * 25)
|
|
386
|
+
|
|
387
|
+
def _check_fargate_ok(self, platform: str, queue: str):
|
|
388
|
+
if platform != "fargate":
|
|
389
|
+
return
|
|
390
|
+
if not self.settings.execution_role_arn:
|
|
391
|
+
raise WorkflowError(
|
|
392
|
+
f"Queue '{queue}' runs on Fargate, which requires --aws-batch-flex-execution-role-arn."
|
|
393
|
+
)
|
|
394
|
+
if self.mounts and not self._warned_fargate_mounts:
|
|
395
|
+
self._warned_fargate_mounts = True
|
|
396
|
+
self.logger.warning(
|
|
397
|
+
"Fargate jobs get no host mounts (bootstrap=host env, --aws-batch-flex-volumes, "
|
|
398
|
+
"--aws-batch-flex-conda-path): they install Snakemake at job start instead, and "
|
|
399
|
+
"conda: rules need conda inside the image there."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _bootstrap_for(self, platform: str, host_mount: bool = True) -> str:
|
|
403
|
+
# Without the host env (Fargate, or a job definition that doesn't mount it):
|
|
404
|
+
# install at job start instead.
|
|
405
|
+
if self.settings.bootstrap == "host" and (platform == "fargate" or not host_mount):
|
|
406
|
+
return "auto"
|
|
407
|
+
return self.settings.bootstrap
|
|
408
|
+
|
|
409
|
+
def _prelude_for(self, platform: str, host_mount: bool = True, conda_mount: bool = True) -> str:
|
|
410
|
+
key = (platform, host_mount, conda_mount)
|
|
411
|
+
if key not in self._preludes:
|
|
412
|
+
default = getattr(self.workflow.storage_settings, "default_storage_provider", None)
|
|
413
|
+
mode = self._bootstrap_for(platform, host_mount)
|
|
414
|
+
base = snakemake_prelude(self.settings, mode, default, self.resolved_at)
|
|
415
|
+
if mode in ("auto", "always") and not self._said_resolved:
|
|
416
|
+
self._said_resolved = True
|
|
417
|
+
self.logger.info(f"Jobs that install Snakemake at start get the packages as of {self.resolved_at}.")
|
|
418
|
+
if self.settings.conda_path and platform == "ec2" and conda_mount:
|
|
419
|
+
base += "\n" + conda_snippet(self.settings.conda_path)
|
|
420
|
+
base += "\n" + launcher_snippet(bool(self.job_code)) # SMK_FLEX_SCRIPT_PYTHON decides per job
|
|
421
|
+
self._preludes[key] = base
|
|
422
|
+
return self._preludes[key]
|
|
423
|
+
|
|
424
|
+
def _existing_jobdef(self, job: JobExecutorInterface, platform: str, image: str, shm_mb, disk_gib=None):
|
|
425
|
+
"""The existing job definition this job should use, or None; warns once per rule
|
|
426
|
+
about rule settings that the job definition overrides."""
|
|
427
|
+
ref = job.resources.get("batch_job_definition") or self.settings.job_definition
|
|
428
|
+
if not ref:
|
|
429
|
+
return None
|
|
430
|
+
ref = str(ref)
|
|
431
|
+
if ref not in self._existing_defs:
|
|
432
|
+
self._existing_defs[ref] = describe_existing(self.batch, ref)
|
|
433
|
+
ex = self._existing_defs[ref]
|
|
434
|
+
if ex.platform != platform:
|
|
435
|
+
raise WorkflowError(
|
|
436
|
+
f"{job.name}: job definition '{ref}' is for {ex.platform.upper()} but queue "
|
|
437
|
+
f"{self._queue_for(job)} runs on {platform.upper()}."
|
|
438
|
+
)
|
|
439
|
+
key = (job.name, ref)
|
|
440
|
+
if key not in self._warned_existing:
|
|
441
|
+
self._warned_existing.add(key)
|
|
442
|
+
notes = []
|
|
443
|
+
subjobs = list(job.jobs) if job.is_group() else [job]
|
|
444
|
+
if image != ex.image and any(getattr(j, "container_img_url", None) for j in subjobs):
|
|
445
|
+
notes.append(f"its image {ex.image} is used, not {image}")
|
|
446
|
+
if self.settings.bootstrap == "host" and self.settings.host_env_path not in ex.container_paths:
|
|
447
|
+
notes.append(f"it doesn't mount {self.settings.host_env_path}, so Snakemake is installed at job start")
|
|
448
|
+
if shm_mb:
|
|
449
|
+
notes.append("batch_shm_mb is ignored (set sharedMemorySize in the job definition)")
|
|
450
|
+
if disk_gib:
|
|
451
|
+
notes.append("disk_mb is ignored (set ephemeralStorage in the job definition)")
|
|
452
|
+
if self.settings.job_role_arn and ex.job_role and ex.job_role != self.settings.job_role_arn:
|
|
453
|
+
notes.append(f"its job role {ex.job_role} is used")
|
|
454
|
+
if notes:
|
|
455
|
+
self.logger.warning(f"{job.name} uses job definition '{ref}': " + "; ".join(notes) + ".")
|
|
456
|
+
return ex
|
|
457
|
+
|
|
458
|
+
def _image_for(self, job: JobExecutorInterface) -> str:
|
|
459
|
+
subjobs = list(job.jobs) if job.is_group() else [job]
|
|
460
|
+
images = set()
|
|
461
|
+
for j in subjobs:
|
|
462
|
+
# `container_img_url` is a Snakemake Job attribute, not (yet) part of the
|
|
463
|
+
# stable executor-plugin interface, hence getattr.
|
|
464
|
+
url = getattr(j, "container_img_url", None)
|
|
465
|
+
images.add(normalize_image(url) if url else self.default_image)
|
|
466
|
+
if len(images) > 1:
|
|
467
|
+
raise WorkflowError(
|
|
468
|
+
f"Group job {job.name} mixes container images {sorted(images)}; "
|
|
469
|
+
"all rules in a group must use the same image."
|
|
470
|
+
)
|
|
471
|
+
image = images.pop()
|
|
472
|
+
if mutable_tag(image) and image not in self._warned_tags:
|
|
473
|
+
self._warned_tags.add(image)
|
|
474
|
+
self.logger.warning(
|
|
475
|
+
f"Image {image} has no version tag (or :latest), so what it contains can change "
|
|
476
|
+
"between jobs and runs. Use a fixed tag (e.g. samtools:1.21--h50ea8bc_0) or a digest (@sha256:...)."
|
|
477
|
+
)
|
|
478
|
+
return image
|
|
479
|
+
|
|
480
|
+
def _queue_for(self, job: JobExecutorInterface) -> str:
|
|
481
|
+
return str(job.resources.get("batch_queue") or self.settings.queue)
|
|
482
|
+
|
|
483
|
+
def run_job(self, job: JobExecutorInterface):
|
|
484
|
+
image = self._image_for(job)
|
|
485
|
+
queue = self._queue_for(job)
|
|
486
|
+
platform = self._platform_for(job, queue)
|
|
487
|
+
self._handle_orphans(queue)
|
|
488
|
+
self._check_fargate_ok(platform, queue)
|
|
489
|
+
req = job_requirements(job.resources, job.threads, platform, self.settings.mem_overhead_mib)
|
|
490
|
+
asked = req.memory_mib # by the rule, before the retry's extra
|
|
491
|
+
self._more_memory_after_oom(job, req, platform)
|
|
492
|
+
script_python = str(job.resources.get("batch_script_python") or self.settings.script_python).lower()
|
|
493
|
+
if script_python not in ("image", "host"):
|
|
494
|
+
raise WorkflowError(
|
|
495
|
+
f"{job.name}: batch_script_python must be 'image' or 'host', got '{script_python}'."
|
|
496
|
+
)
|
|
497
|
+
shm_mb = self._shm_for(job, platform)
|
|
498
|
+
disk_gib = self._disk_for(job, platform)
|
|
499
|
+
existing = self._existing_jobdef(job, platform, image, shm_mb, disk_gib)
|
|
500
|
+
if existing:
|
|
501
|
+
jobdef, image = existing.arn, existing.image
|
|
502
|
+
host_mount = self.settings.host_env_path in existing.container_paths
|
|
503
|
+
conda_mount = bool(self.settings.conda_path) and self.settings.conda_path in existing.container_paths
|
|
504
|
+
else:
|
|
505
|
+
jobdef = self.jobdefs.ensure(image, platform, shm_mb, disk_gib)
|
|
506
|
+
host_mount = conda_mount = True
|
|
507
|
+
|
|
508
|
+
script = f"{self._prelude_for(platform, host_mount, conda_mount)}\n{keep_unneeded_temp(self.format_job_exec(job))}"
|
|
509
|
+
resource_reqs = [
|
|
510
|
+
{"type": "VCPU", "value": req.vcpu},
|
|
511
|
+
{"type": "MEMORY", "value": str(req.memory_mib)},
|
|
512
|
+
]
|
|
513
|
+
if req.gpu:
|
|
514
|
+
resource_reqs.append({"type": "GPU", "value": str(req.gpu)})
|
|
515
|
+
|
|
516
|
+
kwargs = dict(
|
|
517
|
+
jobName=batch_job_name(self.get_jobname(job)),
|
|
518
|
+
jobQueue=queue,
|
|
519
|
+
jobDefinition=jobdef,
|
|
520
|
+
containerOverrides={
|
|
521
|
+
# what the image's ENTRYPOINT, which Batch can't change, runs as /bin/sh -c script
|
|
522
|
+
"command": self.entrypoints.command(image, script, job.resources.get("batch_entrypoint"), job.name),
|
|
523
|
+
"resourceRequirements": resource_reqs,
|
|
524
|
+
"environment": [
|
|
525
|
+
{"name": k, "value": str(v)} for k, v in self.envvars().items()
|
|
526
|
+
]
|
|
527
|
+
+ [{"name": "SMK_FLEX_SCRIPT_PYTHON", "value": script_python}]
|
|
528
|
+
# the region, which jobs can't look up on the instance (IMDS) when it's closed to them
|
|
529
|
+
+ [{"name": n, "value": self.region} for n in ("AWS_REGION", "AWS_DEFAULT_REGION") if self.region]
|
|
530
|
+
+ ([{"name": "SMK_FLEX_JOB_CODE", "value": self.job_code}] if self.job_code else [])
|
|
531
|
+
+ ([{"name": "SMK_FLEX_CONDA_CACHE", "value": self.conda_cache}]
|
|
532
|
+
if self.job_code and self.conda_cache != "none" else []),
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
spot_retries = self._spot_retries_for(job)
|
|
536
|
+
if spot_retries:
|
|
537
|
+
kwargs["retryStrategy"] = {
|
|
538
|
+
"attempts": spot_retries + 1,
|
|
539
|
+
"evaluateOnExit": [
|
|
540
|
+
{"onStatusReason": "Host EC2*", "action": "RETRY"},
|
|
541
|
+
{"onReason": "*", "action": "EXIT"},
|
|
542
|
+
],
|
|
543
|
+
}
|
|
544
|
+
priority = self._priority_for(job, queue)
|
|
545
|
+
if priority is not None:
|
|
546
|
+
kwargs["schedulingPriorityOverride"] = priority
|
|
547
|
+
if req.timeout_seconds:
|
|
548
|
+
kwargs["timeout"] = {"attemptDurationSeconds": req.timeout_seconds}
|
|
549
|
+
tags = job_tags(
|
|
550
|
+
run_id=self.run_id,
|
|
551
|
+
workflow_id=self.workflow_id,
|
|
552
|
+
# Snakemake returns plain names; the interface type says rule objects. Accept both.
|
|
553
|
+
rule_names=[getattr(r, "name", r) for r in job.rules],
|
|
554
|
+
jobid=job.jobid,
|
|
555
|
+
global_tags=self.tags,
|
|
556
|
+
rule_tags=parse_resource_string(job.resources.get("batch_tags")),
|
|
557
|
+
auto=not self.settings.no_auto_tags,
|
|
558
|
+
)
|
|
559
|
+
if tags:
|
|
560
|
+
kwargs["tags"] = tags
|
|
561
|
+
kwargs["propagateTags"] = True
|
|
562
|
+
|
|
563
|
+
size = len(json.dumps(kwargs["containerOverrides"], separators=(",", ":")))
|
|
564
|
+
if size > ECS_OVERRIDES_MAX:
|
|
565
|
+
raise WorkflowError(
|
|
566
|
+
f"{job.name}: its command and environment come to {size} characters, more than the "
|
|
567
|
+
f"{ECS_OVERRIDES_MAX} ECS allows per job. What makes them long: many storage plugins or "
|
|
568
|
+
"extra packages installed at job start (bootstrap host avoids that), a long storage "
|
|
569
|
+
"prefix, or very many input/output files in one job."
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
resp = self.batch.submit_job(**kwargs)
|
|
573
|
+
except (ClientError, BotoCoreError) as e:
|
|
574
|
+
hint = f"\n{SUBMIT_HINT}" if _not_allowed(e) else ""
|
|
575
|
+
raise WorkflowError(f"AWS Batch submit_job failed: {e}{hint}") from e
|
|
576
|
+
|
|
577
|
+
self.logger.info(
|
|
578
|
+
f"Submitted {job.name} as AWS Batch job {resp['jobId']} "
|
|
579
|
+
f"({image}, {req.vcpu} vCPU, {req.memory_mib} MiB, queue {queue}, {platform})"
|
|
580
|
+
)
|
|
581
|
+
self.report_job_submission(
|
|
582
|
+
SubmittedJobInfo(
|
|
583
|
+
job,
|
|
584
|
+
external_jobid=resp["jobId"],
|
|
585
|
+
aux={"smkflex": Submitted(image, queue, platform, float(req.vcpu), req.memory_mib, asked)},
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
async def check_active_jobs(
|
|
590
|
+
self, active_jobs: List[SubmittedJobInfo]
|
|
591
|
+
) -> AsyncGenerator[SubmittedJobInfo, None]:
|
|
592
|
+
by_id = {j.external_jobid: j for j in active_jobs}
|
|
593
|
+
ids = list(by_id)
|
|
594
|
+
described = {}
|
|
595
|
+
for i in range(0, len(ids), 100): # describe_jobs takes at most 100 ids
|
|
596
|
+
async with self.status_rate_limiter:
|
|
597
|
+
try:
|
|
598
|
+
resp = await asyncio.to_thread(
|
|
599
|
+
self.batch.describe_jobs, jobs=ids[i : i + 100]
|
|
600
|
+
)
|
|
601
|
+
except (ClientError, BotoCoreError) as e:
|
|
602
|
+
self.logger.warning(f"AWS Batch status query failed, will retry: {e}")
|
|
603
|
+
for jid in ids[i : i + 100]:
|
|
604
|
+
yield by_id[jid]
|
|
605
|
+
continue
|
|
606
|
+
for d in resp["jobs"]:
|
|
607
|
+
described[d["jobId"]] = d
|
|
608
|
+
|
|
609
|
+
for jid, info in by_id.items():
|
|
610
|
+
d = described.get(jid)
|
|
611
|
+
if d is None:
|
|
612
|
+
self.report_job_error(
|
|
613
|
+
info, msg=f"AWS Batch no longer knows job {jid} (expired?)."
|
|
614
|
+
)
|
|
615
|
+
continue
|
|
616
|
+
status = d["status"]
|
|
617
|
+
if status in ("SUCCEEDED", "FAILED"):
|
|
618
|
+
await asyncio.to_thread(self._record_usage, info, d)
|
|
619
|
+
if status == "SUCCEEDED":
|
|
620
|
+
self.report_job_success(info)
|
|
621
|
+
elif status == "FAILED":
|
|
622
|
+
if _out_of_memory(d):
|
|
623
|
+
times, _asked = self._oom.get(info.job.jobid, (0, None))
|
|
624
|
+
self._oom[info.job.jobid] = (times + 1, _sub(info).asked)
|
|
625
|
+
msg = await asyncio.to_thread(self._failure_message, d, info)
|
|
626
|
+
self.report_job_error(info, msg=msg)
|
|
627
|
+
elif status in ACTIVE:
|
|
628
|
+
self._warn_if_stuck(d, info)
|
|
629
|
+
yield info
|
|
630
|
+
else:
|
|
631
|
+
yield info
|
|
632
|
+
|
|
633
|
+
def _record_usage(self, info: SubmittedJobInfo, d: dict):
|
|
634
|
+
sub = _sub(info)
|
|
635
|
+
self.usage.add(info.job.name, d, sub.vcpu, sub.mem, sub.platform)
|
|
636
|
+
c = d.get("container") or {}
|
|
637
|
+
if c.get("logStreamName") and d.get("status") == "SUCCEEDED":
|
|
638
|
+
group = log_group_of(c, self.settings.log_group)
|
|
639
|
+
self.usage.add_log(info.job.name, group, c["logStreamName"])
|
|
640
|
+
|
|
641
|
+
def shutdown(self):
|
|
642
|
+
super().shutdown()
|
|
643
|
+
if self.settings.no_usage_summary:
|
|
644
|
+
return
|
|
645
|
+
self.usage.fetch_peaks(self.cwlogs)
|
|
646
|
+
table = self.usage.table(self.settings.mem_overhead_mib)
|
|
647
|
+
if table:
|
|
648
|
+
self.logger.info(table)
|
|
649
|
+
|
|
650
|
+
def additional_general_args(self):
|
|
651
|
+
# A version-tagged wrapper ("v9.19.1/bio/...") is fetched with `git clone` when no prefix is
|
|
652
|
+
# set, and stock images have no git. The plain HTTP prefix fetches the same files without git.
|
|
653
|
+
# (Snakemake joins the result as a string, despite the interface docstring saying list.)
|
|
654
|
+
if getattr(self.workflow.workflow_settings, "wrapper_prefix", None) is None:
|
|
655
|
+
return f"--wrapper-prefix {WRAPPER_HTTP_PREFIX}"
|
|
656
|
+
return ""
|
|
657
|
+
|
|
658
|
+
def cancel_jobs(self, active_jobs: List[SubmittedJobInfo]):
|
|
659
|
+
# In parallel: a head job that is stopped has little time (ECS_CONTAINER_STOP_TIMEOUT)
|
|
660
|
+
# to cancel every job and save its state.
|
|
661
|
+
def cancel(info):
|
|
662
|
+
try:
|
|
663
|
+
self.batch.terminate_job(
|
|
664
|
+
jobId=info.external_jobid, reason="Cancelled by Snakemake"
|
|
665
|
+
)
|
|
666
|
+
except (ClientError, BotoCoreError) as e:
|
|
667
|
+
self.logger.warning(f"Could not terminate {info.external_jobid}: {e}")
|
|
668
|
+
|
|
669
|
+
with ThreadPoolExecutor(CANCEL_THREADS) as pool:
|
|
670
|
+
list(pool.map(cancel, active_jobs))
|
|
671
|
+
|
|
672
|
+
def _warn_if_stuck(self, d: dict, info: SubmittedJobInfo):
|
|
673
|
+
sub = _sub(info)
|
|
674
|
+
if d["status"] != "RUNNABLE" or sub.warned:
|
|
675
|
+
return
|
|
676
|
+
waited = (time.time() * 1000 - d.get("createdAt", time.time() * 1000)) / 60000
|
|
677
|
+
if waited >= self.settings.runnable_warn_minutes:
|
|
678
|
+
sub.warned = True
|
|
679
|
+
self.logger.warning(
|
|
680
|
+
f"AWS Batch job {d['jobId']} has waited {waited:.0f} min in RUNNABLE "
|
|
681
|
+
f"on queue {sub.queue}. Usually the compute environment cannot "
|
|
682
|
+
"provide the requested vCPU/memory (max vCPUs reached, instance types "
|
|
683
|
+
"too small, or subnets without capacity). Check the Batch console."
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def _failure_message(self, d: dict, info: SubmittedJobInfo) -> str:
|
|
687
|
+
c = d.get("container") or {}
|
|
688
|
+
lines = [
|
|
689
|
+
f"AWS Batch job {d['jobId']} FAILED (image {_sub(info).image}).",
|
|
690
|
+
]
|
|
691
|
+
if d.get("statusReason"):
|
|
692
|
+
lines.append(f" status reason: {d['statusReason']}")
|
|
693
|
+
if c.get("reason"):
|
|
694
|
+
lines.append(f" container reason: {c['reason']}")
|
|
695
|
+
if c.get("exitCode") is not None:
|
|
696
|
+
lines.append(f" exit code: {c['exitCode']}")
|
|
697
|
+
if _out_of_memory(d):
|
|
698
|
+
mem = _sub(info).mem
|
|
699
|
+
factor = self.settings.oom_memory_factor
|
|
700
|
+
retries = getattr(info.job, "restart_times", 0) or 0
|
|
701
|
+
if retries >= info.job.attempt and factor > 1:
|
|
702
|
+
lines.append(f" hint: the job ran out of memory ({mem} MiB); Snakemake retries it with more.")
|
|
703
|
+
else:
|
|
704
|
+
lines.append(
|
|
705
|
+
f" hint: the job ran out of memory ({mem} MiB). Raise the rule's mem_mb, or run with "
|
|
706
|
+
"--retries 2: the plugin then retries jobs that ran out of memory with twice as much."
|
|
707
|
+
)
|
|
708
|
+
stream = c.get("logStreamName")
|
|
709
|
+
if stream:
|
|
710
|
+
group = log_group_of(c, self.settings.log_group)
|
|
711
|
+
lines.append(f" CloudWatch: group {group}, stream {stream}")
|
|
712
|
+
try:
|
|
713
|
+
events = self.cwlogs.get_log_events(
|
|
714
|
+
logGroupName=group,
|
|
715
|
+
logStreamName=stream,
|
|
716
|
+
limit=40,
|
|
717
|
+
startFromHead=False,
|
|
718
|
+
)["events"]
|
|
719
|
+
if events:
|
|
720
|
+
lines.append(" last log lines:")
|
|
721
|
+
lines += [f" {e['message'].rstrip()}" for e in events]
|
|
722
|
+
lines += known_problem_hints([e["message"] for e in events])
|
|
723
|
+
except (ClientError, BotoCoreError):
|
|
724
|
+
lines.append(" (could not fetch log tail)")
|
|
725
|
+
return "\n".join(lines) + "\n"
|