cpg-utils 5.7.2__tar.gz → 5.8.0__tar.gz
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.
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/PKG-INFO +4 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cloudpath_hail_az.py +1 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/config.py +5 -2
- cpg_utils-5.8.0/cpg_utils/dataproc_runner.py +927 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/hail_batch.py +0 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/PKG-INFO +4 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/SOURCES.txt +2 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/requires.txt +3 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/pyproject.toml +4 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/setup.py +4 -1
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_config.py +17 -0
- cpg_utils-5.8.0/test/test_dataproc_runner.py +160 -0
- cpg_utils-5.7.2/test/test_doctests.py +0 -22
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/LICENSE +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/README.md +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/__init__.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cloud.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/constants.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cromwell.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cromwell_model.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/dataproc.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/existence_checks.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/git.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/membership.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/metamist_registration.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/py.typed +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/slack.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/dependency_links.txt +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/top_level.txt +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/setup.cfg +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/__init__.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_cromwell.py +0 -0
- {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_get_google_identity_token.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cpg-utils
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.8.0
|
|
4
4
|
Summary: Library of convenience functions specific to the CPG
|
|
5
5
|
Home-page: https://github.com/populationgenomics/cpg-utils
|
|
6
6
|
License: MIT
|
|
@@ -24,8 +24,11 @@ Requires-Dist: frozendict
|
|
|
24
24
|
Requires-Dist: google-auth>=1.27.0
|
|
25
25
|
Requires-Dist: google-auth-oauthlib
|
|
26
26
|
Requires-Dist: google-cloud-artifact-registry
|
|
27
|
+
Requires-Dist: google-cloud-dataproc
|
|
27
28
|
Requires-Dist: google-cloud-secret-manager
|
|
29
|
+
Requires-Dist: google-cloud-storage
|
|
28
30
|
Requires-Dist: metamist
|
|
31
|
+
Requires-Dist: python-slugify
|
|
29
32
|
Requires-Dist: requests
|
|
30
33
|
Requires-Dist: tabulate
|
|
31
34
|
Requires-Dist: toml
|
|
@@ -26,7 +26,7 @@ class HailAzureBlobPath(AzureBlobPath):
|
|
|
26
26
|
>>> CloudPath('hail-az://myaccount/mycontainer/tmp')
|
|
27
27
|
HailAzureBlobPath('hail-az://myaccount/mycontainer/tmp')
|
|
28
28
|
>>> CloudPath('https://myaccount.blob.core.windows.net/mycontainer/tmp')
|
|
29
|
-
|
|
29
|
+
HttpsPath('https://myaccount.blob.core.windows.net/mycontainer/tmp')
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
cloud_prefix: str = 'hail-az://'
|
|
@@ -576,9 +576,12 @@ def dataset_for_access_level(dataset_name: str) -> str:
|
|
|
576
576
|
"""
|
|
577
577
|
Checks access_level in config, modifying dataset with "-test" suffix if appropriate.
|
|
578
578
|
|
|
579
|
-
|
|
579
|
+
# with access_level != 'test'
|
|
580
|
+
> dataset_for_access_level('dataset')
|
|
580
581
|
'dataset'
|
|
581
|
-
|
|
582
|
+
|
|
583
|
+
# with access_level == 'test'
|
|
584
|
+
> dataset_for_access_level('dataset')
|
|
582
585
|
'dataset-test'
|
|
583
586
|
"""
|
|
584
587
|
access_level = config_retrieve(['workflow', 'access_level'], 'standard')
|
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""Dataproc client wrapper for running Hail jobs on ephemeral clusters.
|
|
2
|
+
|
|
3
|
+
A non-Hail alternative to cpg_utils.dataproc. Instead of wrapping the cluster
|
|
4
|
+
lifecycle inside a Hail Batch driver job and shelling out to hailctl, this
|
|
5
|
+
module drives the Dataproc REST API directly through the google-cloud-dataproc
|
|
6
|
+
library. It is intended for orchestrators such as Nextflow that need to submit
|
|
7
|
+
one or more Hail PySpark scripts to an ephemeral cluster and tear it down when
|
|
8
|
+
done.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
from cpg_utils.dataproc_runner import HailDataprocCluster
|
|
12
|
+
|
|
13
|
+
with HailDataprocCluster(
|
|
14
|
+
project='my-project',
|
|
15
|
+
region='australia-southeast1',
|
|
16
|
+
cluster_name_prefix='my-pipeline',
|
|
17
|
+
staging_bucket='gs://my-staging',
|
|
18
|
+
temp_bucket='gs://my-temp',
|
|
19
|
+
labels={'ar-guid': 'abc123', 'stage': 'align'},
|
|
20
|
+
) as cluster:
|
|
21
|
+
script_uri = cluster.upload('local_script.py')
|
|
22
|
+
input_uri = cluster.upload('samplesheet.tsv')
|
|
23
|
+
cluster.run_job(
|
|
24
|
+
script_uri,
|
|
25
|
+
args=['--samplesheet', input_uri, '--out', 'gs://out/vds'],
|
|
26
|
+
)
|
|
27
|
+
# Multiple jobs can be submitted to the same cluster before shutdown.
|
|
28
|
+
cluster.run_job(script_uri, args=['--samplesheet', input_uri, '--stage', 'qc'])
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import signal
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from contextlib import suppress
|
|
38
|
+
from types import FrameType, TracebackType
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from cloudpathlib import GSClient, GSPath
|
|
42
|
+
from google.api_core import exceptions as gax_exceptions
|
|
43
|
+
from google.cloud import dataproc_v1, storage
|
|
44
|
+
from slugify import slugify
|
|
45
|
+
|
|
46
|
+
DEFAULT_WHEEL = (
|
|
47
|
+
'gs://hail-common/hailctl/dataproc/{version}/hail-{version}-py3-none-any.whl'
|
|
48
|
+
)
|
|
49
|
+
DEFAULT_HAIL_VERSION = '0.2.138'
|
|
50
|
+
DEFAULT_HAIL_IMAGE = '2.2-debian12'
|
|
51
|
+
DEFAULT_INIT = 'gs://hail-common/hailctl/dataproc/{version}/init_notebook.py'
|
|
52
|
+
DEFAULT_MASTER_TYPE = 'n1-highmem-4'
|
|
53
|
+
DEFAULT_WORKER_TYPE = 'n1-highmem-4'
|
|
54
|
+
DEFAULT_NUM_WORKERS = 2
|
|
55
|
+
DEFAULT_NUM_SECONDARY_WORKERS = 2
|
|
56
|
+
DEFAULT_MAX_IDLE_SECONDS = 900
|
|
57
|
+
DEFAULT_MAX_AGE_SECONDS = 86400
|
|
58
|
+
DEFAULT_BOOT_DISK_SIZE_GB = 100
|
|
59
|
+
DEFAULT_BOOT_DISK_TYPE = 'pd-standard'
|
|
60
|
+
DEFAULT_NUM_LOCAL_SSDS = 0
|
|
61
|
+
DEFAULT_INIT_TIMEOUT_SECONDS = 600
|
|
62
|
+
DEFAULT_LOG_POLL_SECONDS = 5.0
|
|
63
|
+
DEFAULT_JOB_POLL_INITIAL_SECONDS = 10.0
|
|
64
|
+
DEFAULT_JOB_POLL_MAX_SECONDS = 150.0
|
|
65
|
+
DEFAULT_JOB_POLL_BACKOFF = 1.5
|
|
66
|
+
|
|
67
|
+
# GCP label keys/values: lowercase letters, digits, underscore, hyphen; <=63 chars.
|
|
68
|
+
# Keys must start with a lowercase letter. values may be empty.
|
|
69
|
+
_KEY_VALUE_INVALID = r'[^a-z0-9_-]'
|
|
70
|
+
# Cluster names are stricter: no underscores allowed, max length 52 chars.
|
|
71
|
+
_CLUSTER_INVALID = r'[^a-z0-9-]'
|
|
72
|
+
|
|
73
|
+
_TERMINAL_ERROR_STATES = frozenset({'ERROR', 'CANCELLED'})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DataprocJobError(RuntimeError):
|
|
77
|
+
"""Raised when a Dataproc job reaches a non-DONE terminal state.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
job: The failing Dataproc Job returned by the API.
|
|
81
|
+
state: The terminal state name, either ERROR or CANCELLED.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, job: dataproc_v1.Job, state: str) -> None:
|
|
85
|
+
details = job.status.details or 'No details available'
|
|
86
|
+
super().__init__(
|
|
87
|
+
f'Dataproc job {job.reference.job_id} failed with state '
|
|
88
|
+
f'{state}: {details}',
|
|
89
|
+
)
|
|
90
|
+
self.job = job
|
|
91
|
+
self.state = state
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unique_cluster_name(name: str) -> str:
|
|
95
|
+
"""Build a unique Dataproc cluster name from a prefix.
|
|
96
|
+
|
|
97
|
+
Dataproc cluster names allow only lowercase letters, digits, and hyphens,
|
|
98
|
+
and must be at most 51 characters. The sanitised prefix is truncated to
|
|
99
|
+
42 characters and a hyphen plus eight hex characters of uuid4 are
|
|
100
|
+
appended so the total stays within the 51 character limit.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
name: The desired cluster name prefix.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A sanitised name with a unique eight-character hex suffix.
|
|
107
|
+
|
|
108
|
+
(full result cannot be doctested due to the unique component)
|
|
109
|
+
>>> unique_cluster_name('My_Cluster_1')[:12]
|
|
110
|
+
'my-cluster-1'
|
|
111
|
+
"""
|
|
112
|
+
# sanitised name, truncated to 42 chars - the hyphen and uuid add 9 chars, for a 51-char dataproc max length name
|
|
113
|
+
sanitised_name = slugify(name, regex_pattern=_CLUSTER_INVALID, max_length=42)
|
|
114
|
+
if not sanitised_name[0].isalpha():
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f'Invalid cluster name: {sanitised_name} - must start with a lowercase letter.',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return f'{sanitised_name}-{uuid.uuid4().hex[:8]}'
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sanitise_labels(labels: dict[str, str]) -> dict[str, str]:
|
|
123
|
+
"""Return a copy of labels with keys and values sanitised for GCP.
|
|
124
|
+
|
|
125
|
+
Entries whose sanitised key does not start with a lowercase letter causes an error.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
labels: Raw label mapping from caller input.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A new dictionary containing only the valid sanitised entries.
|
|
132
|
+
|
|
133
|
+
>>> sanitise_labels({'AR-GUID': 'Abc123', 'Bad Key!': 'x'})
|
|
134
|
+
{'ar-guid': 'abc123', 'bad-key': 'x'}
|
|
135
|
+
"""
|
|
136
|
+
failing_labels: dict[str, str] = {}
|
|
137
|
+
result: dict[str, str] = {}
|
|
138
|
+
for raw_key, raw_value in labels.items():
|
|
139
|
+
key = slugify(raw_key, regex_pattern=_KEY_VALUE_INVALID, max_length=63)
|
|
140
|
+
value = slugify(str(raw_value), regex_pattern=_KEY_VALUE_INVALID)
|
|
141
|
+
if not key or not key[0].isalpha():
|
|
142
|
+
failing_labels[key] = value
|
|
143
|
+
continue
|
|
144
|
+
result[key] = value
|
|
145
|
+
if failing_labels:
|
|
146
|
+
fail_string = f"""\
|
|
147
|
+
During key-value sanitisation on the provided labels, the following failed to conform to requirements:
|
|
148
|
+
Input: {labels}
|
|
149
|
+
Failures: {failing_labels}\n
|
|
150
|
+
Note: keys must start with a lowercase letter and contain only letters, digits, underscores, and hyphens.
|
|
151
|
+
See https://docs.cloud.google.com/resource-manager/docs/labels-overview
|
|
152
|
+
"""
|
|
153
|
+
raise ValueError(fail_string)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_label_kvs(items: list[str]) -> dict[str, str]:
|
|
158
|
+
"""Parse key=value strings into a sanitised label dictionary.
|
|
159
|
+
|
|
160
|
+
Intended to format CLI-passed arguments. Malformed entries missing an equals sign cause an error.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
items: Raw key=value strings from the command line.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
A sanitised label dictionary.
|
|
167
|
+
|
|
168
|
+
>>> parse_label_kvs(['ar-guid=abc', 'Stage=Align'])
|
|
169
|
+
{'ar-guid': 'abc', 'stage': 'align'}
|
|
170
|
+
"""
|
|
171
|
+
parsed: dict[str, str] = {}
|
|
172
|
+
failing_kv: list[str] = []
|
|
173
|
+
for item in items:
|
|
174
|
+
if '=' not in item:
|
|
175
|
+
failing_kv.append(item)
|
|
176
|
+
continue
|
|
177
|
+
key, value = item.split('=', 1)
|
|
178
|
+
parsed[key] = value
|
|
179
|
+
|
|
180
|
+
if failing_kv:
|
|
181
|
+
fail_string = f"""\
|
|
182
|
+
One or more key=value parameters did not contain an equals character, so they could not be parsed:
|
|
183
|
+
{failing_kv}
|
|
184
|
+
"""
|
|
185
|
+
raise ValueError(fail_string)
|
|
186
|
+
return sanitise_labels(parsed)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def resolve_autoscaling_policy_uri(
|
|
190
|
+
policy_ref: str,
|
|
191
|
+
project: str | None = None,
|
|
192
|
+
region: str | None = None,
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Return the full resource URI for an autoscaling policy.
|
|
195
|
+
|
|
196
|
+
Accepts either a bare policy ID or an already-qualified resource URI. If
|
|
197
|
+
policy_ref already starts with projects/ it is returned unchanged.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
project: GCP project ID owning the policy. Can be None if policy_ref is a full path.
|
|
201
|
+
region: Dataproc region hosting the policy. Can be None if policy_ref is a full path.
|
|
202
|
+
policy_ref: Bare policy ID or full resource URI.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The fully qualified autoscaling policy resource URI.
|
|
206
|
+
|
|
207
|
+
>>> resolve_autoscaling_policy_uri('my-policy', 'proj', 'australia-southeast1')
|
|
208
|
+
'projects/proj/regions/australia-southeast1/autoscalingPolicies/my-policy'
|
|
209
|
+
>>> resolve_autoscaling_policy_uri(
|
|
210
|
+
... 'projects/other/regions/eu/autoscalingPolicies/foo',
|
|
211
|
+
... 'proj', 'australia-southeast1',
|
|
212
|
+
... )
|
|
213
|
+
'projects/other/regions/eu/autoscalingPolicies/foo'
|
|
214
|
+
"""
|
|
215
|
+
if policy_ref.startswith('projects/'):
|
|
216
|
+
return policy_ref
|
|
217
|
+
if not all((project, region, policy_ref)):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f'Invalid autoscaling policy components: {project}, {region}, {policy_ref}',
|
|
220
|
+
)
|
|
221
|
+
# projects/seqera-trial/regions/australia-southeast1/autoscalingPolicies/trial-combiner-1
|
|
222
|
+
# projects/australia-southeast1/regions/trial-combiner-1
|
|
223
|
+
return f'projects/{project}/regions/{region}/autoscalingPolicies/{policy_ref}'
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def upload_to_gcs(
|
|
227
|
+
local_path: str,
|
|
228
|
+
bucket: str,
|
|
229
|
+
prefix: str = '',
|
|
230
|
+
client: GSClient | None = None,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""
|
|
233
|
+
Upload a local file to GCS and return the gs:// URI.
|
|
234
|
+
|
|
235
|
+
The uploaded blob path is bucket/prefix/basename. Passing an empty prefix
|
|
236
|
+
uploads to the bucket root.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
local_path: Path to the file on the local filesystem.
|
|
240
|
+
bucket: Target bucket, either as gs://bucket-name or bucket-name.
|
|
241
|
+
prefix: Blob path prefix within the bucket.
|
|
242
|
+
client: Optional pre-built storage client, mainly for tests.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The gs:// URI of the uploaded blob.
|
|
246
|
+
"""
|
|
247
|
+
bucket_name = bucket.removeprefix('gs://').rstrip('/')
|
|
248
|
+
dest = (
|
|
249
|
+
GSPath(f'gs://{bucket_name}', client=client)
|
|
250
|
+
/ prefix.strip('/')
|
|
251
|
+
/ os.path.basename(local_path)
|
|
252
|
+
)
|
|
253
|
+
dest.upload_from(local_path)
|
|
254
|
+
return str(dest)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def populate_packages(
|
|
258
|
+
packages: list[str] | None = None,
|
|
259
|
+
hail_version: str = DEFAULT_HAIL_VERSION,
|
|
260
|
+
) -> list[str]:
|
|
261
|
+
"""
|
|
262
|
+
Blend any requested packages with default packages
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
packages:
|
|
266
|
+
hail_version: str, the version of hail to use
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
a list of packages. At minimum this will contain cpg-utils and a version of hail
|
|
270
|
+
if hail was not user specified, it will be added at the default version pin.
|
|
271
|
+
"""
|
|
272
|
+
new_packages: list[str] = []
|
|
273
|
+
|
|
274
|
+
# two key packages
|
|
275
|
+
cpg_utils_specified: bool = False
|
|
276
|
+
hail_specified: bool = False
|
|
277
|
+
|
|
278
|
+
# iterate over the provided package list
|
|
279
|
+
for each_package in packages or []:
|
|
280
|
+
if each_package.startswith('hail'):
|
|
281
|
+
hail_specified = True
|
|
282
|
+
elif each_package.startswith('cpg-utils'):
|
|
283
|
+
cpg_utils_specified = True
|
|
284
|
+
new_packages.append(each_package)
|
|
285
|
+
|
|
286
|
+
if not cpg_utils_specified:
|
|
287
|
+
new_packages.append('cpg-utils')
|
|
288
|
+
if not hail_specified:
|
|
289
|
+
new_packages.append(f'hail=={hail_version}')
|
|
290
|
+
|
|
291
|
+
return new_packages
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class HailDataprocCluster:
|
|
295
|
+
"""Ephemeral Dataproc cluster preconfigured for Hail PySpark jobs.
|
|
296
|
+
|
|
297
|
+
Construction does not create the cluster. Call start, enter the object as
|
|
298
|
+
a context manager, or invoke submit_job to lazily create it. submit_job
|
|
299
|
+
returns the Dataproc job id without waiting. wait_for_job polls a
|
|
300
|
+
submitted job until it reaches a terminal state and by default streams
|
|
301
|
+
driver stdout to this process. run_job is a convenience wrapper that
|
|
302
|
+
submits and then waits. Multiple jobs may be submitted to the same
|
|
303
|
+
cluster before shutdown, and each submit_job call reuses the running
|
|
304
|
+
cluster. shutdown deletes the cluster and is idempotent. The context
|
|
305
|
+
manager runs shutdown on exit, and install_sigterm_cleanup wires it to
|
|
306
|
+
SIGTERM.
|
|
307
|
+
|
|
308
|
+
service_account sets the identity the cluster VMs run as, i.e. the identity Hail code uses to
|
|
309
|
+
read from and write to GCS.
|
|
310
|
+
If unset, Dataproc falls back to the project's default compute service account. At CPG that should
|
|
311
|
+
normally be dataproc-<access_level>@<project>.iam.gserviceaccount.com, as in the hailctl implementation.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
project: str,
|
|
317
|
+
region: str,
|
|
318
|
+
cluster_name_prefix: str,
|
|
319
|
+
staging_bucket: str,
|
|
320
|
+
temp_bucket: str,
|
|
321
|
+
*,
|
|
322
|
+
master_type: str = DEFAULT_MASTER_TYPE,
|
|
323
|
+
worker_type: str = DEFAULT_WORKER_TYPE,
|
|
324
|
+
num_workers: int = DEFAULT_NUM_WORKERS,
|
|
325
|
+
num_secondary_workers: int = DEFAULT_NUM_SECONDARY_WORKERS,
|
|
326
|
+
max_idle_seconds: int = DEFAULT_MAX_IDLE_SECONDS,
|
|
327
|
+
max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS,
|
|
328
|
+
autoscaling_policy: str | None = None,
|
|
329
|
+
service_account: str | None = None,
|
|
330
|
+
labels: dict[str, str] | None = None,
|
|
331
|
+
hail_version: str = DEFAULT_HAIL_VERSION,
|
|
332
|
+
hail_image: str = DEFAULT_HAIL_IMAGE,
|
|
333
|
+
wheel: str | None = None,
|
|
334
|
+
init_script: str | None = None,
|
|
335
|
+
packages: list[str] | None = None,
|
|
336
|
+
boot_disk_size_gb: int = DEFAULT_BOOT_DISK_SIZE_GB,
|
|
337
|
+
boot_disk_type: str = DEFAULT_BOOT_DISK_TYPE,
|
|
338
|
+
num_local_ssds: int = DEFAULT_NUM_LOCAL_SSDS,
|
|
339
|
+
init_timeout_seconds: int = DEFAULT_INIT_TIMEOUT_SECONDS,
|
|
340
|
+
cluster_client: dataproc_v1.ClusterControllerClient | None = None,
|
|
341
|
+
job_client: dataproc_v1.JobControllerClient | None = None,
|
|
342
|
+
storage_client: storage.Client | None = None,
|
|
343
|
+
preemptible_workers: bool = True,
|
|
344
|
+
) -> None:
|
|
345
|
+
self._project = project
|
|
346
|
+
self._region = region
|
|
347
|
+
self._staging_bucket = staging_bucket.rstrip('/')
|
|
348
|
+
self._temp_bucket = temp_bucket.rstrip('/')
|
|
349
|
+
self._master_type = master_type
|
|
350
|
+
self._worker_type = worker_type
|
|
351
|
+
self._num_workers = num_workers
|
|
352
|
+
self._num_secondary_workers = num_secondary_workers
|
|
353
|
+
self._max_idle_seconds = max_idle_seconds
|
|
354
|
+
self._max_age_seconds = max_age_seconds
|
|
355
|
+
self._hail_version = hail_version
|
|
356
|
+
self._hail_image = hail_image
|
|
357
|
+
self._wheel = wheel or DEFAULT_WHEEL.format(version=hail_version)
|
|
358
|
+
self._init_script = init_script or DEFAULT_INIT.format(version=hail_version)
|
|
359
|
+
self._packages = populate_packages(packages, hail_version=hail_version)
|
|
360
|
+
self._boot_disk_size_gb = boot_disk_size_gb
|
|
361
|
+
self._boot_disk_type = boot_disk_type
|
|
362
|
+
self._num_local_ssds = num_local_ssds
|
|
363
|
+
self._init_timeout_seconds = init_timeout_seconds
|
|
364
|
+
self._service_account = service_account
|
|
365
|
+
self._labels = sanitise_labels(labels or {})
|
|
366
|
+
self._policy_uri: str | None = (
|
|
367
|
+
resolve_autoscaling_policy_uri(
|
|
368
|
+
project=project,
|
|
369
|
+
region=region,
|
|
370
|
+
policy_ref=autoscaling_policy,
|
|
371
|
+
)
|
|
372
|
+
if autoscaling_policy
|
|
373
|
+
else None
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self._name = unique_cluster_name(cluster_name_prefix)
|
|
377
|
+
# 8 hex chars of uniqueness; keeps the total name well under the 51-char limit.
|
|
378
|
+
|
|
379
|
+
endpoint = f'{region}-dataproc.googleapis.com:443'
|
|
380
|
+
self._cluster_client = cluster_client or dataproc_v1.ClusterControllerClient(
|
|
381
|
+
client_options={'api_endpoint': endpoint},
|
|
382
|
+
)
|
|
383
|
+
self._job_client = job_client or dataproc_v1.JobControllerClient(
|
|
384
|
+
client_options={'api_endpoint': endpoint},
|
|
385
|
+
)
|
|
386
|
+
self._storage_client = storage_client or storage.Client()
|
|
387
|
+
self._gs_client = GSClient(storage_client=self._storage_client)
|
|
388
|
+
|
|
389
|
+
self._started = False
|
|
390
|
+
self._shutdown_called = False
|
|
391
|
+
self._cluster: dataproc_v1.Cluster | None = None
|
|
392
|
+
self._job_ids: list[str] = []
|
|
393
|
+
self._preemptible_workers = preemptible_workers
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def name(self) -> str:
|
|
397
|
+
"""The generated cluster name, prefix plus eight random hex characters."""
|
|
398
|
+
return self._name
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def project(self) -> str:
|
|
402
|
+
return self._project
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def region(self) -> str:
|
|
406
|
+
return self._region
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def labels(self) -> dict[str, str]:
|
|
410
|
+
"""A copy of the sanitised labels applied to the cluster."""
|
|
411
|
+
return dict(self._labels)
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def autoscaling_policy_uri(self) -> str | None:
|
|
415
|
+
return self._policy_uri
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def service_account(self) -> str | None:
|
|
419
|
+
"""The service account the cluster VMs run as, or None for the GCE default."""
|
|
420
|
+
return self._service_account
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def job_ids(self) -> list[str]:
|
|
424
|
+
"""Job ids of every job submitted to this cluster, in submission order."""
|
|
425
|
+
return list(self._job_ids)
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def is_started(self) -> bool:
|
|
429
|
+
return self._started
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def hail_version(self) -> str:
|
|
433
|
+
return self._hail_version
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def wheel(self) -> str:
|
|
437
|
+
return self._wheel
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def init_script(self) -> str:
|
|
441
|
+
return self._init_script
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def preemptible_workers(self) -> bool:
|
|
445
|
+
return self._preemptible_workers
|
|
446
|
+
|
|
447
|
+
def start(self) -> dataproc_v1.Cluster:
|
|
448
|
+
"""
|
|
449
|
+
Create the cluster if not already started and return it.
|
|
450
|
+
|
|
451
|
+
Idempotent. If start has already been called, the existing cluster is returned without another API call.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
The created or already-running Dataproc Cluster.
|
|
455
|
+
"""
|
|
456
|
+
if self.is_started and self._cluster is not None:
|
|
457
|
+
return self._cluster
|
|
458
|
+
|
|
459
|
+
cluster_config = self._build_cluster_config()
|
|
460
|
+
print(f"Creating cluster '{self._name}' in {self._region}...")
|
|
461
|
+
operation = self._cluster_client.create_cluster(
|
|
462
|
+
request={
|
|
463
|
+
'project_id': self._project,
|
|
464
|
+
'region': self._region,
|
|
465
|
+
'cluster': cluster_config,
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
# set started = True before launching. If the cluster times out during the creation it can enter an error state
|
|
469
|
+
# by setting started=True early, any failures will be caught and managed by the context handler
|
|
470
|
+
# ERROR state dataproc clusters need to be deleted manually, the idle TTL does not apply
|
|
471
|
+
self._started = True
|
|
472
|
+
self._cluster = operation.result()
|
|
473
|
+
print(f"Cluster '{self._name}' created successfully.")
|
|
474
|
+
return self._cluster
|
|
475
|
+
|
|
476
|
+
def _build_cluster_config(self) -> dict[str, Any]:
|
|
477
|
+
disk_config: dict[str, Any] = {
|
|
478
|
+
'boot_disk_type': self._boot_disk_type,
|
|
479
|
+
'boot_disk_size_gb': self._boot_disk_size_gb,
|
|
480
|
+
}
|
|
481
|
+
# Local SSDs are a fixed 375 GB each on GCE; only the count is
|
|
482
|
+
# configurable, so there is no size field to set here.
|
|
483
|
+
if self._num_local_ssds:
|
|
484
|
+
disk_config['num_local_ssds'] = self._num_local_ssds
|
|
485
|
+
|
|
486
|
+
config: dict[str, Any] = {
|
|
487
|
+
'project_id': self._project,
|
|
488
|
+
'cluster_name': self._name,
|
|
489
|
+
'labels': dict(self._labels),
|
|
490
|
+
'config': {
|
|
491
|
+
'gce_cluster_config': {
|
|
492
|
+
'zone_uri': '', # Let Dataproc auto-zone within region.
|
|
493
|
+
'metadata': {
|
|
494
|
+
'WHEEL': self.wheel,
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
'master_config': {
|
|
498
|
+
'num_instances': 1,
|
|
499
|
+
'machine_type_uri': self._master_type,
|
|
500
|
+
'disk_config': dict(disk_config),
|
|
501
|
+
},
|
|
502
|
+
'worker_config': {
|
|
503
|
+
'num_instances': self._num_workers,
|
|
504
|
+
'machine_type_uri': self._worker_type,
|
|
505
|
+
'disk_config': dict(disk_config),
|
|
506
|
+
},
|
|
507
|
+
'software_config': {
|
|
508
|
+
'image_version': self._hail_image,
|
|
509
|
+
'properties': {
|
|
510
|
+
'spark:spark.serializer': (
|
|
511
|
+
'org.apache.spark.serializer.KryoSerializer'
|
|
512
|
+
),
|
|
513
|
+
'spark:spark.kryo.registrator': (
|
|
514
|
+
'is.hail.kryo.HailKryoRegistrator'
|
|
515
|
+
),
|
|
516
|
+
'dataproc:dataproc.logging.stackdriver.job.driver.enable': (
|
|
517
|
+
'true'
|
|
518
|
+
),
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
'initialization_actions': [
|
|
522
|
+
{
|
|
523
|
+
'executable_file': self.init_script,
|
|
524
|
+
'execution_timeout': {'seconds': self._init_timeout_seconds},
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
'lifecycle_config': {
|
|
528
|
+
'auto_delete_ttl': {'seconds': self._max_age_seconds},
|
|
529
|
+
'idle_delete_ttl': {'seconds': self._max_idle_seconds},
|
|
530
|
+
},
|
|
531
|
+
'temp_bucket': self._temp_bucket.removeprefix('gs://'),
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
# Without this the VMs run as the project's default compute service account, typically
|
|
536
|
+
# this has no access to dataset buckets.
|
|
537
|
+
if self._service_account:
|
|
538
|
+
config['config']['gce_cluster_config'][
|
|
539
|
+
'service_account'
|
|
540
|
+
] = self._service_account
|
|
541
|
+
if self._packages:
|
|
542
|
+
pkgs = '|'.join(self._packages)
|
|
543
|
+
config['config']['gce_cluster_config']['metadata']['PKGS'] = pkgs
|
|
544
|
+
if self._policy_uri:
|
|
545
|
+
config['config']['autoscaling_config'] = {'policy_uri': self._policy_uri}
|
|
546
|
+
|
|
547
|
+
# Add secondary (preemptible) workers if requested, or if autoscaling may scale them.
|
|
548
|
+
if self._num_secondary_workers or self._policy_uri:
|
|
549
|
+
config['config']['secondary_worker_config'] = {
|
|
550
|
+
'num_instances': self._num_secondary_workers,
|
|
551
|
+
'machine_type_uri': self._worker_type,
|
|
552
|
+
'is_preemptible': self.preemptible_workers,
|
|
553
|
+
'disk_config': dict(disk_config),
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return config
|
|
557
|
+
|
|
558
|
+
def upload(self, local_path: str, prefix: str | None = None) -> str:
|
|
559
|
+
"""Upload a local file to the staging bucket and return the gs:// URI.
|
|
560
|
+
|
|
561
|
+
The default prefix is scripts/<cluster_name>, which keeps artifacts
|
|
562
|
+
from different clusters segregated. Pass an explicit prefix, including
|
|
563
|
+
the empty string for the bucket root, to override.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
local_path: Path to the file on the local filesystem.
|
|
567
|
+
prefix: Optional override for the blob path prefix.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
The gs:// URI of the uploaded blob.
|
|
571
|
+
"""
|
|
572
|
+
effective_prefix = prefix if prefix is not None else f'scripts/{self._name}'
|
|
573
|
+
return upload_to_gcs(
|
|
574
|
+
local_path=local_path,
|
|
575
|
+
bucket=self._staging_bucket,
|
|
576
|
+
prefix=effective_prefix,
|
|
577
|
+
client=self._gs_client,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def submit_job(
|
|
581
|
+
self,
|
|
582
|
+
script_uri: str,
|
|
583
|
+
*,
|
|
584
|
+
args: list[str] | None = None,
|
|
585
|
+
file_uris: list[str] | None = None,
|
|
586
|
+
properties: dict[str, str] | None = None,
|
|
587
|
+
) -> str:
|
|
588
|
+
"""
|
|
589
|
+
Submit a PySpark job to the cluster and return the Dataproc job id.
|
|
590
|
+
|
|
591
|
+
Starts the cluster first if it has not been started. The cluster is not shut down after the job completes.
|
|
592
|
+
Call shutdown or use the context manager when all jobs are done.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
script_uri: gs:// URI of the main Python script to run.
|
|
596
|
+
args: Positional arguments passed to the script.
|
|
597
|
+
file_uris: Extra files localised into the job working directory
|
|
598
|
+
on the cluster. Matches the Dataproc pyspark_job.file_uris field.
|
|
599
|
+
properties: Extra Spark or Dataproc job properties.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
The Dataproc job id assigned by the API.
|
|
603
|
+
"""
|
|
604
|
+
if not self._started:
|
|
605
|
+
self.start()
|
|
606
|
+
|
|
607
|
+
pyspark_job: dict[str, Any] = {
|
|
608
|
+
'main_python_file_uri': script_uri,
|
|
609
|
+
# Jar paths are set by init_notebook.py in spark-defaults.conf.
|
|
610
|
+
'properties': dict(properties) if properties else {},
|
|
611
|
+
}
|
|
612
|
+
if args:
|
|
613
|
+
pyspark_job['args'] = list(args)
|
|
614
|
+
if file_uris:
|
|
615
|
+
pyspark_job['file_uris'] = list(file_uris)
|
|
616
|
+
|
|
617
|
+
job = {
|
|
618
|
+
'placement': {'cluster_name': self._name},
|
|
619
|
+
'pyspark_job': pyspark_job,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
print(f"Submitting Hail job to cluster '{self._name}'...")
|
|
623
|
+
result = self._job_client.submit_job(
|
|
624
|
+
request={
|
|
625
|
+
'project_id': self._project,
|
|
626
|
+
'region': self._region,
|
|
627
|
+
'job': job,
|
|
628
|
+
},
|
|
629
|
+
)
|
|
630
|
+
job_id = result.reference.job_id
|
|
631
|
+
self._job_ids.append(job_id)
|
|
632
|
+
print(f'Job submitted: {job_id}')
|
|
633
|
+
return job_id
|
|
634
|
+
|
|
635
|
+
def wait_for_job(
|
|
636
|
+
self,
|
|
637
|
+
job_id: str,
|
|
638
|
+
*,
|
|
639
|
+
stream_logs: bool = True,
|
|
640
|
+
) -> dataproc_v1.Job:
|
|
641
|
+
"""
|
|
642
|
+
Poll a submitted job until it reaches a terminal state.
|
|
643
|
+
|
|
644
|
+
When stream_logs is true, the job driver stdout is streamed to this process on a background thread
|
|
645
|
+
so logs appear in orchestrators like Seqera in near-real time.
|
|
646
|
+
The streaming thread is stopped whether the poll returns normally or raises.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
job_id: The Dataproc job id returned by submit_job.
|
|
650
|
+
stream_logs: Whether to stream driver stdout in the background.
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
The final Dataproc Job on success.
|
|
654
|
+
|
|
655
|
+
Raises:
|
|
656
|
+
DataprocJobError: If the job reaches ERROR or CANCELLED instead
|
|
657
|
+
of DONE.
|
|
658
|
+
"""
|
|
659
|
+
stop_event = threading.Event()
|
|
660
|
+
log_thread: threading.Thread | None = None
|
|
661
|
+
if stream_logs:
|
|
662
|
+
log_thread = threading.Thread(
|
|
663
|
+
target=_stream_driver_logs,
|
|
664
|
+
args=(
|
|
665
|
+
self._job_client,
|
|
666
|
+
self._storage_client,
|
|
667
|
+
self._project,
|
|
668
|
+
self._region,
|
|
669
|
+
job_id,
|
|
670
|
+
stop_event,
|
|
671
|
+
),
|
|
672
|
+
daemon=True,
|
|
673
|
+
)
|
|
674
|
+
log_thread.start()
|
|
675
|
+
try:
|
|
676
|
+
return _poll_job(self._job_client, self._project, self._region, job_id)
|
|
677
|
+
finally:
|
|
678
|
+
stop_event.set()
|
|
679
|
+
if log_thread is not None:
|
|
680
|
+
log_thread.join(timeout=30)
|
|
681
|
+
|
|
682
|
+
def run_job(
|
|
683
|
+
self,
|
|
684
|
+
script_uri: str,
|
|
685
|
+
*,
|
|
686
|
+
args: list[str] | None = None,
|
|
687
|
+
file_uris: list[str] | None = None,
|
|
688
|
+
properties: dict[str, str] | None = None,
|
|
689
|
+
stream_logs: bool = True,
|
|
690
|
+
) -> dataproc_v1.Job:
|
|
691
|
+
"""Submit a job and wait for it to reach a terminal state.
|
|
692
|
+
|
|
693
|
+
Convenience wrapper over submit_job followed by wait_for_job.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
script_uri: gs:// URI of the main Python script to run.
|
|
697
|
+
args: Positional arguments passed to the script.
|
|
698
|
+
file_uris: Extra files localised into the job working directory.
|
|
699
|
+
properties: Extra Spark or Dataproc job properties.
|
|
700
|
+
stream_logs: Whether to stream driver stdout in the background.
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
The final Dataproc Job on success.
|
|
704
|
+
|
|
705
|
+
Raises:
|
|
706
|
+
DataprocJobError: If the job fails, so orchestrator scripts
|
|
707
|
+
propagate a non-zero exit on failure.
|
|
708
|
+
"""
|
|
709
|
+
job_id = self.submit_job(
|
|
710
|
+
script_uri,
|
|
711
|
+
args=args,
|
|
712
|
+
file_uris=file_uris,
|
|
713
|
+
properties=properties,
|
|
714
|
+
)
|
|
715
|
+
return self.wait_for_job(job_id, stream_logs=stream_logs)
|
|
716
|
+
|
|
717
|
+
def shutdown(self) -> None:
|
|
718
|
+
"""Delete the cluster.
|
|
719
|
+
|
|
720
|
+
Idempotent and never raises. Warnings are printed to stderr on failure
|
|
721
|
+
so this remains safe to call from a finally block or a signal handler.
|
|
722
|
+
"""
|
|
723
|
+
if self._shutdown_called:
|
|
724
|
+
return
|
|
725
|
+
self._shutdown_called = True
|
|
726
|
+
if not self._started:
|
|
727
|
+
return
|
|
728
|
+
print(f"Deleting cluster '{self._name}'...")
|
|
729
|
+
try:
|
|
730
|
+
operation = self._cluster_client.delete_cluster(
|
|
731
|
+
request={
|
|
732
|
+
'project_id': self._project,
|
|
733
|
+
'region': self._region,
|
|
734
|
+
'cluster_name': self._name,
|
|
735
|
+
},
|
|
736
|
+
)
|
|
737
|
+
operation.result()
|
|
738
|
+
print(f"Cluster '{self._name}' deleted.")
|
|
739
|
+
except Exception as e: # noqa: BLE001 - cleanup path must not raise
|
|
740
|
+
print(
|
|
741
|
+
f"Warning: failed to delete cluster '{self._name}': {e}",
|
|
742
|
+
file=sys.stderr,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
def __enter__(self) -> 'HailDataprocCluster':
|
|
746
|
+
self.start()
|
|
747
|
+
return self
|
|
748
|
+
|
|
749
|
+
def __exit__(
|
|
750
|
+
self,
|
|
751
|
+
exc_type: type[BaseException] | None,
|
|
752
|
+
exc_val: BaseException | None,
|
|
753
|
+
exc_tb: TracebackType | None,
|
|
754
|
+
) -> None:
|
|
755
|
+
self.shutdown()
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def install_sigterm_cleanup(cluster: HailDataprocCluster) -> None:
|
|
759
|
+
"""Install a SIGTERM handler that shuts down a cluster and exits with code 1.
|
|
760
|
+
|
|
761
|
+
Useful for pipeline orchestrators such as Nextflow that send SIGTERM on
|
|
762
|
+
cancellation. Must be called from the main thread because Python signal
|
|
763
|
+
registration only works there.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
cluster: The cluster to shut down when SIGTERM is received.
|
|
767
|
+
"""
|
|
768
|
+
|
|
769
|
+
def handler(signum: int, _frame: FrameType | None) -> None:
|
|
770
|
+
print(
|
|
771
|
+
f'Received signal {signum}, shutting down cluster {cluster.name}...',
|
|
772
|
+
file=sys.stderr,
|
|
773
|
+
)
|
|
774
|
+
try:
|
|
775
|
+
cluster.shutdown()
|
|
776
|
+
finally:
|
|
777
|
+
sys.exit(1)
|
|
778
|
+
|
|
779
|
+
signal.signal(signal.SIGTERM, handler)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _tail_blob_to_stdout(blob: storage.Blob, offset: int) -> int:
|
|
783
|
+
"""Write any bytes beyond offset in blob to stdout and return the new offset.
|
|
784
|
+
|
|
785
|
+
Uses a metadata reload followed by a range download so only the new bytes
|
|
786
|
+
are transferred each tick.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
blob: The GCS blob to read from.
|
|
790
|
+
offset: Number of bytes already streamed from this blob.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
The updated byte offset after any new bytes were written.
|
|
794
|
+
|
|
795
|
+
Raises:
|
|
796
|
+
google.api_core.exceptions.NotFound: If the blob does not yet exist.
|
|
797
|
+
The caller decides whether to keep polling.
|
|
798
|
+
"""
|
|
799
|
+
blob.reload()
|
|
800
|
+
size = blob.size or 0
|
|
801
|
+
if size <= offset:
|
|
802
|
+
return offset
|
|
803
|
+
chunk = blob.download_as_bytes(start=offset, end=size - 1)
|
|
804
|
+
sys.stdout.write(chunk.decode('utf-8', errors='replace'))
|
|
805
|
+
sys.stdout.flush()
|
|
806
|
+
return size
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _stream_driver_logs(
|
|
810
|
+
job_client: dataproc_v1.JobControllerClient,
|
|
811
|
+
storage_client: storage.Client,
|
|
812
|
+
project: str,
|
|
813
|
+
region: str,
|
|
814
|
+
job_id: str,
|
|
815
|
+
stop_event: threading.Event,
|
|
816
|
+
poll_interval: float = DEFAULT_LOG_POLL_SECONDS,
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Stream Dataproc job driver stdout from GCS to this process.
|
|
819
|
+
|
|
820
|
+
Dataproc writes driver stdout to rolling numbered files under the job
|
|
821
|
+
driver_output_resource_uri, ending in .000000000, .000000001, and so on.
|
|
822
|
+
Mirrors the behaviour of gcloud dataproc jobs wait.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
job_client: Dataproc job controller client used to look up the driver
|
|
826
|
+
output URI.
|
|
827
|
+
storage_client: GCS storage client used to read the driver output.
|
|
828
|
+
project: GCP project id.
|
|
829
|
+
region: Dataproc region.
|
|
830
|
+
job_id: Job id whose driver output should be streamed.
|
|
831
|
+
stop_event: Event set by the caller to signal the streamer to exit.
|
|
832
|
+
poll_interval: Seconds to sleep between polls.
|
|
833
|
+
"""
|
|
834
|
+
file_index = 0
|
|
835
|
+
offset = 0
|
|
836
|
+
uri_prefix: str | None = None
|
|
837
|
+
print(f'[log-stream] started for job {job_id}', flush=True)
|
|
838
|
+
while not stop_event.is_set():
|
|
839
|
+
if uri_prefix is None:
|
|
840
|
+
try:
|
|
841
|
+
job = job_client.get_job(
|
|
842
|
+
request={
|
|
843
|
+
'project_id': project,
|
|
844
|
+
'region': region,
|
|
845
|
+
'job_id': job_id,
|
|
846
|
+
},
|
|
847
|
+
)
|
|
848
|
+
uri_prefix = job.driver_output_resource_uri or None
|
|
849
|
+
except gax_exceptions.GoogleAPIError:
|
|
850
|
+
print(f'[log-stream] failed to get job {job_id}', flush=True)
|
|
851
|
+
if not uri_prefix:
|
|
852
|
+
time.sleep(poll_interval)
|
|
853
|
+
continue
|
|
854
|
+
print(f'[log-stream] found driver output at {uri_prefix}', flush=True)
|
|
855
|
+
|
|
856
|
+
try:
|
|
857
|
+
path = uri_prefix.removeprefix('gs://')
|
|
858
|
+
bucket_name, blob_prefix = path.split('/', 1)
|
|
859
|
+
bucket = storage_client.bucket(bucket_name)
|
|
860
|
+
blob = bucket.blob(f'{blob_prefix}.{file_index:09d}')
|
|
861
|
+
try:
|
|
862
|
+
offset = _tail_blob_to_stdout(blob, offset)
|
|
863
|
+
except gax_exceptions.NotFound:
|
|
864
|
+
# Current-index blob hasn't been created yet; try again next tick.
|
|
865
|
+
pass
|
|
866
|
+
else:
|
|
867
|
+
next_blob = bucket.blob(f'{blob_prefix}.{file_index + 1:09d}')
|
|
868
|
+
if next_blob.exists():
|
|
869
|
+
# One final range-read of the current file: Dataproc may have
|
|
870
|
+
# appended bytes between our last download and the roll.
|
|
871
|
+
with suppress(gax_exceptions.NotFound):
|
|
872
|
+
offset = _tail_blob_to_stdout(blob, offset)
|
|
873
|
+
print(
|
|
874
|
+
f'[log-stream] rolling to output file {file_index + 1}',
|
|
875
|
+
flush=True,
|
|
876
|
+
)
|
|
877
|
+
file_index += 1
|
|
878
|
+
offset = 0
|
|
879
|
+
except gax_exceptions.GoogleAPIError as e:
|
|
880
|
+
print(f'[log-stream] warning: {e}', file=sys.stderr)
|
|
881
|
+
|
|
882
|
+
time.sleep(poll_interval)
|
|
883
|
+
print(f'[log-stream] stopped for job {job_id}', flush=True)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _poll_job(
|
|
887
|
+
job_client: dataproc_v1.JobControllerClient,
|
|
888
|
+
project: str,
|
|
889
|
+
region: str,
|
|
890
|
+
job_id: str,
|
|
891
|
+
initial_interval: float = DEFAULT_JOB_POLL_INITIAL_SECONDS,
|
|
892
|
+
max_interval: float = DEFAULT_JOB_POLL_MAX_SECONDS,
|
|
893
|
+
backoff: float = DEFAULT_JOB_POLL_BACKOFF,
|
|
894
|
+
) -> dataproc_v1.Job:
|
|
895
|
+
"""Poll a job id until it reaches a terminal state.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
job_client: Dataproc job controller client used to fetch the job.
|
|
899
|
+
project: GCP project id.
|
|
900
|
+
region: Dataproc region.
|
|
901
|
+
job_id: Dataproc job id.
|
|
902
|
+
initial_interval: Seconds to sleep between the first two polls.
|
|
903
|
+
max_interval: Ceiling on the polling interval.
|
|
904
|
+
backoff: multiple applied to the interval after each poll, up to max_interval.
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
The final Dataproc Job on success.
|
|
908
|
+
|
|
909
|
+
Raises:
|
|
910
|
+
DataprocJobError: If the job reaches a non-DONE terminal state, so
|
|
911
|
+
orchestrators propagate a non-zero exit rather than silently
|
|
912
|
+
continuing.
|
|
913
|
+
"""
|
|
914
|
+
poll_interval = initial_interval
|
|
915
|
+
while True:
|
|
916
|
+
job = job_client.get_job(
|
|
917
|
+
request={'project_id': project, 'region': region, 'job_id': job_id},
|
|
918
|
+
)
|
|
919
|
+
state = dataproc_v1.JobStatus.State(job.status.state).name
|
|
920
|
+
if state in _TERMINAL_ERROR_STATES:
|
|
921
|
+
raise DataprocJobError(job, state)
|
|
922
|
+
if state == 'DONE':
|
|
923
|
+
print(f'Job {job_id} completed successfully.')
|
|
924
|
+
return job
|
|
925
|
+
print(f'Job {job_id} state: {state} (polling in {poll_interval}s)')
|
|
926
|
+
time.sleep(poll_interval)
|
|
927
|
+
poll_interval = min(poll_interval * backoff, max_interval)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cpg-utils
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.8.0
|
|
4
4
|
Summary: Library of convenience functions specific to the CPG
|
|
5
5
|
Home-page: https://github.com/populationgenomics/cpg-utils
|
|
6
6
|
License: MIT
|
|
@@ -24,8 +24,11 @@ Requires-Dist: frozendict
|
|
|
24
24
|
Requires-Dist: google-auth>=1.27.0
|
|
25
25
|
Requires-Dist: google-auth-oauthlib
|
|
26
26
|
Requires-Dist: google-cloud-artifact-registry
|
|
27
|
+
Requires-Dist: google-cloud-dataproc
|
|
27
28
|
Requires-Dist: google-cloud-secret-manager
|
|
29
|
+
Requires-Dist: google-cloud-storage
|
|
28
30
|
Requires-Dist: metamist
|
|
31
|
+
Requires-Dist: python-slugify
|
|
29
32
|
Requires-Dist: requests
|
|
30
33
|
Requires-Dist: tabulate
|
|
31
34
|
Requires-Dist: toml
|
|
@@ -10,6 +10,7 @@ cpg_utils/constants.py
|
|
|
10
10
|
cpg_utils/cromwell.py
|
|
11
11
|
cpg_utils/cromwell_model.py
|
|
12
12
|
cpg_utils/dataproc.py
|
|
13
|
+
cpg_utils/dataproc_runner.py
|
|
13
14
|
cpg_utils/existence_checks.py
|
|
14
15
|
cpg_utils/git.py
|
|
15
16
|
cpg_utils/hail_batch.py
|
|
@@ -25,5 +26,5 @@ cpg_utils.egg-info/top_level.txt
|
|
|
25
26
|
test/__init__.py
|
|
26
27
|
test/test_config.py
|
|
27
28
|
test/test_cromwell.py
|
|
28
|
-
test/
|
|
29
|
+
test/test_dataproc_runner.py
|
|
29
30
|
test/test_get_google_identity_token.py
|
|
@@ -8,7 +8,7 @@ with open('README.md') as f:
|
|
|
8
8
|
setup(
|
|
9
9
|
name='cpg-utils',
|
|
10
10
|
# This tag is automatically updated by bumpversion
|
|
11
|
-
version='5.
|
|
11
|
+
version='5.8.0',
|
|
12
12
|
description='Library of convenience functions specific to the CPG',
|
|
13
13
|
long_description=long_description,
|
|
14
14
|
long_description_content_type='text/markdown',
|
|
@@ -26,8 +26,11 @@ setup(
|
|
|
26
26
|
'google-auth>=1.27.0',
|
|
27
27
|
'google-auth-oauthlib',
|
|
28
28
|
'google-cloud-artifact-registry',
|
|
29
|
+
'google-cloud-dataproc',
|
|
29
30
|
'google-cloud-secret-manager',
|
|
31
|
+
'google-cloud-storage',
|
|
30
32
|
'metamist',
|
|
33
|
+
'python-slugify',
|
|
31
34
|
'requests',
|
|
32
35
|
'tabulate',
|
|
33
36
|
'toml',
|
|
@@ -12,6 +12,7 @@ import toml
|
|
|
12
12
|
from cpg_utils.config import (
|
|
13
13
|
ConfigError,
|
|
14
14
|
config_retrieve,
|
|
15
|
+
dataset_for_access_level,
|
|
15
16
|
dataset_path,
|
|
16
17
|
get_config,
|
|
17
18
|
image_path,
|
|
@@ -204,3 +205,19 @@ class TestConfig(TestCase):
|
|
|
204
205
|
"""
|
|
205
206
|
mock_get_config.return_value = {'images': {'image-name': 'image/path:version'}}
|
|
206
207
|
self.assertEqual('image/path:version', image_path('image-name'))
|
|
208
|
+
|
|
209
|
+
@patch('cpg_utils.config.get_config')
|
|
210
|
+
def test_dataset_access_levels_test(self, mock_get_config: MagicMock):
|
|
211
|
+
"""
|
|
212
|
+
Test image_path
|
|
213
|
+
"""
|
|
214
|
+
mock_get_config.return_value = {'workflow': {'access_level': 'test'}}
|
|
215
|
+
self.assertEqual(dataset_for_access_level('dataset'), 'dataset-test')
|
|
216
|
+
|
|
217
|
+
@patch('cpg_utils.config.get_config')
|
|
218
|
+
def test_dataset_access_levels_standard(self, mock_get_config: MagicMock):
|
|
219
|
+
"""
|
|
220
|
+
Test image_path
|
|
221
|
+
"""
|
|
222
|
+
mock_get_config.return_value = {'workflow': {'access_level': 'standard'}}
|
|
223
|
+
self.assertEqual(dataset_for_access_level('dataset'), 'dataset')
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from cpg_utils.dataproc_runner import (
|
|
7
|
+
DEFAULT_HAIL_VERSION,
|
|
8
|
+
HailDataprocCluster,
|
|
9
|
+
parse_label_kvs,
|
|
10
|
+
resolve_autoscaling_policy_uri,
|
|
11
|
+
sanitise_labels,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_cluster(**kwargs) -> HailDataprocCluster: # noqa: ANN003
|
|
16
|
+
return HailDataprocCluster(
|
|
17
|
+
project='test-project',
|
|
18
|
+
region='australia-southeast1',
|
|
19
|
+
cluster_name_prefix='test-cluster',
|
|
20
|
+
staging_bucket='gs://staging',
|
|
21
|
+
temp_bucket='gs://temp',
|
|
22
|
+
cluster_client=MagicMock(),
|
|
23
|
+
job_client=MagicMock(),
|
|
24
|
+
storage_client=MagicMock(),
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestDataprocRunnerPackages(unittest.TestCase):
|
|
30
|
+
def test_default_packages_populate_pkgs_metadata(self):
|
|
31
|
+
cluster = _make_cluster()
|
|
32
|
+
config = cluster._build_cluster_config()
|
|
33
|
+
pkgs = config['config']['gce_cluster_config']['metadata']['PKGS']
|
|
34
|
+
self.assertEqual(pkgs, f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}')
|
|
35
|
+
|
|
36
|
+
def test_custom_packages_populate_pkgs_metadata(self):
|
|
37
|
+
cluster = _make_cluster(packages=['cpg-utils', 'hail==0.2.999', 'gnomad'])
|
|
38
|
+
config = cluster._build_cluster_config()
|
|
39
|
+
pkgs = config['config']['gce_cluster_config']['metadata']['PKGS']
|
|
40
|
+
self.assertEqual(pkgs, 'cpg-utils|hail==0.2.999|gnomad')
|
|
41
|
+
|
|
42
|
+
def test_empty_pkgs(self):
|
|
43
|
+
cluster = _make_cluster(packages=[])
|
|
44
|
+
config = cluster._build_cluster_config()
|
|
45
|
+
self.assertEqual(
|
|
46
|
+
config['config']['gce_cluster_config']['metadata'].get('PKGS'),
|
|
47
|
+
f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_null_pkgs(self):
|
|
51
|
+
cluster = _make_cluster(packages=None)
|
|
52
|
+
config = cluster._build_cluster_config()
|
|
53
|
+
self.assertEqual(
|
|
54
|
+
config['config']['gce_cluster_config']['metadata']['PKGS'],
|
|
55
|
+
f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestDataprocRunnerServiceAccount(unittest.TestCase):
|
|
60
|
+
def test_service_account_omitted_by_default(self):
|
|
61
|
+
cluster = _make_cluster()
|
|
62
|
+
config = cluster._build_cluster_config()
|
|
63
|
+
self.assertIsNone(cluster.service_account)
|
|
64
|
+
self.assertNotIn(
|
|
65
|
+
'service_account',
|
|
66
|
+
config['config']['gce_cluster_config'],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def test_explicit_service_account(self):
|
|
70
|
+
sa = 'dataproc-test@test-project.iam.gserviceaccount.com'
|
|
71
|
+
cluster = _make_cluster(service_account=sa)
|
|
72
|
+
config = cluster._build_cluster_config()
|
|
73
|
+
self.assertEqual(cluster.service_account, sa)
|
|
74
|
+
self.assertEqual(
|
|
75
|
+
config['config']['gce_cluster_config']['service_account'],
|
|
76
|
+
sa,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_DISK_ROLES = ('master_config', 'worker_config', 'secondary_worker_config')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestDataprocRunnerDiskConfig(unittest.TestCase):
|
|
84
|
+
def test_default_disk_config(self):
|
|
85
|
+
config = _make_cluster()._build_cluster_config()['config']
|
|
86
|
+
for role in _DISK_ROLES:
|
|
87
|
+
disk_config = config[role]['disk_config']
|
|
88
|
+
self.assertEqual(disk_config['boot_disk_type'], 'pd-standard')
|
|
89
|
+
# num_local_ssds is omitted by default rather than set to 0.
|
|
90
|
+
self.assertNotIn('num_local_ssds', disk_config)
|
|
91
|
+
|
|
92
|
+
def test_custom_boot_disk_type(self):
|
|
93
|
+
config = _make_cluster(boot_disk_type='pd-ssd')._build_cluster_config()[
|
|
94
|
+
'config'
|
|
95
|
+
]
|
|
96
|
+
for role in _DISK_ROLES:
|
|
97
|
+
self.assertEqual(config[role]['disk_config']['boot_disk_type'], 'pd-ssd')
|
|
98
|
+
|
|
99
|
+
def test_local_ssds_added_to_every_role(self):
|
|
100
|
+
config = _make_cluster(num_local_ssds=2)._build_cluster_config()['config']
|
|
101
|
+
for role in _DISK_ROLES:
|
|
102
|
+
self.assertEqual(config[role]['disk_config']['num_local_ssds'], 2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_good_keys():
|
|
106
|
+
kv_pairs = ['good=key', 'also=good']
|
|
107
|
+
assert parse_label_kvs(kv_pairs) == {'good': 'key', 'also': 'good'}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_missing_equals_keys():
|
|
111
|
+
kv_pairs = ['good=key', 'bad']
|
|
112
|
+
with pytest.raises(ValueError) as ve:
|
|
113
|
+
parse_label_kvs(kv_pairs)
|
|
114
|
+
assert (
|
|
115
|
+
'One or more key=value parameters did not contain an equals character, so they could'
|
|
116
|
+
in str(ve.value)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_key_sanitation():
|
|
121
|
+
kv_pairs = ['good=key', 'also=good', r'what\'s_this!=#ARGH#']
|
|
122
|
+
assert parse_label_kvs(kv_pairs) == {
|
|
123
|
+
'good': 'key',
|
|
124
|
+
'also': 'good',
|
|
125
|
+
'what-s_this': 'argh',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_sanitise_labels_good():
|
|
130
|
+
assert sanitise_labels({'AR-GUID': 'Abc123', 'Bad Key!': 'x'}) == {
|
|
131
|
+
'ar-guid': 'abc123',
|
|
132
|
+
'bad-key': 'x',
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_sanitise_labels_bad():
|
|
137
|
+
with pytest.raises(ValueError) as ve:
|
|
138
|
+
sanitise_labels({'1AR-GUID': 'Abc123'})
|
|
139
|
+
assert "Failures: {'1ar-guid': 'abc123'}" in str(ve.value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_autoscaling_policy_resolver():
|
|
143
|
+
assert resolve_autoscaling_policy_uri(policy_ref='projects/stub') == 'projects/stub'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_autoscaling_policy_resolver_from_parts():
|
|
147
|
+
assert (
|
|
148
|
+
resolve_autoscaling_policy_uri(
|
|
149
|
+
policy_ref='1234',
|
|
150
|
+
project='project',
|
|
151
|
+
region='aus',
|
|
152
|
+
)
|
|
153
|
+
== 'projects/project/regions/aus/autoscalingPolicies/1234'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_autoscaling_policy_resolver_fails():
|
|
158
|
+
with pytest.raises(ValueError) as ve:
|
|
159
|
+
_ = resolve_autoscaling_policy_uri(policy_ref='not_projects/stub')
|
|
160
|
+
assert 'Invalid autoscaling policy' in str(ve.value)
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import doctest
|
|
2
|
-
import pkgutil
|
|
3
|
-
import unittest
|
|
4
|
-
|
|
5
|
-
import cpg_utils
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def load_tests(
|
|
9
|
-
loader: unittest.TestLoader, # noqa: ARG001
|
|
10
|
-
tests: unittest.TestSuite,
|
|
11
|
-
ignore: str | None, # noqa: ARG001
|
|
12
|
-
) -> unittest.TestSuite:
|
|
13
|
-
|
|
14
|
-
# Load all doctests from the cpg_utils package
|
|
15
|
-
for module in pkgutil.iter_modules(cpg_utils.__path__):
|
|
16
|
-
tests.addTests(doctest.DocTestSuite('cpg_utils.' + module.name))
|
|
17
|
-
|
|
18
|
-
return tests
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if __name__ == "__main__":
|
|
22
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|