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
cloud_submit/cli.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import datetime as dt
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from .execution.utils import CloudSubmitError
|
|
9
|
+
from .controller import Controller
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def abort(msg):
|
|
13
|
+
sys.stderr.write(msg)
|
|
14
|
+
sys.stderr.write('\n')
|
|
15
|
+
sys.exit(1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init(ctx):
|
|
19
|
+
ctx.ensure_object(dict)
|
|
20
|
+
if ctx.obj['controller'] is not None:
|
|
21
|
+
return
|
|
22
|
+
project_root = os.path.abspath(ctx.obj['project_root'])
|
|
23
|
+
ctx.obj['project_root'] = project_root
|
|
24
|
+
user = ctx.obj['user']
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.join(project_root, 'src'))
|
|
27
|
+
userconfig_path = os.path.join(project_root, 'userconfig', f'{user}.yaml')
|
|
28
|
+
try:
|
|
29
|
+
stream = open(userconfig_path, 'r')
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
abort(
|
|
32
|
+
f'Could not find user config at {userconfig_path}. Are you in the '
|
|
33
|
+
'project root directory? When working outside the project root '
|
|
34
|
+
'directory you must specify it with -p.'
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
userconfig = yaml.safe_load(stream)
|
|
38
|
+
except yaml.YAMLError as e:
|
|
39
|
+
abort(f'Error reading user config at {userconfig_path}: {str(e)}')
|
|
40
|
+
finally:
|
|
41
|
+
stream.close()
|
|
42
|
+
|
|
43
|
+
from csub.build_config import build_config
|
|
44
|
+
csubconfig = build_config(project_root, userconfig)
|
|
45
|
+
controller = Controller(csubconfig)
|
|
46
|
+
ctx.obj['config'] = csubconfig
|
|
47
|
+
ctx.obj['controller'] = controller
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tabulate(data, header=None):
|
|
51
|
+
if not data:
|
|
52
|
+
return ''
|
|
53
|
+
if header is not None:
|
|
54
|
+
data = [header] + data
|
|
55
|
+
data = [
|
|
56
|
+
[('' if cell is None else cell) for cell in row]
|
|
57
|
+
for row in data
|
|
58
|
+
]
|
|
59
|
+
maxlen = None
|
|
60
|
+
for row in data:
|
|
61
|
+
if maxlen is None:
|
|
62
|
+
maxlen = [len(cell) for cell in row]
|
|
63
|
+
elif len(row) == len(maxlen):
|
|
64
|
+
maxlen = [max(len(cell), l) for cell, l in zip(row, maxlen)]
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError('Inconsistent element count in rows.')
|
|
67
|
+
|
|
68
|
+
result = []
|
|
69
|
+
for row in data:
|
|
70
|
+
output_row = []
|
|
71
|
+
for cell, l in zip(row, maxlen):
|
|
72
|
+
output_row.append(cell.ljust(l))
|
|
73
|
+
result.append(' '.join(output_row))
|
|
74
|
+
return '\n'.join(result)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_steps(pipeline, steps_arg):
|
|
78
|
+
if steps_arg is None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
tokens = steps_arg.split(',')
|
|
82
|
+
all_steps = [step.name for step in pipeline.steps]
|
|
83
|
+
indices = {step: i for i, step in enumerate(all_steps)}
|
|
84
|
+
|
|
85
|
+
selected = set()
|
|
86
|
+
for token in tokens:
|
|
87
|
+
token = token.split(':')
|
|
88
|
+
if len(token) == 1:
|
|
89
|
+
if token[0] not in indices:
|
|
90
|
+
raise CloudSubmitError(f'Invalid step name: {token[0]}')
|
|
91
|
+
selected.add(token[0])
|
|
92
|
+
elif len(token) == 2:
|
|
93
|
+
try:
|
|
94
|
+
begin = indices[token[0]]
|
|
95
|
+
except KeyError:
|
|
96
|
+
raise CloudSubmitError(f'Invalid step name: {token[0]}')
|
|
97
|
+
try:
|
|
98
|
+
end = indices[token[1]]
|
|
99
|
+
except KeyError:
|
|
100
|
+
raise CloudSubmitError(f'Invalid step name: {token[1]}')
|
|
101
|
+
for step in all_steps[beg:end+1]:
|
|
102
|
+
selected.add(step)
|
|
103
|
+
else:
|
|
104
|
+
raise CloudSubmitError(f'Invalid step range: {":".join(token)}')
|
|
105
|
+
|
|
106
|
+
return [step for step in all_steps if step in selected]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@click.group()
|
|
110
|
+
@click.option(
|
|
111
|
+
'--project-root', '-p',
|
|
112
|
+
type=click.Path(exists=True, file_okay=False),
|
|
113
|
+
default='.',
|
|
114
|
+
help='Path to the project root directory. Defaults to current directory.',
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
'--user', '-u',
|
|
118
|
+
type=str,
|
|
119
|
+
default='default',
|
|
120
|
+
help='Path to the project root directory. Defaults to current directory.',
|
|
121
|
+
)
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def main(ctx, project_root, user):
|
|
124
|
+
ctx.ensure_object(dict)
|
|
125
|
+
ctx.obj['project_root'] = project_root
|
|
126
|
+
ctx.obj['user'] = user
|
|
127
|
+
ctx.obj['controller'] = None
|
|
128
|
+
ctx.obj['config'] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# pipelines subcommand
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@main.group('pipelines', help='View and execute pipelines.')
|
|
135
|
+
def pipelines():
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pipelines.command(
|
|
140
|
+
name='list',
|
|
141
|
+
help="""List available pipelines and their steps.
|
|
142
|
+
|
|
143
|
+
If one or more PIPELINES arguments are given those pipelines will be
|
|
144
|
+
listed (if they exist). Otherwise all available pipelines are listed.
|
|
145
|
+
""",
|
|
146
|
+
)
|
|
147
|
+
@click.option(
|
|
148
|
+
'--steps', '-s',
|
|
149
|
+
is_flag=True,
|
|
150
|
+
help='Also list the steps of each pipeline.'
|
|
151
|
+
)
|
|
152
|
+
@click.argument(
|
|
153
|
+
'pipelines',
|
|
154
|
+
type=str,
|
|
155
|
+
nargs=-1,
|
|
156
|
+
)
|
|
157
|
+
@click.pass_context
|
|
158
|
+
def list_pipelines(ctx, steps, pipelines):
|
|
159
|
+
init(ctx)
|
|
160
|
+
config = ctx.obj['config']
|
|
161
|
+
if pipelines:
|
|
162
|
+
pipelines = set(pipelines)
|
|
163
|
+
else:
|
|
164
|
+
pipelines = None
|
|
165
|
+
for pipeline in config.pipelines.values():
|
|
166
|
+
if pipelines is None or pipeline.name in pipelines:
|
|
167
|
+
print(pipeline.name)
|
|
168
|
+
if steps:
|
|
169
|
+
for step in pipeline.steps:
|
|
170
|
+
print(' ' + step.name)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pipelines.command(
|
|
174
|
+
name='run',
|
|
175
|
+
help="""Run a pipeline (or a part of it).
|
|
176
|
+
|
|
177
|
+
Run the pipeline PIPELINE. Specify STEPS to select a subset of steps to run.
|
|
178
|
+
STEPS should be a comma-separated list of step names or step ranges of the form
|
|
179
|
+
start:end where start and end are step names. If steps is not specified all
|
|
180
|
+
steps are executed. By default, all *execution* images needed for the run are
|
|
181
|
+
re-built (but you can use --no-rebuild to prevent that).
|
|
182
|
+
"""
|
|
183
|
+
)
|
|
184
|
+
@click.option(
|
|
185
|
+
'--env', '-e',
|
|
186
|
+
type=str,
|
|
187
|
+
default=None,
|
|
188
|
+
help='The name of the environment to use for the run.',
|
|
189
|
+
)
|
|
190
|
+
@click.option(
|
|
191
|
+
'--build-env',
|
|
192
|
+
type=str,
|
|
193
|
+
default=None,
|
|
194
|
+
help='The name of the environment to use for building images.',
|
|
195
|
+
)
|
|
196
|
+
@click.option(
|
|
197
|
+
'--run-id', '-r',
|
|
198
|
+
type=str,
|
|
199
|
+
default=None,
|
|
200
|
+
help=(
|
|
201
|
+
'The ID for the run. This will be used, for example, to uniquely name '
|
|
202
|
+
'artifacts. Defaults to the current timestamp in YYYYMMDD-HHMMSS-XXXX '
|
|
203
|
+
'format, where XXXX is a four-character UUID.'
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
'--timestamp', '-t',
|
|
208
|
+
type=str,
|
|
209
|
+
default=None,
|
|
210
|
+
help=(
|
|
211
|
+
'The timestamp used as the submit time of the pipeline. '
|
|
212
|
+
'If not specified the default submit timestamp of the pipeline is '
|
|
213
|
+
'used. If that is not specified either the current time is used. '
|
|
214
|
+
'You can use this option to simulate runs submitted at a different '
|
|
215
|
+
'time. The value must be in ISO format (YYYY-MM-DDTHH:MM:SS). '
|
|
216
|
+
'You can also use "-t now" to use the current time irrespective of '
|
|
217
|
+
'the pipeline default.'
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
@click.option('--stream-logs', is_flag=True)
|
|
221
|
+
@click.argument(
|
|
222
|
+
'pipeline',
|
|
223
|
+
type=str,
|
|
224
|
+
required=True,
|
|
225
|
+
)
|
|
226
|
+
@click.argument(
|
|
227
|
+
'steps',
|
|
228
|
+
type=str,
|
|
229
|
+
default=None,
|
|
230
|
+
)
|
|
231
|
+
@click.pass_context
|
|
232
|
+
def run_pipeline(
|
|
233
|
+
ctx,
|
|
234
|
+
env,
|
|
235
|
+
build_env,
|
|
236
|
+
run_id,
|
|
237
|
+
timestamp,
|
|
238
|
+
stream_logs,
|
|
239
|
+
pipeline,
|
|
240
|
+
steps
|
|
241
|
+
):
|
|
242
|
+
init(ctx)
|
|
243
|
+
config = ctx.obj['config']
|
|
244
|
+
controller = ctx.obj['controller']
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
pipeline = config.pipelines[pipeline]
|
|
248
|
+
except KeyError:
|
|
249
|
+
abort(f'Pipeline not found: {pipeline}')
|
|
250
|
+
|
|
251
|
+
if timestamp is not None and timestamp != 'now':
|
|
252
|
+
timestamp = dt.datetime.fromisoformat(timestamp)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
steps = get_steps(pipeline, steps)
|
|
256
|
+
execution_data = controller.run_pipeline(
|
|
257
|
+
pipeline.name,
|
|
258
|
+
steps=steps,
|
|
259
|
+
run_id=run_id,
|
|
260
|
+
timestamp=timestamp,
|
|
261
|
+
env=env,
|
|
262
|
+
build_env=build_env,
|
|
263
|
+
)
|
|
264
|
+
if stream_logs:
|
|
265
|
+
controller.print_logs(
|
|
266
|
+
run_id=execution_data['run_id'],
|
|
267
|
+
since=execution_data['start_timestamp'],
|
|
268
|
+
env=env,
|
|
269
|
+
stream=True,
|
|
270
|
+
)
|
|
271
|
+
except CloudSubmitError as e:
|
|
272
|
+
abort(str(e))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# images subgroup
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@main.group('images', help='Manage docker images.')
|
|
279
|
+
def images():
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@images.command(
|
|
284
|
+
name='build',
|
|
285
|
+
help='Build one or more docker images.',
|
|
286
|
+
)
|
|
287
|
+
@click.option(
|
|
288
|
+
'--env', '-e',
|
|
289
|
+
type=str,
|
|
290
|
+
default=None,
|
|
291
|
+
help='The name of the build environment to use.',
|
|
292
|
+
)
|
|
293
|
+
@click.option(
|
|
294
|
+
'--build-id', '-b',
|
|
295
|
+
type=str,
|
|
296
|
+
default=None,
|
|
297
|
+
help=(
|
|
298
|
+
'The ID for the build. This will be used as the image tag. '
|
|
299
|
+
'Defaults to the current timestamp in YYYYMMDD-HHMMSS-XXXX format, '
|
|
300
|
+
'where XXXX is a four-character UUID.'
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
@click.option(
|
|
304
|
+
'--all', '-a', 'build_all',
|
|
305
|
+
is_flag=True,
|
|
306
|
+
help='Build all images.',
|
|
307
|
+
)
|
|
308
|
+
@click.argument('images', type=str, nargs=-1)
|
|
309
|
+
@click.pass_context
|
|
310
|
+
def build(ctx, env, build_id, build_all, images):
|
|
311
|
+
init(ctx)
|
|
312
|
+
config = ctx.obj['config']
|
|
313
|
+
controller = ctx.obj['controller']
|
|
314
|
+
|
|
315
|
+
for image in images:
|
|
316
|
+
if image not in config.images:
|
|
317
|
+
abort(f'No definition found for image {image}.')
|
|
318
|
+
if build_all:
|
|
319
|
+
images = list(config.images.keys())
|
|
320
|
+
try:
|
|
321
|
+
controller.build(images, build_id=build_id, env=env)
|
|
322
|
+
except CloudSubmitError as e:
|
|
323
|
+
abort(str(e))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@images.command(
|
|
327
|
+
name='list',
|
|
328
|
+
help='List the images associated with the project.',
|
|
329
|
+
)
|
|
330
|
+
@click.option(
|
|
331
|
+
'--env', '-e',
|
|
332
|
+
type=str,
|
|
333
|
+
default=None,
|
|
334
|
+
help='The name of the environment to use for listing images.',
|
|
335
|
+
)
|
|
336
|
+
@click.option(
|
|
337
|
+
'--images', '-i',
|
|
338
|
+
type=str,
|
|
339
|
+
default=None,
|
|
340
|
+
help=(
|
|
341
|
+
'Comma-separated list of names for the images to show. '
|
|
342
|
+
'If absent, all declared images are shown.'
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
@click.option(
|
|
346
|
+
'--local', '-l',
|
|
347
|
+
is_flag=True,
|
|
348
|
+
help='Show local image refs.',
|
|
349
|
+
)
|
|
350
|
+
@click.option(
|
|
351
|
+
'--remote', '-r',
|
|
352
|
+
is_flag=True,
|
|
353
|
+
help='Show image refs in remote registry.',
|
|
354
|
+
)
|
|
355
|
+
@click.option(
|
|
356
|
+
'--build-ids', '-b',
|
|
357
|
+
type=str,
|
|
358
|
+
default=None,
|
|
359
|
+
help=(
|
|
360
|
+
'Comma-separated list of build IDs. Only images '
|
|
361
|
+
'with the given build IDs are shown.'
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
@click.pass_context
|
|
365
|
+
def list_images(ctx, env, images, local, remote, build_ids):
|
|
366
|
+
if local and remote:
|
|
367
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
368
|
+
init(ctx)
|
|
369
|
+
config = ctx.obj['config']
|
|
370
|
+
controller = ctx.obj['controller']
|
|
371
|
+
|
|
372
|
+
if images is not None:
|
|
373
|
+
images = list(set(images.split(',')))
|
|
374
|
+
if build_ids is not None:
|
|
375
|
+
build_ids = list(set(build_ids.split(',')))
|
|
376
|
+
|
|
377
|
+
if local or remote:
|
|
378
|
+
try:
|
|
379
|
+
results = controller.list_image_refs(
|
|
380
|
+
images=images,
|
|
381
|
+
ids=build_ids,
|
|
382
|
+
env=env,
|
|
383
|
+
remote=remote,
|
|
384
|
+
)
|
|
385
|
+
except CloudSubmitError as e:
|
|
386
|
+
abort(str(e))
|
|
387
|
+
for ref in results:
|
|
388
|
+
print(ref)
|
|
389
|
+
else:
|
|
390
|
+
data = controller.list_images(images=images)
|
|
391
|
+
data = [
|
|
392
|
+
(name, tpe, ('' if ref is None else ref.split('@')[0]))
|
|
393
|
+
for name, tpe, ref in data
|
|
394
|
+
]
|
|
395
|
+
table = tabulate(data, header=['NAME', 'TYPE', 'REF'])
|
|
396
|
+
if table:
|
|
397
|
+
print(table)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@images.command(
|
|
401
|
+
name='remove',
|
|
402
|
+
help='Remove images associated with the project.',
|
|
403
|
+
)
|
|
404
|
+
@click.option(
|
|
405
|
+
'--env', '-e',
|
|
406
|
+
type=str,
|
|
407
|
+
default=None,
|
|
408
|
+
help='The name of the environment to use for listing images.',
|
|
409
|
+
)
|
|
410
|
+
@click.option(
|
|
411
|
+
'--images', '-i',
|
|
412
|
+
type=str,
|
|
413
|
+
default=None,
|
|
414
|
+
help=(
|
|
415
|
+
'Comma-separated list of names for the images to remove. '
|
|
416
|
+
'If absent, all declared images will be removed.'
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
@click.option(
|
|
420
|
+
'--local', '-l',
|
|
421
|
+
is_flag=True,
|
|
422
|
+
help='Remove images only locally.',
|
|
423
|
+
)
|
|
424
|
+
@click.option(
|
|
425
|
+
'--remote', '-r',
|
|
426
|
+
is_flag=True,
|
|
427
|
+
help='Remove images only from remote registry.',
|
|
428
|
+
)
|
|
429
|
+
@click.option(
|
|
430
|
+
'--build-ids', '-b',
|
|
431
|
+
type=str,
|
|
432
|
+
default=None,
|
|
433
|
+
help=(
|
|
434
|
+
'Comma-separated list of build IDs. Only images '
|
|
435
|
+
'with the given build IDs are removed.'
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
@click.pass_context
|
|
439
|
+
def remove_images(ctx, env, images, local, remote, build_ids):
|
|
440
|
+
if local and remote:
|
|
441
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
442
|
+
init(ctx)
|
|
443
|
+
config = ctx.obj['config']
|
|
444
|
+
controller = ctx.obj['controller']
|
|
445
|
+
|
|
446
|
+
remove_local = True
|
|
447
|
+
remove_remote = True
|
|
448
|
+
if local:
|
|
449
|
+
remove_remote = False
|
|
450
|
+
if remote:
|
|
451
|
+
remove_local = False
|
|
452
|
+
|
|
453
|
+
if images is not None:
|
|
454
|
+
images = list(set(images.split(',')))
|
|
455
|
+
if build_ids is not None:
|
|
456
|
+
build_ids = list(set(build_ids.split(',')))
|
|
457
|
+
|
|
458
|
+
if remove_local:
|
|
459
|
+
try:
|
|
460
|
+
refs = controller.list_image_refs(
|
|
461
|
+
images=images,
|
|
462
|
+
ids=build_ids,
|
|
463
|
+
env=env,
|
|
464
|
+
remote=False,
|
|
465
|
+
)
|
|
466
|
+
controller.remove_image_refs(refs, remote=False, env=env)
|
|
467
|
+
except CloudSubmitError as e:
|
|
468
|
+
abort(str(e))
|
|
469
|
+
if remove_remote:
|
|
470
|
+
try:
|
|
471
|
+
refs = controller.list_image_refs(
|
|
472
|
+
images=images,
|
|
473
|
+
ids=build_ids,
|
|
474
|
+
env=env,
|
|
475
|
+
remote=True,
|
|
476
|
+
)
|
|
477
|
+
controller.remove_image_refs(refs, remote=True, env=env)
|
|
478
|
+
except CloudSubmitError as e:
|
|
479
|
+
abort(str(e))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@images.command(
|
|
483
|
+
name='set',
|
|
484
|
+
help='Set the image reference for image IMAGE to REF.'
|
|
485
|
+
)
|
|
486
|
+
@click.option(
|
|
487
|
+
'--env', '-e',
|
|
488
|
+
type=str,
|
|
489
|
+
default=None,
|
|
490
|
+
help='The name of the environment to use.',
|
|
491
|
+
)
|
|
492
|
+
@click.argument('image')
|
|
493
|
+
@click.argument('ref')
|
|
494
|
+
@click.pass_context
|
|
495
|
+
def set_image(ctx, env, image, ref):
|
|
496
|
+
init(ctx)
|
|
497
|
+
controller = ctx.obj['controller']
|
|
498
|
+
controller.set_image(image, ref, env=env)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@images.command(
|
|
502
|
+
name='unset',
|
|
503
|
+
help='Clear the image references for IMAGES.'
|
|
504
|
+
)
|
|
505
|
+
@click.option(
|
|
506
|
+
'--env', '-e',
|
|
507
|
+
type=str,
|
|
508
|
+
default=None,
|
|
509
|
+
help='The name of the environment to use.',
|
|
510
|
+
)
|
|
511
|
+
@click.option(
|
|
512
|
+
'--all', '-a', 'unset_all',
|
|
513
|
+
is_flag=True,
|
|
514
|
+
help='Clear all image references.',
|
|
515
|
+
)
|
|
516
|
+
@click.argument('images', type=str, nargs=-1)
|
|
517
|
+
@click.pass_context
|
|
518
|
+
def unset_image(ctx, env, unset_all, images):
|
|
519
|
+
init(ctx)
|
|
520
|
+
config = ctx.obj['config']
|
|
521
|
+
controller = ctx.obj['controller']
|
|
522
|
+
if unset_all:
|
|
523
|
+
images = list(config.images.keys())
|
|
524
|
+
for image in images:
|
|
525
|
+
controller.unset_image(image, env=env)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# artifacts subgroup
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@main.group('artifacts', help='Manage artifacts.')
|
|
532
|
+
def artifacts():
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@artifacts.command(
|
|
537
|
+
name='list',
|
|
538
|
+
help="""List local or remote artifacts.
|
|
539
|
+
|
|
540
|
+
If neither --local nor --remote are specified this just lists the declared
|
|
541
|
+
artifacts for the project. Otherwise it lists the artifacts that are stored
|
|
542
|
+
locally or remotely (from different runs).
|
|
543
|
+
""",
|
|
544
|
+
)
|
|
545
|
+
@click.option(
|
|
546
|
+
'--env', '-e',
|
|
547
|
+
type=str,
|
|
548
|
+
default=None,
|
|
549
|
+
help='The name of the environment to use for listing artifacts.',
|
|
550
|
+
)
|
|
551
|
+
@click.option(
|
|
552
|
+
'--artifacts', '-a',
|
|
553
|
+
type=str,
|
|
554
|
+
default=None,
|
|
555
|
+
help=(
|
|
556
|
+
'Comma-separated list of names for the artifacts to show. '
|
|
557
|
+
'If absent, all declared artifacts are shown.'
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
@click.option(
|
|
561
|
+
'--local',
|
|
562
|
+
is_flag=True,
|
|
563
|
+
help='Show local artifact paths.',
|
|
564
|
+
)
|
|
565
|
+
@click.option(
|
|
566
|
+
'--remote',
|
|
567
|
+
is_flag=True,
|
|
568
|
+
help='Show remote artifact paths.',
|
|
569
|
+
)
|
|
570
|
+
@click.option(
|
|
571
|
+
'--run-ids', '-r',
|
|
572
|
+
type=str,
|
|
573
|
+
default=None,
|
|
574
|
+
help=(
|
|
575
|
+
'Comma-separated list of run IDs. Only artifacts associated with '
|
|
576
|
+
'the given runs are shown.'
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
@click.pass_context
|
|
580
|
+
def list_artifacts(ctx, env, artifacts, local, remote, run_ids):
|
|
581
|
+
if local and remote:
|
|
582
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
583
|
+
init(ctx)
|
|
584
|
+
config = ctx.obj['config']
|
|
585
|
+
controller = ctx.obj['controller']
|
|
586
|
+
|
|
587
|
+
if artifacts is not None:
|
|
588
|
+
artifacts = sorted(set(artifacts.split(',')))
|
|
589
|
+
if run_ids is not None:
|
|
590
|
+
run_ids = sorted(set(run_ids.split(',')))
|
|
591
|
+
|
|
592
|
+
if local or remote:
|
|
593
|
+
try:
|
|
594
|
+
env_handler = config.get_run_env(env)
|
|
595
|
+
artifact_names, run_id_lists = controller.list_artifacts(
|
|
596
|
+
artifact_names=artifacts,
|
|
597
|
+
run_ids=run_ids,
|
|
598
|
+
env=env,
|
|
599
|
+
remote=remote,
|
|
600
|
+
)
|
|
601
|
+
data = []
|
|
602
|
+
for name, runs in zip(artifact_names, run_id_lists):
|
|
603
|
+
if name not in config.artifacts:
|
|
604
|
+
continue
|
|
605
|
+
artifact = config.artifacts[name]
|
|
606
|
+
for run_id in runs:
|
|
607
|
+
if local:
|
|
608
|
+
path = env_handler.get_local_artifact_path(
|
|
609
|
+
artifact, run_id)
|
|
610
|
+
else:
|
|
611
|
+
path = env_handler.get_remote_artifact_path(
|
|
612
|
+
artifact, run_id)
|
|
613
|
+
data.append((
|
|
614
|
+
name,
|
|
615
|
+
artifact.kind,
|
|
616
|
+
artifact.scope,
|
|
617
|
+
run_id,
|
|
618
|
+
path,
|
|
619
|
+
))
|
|
620
|
+
except CloudSubmitError as e:
|
|
621
|
+
abort(str(e))
|
|
622
|
+
table = tabulate(
|
|
623
|
+
data,
|
|
624
|
+
header=['NAME', 'KIND', 'SCOPE', 'RUN_ID', 'PATH'],
|
|
625
|
+
)
|
|
626
|
+
if table:
|
|
627
|
+
print(table)
|
|
628
|
+
else:
|
|
629
|
+
data = [
|
|
630
|
+
(a.name, a.kind, a.scope)
|
|
631
|
+
for a in config.artifacts.values()
|
|
632
|
+
if artifacts is None or a.name in artifacts
|
|
633
|
+
]
|
|
634
|
+
table = tabulate(data, header=['NAME', 'KIND', 'SCOPE'])
|
|
635
|
+
if table:
|
|
636
|
+
print(table)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@artifacts.command(
|
|
640
|
+
name='remove',
|
|
641
|
+
help='Remove artifacts.',
|
|
642
|
+
)
|
|
643
|
+
@click.option(
|
|
644
|
+
'--env', '-e',
|
|
645
|
+
type=str,
|
|
646
|
+
default=None,
|
|
647
|
+
help='The name of the environment to use for removing artifacts.',
|
|
648
|
+
)
|
|
649
|
+
@click.option(
|
|
650
|
+
'--artifacts', '-a', 'artifact_names',
|
|
651
|
+
type=str,
|
|
652
|
+
default=None,
|
|
653
|
+
help=(
|
|
654
|
+
'Comma-separated list of names for the artifacts to remove. '
|
|
655
|
+
'If absent, all declared artifacts will be removed.'
|
|
656
|
+
),
|
|
657
|
+
)
|
|
658
|
+
@click.option(
|
|
659
|
+
'--local',
|
|
660
|
+
is_flag=True,
|
|
661
|
+
help='Remove artifacts only locally.',
|
|
662
|
+
)
|
|
663
|
+
@click.option(
|
|
664
|
+
'--remote',
|
|
665
|
+
is_flag=True,
|
|
666
|
+
help='Remove artifacts only from remote storage.',
|
|
667
|
+
)
|
|
668
|
+
@click.option(
|
|
669
|
+
'--run-ids', '-r',
|
|
670
|
+
type=str,
|
|
671
|
+
default=None,
|
|
672
|
+
help=(
|
|
673
|
+
'Comma-separated list of run IDs. Only artifacts '
|
|
674
|
+
'from the given runs are removed. If absent, artifacts from '
|
|
675
|
+
'ALL runs are removed.'
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
@click.pass_context
|
|
679
|
+
def remove_artifacts(ctx, env, artifact_names, local, remote, run_ids):
|
|
680
|
+
if local and remote:
|
|
681
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
682
|
+
init(ctx)
|
|
683
|
+
config = ctx.obj['config']
|
|
684
|
+
controller = ctx.obj['controller']
|
|
685
|
+
|
|
686
|
+
remove_local = True
|
|
687
|
+
remove_remote = True
|
|
688
|
+
if local:
|
|
689
|
+
remove_remote = False
|
|
690
|
+
if remote:
|
|
691
|
+
remove_local = False
|
|
692
|
+
|
|
693
|
+
if artifact_names is not None:
|
|
694
|
+
artifact_names = list(set(artifact_names.split(',')))
|
|
695
|
+
if run_ids is not None:
|
|
696
|
+
run_ids = list(set(run_ids.split(',')))
|
|
697
|
+
|
|
698
|
+
with config.in_project_root():
|
|
699
|
+
if remove_local:
|
|
700
|
+
try:
|
|
701
|
+
artifact_names, run_id_lists = controller.list_artifacts(
|
|
702
|
+
artifact_names=artifact_names,
|
|
703
|
+
run_ids=run_ids,
|
|
704
|
+
env=env,
|
|
705
|
+
remote=False,
|
|
706
|
+
)
|
|
707
|
+
controller.remove_artifacts(
|
|
708
|
+
artifact_names, run_id_lists, remote=False, env=env)
|
|
709
|
+
except CloudSubmitError as e:
|
|
710
|
+
abort(str(e))
|
|
711
|
+
if remove_remote:
|
|
712
|
+
try:
|
|
713
|
+
artifact_names, run_id_lists = controller.list_artifacts(
|
|
714
|
+
artifact_names=artifact_names,
|
|
715
|
+
run_ids=run_ids,
|
|
716
|
+
env=env,
|
|
717
|
+
remote=True,
|
|
718
|
+
)
|
|
719
|
+
controller.remove_artifacts(
|
|
720
|
+
artifact_names, run_id_lists, remote=True, env=env)
|
|
721
|
+
except CloudSubmitError as e:
|
|
722
|
+
abort(str(e))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@artifacts.command(
|
|
726
|
+
name='pull',
|
|
727
|
+
help='Download artifacts from remote storage.',
|
|
728
|
+
)
|
|
729
|
+
@click.option(
|
|
730
|
+
'--env', '-e',
|
|
731
|
+
type=str,
|
|
732
|
+
default=None,
|
|
733
|
+
help='The name of the environment to use for pulling artifacts.',
|
|
734
|
+
)
|
|
735
|
+
@click.option(
|
|
736
|
+
'--artifacts', '-a', 'artifact_names',
|
|
737
|
+
type=str,
|
|
738
|
+
default=None,
|
|
739
|
+
help=(
|
|
740
|
+
'Comma-separated list of names for the artifacts to pull. '
|
|
741
|
+
'If absent, all declared artifacts will be pulled.'
|
|
742
|
+
),
|
|
743
|
+
)
|
|
744
|
+
@click.option(
|
|
745
|
+
'--run-ids', '-r',
|
|
746
|
+
type=str,
|
|
747
|
+
default=None,
|
|
748
|
+
help=(
|
|
749
|
+
'Comma-separated list of run IDs. Only artifacts '
|
|
750
|
+
'from the given runs are pulled. If absent, artifacts from '
|
|
751
|
+
'ALL runs are pulled.'
|
|
752
|
+
),
|
|
753
|
+
)
|
|
754
|
+
@click.pass_context
|
|
755
|
+
def pull_artifacts(ctx, env, artifact_names, run_ids):
|
|
756
|
+
init(ctx)
|
|
757
|
+
config = ctx.obj['config']
|
|
758
|
+
controller = ctx.obj['controller']
|
|
759
|
+
|
|
760
|
+
if artifact_names is not None:
|
|
761
|
+
artifact_names = list(set(artifact_names.split(',')))
|
|
762
|
+
if run_ids is not None:
|
|
763
|
+
run_ids = list(set(run_ids.split(',')))
|
|
764
|
+
|
|
765
|
+
artifact_names, run_id_lists = controller.list_artifacts(
|
|
766
|
+
artifact_names=artifact_names,
|
|
767
|
+
run_ids=run_ids,
|
|
768
|
+
env=env,
|
|
769
|
+
remote=True,
|
|
770
|
+
)
|
|
771
|
+
try:
|
|
772
|
+
controller.pull_artifacts(artifact_names, run_id_lists)
|
|
773
|
+
except CloudSubmitError as e:
|
|
774
|
+
abort(str(e))
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@artifacts.command(
|
|
778
|
+
name='push',
|
|
779
|
+
help='Upload artifacts to remote storage.',
|
|
780
|
+
)
|
|
781
|
+
@click.option(
|
|
782
|
+
'--env', '-e',
|
|
783
|
+
type=str,
|
|
784
|
+
default=None,
|
|
785
|
+
help='The name of the environment to use for pushing artifacts.',
|
|
786
|
+
)
|
|
787
|
+
@click.option(
|
|
788
|
+
'--artifacts', '-a', 'artifact_names',
|
|
789
|
+
type=str,
|
|
790
|
+
default=None,
|
|
791
|
+
help=(
|
|
792
|
+
'Comma-separated list of names for the artifacts to push. '
|
|
793
|
+
'If absent, all declared artifacts will be pushed.'
|
|
794
|
+
),
|
|
795
|
+
)
|
|
796
|
+
@click.option(
|
|
797
|
+
'--run-ids', '-r',
|
|
798
|
+
type=str,
|
|
799
|
+
default=None,
|
|
800
|
+
help=(
|
|
801
|
+
'Comma-separated list of run IDs. Only artifacts '
|
|
802
|
+
'from the given runs are pushed. If absent, artifacts from '
|
|
803
|
+
'ALL runs are pushed.'
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
@click.pass_context
|
|
807
|
+
def push_artifacts(ctx, env, artifact_names, run_ids):
|
|
808
|
+
init(ctx)
|
|
809
|
+
config = ctx.obj['config']
|
|
810
|
+
controller = ctx.obj['controller']
|
|
811
|
+
|
|
812
|
+
if artifact_names is not None:
|
|
813
|
+
artifact_names = list(set(artifact_names.split(',')))
|
|
814
|
+
if run_ids is not None:
|
|
815
|
+
run_ids = list(set(run_ids.split(',')))
|
|
816
|
+
|
|
817
|
+
artifact_names, run_id_lists = controller.list_artifacts(
|
|
818
|
+
artifact_names=artifact_names,
|
|
819
|
+
run_ids=run_ids,
|
|
820
|
+
env=env,
|
|
821
|
+
remote=False,
|
|
822
|
+
)
|
|
823
|
+
try:
|
|
824
|
+
controller.push_artifacts(artifact_names, run_id_lists)
|
|
825
|
+
except CloudSubmitError as e:
|
|
826
|
+
abort(str(e))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@artifacts.command(
|
|
830
|
+
name='copy',
|
|
831
|
+
help='Copy artifacts from FROM_RUN_ID to TO_RUN_ID.',
|
|
832
|
+
)
|
|
833
|
+
@click.option(
|
|
834
|
+
'--env', '-e',
|
|
835
|
+
type=str,
|
|
836
|
+
default=None,
|
|
837
|
+
help='The name of the environment to use for copying artifacts.',
|
|
838
|
+
)
|
|
839
|
+
@click.option(
|
|
840
|
+
'--artifacts', '-a', 'artifact_names',
|
|
841
|
+
type=str,
|
|
842
|
+
default=None,
|
|
843
|
+
help=(
|
|
844
|
+
'Comma-separated list of names for the artifacts to copy. '
|
|
845
|
+
'If absent, all declared artifacts will be copied.'
|
|
846
|
+
),
|
|
847
|
+
)
|
|
848
|
+
@click.option(
|
|
849
|
+
'--local',
|
|
850
|
+
is_flag=True,
|
|
851
|
+
help='Copy artifacts only locally.',
|
|
852
|
+
)
|
|
853
|
+
@click.option(
|
|
854
|
+
'--remote',
|
|
855
|
+
is_flag=True,
|
|
856
|
+
help='Copy artifacts only in remote storage.',
|
|
857
|
+
)
|
|
858
|
+
@click.argument('from_run_id', type=str)
|
|
859
|
+
@click.argument('to_run_id', type=str)
|
|
860
|
+
@click.pass_context
|
|
861
|
+
def copy_artifacts(
|
|
862
|
+
ctx,
|
|
863
|
+
env,
|
|
864
|
+
artifact_names,
|
|
865
|
+
local,
|
|
866
|
+
remote,
|
|
867
|
+
from_run_id,
|
|
868
|
+
to_run_id
|
|
869
|
+
):
|
|
870
|
+
if local and remote:
|
|
871
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
872
|
+
init(ctx)
|
|
873
|
+
config = ctx.obj['config']
|
|
874
|
+
controller = ctx.obj['controller']
|
|
875
|
+
|
|
876
|
+
copy_local = True
|
|
877
|
+
copy_remote = True
|
|
878
|
+
if local:
|
|
879
|
+
copy_remote = False
|
|
880
|
+
if remote:
|
|
881
|
+
copy_local = False
|
|
882
|
+
|
|
883
|
+
if artifact_names is None:
|
|
884
|
+
artifact_names = list(config.artifacts.keys())
|
|
885
|
+
else:
|
|
886
|
+
artifact_names = list(set(artifact_names.split(',')))
|
|
887
|
+
|
|
888
|
+
try:
|
|
889
|
+
if copy_local:
|
|
890
|
+
controller.copy_artifacts(
|
|
891
|
+
artifact_names, from_run_id, to_run_id, remote=False, env=env)
|
|
892
|
+
if copy_remote:
|
|
893
|
+
controller.copy_artifacts(
|
|
894
|
+
artifact_names, from_run_id, to_run_id, remote=True, env=env)
|
|
895
|
+
except CloudSubmitError as e:
|
|
896
|
+
abort(str(e))
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@artifacts.command(
|
|
900
|
+
name='move',
|
|
901
|
+
help='Move artifacts from FROM_RUN_ID to TO_RUN_ID.',
|
|
902
|
+
)
|
|
903
|
+
@click.option(
|
|
904
|
+
'--env', '-e',
|
|
905
|
+
type=str,
|
|
906
|
+
default=None,
|
|
907
|
+
help='The name of the environment to use for moving artifacts.',
|
|
908
|
+
)
|
|
909
|
+
@click.option(
|
|
910
|
+
'--artifacts', '-a', 'artifact_names',
|
|
911
|
+
type=str,
|
|
912
|
+
default=None,
|
|
913
|
+
help=(
|
|
914
|
+
'Comma-separated list of names for the artifacts to move. '
|
|
915
|
+
'If absent, all declared artifacts will be moved.'
|
|
916
|
+
),
|
|
917
|
+
)
|
|
918
|
+
@click.option(
|
|
919
|
+
'--local',
|
|
920
|
+
is_flag=True,
|
|
921
|
+
help='Move artifacts only locally.',
|
|
922
|
+
)
|
|
923
|
+
@click.option(
|
|
924
|
+
'--remote',
|
|
925
|
+
is_flag=True,
|
|
926
|
+
help='Move artifacts only in remote storage.',
|
|
927
|
+
)
|
|
928
|
+
@click.argument('from_run_id', type=str)
|
|
929
|
+
@click.argument('to_run_id', type=str)
|
|
930
|
+
@click.pass_context
|
|
931
|
+
def move_artifacts(
|
|
932
|
+
ctx,
|
|
933
|
+
env,
|
|
934
|
+
artifact_names,
|
|
935
|
+
local,
|
|
936
|
+
remote,
|
|
937
|
+
from_run_id,
|
|
938
|
+
to_run_id
|
|
939
|
+
):
|
|
940
|
+
if local and remote:
|
|
941
|
+
abort('The flags --local and --remote are mutually exclusive.')
|
|
942
|
+
init(ctx)
|
|
943
|
+
config = ctx.obj['config']
|
|
944
|
+
controller = ctx.obj['controller']
|
|
945
|
+
|
|
946
|
+
move_local = True
|
|
947
|
+
move_remote = True
|
|
948
|
+
if local:
|
|
949
|
+
move_remote = False
|
|
950
|
+
if remote:
|
|
951
|
+
move_local = False
|
|
952
|
+
|
|
953
|
+
if artifact_names is None:
|
|
954
|
+
artifact_names = list(config.artifacts.keys())
|
|
955
|
+
else:
|
|
956
|
+
artifact_names = list(set(artifact_names.split(',')))
|
|
957
|
+
|
|
958
|
+
try:
|
|
959
|
+
if move_local:
|
|
960
|
+
controller.move_artifacts(
|
|
961
|
+
artifact_names, from_run_id, to_run_id, remote=False, env=env)
|
|
962
|
+
if move_remote:
|
|
963
|
+
controller.move_artifacts(
|
|
964
|
+
artifact_names, from_run_id, to_run_id, remote=True, env=env)
|
|
965
|
+
except CloudSubmitError as e:
|
|
966
|
+
abort(str(e))
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# logs command
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
@main.command(
|
|
973
|
+
name='logs',
|
|
974
|
+
help='Show logs from run RUN_ID.',
|
|
975
|
+
)
|
|
976
|
+
@click.option(
|
|
977
|
+
'--env', '-e',
|
|
978
|
+
type=str,
|
|
979
|
+
default=None,
|
|
980
|
+
help='The name of the build environment to use.',
|
|
981
|
+
)
|
|
982
|
+
@click.option(
|
|
983
|
+
'--since',
|
|
984
|
+
type=str,
|
|
985
|
+
default='1m',
|
|
986
|
+
help=(
|
|
987
|
+
'The time from which to retrieve the logs. This should be a timestamp '
|
|
988
|
+
'in ISO format, e.g. 2025-01-01T00:00:00. You can also pass a number '
|
|
989
|
+
'N suffixed h, m, or s to retrieve logs from the last N hours, minutes '
|
|
990
|
+
'or seconds.'
|
|
991
|
+
),
|
|
992
|
+
)
|
|
993
|
+
@click.option('--stream', '-s', is_flag=True)
|
|
994
|
+
@click.argument('run_id', type=str)
|
|
995
|
+
@click.pass_context
|
|
996
|
+
def print_logs(ctx, env, since, stream, run_id):
|
|
997
|
+
init(ctx)
|
|
998
|
+
controller = ctx.obj['controller']
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
if since.endswith('h'):
|
|
1002
|
+
since = dt.timedelta(seconds=float(since[:-1])*3600)
|
|
1003
|
+
elif since.endswith('m'):
|
|
1004
|
+
since = dt.timedelta(seconds=float(since[:-1])*60)
|
|
1005
|
+
elif since.endswith('s'):
|
|
1006
|
+
since = dt.timedelta(seconds=float(since[:-1]))
|
|
1007
|
+
else:
|
|
1008
|
+
since = dt.datetime.fromisoformat(since)
|
|
1009
|
+
except ValueError:
|
|
1010
|
+
abort(f'Invalid --since argument: {since}')
|
|
1011
|
+
try:
|
|
1012
|
+
controller.print_logs(run_id, since=since, env=env, stream=stream)
|
|
1013
|
+
except CloudSubmitError as e:
|
|
1014
|
+
abort(str(e))
|