toil 9.0.0__py3-none-any.whl → 9.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.
- toil/batchSystems/abstractBatchSystem.py +13 -5
- toil/batchSystems/abstractGridEngineBatchSystem.py +17 -5
- toil/batchSystems/kubernetes.py +13 -2
- toil/batchSystems/mesos/batchSystem.py +33 -2
- toil/batchSystems/slurm.py +191 -16
- toil/cwl/cwltoil.py +17 -82
- toil/fileStores/__init__.py +1 -1
- toil/fileStores/abstractFileStore.py +5 -2
- toil/fileStores/cachingFileStore.py +1 -1
- toil/job.py +30 -14
- toil/jobStores/abstractJobStore.py +24 -19
- toil/jobStores/aws/jobStore.py +862 -1963
- toil/jobStores/aws/utils.py +24 -270
- toil/jobStores/googleJobStore.py +25 -9
- toil/jobStores/utils.py +0 -327
- toil/leader.py +27 -22
- toil/lib/aws/config.py +22 -0
- toil/lib/aws/s3.py +477 -9
- toil/lib/aws/utils.py +22 -33
- toil/lib/checksum.py +88 -0
- toil/lib/conversions.py +33 -31
- toil/lib/directory.py +217 -0
- toil/lib/ec2.py +97 -29
- toil/lib/exceptions.py +2 -1
- toil/lib/expando.py +2 -2
- toil/lib/generatedEC2Lists.py +73 -16
- toil/lib/io.py +33 -2
- toil/lib/memoize.py +21 -7
- toil/lib/pipes.py +385 -0
- toil/lib/retry.py +1 -1
- toil/lib/threading.py +1 -1
- toil/lib/web.py +4 -5
- toil/provisioners/__init__.py +5 -2
- toil/provisioners/aws/__init__.py +43 -36
- toil/provisioners/aws/awsProvisioner.py +22 -13
- toil/provisioners/node.py +60 -12
- toil/resource.py +3 -13
- toil/test/__init__.py +14 -16
- toil/test/batchSystems/test_slurm.py +103 -14
- toil/test/cwl/staging_cat.cwl +27 -0
- toil/test/cwl/staging_make_file.cwl +25 -0
- toil/test/cwl/staging_workflow.cwl +43 -0
- toil/test/cwl/zero_default.cwl +61 -0
- toil/test/docs/scripts/tutorial_staging.py +17 -8
- toil/test/jobStores/jobStoreTest.py +23 -133
- toil/test/lib/aws/test_iam.py +7 -7
- toil/test/lib/aws/test_s3.py +30 -33
- toil/test/lib/aws/test_utils.py +9 -9
- toil/test/provisioners/aws/awsProvisionerTest.py +59 -6
- toil/test/src/autoDeploymentTest.py +2 -3
- toil/test/src/fileStoreTest.py +89 -87
- toil/test/utils/ABCWorkflowDebug/ABC.txt +1 -0
- toil/test/utils/ABCWorkflowDebug/debugWorkflow.py +4 -4
- toil/test/utils/toilKillTest.py +35 -28
- toil/test/wdl/md5sum/md5sum.json +1 -1
- toil/test/wdl/wdltoil_test.py +98 -38
- toil/test/wdl/wdltoil_test_kubernetes.py +9 -0
- toil/utils/toilDebugFile.py +6 -3
- toil/utils/toilStats.py +17 -2
- toil/version.py +6 -6
- toil/wdl/wdltoil.py +1032 -546
- toil/worker.py +5 -2
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/METADATA +12 -12
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/RECORD +68 -61
- toil/lib/iterables.py +0 -112
- toil/test/docs/scripts/stagingExampleFiles/in.txt +0 -1
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/WHEEL +0 -0
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/entry_points.txt +0 -0
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/licenses/LICENSE +0 -0
- {toil-9.0.0.dist-info → toil-9.1.0.dist-info}/top_level.txt +0 -0
toil/lib/checksum.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (C) 2015-2021 Regents of the University of California
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import logging
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
from typing import BinaryIO, Union, List, TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from toil.lib.aws.config import S3_PART_SIZE
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
# mypy complaint: https://github.com/python/typeshed/issues/2928
|
|
24
|
+
from hashlib import _Hash
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChecksumError(Exception):
|
|
31
|
+
"""Raised when a download does not contain the correct data."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Etag:
|
|
35
|
+
"""A hasher for s3 etags."""
|
|
36
|
+
def __init__(self, chunk_size: int) -> None:
|
|
37
|
+
self.etag_bytes: int = 0
|
|
38
|
+
self.etag_parts: List[bytes] = []
|
|
39
|
+
self.etag_hasher: "_Hash" = hashlib.md5()
|
|
40
|
+
self.chunk_size: int = chunk_size
|
|
41
|
+
|
|
42
|
+
def update(self, chunk: bytes) -> None:
|
|
43
|
+
if self.etag_bytes + len(chunk) > self.chunk_size:
|
|
44
|
+
chunk_head = chunk[:self.chunk_size - self.etag_bytes]
|
|
45
|
+
chunk_tail = chunk[self.chunk_size - self.etag_bytes:]
|
|
46
|
+
self.etag_hasher.update(chunk_head)
|
|
47
|
+
self.etag_parts.append(self.etag_hasher.digest())
|
|
48
|
+
self.etag_hasher = hashlib.md5()
|
|
49
|
+
self.etag_hasher.update(chunk_tail)
|
|
50
|
+
self.etag_bytes = len(chunk_tail)
|
|
51
|
+
else:
|
|
52
|
+
self.etag_hasher.update(chunk)
|
|
53
|
+
self.etag_bytes += len(chunk)
|
|
54
|
+
|
|
55
|
+
def hexdigest(self) -> str:
|
|
56
|
+
if self.etag_bytes:
|
|
57
|
+
self.etag_parts.append(self.etag_hasher.digest())
|
|
58
|
+
self.etag_bytes = 0
|
|
59
|
+
if len(self.etag_parts) > 1:
|
|
60
|
+
etag = hashlib.md5(b"".join(self.etag_parts)).hexdigest()
|
|
61
|
+
return f'{etag}-{len(self.etag_parts)}'
|
|
62
|
+
else:
|
|
63
|
+
return self.etag_hasher.hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
hashers = {'sha1': hashlib.sha1(),
|
|
67
|
+
'sha256': hashlib.sha256(),
|
|
68
|
+
'etag': Etag(chunk_size=S3_PART_SIZE)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def compute_checksum_for_file(local_file_path: str, algorithm: str = 'sha1') -> str:
|
|
72
|
+
with open(local_file_path, 'rb') as fh:
|
|
73
|
+
checksum_result = compute_checksum_for_content(fh, algorithm=algorithm)
|
|
74
|
+
return checksum_result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_checksum_for_content(fh: Union[BinaryIO, BytesIO], algorithm: str = 'sha1') -> str:
|
|
78
|
+
"""
|
|
79
|
+
Note: Chunk size matters for s3 etags, and must be the same to get the same hash from the same object.
|
|
80
|
+
Therefore this buffer is not modifiable throughout Toil.
|
|
81
|
+
"""
|
|
82
|
+
hasher: "_Hash" = hashers[algorithm] # type: ignore
|
|
83
|
+
contents = fh.read(S3_PART_SIZE)
|
|
84
|
+
while contents != b'':
|
|
85
|
+
hasher.update(contents)
|
|
86
|
+
contents = fh.read(S3_PART_SIZE)
|
|
87
|
+
|
|
88
|
+
return f'{algorithm}${hasher.hexdigest()}'
|
toil/lib/conversions.py
CHANGED
|
@@ -2,40 +2,28 @@
|
|
|
2
2
|
Conversion utilities for mapping memory, disk, core declarations from strings to numbers and vice versa.
|
|
3
3
|
Also contains general conversion functions
|
|
4
4
|
"""
|
|
5
|
-
|
|
6
5
|
import math
|
|
7
|
-
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
from typing import Optional, SupportsInt, Union, List
|
|
9
|
+
|
|
10
|
+
KIB = 1024
|
|
11
|
+
MIB = 1024 ** 2
|
|
12
|
+
GIB = 1024 ** 3
|
|
13
|
+
TIB = 1024 ** 4
|
|
14
|
+
PIB = 1024 ** 5
|
|
15
|
+
EIB = 1024 ** 6
|
|
16
|
+
|
|
17
|
+
KB = 1000
|
|
18
|
+
MB = 1000 ** 2
|
|
19
|
+
GB = 1000 ** 3
|
|
20
|
+
TB = 1000 ** 4
|
|
21
|
+
PB = 1000 ** 5
|
|
22
|
+
EB = 1000 ** 6
|
|
8
23
|
|
|
9
24
|
# See https://en.wikipedia.org/wiki/Binary_prefix
|
|
10
|
-
BINARY_PREFIXES = [
|
|
11
|
-
|
|
12
|
-
"mi",
|
|
13
|
-
"gi",
|
|
14
|
-
"ti",
|
|
15
|
-
"pi",
|
|
16
|
-
"ei",
|
|
17
|
-
"kib",
|
|
18
|
-
"mib",
|
|
19
|
-
"gib",
|
|
20
|
-
"tib",
|
|
21
|
-
"pib",
|
|
22
|
-
"eib",
|
|
23
|
-
]
|
|
24
|
-
DECIMAL_PREFIXES = [
|
|
25
|
-
"b",
|
|
26
|
-
"k",
|
|
27
|
-
"m",
|
|
28
|
-
"g",
|
|
29
|
-
"t",
|
|
30
|
-
"p",
|
|
31
|
-
"e",
|
|
32
|
-
"kb",
|
|
33
|
-
"mb",
|
|
34
|
-
"gb",
|
|
35
|
-
"tb",
|
|
36
|
-
"pb",
|
|
37
|
-
"eb",
|
|
38
|
-
]
|
|
25
|
+
BINARY_PREFIXES = ['ki', 'mi', 'gi', 'ti', 'pi', 'ei', 'kib', 'mib', 'gib', 'tib', 'pib', 'eib']
|
|
26
|
+
DECIMAL_PREFIXES = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb']
|
|
39
27
|
VALID_PREFIXES = BINARY_PREFIXES + DECIMAL_PREFIXES
|
|
40
28
|
|
|
41
29
|
|
|
@@ -185,3 +173,17 @@ def strtobool(val: str) -> bool:
|
|
|
185
173
|
def opt_strtobool(b: Optional[str]) -> Optional[bool]:
|
|
186
174
|
"""Convert an optional string representation of bool to None or bool"""
|
|
187
175
|
return b if b is None else strtobool(b)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def modify_url(url: str, remove: List[str]) -> str:
|
|
179
|
+
"""
|
|
180
|
+
Given a valid URL string, split out the params, remove any offending
|
|
181
|
+
params in 'remove', and return the cleaned URL.
|
|
182
|
+
"""
|
|
183
|
+
scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url)
|
|
184
|
+
params = urllib.parse.parse_qs(query)
|
|
185
|
+
for param_key in remove:
|
|
186
|
+
if param_key in params:
|
|
187
|
+
del params[param_key]
|
|
188
|
+
query = urllib.parse.urlencode(params, doseq=True)
|
|
189
|
+
return urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
|
toil/lib/directory.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Copyright (C) 2015-2025 Regents of the University of California
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import base64
|
|
17
|
+
|
|
18
|
+
from urllib.parse import quote, unquote
|
|
19
|
+
|
|
20
|
+
from typing import Iterator, Optional, Union
|
|
21
|
+
|
|
22
|
+
TOIL_DIR_URI_SCHEME = "toildir:"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DirectoryContents = dict[str, Union[str, "DirectoryContents"]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_directory_dict_invariants(contents: DirectoryContents) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Make sure a directory structure dict makes sense. Throws an error
|
|
31
|
+
otherwise.
|
|
32
|
+
|
|
33
|
+
Currently just checks to make sure no empty-string keys exist.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
for name, item in contents.items():
|
|
37
|
+
if name == "":
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"Found nameless entry in directory: " + json.dumps(contents, indent=2)
|
|
40
|
+
)
|
|
41
|
+
if isinstance(item, dict):
|
|
42
|
+
check_directory_dict_invariants(item)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_directory(
|
|
46
|
+
dir_path: str,
|
|
47
|
+
) -> tuple[DirectoryContents, Optional[str], str, Optional[str], Optional[str]]:
|
|
48
|
+
"""
|
|
49
|
+
Decode a directory from a "toildir:" path to a directory (or a file in it).
|
|
50
|
+
|
|
51
|
+
:returns: the decoded directory dict, the remaining part of the path (which
|
|
52
|
+
may be None), an identifier string for the directory (which is the
|
|
53
|
+
stored name URI if one was provided), and the name URI and source task
|
|
54
|
+
info.
|
|
55
|
+
"""
|
|
56
|
+
if not dir_path.startswith(TOIL_DIR_URI_SCHEME):
|
|
57
|
+
raise RuntimeError(f"Cannot decode non-directory path: {dir_path}")
|
|
58
|
+
|
|
59
|
+
# We will decode the directory and then look inside it
|
|
60
|
+
|
|
61
|
+
# Since this was encoded by upload_directory we know the
|
|
62
|
+
# next pieces are encoded source URL, encoded source task, and JSON
|
|
63
|
+
# describing the directory structure, and it can't contain any slashes.
|
|
64
|
+
#
|
|
65
|
+
# So split on slash to separate all that from the path components within
|
|
66
|
+
# the directory to whatever we're trying to get.
|
|
67
|
+
parts = dir_path[len(TOIL_DIR_URI_SCHEME) :].split("/", 1)
|
|
68
|
+
|
|
69
|
+
# Before the first slash is the encoded data describing the directory contents
|
|
70
|
+
encoded_name, encoded_source, dir_data = parts[0].split(":")
|
|
71
|
+
# Decode the name and source, replacing empty string with None again.
|
|
72
|
+
name: Optional[str] = unquote(encoded_name) or None
|
|
73
|
+
source: Optional[str] = unquote(encoded_source) or None
|
|
74
|
+
|
|
75
|
+
# We need the unique key identifying this directory, which is where it came
|
|
76
|
+
# from if stored, or the encoded data itself otherwise.
|
|
77
|
+
# TODO: Is this too complicated?
|
|
78
|
+
directory_identifier = name if name is not None else dir_data
|
|
79
|
+
|
|
80
|
+
# Decode what to download
|
|
81
|
+
contents = json.loads(
|
|
82
|
+
base64.urlsafe_b64decode(dir_data.encode("utf-8")).decode("utf-8")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
check_directory_dict_invariants(contents)
|
|
86
|
+
|
|
87
|
+
if len(parts) == 1 or parts[1] == "/":
|
|
88
|
+
# We didn't have any subdirectory
|
|
89
|
+
return contents, None, directory_identifier, name, source
|
|
90
|
+
else:
|
|
91
|
+
# We have a path below this
|
|
92
|
+
return contents, parts[1], directory_identifier, name, source
|
|
93
|
+
|
|
94
|
+
def encode_directory(contents: DirectoryContents, name: Optional[str] = None, source: Optional[str] = None) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Encode a directory from a "toildir:" path to a directory (or a file in it).
|
|
97
|
+
|
|
98
|
+
:param contents: the directory dict, which is a dict from name to URI for a
|
|
99
|
+
file or dict for a subdirectory.
|
|
100
|
+
:param name: the path or URI the directory belongs at, including its
|
|
101
|
+
basename. May not be empty if set.
|
|
102
|
+
:param source: the name of a workflow component that uploaded the
|
|
103
|
+
directory. May not be empty if set.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
check_directory_dict_invariants(contents)
|
|
107
|
+
|
|
108
|
+
parts = [
|
|
109
|
+
TOIL_DIR_URI_SCHEME[:-1],
|
|
110
|
+
quote(name or "", safe=""),
|
|
111
|
+
quote(source or "", safe=""),
|
|
112
|
+
base64.urlsafe_b64encode(
|
|
113
|
+
json.dumps(contents).encode("utf-8")
|
|
114
|
+
).decode("utf-8"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
return ":".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def directory_item_exists(dir_path: str) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Checks that a URL to a Toil directory or thing in it actually exists.
|
|
123
|
+
|
|
124
|
+
Assumes that all the pointed-to URLs exist; just checks tha tthe thing is
|
|
125
|
+
actually in the encoded directory structure.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
get_directory_item(dir_path)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
def get_directory_contents_item(contents: DirectoryContents, remaining_path: Optional[str]) -> Union[DirectoryContents, str]:
|
|
135
|
+
"""
|
|
136
|
+
Get a subdirectory or file from a decoded directory and remaining path.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
if remaining_path is None:
|
|
140
|
+
return contents
|
|
141
|
+
|
|
142
|
+
here: Union[str, DirectoryContents] = contents
|
|
143
|
+
for part in remaining_path.split("/"):
|
|
144
|
+
if not isinstance(here, dict):
|
|
145
|
+
# We're trying to go inside a file
|
|
146
|
+
raise FileNotFoundError(remaining_path)
|
|
147
|
+
if part not in here:
|
|
148
|
+
# We've hit a nonexistent path component
|
|
149
|
+
raise FileNotFoundError(remaining_path)
|
|
150
|
+
here = here[part]
|
|
151
|
+
# If we get here we successfully looked up the thing in the structure
|
|
152
|
+
return here
|
|
153
|
+
|
|
154
|
+
def get_directory_item(dir_path: str) -> Union[DirectoryContents, str]:
|
|
155
|
+
"""
|
|
156
|
+
Get a subdirectory or file from a URL pointing to or into a toildir: directory.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
contents, remaining_path, _, _, _ = decode_directory(dir_path)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
return get_directory_contents_item(contents, remaining_path)
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
# Rewrite file not found to be about the full thing we went to look up.
|
|
165
|
+
raise FileNotFoundError(dir_path)
|
|
166
|
+
|
|
167
|
+
def directory_contents_items(contents: DirectoryContents) -> Iterator[tuple[str, Union[str, None]]]:
|
|
168
|
+
"""
|
|
169
|
+
Yield each file or directory under the given contents, including itself.
|
|
170
|
+
|
|
171
|
+
Yields parent items before children.
|
|
172
|
+
|
|
173
|
+
Yields each item as a str path from the root (possibly empty), and either a
|
|
174
|
+
str value for files or a None for directories.
|
|
175
|
+
|
|
176
|
+
The path won't have trailing slashes.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
# Yield the thing itself
|
|
180
|
+
yield ("", None)
|
|
181
|
+
|
|
182
|
+
for k, v in contents.items():
|
|
183
|
+
if isinstance(v, str):
|
|
184
|
+
# Yield a file
|
|
185
|
+
yield (k, v)
|
|
186
|
+
else:
|
|
187
|
+
# Recurse on the directory
|
|
188
|
+
for child_path, child_value in directory_contents_items(v):
|
|
189
|
+
yield (f"{k}/{child_path}", child_value)
|
|
190
|
+
|
|
191
|
+
def directory_items(dir_path: str) -> Iterator[tuple[str, Union[str, None]]]:
|
|
192
|
+
"""
|
|
193
|
+
Yield each file or directory under the given path, including itself.
|
|
194
|
+
|
|
195
|
+
Yields parent items before children.
|
|
196
|
+
|
|
197
|
+
Yields each item as a str path from the root (possibly empty), and either a
|
|
198
|
+
str value for files or a None for directories.
|
|
199
|
+
|
|
200
|
+
The path won't have trailing slashes.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
item = get_directory_item(dir_path)
|
|
204
|
+
|
|
205
|
+
if isinstance(item, str):
|
|
206
|
+
# Only one item and it's this file
|
|
207
|
+
yield ("", item)
|
|
208
|
+
else:
|
|
209
|
+
# It's a directory in there.
|
|
210
|
+
yield from directory_contents_items(item)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
toil/lib/ec2.py
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
|
-
from base64 import b64encode
|
|
3
|
+
from base64 import b64encode, b64decode
|
|
4
|
+
import binascii
|
|
4
5
|
from collections.abc import Generator, Iterable, Mapping
|
|
5
|
-
from typing import
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
Callable,
|
|
10
|
+
Literal,
|
|
11
|
+
Optional,
|
|
12
|
+
Union,
|
|
13
|
+
)
|
|
6
14
|
|
|
7
15
|
from toil.lib.aws.session import establish_boto3_session
|
|
8
|
-
from toil.lib.aws.utils import flatten_tags
|
|
16
|
+
from toil.lib.aws.utils import flatten_tags, boto3_pager
|
|
9
17
|
from toil.lib.exceptions import panic
|
|
10
18
|
from toil.lib.retry import (
|
|
11
19
|
ErrorCondition,
|
|
@@ -29,6 +37,19 @@ a_short_time = 5
|
|
|
29
37
|
a_long_time = 60 * 60
|
|
30
38
|
logger = logging.getLogger(__name__)
|
|
31
39
|
|
|
40
|
+
def is_base64(value: str) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Return True if value is base64-decodeable, and False otherwise.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
b64decode(
|
|
46
|
+
value.encode("utf-8"),
|
|
47
|
+
validate=True
|
|
48
|
+
)
|
|
49
|
+
return True
|
|
50
|
+
except binascii.Error:
|
|
51
|
+
return False
|
|
52
|
+
|
|
32
53
|
|
|
33
54
|
class UserError(RuntimeError):
|
|
34
55
|
def __init__(self, message=None, cause=None):
|
|
@@ -129,7 +150,7 @@ def wait_instances_running(
|
|
|
129
150
|
elif i["State"]["Name"] == "running":
|
|
130
151
|
if i["InstanceId"] in running_ids:
|
|
131
152
|
raise RuntimeError(
|
|
132
|
-
"
|
|
153
|
+
f"Instance {i['InstanceId']} was already added to the list of running instance IDs. Maybe there is a duplicate."
|
|
133
154
|
)
|
|
134
155
|
running_ids.add(i["InstanceId"])
|
|
135
156
|
yield i
|
|
@@ -151,12 +172,15 @@ def wait_instances_running(
|
|
|
151
172
|
time.sleep(seconds)
|
|
152
173
|
for attempt in retry_ec2():
|
|
153
174
|
with attempt:
|
|
154
|
-
|
|
175
|
+
# describe_instances weirdly really describes reservations
|
|
176
|
+
reservations = boto3_pager(
|
|
177
|
+
boto3_ec2.describe_instances,
|
|
178
|
+
"Reservations",
|
|
155
179
|
InstanceIds=list(pending_ids)
|
|
156
180
|
)
|
|
157
181
|
instances = [
|
|
158
182
|
instance
|
|
159
|
-
for reservation in
|
|
183
|
+
for reservation in reservations
|
|
160
184
|
for instance in reservation["Instances"]
|
|
161
185
|
]
|
|
162
186
|
|
|
@@ -184,6 +208,9 @@ def wait_spot_requests_active(
|
|
|
184
208
|
|
|
185
209
|
if timeout is not None:
|
|
186
210
|
timeout = time.time() + timeout
|
|
211
|
+
|
|
212
|
+
# These hold spot instance request IDs.
|
|
213
|
+
# Not to be confused with instance IDs.
|
|
187
214
|
active_ids = set()
|
|
188
215
|
other_ids = set()
|
|
189
216
|
open_ids = None
|
|
@@ -201,34 +228,37 @@ def wait_spot_requests_active(
|
|
|
201
228
|
batch = []
|
|
202
229
|
for r in requests:
|
|
203
230
|
r: "SpotInstanceRequestTypeDef" # pycharm thinks it is a string
|
|
231
|
+
assert isinstance(r, dict), f"Found garbage posing as a spot request: {r}"
|
|
204
232
|
if r["State"] == "open":
|
|
205
|
-
open_ids.add(r["
|
|
206
|
-
if r["Status"] == "pending-evaluation":
|
|
207
|
-
eval_ids.add(r["
|
|
208
|
-
elif r["Status"] == "pending-fulfillment":
|
|
209
|
-
fulfill_ids.add(r["
|
|
233
|
+
open_ids.add(r["SpotInstanceRequestId"])
|
|
234
|
+
if r["Status"]["Code"] == "pending-evaluation":
|
|
235
|
+
eval_ids.add(r["SpotInstanceRequestId"])
|
|
236
|
+
elif r["Status"]["Code"] == "pending-fulfillment":
|
|
237
|
+
fulfill_ids.add(r["SpotInstanceRequestId"])
|
|
210
238
|
else:
|
|
211
239
|
logger.info(
|
|
212
240
|
"Request %s entered status %s indicating that it will not be "
|
|
213
|
-
"fulfilled anytime soon.",
|
|
214
|
-
r["
|
|
215
|
-
r["Status"],
|
|
241
|
+
"fulfilled anytime soon. (Message: %s)",
|
|
242
|
+
r["SpotInstanceRequestId"],
|
|
243
|
+
r["Status"]["Code"],
|
|
244
|
+
r["Status"].get("Message"),
|
|
216
245
|
)
|
|
217
246
|
elif r["State"] == "active":
|
|
218
|
-
if r["
|
|
247
|
+
if r["SpotInstanceRequestId"] in active_ids:
|
|
219
248
|
raise RuntimeError(
|
|
220
249
|
"A request was already added to the list of active requests. Maybe there are duplicate requests."
|
|
221
250
|
)
|
|
222
|
-
active_ids.add(r["
|
|
251
|
+
active_ids.add(r["SpotInstanceRequestId"])
|
|
223
252
|
batch.append(r)
|
|
224
253
|
else:
|
|
225
|
-
if r["
|
|
254
|
+
if r["SpotInstanceRequestId"] in other_ids:
|
|
226
255
|
raise RuntimeError(
|
|
227
256
|
"A request was already added to the list of other IDs. Maybe there are duplicate requests."
|
|
228
257
|
)
|
|
229
|
-
other_ids.add(r["
|
|
258
|
+
other_ids.add(r["SpotInstanceRequestId"])
|
|
230
259
|
batch.append(r)
|
|
231
260
|
if batch:
|
|
261
|
+
logger.debug("Found %d new active/other spot requests", len(batch))
|
|
232
262
|
yield batch
|
|
233
263
|
logger.info(
|
|
234
264
|
"%i spot requests(s) are open (%i of which are pending evaluation and %i "
|
|
@@ -247,8 +277,10 @@ def wait_spot_requests_active(
|
|
|
247
277
|
time.sleep(sleep_time)
|
|
248
278
|
for attempt in retry_ec2(retry_while=spot_request_not_found):
|
|
249
279
|
with attempt:
|
|
250
|
-
requests =
|
|
251
|
-
|
|
280
|
+
requests = boto3_pager(
|
|
281
|
+
boto3_ec2.describe_spot_instance_requests,
|
|
282
|
+
"SpotInstanceRequests",
|
|
283
|
+
SpotInstanceRequestIds=list(open_ids),
|
|
252
284
|
)
|
|
253
285
|
except BaseException:
|
|
254
286
|
if open_ids:
|
|
@@ -264,14 +296,20 @@ def create_spot_instances(
|
|
|
264
296
|
boto3_ec2: "EC2Client",
|
|
265
297
|
price,
|
|
266
298
|
image_id,
|
|
267
|
-
spec,
|
|
299
|
+
spec: dict[Literal["LaunchSpecification"], dict[str, Any]],
|
|
268
300
|
num_instances=1,
|
|
269
301
|
timeout=None,
|
|
270
302
|
tentative=False,
|
|
271
|
-
tags=None,
|
|
303
|
+
tags: dict[str, str] = None,
|
|
272
304
|
) -> Generator["DescribeInstancesResultTypeDef", None, None]:
|
|
273
305
|
"""
|
|
274
306
|
Create instances on the spot market.
|
|
307
|
+
|
|
308
|
+
The "UserData" field in "LaunchSpecification" in spec MUST ALREADY BE
|
|
309
|
+
base64-encoded. It will NOT be automatically encoded.
|
|
310
|
+
|
|
311
|
+
:param tags: Dict from tag key to tag value of tags to apply to the
|
|
312
|
+
request.
|
|
275
313
|
"""
|
|
276
314
|
|
|
277
315
|
def spotRequestNotFound(e):
|
|
@@ -280,20 +318,27 @@ def create_spot_instances(
|
|
|
280
318
|
spec["LaunchSpecification"].update(
|
|
281
319
|
{"ImageId": image_id}
|
|
282
320
|
) # boto3 image id is in the launch specification
|
|
321
|
+
|
|
322
|
+
user_data = spec["LaunchSpecification"].get("UserData", "")
|
|
323
|
+
assert is_base64(user_data), f"Spot user data needs to be base64-encoded: {user_data}"
|
|
324
|
+
|
|
283
325
|
for attempt in retry_ec2(
|
|
284
326
|
retry_for=a_long_time, retry_while=inconsistencies_detected
|
|
285
327
|
):
|
|
286
328
|
with attempt:
|
|
287
329
|
requests_dict = boto3_ec2.request_spot_instances(
|
|
288
|
-
SpotPrice=price, InstanceCount=num_instances, **spec
|
|
330
|
+
SpotPrice=str(price), InstanceCount=num_instances, **spec
|
|
289
331
|
)
|
|
290
332
|
requests = requests_dict["SpotInstanceRequests"]
|
|
291
333
|
|
|
334
|
+
assert isinstance(requests, list)
|
|
335
|
+
|
|
292
336
|
if tags is not None:
|
|
337
|
+
flat_tags = flatten_tags(tags)
|
|
293
338
|
for requestID in (request["SpotInstanceRequestId"] for request in requests):
|
|
294
339
|
for attempt in retry_ec2(retry_while=spotRequestNotFound):
|
|
295
340
|
with attempt:
|
|
296
|
-
boto3_ec2.create_tags(Resources=[requestID], Tags=
|
|
341
|
+
boto3_ec2.create_tags(Resources=[requestID], Tags=flat_tags)
|
|
297
342
|
|
|
298
343
|
num_active, num_other = 0, 0
|
|
299
344
|
# noinspection PyUnboundLocalVariable,PyTypeChecker
|
|
@@ -310,7 +355,7 @@ def create_spot_instances(
|
|
|
310
355
|
else:
|
|
311
356
|
logger.info(
|
|
312
357
|
"Request %s in unexpected state %s.",
|
|
313
|
-
request["
|
|
358
|
+
request["SpotInstanceRequestId"],
|
|
314
359
|
request["State"],
|
|
315
360
|
)
|
|
316
361
|
num_other += 1
|
|
@@ -324,7 +369,14 @@ def create_spot_instances(
|
|
|
324
369
|
boto3_ec2.modify_instance_metadata_options(
|
|
325
370
|
InstanceId=instance_id, HttpPutResponseHopLimit=3
|
|
326
371
|
)
|
|
327
|
-
|
|
372
|
+
# We can't use the normal boto3_pager here because we're weirdly
|
|
373
|
+
# specced as yielding the pages ourselves.
|
|
374
|
+
# TODO: Change this to just yield instance descriptions instead.
|
|
375
|
+
page = boto3_ec2.describe_instances(InstanceIds=instance_ids)
|
|
376
|
+
while page.get("NextToken") is not None:
|
|
377
|
+
yield page
|
|
378
|
+
page = boto3_ec2.describe_instances(InstanceIds=instance_ids, NextToken=page["NextToken"])
|
|
379
|
+
yield page
|
|
328
380
|
if not num_active:
|
|
329
381
|
message = "None of the spot requests entered the active state"
|
|
330
382
|
if tentative:
|
|
@@ -335,6 +387,9 @@ def create_spot_instances(
|
|
|
335
387
|
logger.warning("%i request(s) entered a state other than active.", num_other)
|
|
336
388
|
|
|
337
389
|
|
|
390
|
+
# TODO: Get rid of this and use create_instances instead.
|
|
391
|
+
# Right now we need it because we have code that needs an InstanceTypeDef for
|
|
392
|
+
# either a spot or an ondemand instance.
|
|
338
393
|
def create_ondemand_instances(
|
|
339
394
|
boto3_ec2: "EC2Client",
|
|
340
395
|
image_id: str,
|
|
@@ -344,7 +399,19 @@ def create_ondemand_instances(
|
|
|
344
399
|
"""
|
|
345
400
|
Requests the RunInstances EC2 API call but accounts for the race between recently created
|
|
346
401
|
instance profiles, IAM roles and an instance creation that refers to them.
|
|
402
|
+
|
|
403
|
+
The "UserData" field in spec MUST NOT be base64 encoded; it will be
|
|
404
|
+
base64-encoded by boto3 automatically. See
|
|
405
|
+
<https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/run_instances.html>.
|
|
406
|
+
|
|
407
|
+
Replaced by create_instances.
|
|
347
408
|
"""
|
|
409
|
+
|
|
410
|
+
user_data: str = spec.get("UserData", "")
|
|
411
|
+
if user_data:
|
|
412
|
+
# Hope any real user data contains some characters not allowed in base64
|
|
413
|
+
assert not is_base64(user_data), f"On-demand user data needs to not be base64-encoded: {user_data}"
|
|
414
|
+
|
|
348
415
|
instance_type = spec["InstanceType"]
|
|
349
416
|
logger.info("Creating %s instance(s) ... ", instance_type)
|
|
350
417
|
boto_instance_list = []
|
|
@@ -434,12 +501,13 @@ def create_instances(
|
|
|
434
501
|
Not to be confused with "run_instances" (same input args; returns a dictionary):
|
|
435
502
|
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html#EC2.Client.run_instances
|
|
436
503
|
|
|
437
|
-
|
|
504
|
+
:param user_data: non-base64-encoded user data to control instance startup.
|
|
505
|
+
:param tags: if given, these tags are applied to the instances, and all volumes.
|
|
438
506
|
"""
|
|
439
507
|
logger.info("Creating %s instance(s) ... ", instance_type)
|
|
440
508
|
|
|
441
|
-
if isinstance(user_data,
|
|
442
|
-
user_data = user_data.
|
|
509
|
+
if isinstance(user_data, bytes):
|
|
510
|
+
user_data = user_data.decode("utf-8")
|
|
443
511
|
|
|
444
512
|
request = {
|
|
445
513
|
"ImageId": image_id,
|
toil/lib/exceptions.py
CHANGED
|
@@ -18,6 +18,7 @@ import sys
|
|
|
18
18
|
from typing import Optional
|
|
19
19
|
import logging
|
|
20
20
|
from urllib.parse import ParseResult
|
|
21
|
+
from types import TracebackType
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
# TODO: isn't this built in to Python 3 now?
|
|
@@ -56,7 +57,7 @@ class panic:
|
|
|
56
57
|
raise_(exc_type, exc_value, traceback)
|
|
57
58
|
|
|
58
59
|
|
|
59
|
-
def raise_(exc_type, exc_value, traceback) -> None:
|
|
60
|
+
def raise_(exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None:
|
|
60
61
|
if exc_value is not None:
|
|
61
62
|
exc = exc_value
|
|
62
63
|
else:
|
toil/lib/expando.py
CHANGED
|
@@ -67,10 +67,10 @@ class Expando(dict):
|
|
|
67
67
|
...
|
|
68
68
|
KeyError: 'foo'
|
|
69
69
|
|
|
70
|
-
>>> del o.foo
|
|
70
|
+
>>> del o.foo # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
71
71
|
Traceback (most recent call last):
|
|
72
72
|
...
|
|
73
|
-
AttributeError: foo
|
|
73
|
+
AttributeError: 'Expando' object has no attribute 'foo'
|
|
74
74
|
|
|
75
75
|
And copied:
|
|
76
76
|
|