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,207 @@
|
|
|
1
|
+
"""Images with an ENTRYPOINT. Batch can't set or clear an image's entrypoint (a job definition
|
|
2
|
+
has no field for it), and a job's command is Docker's Cmd, which Docker appends to the
|
|
3
|
+
entrypoint: an image with ENTRYPOINT ["python3"] would run `python3 /bin/sh -c <script>`.
|
|
4
|
+
|
|
5
|
+
So the plugin reads each image's entrypoint from its registry, once per run, and gives its jobs
|
|
6
|
+
a command that entrypoint runs: `-c <script>` for a shell, a `-c` that hands over to /bin/sh for
|
|
7
|
+
Python, and `/bin/sh -c <script>` for everything else. That includes no entrypoint, wrappers such
|
|
8
|
+
as tini, and entrypoint scripts, which end with `exec "$@"` (continuumio/miniconda3's,
|
|
9
|
+
mambaorg/micromamba's). If the registry can't be read, jobs get `/bin/sh -c` too, as before."""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import boto3
|
|
21
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
22
|
+
|
|
23
|
+
from .jobdefs import image_registry, is_ecr
|
|
24
|
+
|
|
25
|
+
SHELLS = {"sh", "bash", "dash", "ash", "zsh", "ksh"}
|
|
26
|
+
WRAPPERS = {"tini", "dumb-init", "catatonit", "env", "env-execute"} # run what follows their options
|
|
27
|
+
WITH_VALUE = {"-u", "--unset", "-C", "--chdir", "-e", "-p", "-r", "--rewrite"} # env's, tini's, dumb-init's
|
|
28
|
+
PYTHON = re.compile(r"(python|pypy)[0-9.]*")
|
|
29
|
+
RUNS_NEXT = re.compile(r"-[A-Za-z]*c") # -c, -lc, -ec: the shell or Python runs the next argument
|
|
30
|
+
# Python's part: become /bin/sh -c <script> (the argument after it), so that Batch's stop signal
|
|
31
|
+
# goes to the job and not to a Python waiting for it
|
|
32
|
+
EXEC_SH = "import os,sys;os.execv('/bin/sh',['/bin/sh','-c']+sys.argv[1:])"
|
|
33
|
+
MANIFESTS = ", ".join([
|
|
34
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
35
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
36
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
37
|
+
"application/vnd.oci.image.index.v1+json",
|
|
38
|
+
])
|
|
39
|
+
TIMEOUT = 10 # seconds per registry request
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def command(entrypoint, script):
|
|
43
|
+
"""(Docker's Cmd that makes `entrypoint` run `script`, whether the plugin knows that
|
|
44
|
+
entrypoint). One it doesn't know gets /bin/sh -c, as an entrypoint script expects."""
|
|
45
|
+
sh = ["/bin/sh", "-c", script]
|
|
46
|
+
if not entrypoint:
|
|
47
|
+
return sh, True
|
|
48
|
+
exe, args = os.path.basename(entrypoint[0]), list(entrypoint[1:])
|
|
49
|
+
options = all(a.startswith("-") for a in args)
|
|
50
|
+
if exe in SHELLS and options:
|
|
51
|
+
return ([script] if args and RUNS_NEXT.fullmatch(args[-1]) else ["-c", script]), True
|
|
52
|
+
if PYTHON.fullmatch(exe) and options: # e.g. ["python3"] or ["python3", "-u"]
|
|
53
|
+
return ([EXEC_SH, script] if args and RUNS_NEXT.fullmatch(args[-1]) else ["-c", EXEC_SH, script]), True
|
|
54
|
+
if exe in WRAPPERS:
|
|
55
|
+
rest = args
|
|
56
|
+
while rest and (rest[0].startswith("-") or (exe == "env" and "=" in rest[0])):
|
|
57
|
+
rest = rest[2:] if rest[0] in WITH_VALUE else rest[1:]
|
|
58
|
+
return command(rest, script) if rest else (sh, True)
|
|
59
|
+
return sh, False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_image(image: str):
|
|
63
|
+
"""(registry API host, repository, tag or digest) of an image reference."""
|
|
64
|
+
name, ref = image.split("@", 1) if "@" in image else (image, "latest")
|
|
65
|
+
if "@" not in image and name.rfind(":") > name.rfind("/"):
|
|
66
|
+
name, ref = name.rsplit(":", 1)
|
|
67
|
+
first, _, rest = name.partition("/")
|
|
68
|
+
host, repo = (first, rest) if rest and ("." in first or ":" in first or first == "localhost") else ("docker.io", name)
|
|
69
|
+
if host in ("docker.io", "index.docker.io", "registry-1.docker.io"):
|
|
70
|
+
host = "registry-1.docker.io"
|
|
71
|
+
if "/" not in repo: # Docker Hub's own images
|
|
72
|
+
repo = f"library/{repo}"
|
|
73
|
+
return host, repo, ref
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _NoAuthElsewhere(urllib.request.HTTPRedirectHandler):
|
|
77
|
+
"""Registries send blobs from other hosts (ECR from S3, with a presigned URL that refuses a
|
|
78
|
+
request that also has an Authorization header): it isn't sent there."""
|
|
79
|
+
|
|
80
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
81
|
+
new = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
82
|
+
if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
|
|
83
|
+
new.remove_header("Authorization")
|
|
84
|
+
return new
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_OPENER = urllib.request.build_opener(_NoAuthElsewhere())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get(url, headers):
|
|
91
|
+
"""(status, headers, body)."""
|
|
92
|
+
try:
|
|
93
|
+
with _OPENER.open(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as r:
|
|
94
|
+
return r.status, r.headers, r.read()
|
|
95
|
+
except urllib.error.HTTPError as e:
|
|
96
|
+
return e.code, e.headers, e.read()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Entrypoints:
|
|
100
|
+
"""Image entrypoints from their registries: anonymously, with ECR's token, or with the
|
|
101
|
+
registry-credentials secret. Each image is looked up once (a failure too)."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, credentials: Optional[dict] = None, logger=None):
|
|
104
|
+
self.credentials = credentials or {}
|
|
105
|
+
self.logger = logger
|
|
106
|
+
self._found: dict = {} # image -> entrypoint (list) or the reason it couldn't be read (str)
|
|
107
|
+
self._auth: dict = {} # registry -> Authorization header or None
|
|
108
|
+
self._said: set = set()
|
|
109
|
+
|
|
110
|
+
def command(self, image: str, script: str, override=None, what: str = "") -> list:
|
|
111
|
+
"""The job command for `image`. `override` is the batch_entrypoint resource: the
|
|
112
|
+
entrypoint as a JSON list, for an image whose registry can't be read."""
|
|
113
|
+
if override:
|
|
114
|
+
try:
|
|
115
|
+
entrypoint = json.loads(override)
|
|
116
|
+
assert isinstance(entrypoint, list) and all(isinstance(a, str) for a in entrypoint)
|
|
117
|
+
except (ValueError, AssertionError):
|
|
118
|
+
raise WorkflowError(f"{what}: batch_entrypoint must be the image's ENTRYPOINT as a JSON list, "
|
|
119
|
+
f"such as '[\"python3\"]' ('[]' for none), got {override!r}.") from None
|
|
120
|
+
return command(entrypoint, script)[0]
|
|
121
|
+
if image not in self._found:
|
|
122
|
+
try:
|
|
123
|
+
self._found[image] = self._lookup(image)
|
|
124
|
+
except Exception as e: # network, permissions, an unexpected registry answer
|
|
125
|
+
self._found[image] = str(e) or type(e).__name__
|
|
126
|
+
entrypoint = self._found[image]
|
|
127
|
+
if isinstance(entrypoint, str):
|
|
128
|
+
self._say(image, "warning", f"Could not read the ENTRYPOINT of image {image} ({entrypoint}), so its "
|
|
129
|
+
"jobs run `/bin/sh -c ...` as if it had none. If it has one that doesn't run that (such "
|
|
130
|
+
"as [\"python3\"]), give its rules batch_entrypoint='[\"python3\"]'.")
|
|
131
|
+
return command(None, script)[0]
|
|
132
|
+
cmd, known = command(entrypoint, script)
|
|
133
|
+
if entrypoint:
|
|
134
|
+
how = "" if known else " (as an entrypoint script that runs the command it's given)"
|
|
135
|
+
self._say(image, "info", f"Image {image} has ENTRYPOINT {entrypoint}: its jobs start through it{how}.")
|
|
136
|
+
return cmd
|
|
137
|
+
|
|
138
|
+
def _say(self, image, level, text):
|
|
139
|
+
if self.logger and image not in self._said:
|
|
140
|
+
self._said.add(image)
|
|
141
|
+
getattr(self.logger, level)(text)
|
|
142
|
+
|
|
143
|
+
def _lookup(self, image: str) -> list:
|
|
144
|
+
host, repo, ref = parse_image(image)
|
|
145
|
+
|
|
146
|
+
def get(path):
|
|
147
|
+
return json.loads(self._fetch(host, repo, path, image_registry(image)))
|
|
148
|
+
|
|
149
|
+
manifest = get(f"manifests/{ref}")
|
|
150
|
+
if "manifests" in manifest: # one image per platform: Batch runs linux/amd64
|
|
151
|
+
digest = next((m["digest"] for m in manifest["manifests"]
|
|
152
|
+
if m.get("platform", {}).get("os") == "linux"
|
|
153
|
+
and m.get("platform", {}).get("architecture") == "amd64"), None)
|
|
154
|
+
if not digest:
|
|
155
|
+
raise ValueError("no linux/amd64 image")
|
|
156
|
+
manifest = get(f"manifests/{digest}")
|
|
157
|
+
config = get(f"blobs/{manifest['config']['digest']}")
|
|
158
|
+
return list((config.get("config") or {}).get("Entrypoint") or [])
|
|
159
|
+
|
|
160
|
+
def _fetch(self, host, repo, path, registry) -> bytes:
|
|
161
|
+
url = f"https://{host}/v2/{repo}/{path}"
|
|
162
|
+
headers = {"Accept": MANIFESTS}
|
|
163
|
+
auth = self._basic(registry)
|
|
164
|
+
if auth:
|
|
165
|
+
headers["Authorization"] = auth
|
|
166
|
+
status, got, body = _get(url, headers)
|
|
167
|
+
if status == 401: # Docker Hub, quay.io, ghcr.io, public.ecr.aws: a token for this repository
|
|
168
|
+
token = self._token(got.get("WWW-Authenticate", ""), repo, auth)
|
|
169
|
+
if token:
|
|
170
|
+
status, got, body = _get(url, {**headers, "Authorization": token})
|
|
171
|
+
if status != 200:
|
|
172
|
+
raise ValueError(f"{host} answered HTTP {status} for {repo}/{path}")
|
|
173
|
+
return body
|
|
174
|
+
|
|
175
|
+
def _token(self, challenge, repo, auth):
|
|
176
|
+
scheme, _, params = challenge.partition(" ")
|
|
177
|
+
fields = dict(re.findall(r'(\w+)="([^"]*)"', params))
|
|
178
|
+
if scheme.lower() != "bearer" or "realm" not in fields:
|
|
179
|
+
return None
|
|
180
|
+
query = {"scope": fields.get("scope") or f"repository:{repo}:pull"}
|
|
181
|
+
if "service" in fields:
|
|
182
|
+
query["service"] = fields["service"]
|
|
183
|
+
realm = fields["realm"]
|
|
184
|
+
status, _, body = _get(f"{realm}{'&' if '?' in realm else '?'}{urllib.parse.urlencode(query)}",
|
|
185
|
+
{"Authorization": auth} if auth else {})
|
|
186
|
+
doc = json.loads(body) if status == 200 else {}
|
|
187
|
+
token = doc.get("token") or doc.get("access_token")
|
|
188
|
+
return f"Bearer {token}" if token else None
|
|
189
|
+
|
|
190
|
+
def _basic(self, registry):
|
|
191
|
+
"""Authorization for a private registry: ECR's token, or the registry-credentials secret."""
|
|
192
|
+
if registry not in self._auth:
|
|
193
|
+
header = None
|
|
194
|
+
if is_ecr(registry):
|
|
195
|
+
account, region = registry.split(".")[0], registry.split(".")[3]
|
|
196
|
+
data = boto3.client("ecr", region_name=region).get_authorization_token(
|
|
197
|
+
registryIds=[account])["authorizationData"][0]
|
|
198
|
+
header = "Basic " + data["authorizationToken"]
|
|
199
|
+
elif registry in self.credentials:
|
|
200
|
+
arn = self.credentials[registry]
|
|
201
|
+
raw = boto3.client("secretsmanager", region_name=arn.split(":")[3]).get_secret_value(
|
|
202
|
+
SecretId=arn)["SecretString"]
|
|
203
|
+
secret = json.loads(raw) # ECS's format: {"username": ..., "password": ...}
|
|
204
|
+
pair = f"{secret['username']}:{secret['password']}"
|
|
205
|
+
header = "Basic " + base64.b64encode(pair.encode()).decode()
|
|
206
|
+
self._auth[registry] = header
|
|
207
|
+
return self._auth[registry]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Job definitions, registered on demand: one per (image, platform), since Batch can't
|
|
2
|
+
override the image at submit time. The name includes a hash of the definition, so runs reuse
|
|
3
|
+
existing ones."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
from snakemake_interface_common.exceptions import WorkflowError
|
|
13
|
+
|
|
14
|
+
from .tags import MANAGED_BY
|
|
15
|
+
|
|
16
|
+
DEFAULT_LOG_GROUP = "/aws/batch/job"
|
|
17
|
+
FARGATE_PLATFORM = "1.4.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_group_of(container: dict, default: str = DEFAULT_LOG_GROUP) -> str:
|
|
21
|
+
"""The CloudWatch log group of a described job's container: its job definition's, or default."""
|
|
22
|
+
return ((container.get("logConfiguration") or {}).get("options") or {}).get("awslogs-group") or default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_image(url: str) -> str:
|
|
26
|
+
if url.startswith("docker://"):
|
|
27
|
+
return url[len("docker://") :]
|
|
28
|
+
if re.match(r"^[a-z]+://", url):
|
|
29
|
+
raise WorkflowError(
|
|
30
|
+
f"Unsupported container URL '{url}': AWS Batch only runs Docker/OCI images from a "
|
|
31
|
+
"registry such as Docker Hub, quay.io, ghcr.io or ECR (e.g. "
|
|
32
|
+
"'docker://quay.io/biocontainers/samtools:1.21--h50ea8bc_0'). Singularity/Apptainer "
|
|
33
|
+
"images (.sif, oras://, shub://) can't be used."
|
|
34
|
+
)
|
|
35
|
+
return url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mutable_tag(image: str) -> bool:
|
|
39
|
+
"""True for an image without a version: no tag, or :latest, and no digest."""
|
|
40
|
+
if "@" in image:
|
|
41
|
+
return False
|
|
42
|
+
tag = image.rsplit("/", 1)[-1].partition(":")[2]
|
|
43
|
+
return tag in ("", "latest")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def image_registry(image: str) -> str:
|
|
47
|
+
"""Registry host of an image reference; Docker Hub images (no host part) give docker.io."""
|
|
48
|
+
first, _, rest = image.partition("/")
|
|
49
|
+
if rest and ("." in first or ":" in first or first == "localhost"):
|
|
50
|
+
return "docker.io" if first in ("index.docker.io", "registry-1.docker.io") else first
|
|
51
|
+
return "docker.io"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_ecr(registry: str) -> bool:
|
|
55
|
+
return bool(re.match(r"^\d{12}\.dkr\.ecr(-fips)?\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$", registry))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JobDefinitions:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
batch_client,
|
|
62
|
+
logger,
|
|
63
|
+
*,
|
|
64
|
+
prefix: str,
|
|
65
|
+
platform: str,
|
|
66
|
+
job_role_arn: Optional[str],
|
|
67
|
+
execution_role_arn: Optional[str],
|
|
68
|
+
log_group: str,
|
|
69
|
+
assign_public_ip: str,
|
|
70
|
+
ephemeral_storage_gib: Optional[int],
|
|
71
|
+
tags: dict,
|
|
72
|
+
mounts: Optional[list] = None,
|
|
73
|
+
registry_credentials: Optional[dict] = None,
|
|
74
|
+
):
|
|
75
|
+
self.batch = batch_client
|
|
76
|
+
self.logger = logger
|
|
77
|
+
self.prefix = re.sub(r"[^A-Za-z0-9_-]", "-", prefix)[:80]
|
|
78
|
+
self.platform = platform
|
|
79
|
+
self.job_role_arn = job_role_arn
|
|
80
|
+
self.execution_role_arn = execution_role_arn
|
|
81
|
+
self.log_group = log_group
|
|
82
|
+
self.assign_public_ip = assign_public_ip
|
|
83
|
+
self.ephemeral_storage_gib = ephemeral_storage_gib
|
|
84
|
+
self.tags = tags
|
|
85
|
+
# [(host_path, container_path, read_only)] bind mounts (EC2 only)
|
|
86
|
+
self.mounts = list(mounts or [])
|
|
87
|
+
# registry host -> Secrets Manager secret ARN (username/password) for private images
|
|
88
|
+
self.registry_credentials = dict(registry_credentials or {})
|
|
89
|
+
self._cache: dict[tuple, str] = {}
|
|
90
|
+
|
|
91
|
+
def container_properties(
|
|
92
|
+
self,
|
|
93
|
+
image: str,
|
|
94
|
+
platform: Optional[str] = None,
|
|
95
|
+
shm_mb: Optional[int] = None,
|
|
96
|
+
disk_gib: Optional[int] = None,
|
|
97
|
+
) -> dict:
|
|
98
|
+
platform = platform or self.platform
|
|
99
|
+
# vCPU/memory here are placeholders; they are overridden on every submit.
|
|
100
|
+
props: dict = {
|
|
101
|
+
"image": image,
|
|
102
|
+
"resourceRequirements": [
|
|
103
|
+
{"type": "VCPU", "value": "1"},
|
|
104
|
+
{"type": "MEMORY", "value": "2048"},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
if self.job_role_arn:
|
|
108
|
+
props["jobRoleArn"] = self.job_role_arn
|
|
109
|
+
secret = self.registry_credentials.get(image_registry(image))
|
|
110
|
+
if secret:
|
|
111
|
+
props["repositoryCredentials"] = {"credentialsParameter": secret}
|
|
112
|
+
# ECS reads the registry secret with the execution role, on EC2 too
|
|
113
|
+
if self.execution_role_arn and (platform == "fargate" or secret):
|
|
114
|
+
props["executionRoleArn"] = self.execution_role_arn
|
|
115
|
+
if self.mounts and platform == "ec2": # Fargate has no host paths
|
|
116
|
+
props["volumes"] = [
|
|
117
|
+
{"name": f"vol{i}", "host": {"sourcePath": host}}
|
|
118
|
+
for i, (host, _c, _ro) in enumerate(self.mounts)
|
|
119
|
+
]
|
|
120
|
+
props["mountPoints"] = [
|
|
121
|
+
{"sourceVolume": f"vol{i}", "containerPath": cont, "readOnly": ro}
|
|
122
|
+
for i, (_h, cont, ro) in enumerate(self.mounts)
|
|
123
|
+
]
|
|
124
|
+
if self.log_group != DEFAULT_LOG_GROUP:
|
|
125
|
+
options = {"awslogs-group": self.log_group}
|
|
126
|
+
region = getattr(getattr(self.batch, "meta", None), "region_name", None)
|
|
127
|
+
if region: # Fargate requires it
|
|
128
|
+
options["awslogs-region"] = region
|
|
129
|
+
props["logConfiguration"] = {"logDriver": "awslogs", "options": options}
|
|
130
|
+
if shm_mb and platform == "ec2": # /dev/shm size; Fargate doesn't support it
|
|
131
|
+
props["linuxParameters"] = {"sharedMemorySize": int(shm_mb)}
|
|
132
|
+
if platform == "fargate":
|
|
133
|
+
props["networkConfiguration"] = {"assignPublicIp": self.assign_public_ip}
|
|
134
|
+
# a fixed version, not LATEST, so jobs don't change platform from one run to the next
|
|
135
|
+
props["fargatePlatformConfiguration"] = {"platformVersion": FARGATE_PLATFORM}
|
|
136
|
+
disk = max(disk_gib or 0, self.ephemeral_storage_gib or 0)
|
|
137
|
+
if disk:
|
|
138
|
+
props["ephemeralStorage"] = {"sizeInGiB": disk}
|
|
139
|
+
return props
|
|
140
|
+
|
|
141
|
+
def ensure(
|
|
142
|
+
self,
|
|
143
|
+
image: str,
|
|
144
|
+
platform: Optional[str] = None,
|
|
145
|
+
shm_mb: Optional[int] = None,
|
|
146
|
+
disk_gib: Optional[int] = None,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""Return the job definition name for `image` on `platform` (and /dev/shm size,
|
|
149
|
+
Fargate disk size), registering it if needed."""
|
|
150
|
+
platform = platform or self.platform
|
|
151
|
+
key = (image, platform, shm_mb, disk_gib)
|
|
152
|
+
if key in self._cache:
|
|
153
|
+
return self._cache[key]
|
|
154
|
+
props = self.container_properties(image, platform, shm_mb, disk_gib)
|
|
155
|
+
digest = hashlib.sha256(
|
|
156
|
+
json.dumps([props, platform], sort_keys=True).encode()
|
|
157
|
+
).hexdigest()[:12]
|
|
158
|
+
name = f"{self.prefix}-{digest}"
|
|
159
|
+
try:
|
|
160
|
+
existing = self.batch.describe_job_definitions(
|
|
161
|
+
jobDefinitionName=name, status="ACTIVE"
|
|
162
|
+
)["jobDefinitions"]
|
|
163
|
+
if not existing:
|
|
164
|
+
self.logger.info(f"Registering AWS Batch job definition {name} for {image} ({platform})")
|
|
165
|
+
kwargs = dict(
|
|
166
|
+
jobDefinitionName=name,
|
|
167
|
+
type="container",
|
|
168
|
+
containerProperties=props,
|
|
169
|
+
platformCapabilities=[platform.upper()],
|
|
170
|
+
)
|
|
171
|
+
# ManagedBy lets scripts/cleanup-jobdefs.sh tell the plugin's definitions apart
|
|
172
|
+
kwargs["tags"] = {**self.tags, **MANAGED_BY}
|
|
173
|
+
self.batch.register_job_definition(**kwargs)
|
|
174
|
+
except ClientError as e:
|
|
175
|
+
raise WorkflowError(f"Could not register AWS Batch job definition: {e}") from e
|
|
176
|
+
self._cache[key] = name
|
|
177
|
+
return name
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class ExistingJobDefinition:
|
|
182
|
+
"""What a job definition made outside the plugin fixes for its jobs."""
|
|
183
|
+
arn: str
|
|
184
|
+
image: str
|
|
185
|
+
platform: str
|
|
186
|
+
container_paths: set
|
|
187
|
+
job_role: Optional[str]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def describe_existing(batch, ref: str) -> ExistingJobDefinition:
|
|
191
|
+
"""`ref` is a name (latest ACTIVE revision), name:revision, or an ARN."""
|
|
192
|
+
try:
|
|
193
|
+
if ref.startswith("arn:") or ":" in ref:
|
|
194
|
+
found = batch.describe_job_definitions(jobDefinitions=[ref])["jobDefinitions"]
|
|
195
|
+
else:
|
|
196
|
+
found, token = [], None
|
|
197
|
+
while True:
|
|
198
|
+
kw = dict(jobDefinitionName=ref, status="ACTIVE")
|
|
199
|
+
if token:
|
|
200
|
+
kw["nextToken"] = token
|
|
201
|
+
resp = batch.describe_job_definitions(**kw)
|
|
202
|
+
found += resp["jobDefinitions"]
|
|
203
|
+
token = resp.get("nextToken")
|
|
204
|
+
if not token:
|
|
205
|
+
break
|
|
206
|
+
except ClientError as e:
|
|
207
|
+
raise WorkflowError(f"Could not look up AWS Batch job definition '{ref}': {e}") from e
|
|
208
|
+
found = [d for d in found if d.get("status", "ACTIVE") == "ACTIVE"]
|
|
209
|
+
if not found:
|
|
210
|
+
raise WorkflowError(f"AWS Batch job definition '{ref}' not found (or not ACTIVE).")
|
|
211
|
+
d = max(found, key=lambda d: d.get("revision", 0))
|
|
212
|
+
props = d.get("containerProperties")
|
|
213
|
+
if d.get("type", "container") != "container" or not props:
|
|
214
|
+
raise WorkflowError(f"Job definition '{ref}' is not a single-container job definition.")
|
|
215
|
+
return ExistingJobDefinition(
|
|
216
|
+
arn=d["jobDefinitionArn"],
|
|
217
|
+
image=props.get("image", ""),
|
|
218
|
+
platform="fargate" if "FARGATE" in (d.get("platformCapabilities") or ["EC2"]) else "ec2",
|
|
219
|
+
container_paths={m.get("containerPath") for m in props.get("mountPoints") or []},
|
|
220
|
+
job_role=props.get("jobRoleArn"),
|
|
221
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Fixes the job's launcher applies before Snakemake starts in the job (it loads this file with
|
|
2
|
+
condacache.py and jobmonitor.py; job_tools_source). Only the standard library."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
MAX_LIBRARIES = 10 # missing system libraries to load before giving up
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _conda_root():
|
|
12
|
+
"""The AMI's conda mounted into the job, found by its condabin on PATH (bootstrap.conda_snippet)."""
|
|
13
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
14
|
+
if d.endswith(os.sep + "condabin"):
|
|
15
|
+
return os.path.dirname(d)
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load(root, lib):
|
|
20
|
+
"""Load the AMI conda's copy of a library into this process (for every later lookup)."""
|
|
21
|
+
import ctypes
|
|
22
|
+
|
|
23
|
+
path = os.path.join(root, "lib", lib)
|
|
24
|
+
if not os.path.exists(path):
|
|
25
|
+
return False
|
|
26
|
+
try:
|
|
27
|
+
ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
|
|
28
|
+
return True
|
|
29
|
+
except OSError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_numpy_loadable():
|
|
34
|
+
"""numpy (and pandas) in Snakemake's env need libraries of the system, such as libstdc++.so.6
|
|
35
|
+
and libz.so.1, which many images don't have where Linux looks (bioconda's keep theirs inside
|
|
36
|
+
their conda env). A Snakefile that imports pandas then fails in every job, and Snakemake
|
|
37
|
+
itself tries pandas in every script: job.
|
|
38
|
+
|
|
39
|
+
The AMI's conda, mounted into the job, has conda-forge's copies (made to work on old and new
|
|
40
|
+
systems): load the missing ones into this process, so numpy finds them already loaded. Only
|
|
41
|
+
Snakemake's own process gets them, not the rule's commands. If numpy still can't load
|
|
42
|
+
(no conda mounted), numpy and pandas look not installed, which Snakemake shrugs off, and the
|
|
43
|
+
log says why."""
|
|
44
|
+
root = _conda_root()
|
|
45
|
+
if root:
|
|
46
|
+
_load(root, "libgcc_s.so.1") # what libstdc++ itself needs
|
|
47
|
+
_load(root, "libstdc++.so.6")
|
|
48
|
+
for _ in range(MAX_LIBRARIES):
|
|
49
|
+
try:
|
|
50
|
+
import numpy # noqa: F401
|
|
51
|
+
return
|
|
52
|
+
except ModuleNotFoundError:
|
|
53
|
+
return
|
|
54
|
+
except ImportError as e:
|
|
55
|
+
error = e
|
|
56
|
+
missing = re.search(r"(\S+\.so[.\d]*): cannot open shared object file", str(e))
|
|
57
|
+
if not (root and missing and _load(root, os.path.basename(missing.group(1)))):
|
|
58
|
+
break
|
|
59
|
+
# what's left of the failed import goes, so the next try starts afresh
|
|
60
|
+
for name in [m for m in sys.modules if m == "numpy" or m.startswith("numpy.")]:
|
|
61
|
+
del sys.modules[name]
|
|
62
|
+
sys.modules["numpy"] = sys.modules["pandas"] = None
|
|
63
|
+
why = (str(error).strip().splitlines() or [""])[-1][:200]
|
|
64
|
+
print(f"[snakemake-batch] numpy and pandas of Snakemake's env can't load in this image, so this job "
|
|
65
|
+
f"runs without them: {why}", file=sys.stderr, flush=True)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Measure a job while it runs, for the table at the end of the run.
|
|
2
|
+
|
|
3
|
+
Runs inside the job (in Snakemake's Python, started by the job's launcher). A thread samples
|
|
4
|
+
the job's container:
|
|
5
|
+
|
|
6
|
+
- memory, every 0.5 seconds: anonymous plus shared memory from the cgroup's memory.stat (v2
|
|
7
|
+
anon/shmem, v1 rss/shmem), i.e. without file cache, which the kernel frees before it kills
|
|
8
|
+
anything. A spike between two samples isn't seen, so it's a lower bound; the cgroup's count
|
|
9
|
+
of processes killed for lack of memory (memory.events oom_kill) says when it fell short;
|
|
10
|
+
- CPUs, every 2 seconds: how many were busy at once, from the cgroup's CPU time (cpu.stat
|
|
11
|
+
usage_usec, or v1 cpuacct.usage) over each 2-second window;
|
|
12
|
+
- disk: the size of the job's working folder (its inputs, outputs and conda downloads),
|
|
13
|
+
every 20 seconds.
|
|
14
|
+
|
|
15
|
+
When Snakemake exits, one line goes to the job's log:
|
|
16
|
+
[snakemake-batch] peak-memory-bytes=... peak-cpus=... avg-cpus=... peak-disk-bytes=... oom-kills=...
|
|
17
|
+
|
|
18
|
+
At the start, the ID of the image the job runs in (what a tag pointed to at the time) goes there
|
|
19
|
+
too, from ECS's container metadata: [snakemake-batch] image ... id=sha256:...
|
|
20
|
+
|
|
21
|
+
Only the standard library is used."""
|
|
22
|
+
|
|
23
|
+
import atexit
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
CGROUP = "/sys/fs/cgroup"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _memory():
|
|
34
|
+
path = os.environ.get("SMK_FLEX_MEMSTAT_FILE", os.path.join(CGROUP, "memory.stat"))
|
|
35
|
+
total = 0
|
|
36
|
+
with open(path) as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
key, _, value = line.partition(" ")
|
|
39
|
+
if key in ("anon", "rss", "shmem"):
|
|
40
|
+
total += int(value)
|
|
41
|
+
return total
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cpu_microseconds():
|
|
45
|
+
path = os.environ.get("SMK_FLEX_CPUSTAT_FILE", os.path.join(CGROUP, "cpu.stat"))
|
|
46
|
+
try:
|
|
47
|
+
with open(path) as f:
|
|
48
|
+
for line in f:
|
|
49
|
+
if line.startswith("usage_usec "):
|
|
50
|
+
return int(line.split()[1])
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
try:
|
|
54
|
+
with open(os.path.join(CGROUP, "cpuacct", "cpuacct.usage")) as f: # cgroup v1, ns
|
|
55
|
+
return int(f.read()) // 1000
|
|
56
|
+
except OSError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _oom_kills():
|
|
61
|
+
"""Processes the kernel killed in this container for lack of memory, or None."""
|
|
62
|
+
for path in (os.path.join(CGROUP, "memory.events"), os.path.join(CGROUP, "memory", "memory.oom_control")):
|
|
63
|
+
try:
|
|
64
|
+
with open(path) as f:
|
|
65
|
+
for line in f:
|
|
66
|
+
if line.startswith("oom_kill "):
|
|
67
|
+
return int(line.split()[1])
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
pass
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _folder_size(path):
|
|
74
|
+
total = 0
|
|
75
|
+
for root, _dirs, files in os.walk(path):
|
|
76
|
+
for name in files:
|
|
77
|
+
try:
|
|
78
|
+
st = os.lstat(os.path.join(root, name))
|
|
79
|
+
total += st.st_blocks * 512 if hasattr(st, "st_blocks") else st.st_size
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
return total
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def log_image():
|
|
86
|
+
url = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
|
|
87
|
+
if not url:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
from urllib.request import urlopen
|
|
91
|
+
|
|
92
|
+
with urlopen(url, timeout=2) as r:
|
|
93
|
+
c = json.load(r)
|
|
94
|
+
print(f"[snakemake-batch] image {c.get('Image')} id={c.get('ImageID')}", file=sys.stderr, flush=True)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def monitor(interval=0.5, cpu_every=4, disk_every=40):
|
|
100
|
+
log_image()
|
|
101
|
+
work = os.environ.get("SMK_WORK") or os.getcwd()
|
|
102
|
+
peak = {"mem": 0, "cpus": 0.0, "disk": 0}
|
|
103
|
+
start_cpu, start_t = _cpu_microseconds(), time.monotonic()
|
|
104
|
+
|
|
105
|
+
def sample_memory():
|
|
106
|
+
try:
|
|
107
|
+
peak["mem"] = max(peak["mem"], _memory())
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def loop():
|
|
112
|
+
last_cpu, last_t, n = start_cpu, start_t, 0
|
|
113
|
+
while True:
|
|
114
|
+
time.sleep(interval)
|
|
115
|
+
n += 1
|
|
116
|
+
try:
|
|
117
|
+
sample_memory()
|
|
118
|
+
if n % cpu_every == 0:
|
|
119
|
+
cpu, t = _cpu_microseconds(), time.monotonic()
|
|
120
|
+
if cpu is not None and last_cpu is not None and t > last_t:
|
|
121
|
+
peak["cpus"] = max(peak["cpus"], (cpu - last_cpu) / 1e6 / (t - last_t))
|
|
122
|
+
last_cpu, last_t = cpu, t
|
|
123
|
+
if n % disk_every == 1:
|
|
124
|
+
peak["disk"] = max(peak["disk"], _folder_size(work))
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
129
|
+
|
|
130
|
+
def report():
|
|
131
|
+
try:
|
|
132
|
+
sample_memory()
|
|
133
|
+
peak["disk"] = max(peak["disk"], _folder_size(work))
|
|
134
|
+
parts = [f"peak-memory-bytes={peak['mem']}", f"peak-cpus={peak['cpus']:.2f}"]
|
|
135
|
+
cpu, t = _cpu_microseconds(), time.monotonic()
|
|
136
|
+
if cpu is not None and start_cpu is not None and t > start_t:
|
|
137
|
+
parts.append(f"avg-cpus={(cpu - start_cpu) / 1e6 / (t - start_t):.2f}")
|
|
138
|
+
parts.append(f"peak-disk-bytes={peak['disk']}")
|
|
139
|
+
kills = _oom_kills()
|
|
140
|
+
if kills is not None:
|
|
141
|
+
parts.append(f"oom-kills={kills}")
|
|
142
|
+
print("[snakemake-batch] " + " ".join(parts), file=sys.stderr, flush=True)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
atexit.register(report)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AWS Batch job names: up to 128 chars, letters/digits/hyphens/underscores, starting with a letter or digit."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
import string
|
|
5
|
+
|
|
6
|
+
MAX_LEN = 128
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def batch_job_name(name: str) -> str:
|
|
10
|
+
"""Make any Snakemake job name valid for Batch; never rejects. Long names keep a hash so they stay distinct."""
|
|
11
|
+
clean = re.sub(r"[^A-Za-z0-9_-]", "_", name)
|
|
12
|
+
if not clean or clean[0] not in string.ascii_letters + string.digits:
|
|
13
|
+
clean = "snk-" + clean
|
|
14
|
+
if len(clean) > MAX_LEN:
|
|
15
|
+
clean = clean[: MAX_LEN - 9] + "-" + hashlib.sha1(name.encode()).hexdigest()[:8]
|
|
16
|
+
return clean
|