cloud-submit 0.1__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.
- cloud_submit/__init__.py +30 -0
- cloud_submit/cli.py +1014 -0
- cloud_submit/config.py +164 -0
- cloud_submit/controller.py +375 -0
- cloud_submit/environment_handler.py +398 -0
- cloud_submit/envs/aws/__init__.py +2 -0
- cloud_submit/envs/aws/local_environment_handler.py +478 -0
- cloud_submit/envs/aws/local_execution_handler.py +5 -0
- cloud_submit/envs/aws/remote_environment_handler.py +401 -0
- cloud_submit/envs/aws/remote_execution_handler.py +87 -0
- cloud_submit/envs/aws/s3_tools.py +46 -0
- cloud_submit/envs/local/environment_handler.py +121 -0
- cloud_submit/envs/local/execution_handler.py +5 -0
- cloud_submit/execution/base_execution_handler.py +49 -0
- cloud_submit/execution/config.py +248 -0
- cloud_submit/execution/execute.py +108 -0
- cloud_submit/execution/utils.py +98 -0
- cloud_submit/images.py +58 -0
- cloud_submit/utils.py +67 -0
- cloud_submit-0.1.dist-info/METADATA +12 -0
- cloud_submit-0.1.dist-info/RECORD +24 -0
- cloud_submit-0.1.dist-info/WHEEL +4 -0
- cloud_submit-0.1.dist-info/entry_points.txt +2 -0
- cloud_submit-0.1.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import uuid
|
|
5
|
+
import subprocess
|
|
6
|
+
import glob
|
|
7
|
+
import shutil
|
|
8
|
+
import errno
|
|
9
|
+
|
|
10
|
+
from .execution.utils import (
|
|
11
|
+
CloudSubmitError,
|
|
12
|
+
run_command,
|
|
13
|
+
ensure_path,
|
|
14
|
+
clear_path,
|
|
15
|
+
)
|
|
16
|
+
from .images import BaseImage, ExecutionImage
|
|
17
|
+
from .execution.config import to_utc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvironmentHandler:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
name,
|
|
24
|
+
project,
|
|
25
|
+
user,
|
|
26
|
+
docker_command='docker',
|
|
27
|
+
docker_registry=None,
|
|
28
|
+
docker_namespace='csub',
|
|
29
|
+
):
|
|
30
|
+
self.name = name
|
|
31
|
+
self._project = project
|
|
32
|
+
self._user = user
|
|
33
|
+
self._docker_command = str(docker_command)
|
|
34
|
+
self._docker_registry = docker_registry
|
|
35
|
+
self._docker_namespace = docker_namespace
|
|
36
|
+
|
|
37
|
+
def _generate_id(self, timestamp, id):
|
|
38
|
+
if id is not None:
|
|
39
|
+
return id
|
|
40
|
+
uid = str(uuid.uuid4())
|
|
41
|
+
timestamp=to_utc(timestamp)
|
|
42
|
+
return timestamp.strftime('%Y%m%d-%H%M%S-') + uid[:4]
|
|
43
|
+
|
|
44
|
+
def generate_build_id(self, timestamp, build_id=None):
|
|
45
|
+
"""Generate an ID for an image build.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
timestamp (datetime): The current date and time.
|
|
49
|
+
build_id (str, optional): The build ID to return. A new unique ID
|
|
50
|
+
is only generated when `build_id` is ``None``.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
generated_build_id (str): Either `build_id` when `build_id` was
|
|
54
|
+
not ``None`` or a new unique ID generated based on `timestamp`.
|
|
55
|
+
"""
|
|
56
|
+
return self._generate_id(timestamp, build_id)
|
|
57
|
+
|
|
58
|
+
def generate_run_id(self, timestamp, run_id=None):
|
|
59
|
+
"""Generate an ID for a pipeline run.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
timestamp (datetime): The current date and time.
|
|
63
|
+
run_id (str, optional): The run ID to return. A new unique ID
|
|
64
|
+
is only generated when `run_id` is ``None``.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
generated_run_id (str): Either `run_id` when `run_id` was
|
|
68
|
+
not ``None`` or a new unique ID generated based on `timestamp`.
|
|
69
|
+
"""
|
|
70
|
+
return self._generate_id(timestamp, run_id)
|
|
71
|
+
|
|
72
|
+
def install_execution_handler(self, path):
|
|
73
|
+
"""Install the execution handler under `path`.
|
|
74
|
+
|
|
75
|
+
Note:
|
|
76
|
+
Derived classes must implement this. They may assume that `path`
|
|
77
|
+
already exists and is a directory. This method must put a Python
|
|
78
|
+
module called ``execution_handler.py`` under `path`. That module
|
|
79
|
+
must define a function called ``create_execution_handler`` which
|
|
80
|
+
takes no arguments and returns an instance of ``BaseExecutionHandler``
|
|
81
|
+
(see below) or of a subclass thereof. When a cloud-submit container is
|
|
82
|
+
executed the ``create_execution_hander`` function is called and the
|
|
83
|
+
returned object is used to perform environment-specific operations
|
|
84
|
+
like uploading or downloading artifacts.
|
|
85
|
+
|
|
86
|
+
Typically, your ``execution_handler.py`` module will define a
|
|
87
|
+
subclass of ``BaseExecutionHandler`` and the
|
|
88
|
+
``create_execution_hander`` function will return an instance of that
|
|
89
|
+
subclass. You may install additional modules or config files under
|
|
90
|
+
`path` if you wish. For example, you may want to include a
|
|
91
|
+
``config.json`` file which holds additional config parameters like
|
|
92
|
+
the S3 bucket where you're artifacts are stored.
|
|
93
|
+
|
|
94
|
+
A few core cloud-submit modules and config files already exist in
|
|
95
|
+
`path` and should not be overwritten (unless you know what you're
|
|
96
|
+
doing). These files are:
|
|
97
|
+
|
|
98
|
+
base_execution_handler.py
|
|
99
|
+
A Python module providing the base class for all execution
|
|
100
|
+
handlers (``BaseExecutionHandler``).
|
|
101
|
+
config.py
|
|
102
|
+
A Python module providing classes commonly found in a cloud-submit
|
|
103
|
+
project configuration (e.g. ``Pipeline``, ``Artifact``,
|
|
104
|
+
``ArtifactLocation`` or ``Step``).
|
|
105
|
+
execute.py
|
|
106
|
+
The Python module that serves as the main entrypoint of every
|
|
107
|
+
execution image.
|
|
108
|
+
utils.py
|
|
109
|
+
A Python module which provides some of the utility functions
|
|
110
|
+
from the ``cloud_submit`` package. (E.g. ``run_command``,
|
|
111
|
+
``read_json`` or ``write_json``.)
|
|
112
|
+
artifacts.json
|
|
113
|
+
A JSON file with information about the artifacts defined for
|
|
114
|
+
the project.
|
|
115
|
+
pipelines.json
|
|
116
|
+
A JSON file with information about the pipelines defined for
|
|
117
|
+
the project.
|
|
118
|
+
|
|
119
|
+
Of course your ``execution_handler.py`` module (or any other modules
|
|
120
|
+
you install) may access the pre-installed modules via relative
|
|
121
|
+
imports.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path (str): The directory in which the execution handler should be
|
|
125
|
+
installed.
|
|
126
|
+
"""
|
|
127
|
+
raise NotImplementedError
|
|
128
|
+
|
|
129
|
+
def _get_image_ref_path(self, image):
|
|
130
|
+
if isinstance(image, BaseImage):
|
|
131
|
+
image_name = image.name
|
|
132
|
+
elif isinstance(image, ExecutionImage):
|
|
133
|
+
image_name = '.'.join([image.name, self.name])
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(f'Cannot handle image of type {type(image)}')
|
|
136
|
+
|
|
137
|
+
return os.path.join('images', self._user, image_name)
|
|
138
|
+
|
|
139
|
+
def get_image_ref(self, image):
|
|
140
|
+
ensure_path(os.path.join('images', self._user))
|
|
141
|
+
path = self._get_image_ref_path(image)
|
|
142
|
+
try:
|
|
143
|
+
with open(path, 'r') as stream:
|
|
144
|
+
ref = stream.read().strip()
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
return None
|
|
147
|
+
return ref
|
|
148
|
+
|
|
149
|
+
def save_image_ref(self, image, ref):
|
|
150
|
+
ensure_path(os.path.join('images', self._user))
|
|
151
|
+
path = self._get_image_ref_path(image)
|
|
152
|
+
with open(path, 'w') as stream:
|
|
153
|
+
stream.write(ref)
|
|
154
|
+
|
|
155
|
+
def clear_image_ref(self, image):
|
|
156
|
+
ensure_path(os.path.join('images', self._user))
|
|
157
|
+
path = self._get_image_ref_path(image)
|
|
158
|
+
try:
|
|
159
|
+
os.remove(path)
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
def get_image_repo_name(self, image):
|
|
164
|
+
prefix = []
|
|
165
|
+
if self._docker_registry is not None:
|
|
166
|
+
prefix.append(self._docker_registry)
|
|
167
|
+
else:
|
|
168
|
+
prefix.append('localhost')
|
|
169
|
+
if self._docker_namespace is not None:
|
|
170
|
+
prefix.append(self._docker_namespace)
|
|
171
|
+
prefix = '/'.join(prefix)
|
|
172
|
+
|
|
173
|
+
if isinstance(image, BaseImage):
|
|
174
|
+
return (
|
|
175
|
+
f'{prefix}'
|
|
176
|
+
f'/{self._project}'
|
|
177
|
+
f'/{self._user}'
|
|
178
|
+
f'/{image.name}'
|
|
179
|
+
)
|
|
180
|
+
elif isinstance(image, ExecutionImage):
|
|
181
|
+
return (
|
|
182
|
+
f'{prefix}'
|
|
183
|
+
f'/{self._project}'
|
|
184
|
+
f'/{self._user}'
|
|
185
|
+
f'/{image.name}'
|
|
186
|
+
f'.{self.name}'
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def pull_base_image(self, ref):
|
|
190
|
+
"""Pull an image from the remote registry.
|
|
191
|
+
|
|
192
|
+
Note:
|
|
193
|
+
Derived classes usually need to overload this to handle authentication
|
|
194
|
+
with the remote registry. In a local environment where there is no
|
|
195
|
+
remote registry this function should check if the requested image
|
|
196
|
+
exists locally and raise a ``CloudSubmitError`` if it does not.
|
|
197
|
+
This function is never called for execution images.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
ref (str): The full docker image reference for the requested image.
|
|
201
|
+
"""
|
|
202
|
+
run_command([
|
|
203
|
+
self._docker_command,
|
|
204
|
+
'pull',
|
|
205
|
+
ref,
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
def list_local_image_tags(self, repo_name):
|
|
209
|
+
"""List locally available tags for a docker image repo.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
repo_name (str): The fully qualified name of the docker image repo.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
tags (list of str): The list of tags that are available locally.
|
|
216
|
+
"""
|
|
217
|
+
result = run_command(
|
|
218
|
+
[
|
|
219
|
+
self._docker_command,
|
|
220
|
+
'image',
|
|
221
|
+
'list',
|
|
222
|
+
'--format', '{{.Tag}}',
|
|
223
|
+
repo_name,
|
|
224
|
+
],
|
|
225
|
+
stdout=subprocess.PIPE,
|
|
226
|
+
text=True,
|
|
227
|
+
)
|
|
228
|
+
result = result.stdout.strip()
|
|
229
|
+
if not result:
|
|
230
|
+
return []
|
|
231
|
+
return result.split('\n')
|
|
232
|
+
|
|
233
|
+
def list_remote_image_tags(self, repo_name):
|
|
234
|
+
"""List remotely available tags for a docker image repo.
|
|
235
|
+
|
|
236
|
+
Note:
|
|
237
|
+
Derived classes must overload this to handle communication with the
|
|
238
|
+
remote registry.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
repo_name (str): The fully qualified name of the docker image repo.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
tags (list of str): The list of the tags that are available in the
|
|
245
|
+
remote registry.
|
|
246
|
+
"""
|
|
247
|
+
raise NotImplementedError
|
|
248
|
+
|
|
249
|
+
def remove_local_image_refs(self, refs):
|
|
250
|
+
run_command([
|
|
251
|
+
self._docker_command,
|
|
252
|
+
'image',
|
|
253
|
+
'remove',
|
|
254
|
+
*refs,
|
|
255
|
+
])
|
|
256
|
+
|
|
257
|
+
def remove_remote_image_refs(self, refs):
|
|
258
|
+
raise NotImplementedError
|
|
259
|
+
|
|
260
|
+
def build_image(self, path, image, build_id):
|
|
261
|
+
repo = self.get_image_repo_name(image)
|
|
262
|
+
ref = ':'.join([repo, build_id])
|
|
263
|
+
run_command([
|
|
264
|
+
self._docker_command,
|
|
265
|
+
'build',
|
|
266
|
+
'-t', ref,
|
|
267
|
+
path,
|
|
268
|
+
])
|
|
269
|
+
return ref
|
|
270
|
+
|
|
271
|
+
def get_local_artifact_path(self, artifact, run_id=None):
|
|
272
|
+
if artifact.kind != 'file':
|
|
273
|
+
raise CloudSubmitError(
|
|
274
|
+
f'Cannot get path for local artifact {artifact.name}. '
|
|
275
|
+
"Only artifacts of kind 'file' are supported and this one "
|
|
276
|
+
f'is of kind {repr(artifact.kind)}.'
|
|
277
|
+
)
|
|
278
|
+
if artifact.scope == 'project':
|
|
279
|
+
return os.path.join('artifacts', 'shared', artifact.name)
|
|
280
|
+
elif artifact.scope == 'user':
|
|
281
|
+
return os.path.join(
|
|
282
|
+
'artifacts', 'users', self._user, 'shared', artifact.name)
|
|
283
|
+
elif artifact.scope == 'run':
|
|
284
|
+
if run_id is None:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
'You must specify `run_id` to get the path '
|
|
287
|
+
"for an artifact with scope 'run'"
|
|
288
|
+
)
|
|
289
|
+
return os.path.join(
|
|
290
|
+
'artifacts', 'users', self._user, 'runs', run_id, artifact.name)
|
|
291
|
+
else:
|
|
292
|
+
raise CloudSubmitError(
|
|
293
|
+
f'Unknown scope {artifact.scope} for artifact {artifact.name}.')
|
|
294
|
+
|
|
295
|
+
def get_remote_artifact_path(self, artifact, run_id=None):
|
|
296
|
+
raise NotImplementedError
|
|
297
|
+
|
|
298
|
+
def list_local_artifacts(self, artifacts, run_ids=None):
|
|
299
|
+
if run_ids is not None:
|
|
300
|
+
run_ids = set(run_ids)
|
|
301
|
+
|
|
302
|
+
results = []
|
|
303
|
+
for artifact in artifacts:
|
|
304
|
+
if artifact.kind != 'file':
|
|
305
|
+
raise CloudSubmitError(
|
|
306
|
+
f'Cannot list run IDs for local artifact {artifact.name}. '
|
|
307
|
+
"Only artifacts of kind 'file' are supported and this one "
|
|
308
|
+
f'is of kind {repr(artifact.kind)}.'
|
|
309
|
+
)
|
|
310
|
+
if artifact.scope == 'run':
|
|
311
|
+
pattern = os.path.join(
|
|
312
|
+
'artifacts', 'users', self._user,
|
|
313
|
+
'runs', '*', artifact.name,
|
|
314
|
+
)
|
|
315
|
+
files = glob.glob(pattern)
|
|
316
|
+
ids = [os.path.basename(os.path.dirname(f)) for f in files]
|
|
317
|
+
if run_ids is not None:
|
|
318
|
+
ids = [i for i in ids if i in run_ids]
|
|
319
|
+
elif artifact.scope == 'user':
|
|
320
|
+
pattern = os.path.join(
|
|
321
|
+
'artifacts', 'users', self._user, 'shared', artifact.name)
|
|
322
|
+
files = glob.glob(pattern)
|
|
323
|
+
if files:
|
|
324
|
+
ids = [None]
|
|
325
|
+
else:
|
|
326
|
+
ids = []
|
|
327
|
+
elif artifact.scope == 'project':
|
|
328
|
+
pattern = os.path.join(
|
|
329
|
+
'artifacts', 'shared', artifact.name)
|
|
330
|
+
files = glob.glob(pattern)
|
|
331
|
+
if files:
|
|
332
|
+
ids = [None]
|
|
333
|
+
else:
|
|
334
|
+
ids = []
|
|
335
|
+
results.append(ids)
|
|
336
|
+
return results
|
|
337
|
+
|
|
338
|
+
def list_remote_artifacts(self, artifacts, run_ids=None):
|
|
339
|
+
raise NotImplementedError
|
|
340
|
+
|
|
341
|
+
def push_artifacts(self, artifacts, run_ids):
|
|
342
|
+
raise NotImplementedError
|
|
343
|
+
|
|
344
|
+
def pull_artifacts(self, artifacts, run_ids):
|
|
345
|
+
raise NotImplementedError
|
|
346
|
+
|
|
347
|
+
def remove_local_artifacts(self, artifacts, run_ids):
|
|
348
|
+
for artifact, runs in zip(artifacts, run_ids):
|
|
349
|
+
if artifact.kind != 'file':
|
|
350
|
+
continue
|
|
351
|
+
for run_id in runs:
|
|
352
|
+
path = self.get_local_artifact_path(artifact, run_id)
|
|
353
|
+
clear_path(path)
|
|
354
|
+
|
|
355
|
+
def remove_remote_artifacts(self, artifacts, run_ids):
|
|
356
|
+
raise NotImplementedError
|
|
357
|
+
|
|
358
|
+
def copy_local_artifacts(self, artifacts, from_run_id, to_run_id):
|
|
359
|
+
for artifact in artifacts:
|
|
360
|
+
src_path = self.get_local_artifact_path(artifact, from_run_id)
|
|
361
|
+
dst_path = self.get_local_artifact_path(artifact, to_run_id)
|
|
362
|
+
if os.path.exists(src_path):
|
|
363
|
+
ensure_path(os.path.dirname(dst_path))
|
|
364
|
+
try:
|
|
365
|
+
shutil.copytree(src_path, dst_path)
|
|
366
|
+
except OSError as e:
|
|
367
|
+
if e.errno in (errno.ENOTDIR, errno.EINVAL):
|
|
368
|
+
shutil.copy(src_path, dst_path)
|
|
369
|
+
else:
|
|
370
|
+
raise
|
|
371
|
+
|
|
372
|
+
def copy_remote_artifacts(self, artifacts, from_run_id, to_run_id):
|
|
373
|
+
raise NotImplementedError
|
|
374
|
+
|
|
375
|
+
def move_local_artifacts(self, artifacts, from_run_id, to_run_id):
|
|
376
|
+
for artifact in artifacts:
|
|
377
|
+
src_path = self.get_local_artifact_path(artifact, from_run_id)
|
|
378
|
+
dst_path = self.get_local_artifact_path(artifact, to_run_id)
|
|
379
|
+
if os.path.exists(src_path):
|
|
380
|
+
ensure_path(os.path.dirname(dst_path))
|
|
381
|
+
shutil.move(src_path, dst_path)
|
|
382
|
+
|
|
383
|
+
def move_remote_artifacts(self, artifacts, from_run_id, to_run_id):
|
|
384
|
+
raise NotImplementedError
|
|
385
|
+
|
|
386
|
+
def run_pipeline(
|
|
387
|
+
self,
|
|
388
|
+
pipeline,
|
|
389
|
+
image_refs,
|
|
390
|
+
overwrite_artifacts,
|
|
391
|
+
timestamp,
|
|
392
|
+
run_id,
|
|
393
|
+
temp_path
|
|
394
|
+
):
|
|
395
|
+
raise NotImplementedError
|
|
396
|
+
|
|
397
|
+
def print_logs(self, run_id, start_timestamp, stream=False):
|
|
398
|
+
raise NotImplementedError
|