outerbounds 0.3.183rc1__py3-none-any.whl → 0.3.185__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.
- outerbounds/__init__.py +1 -3
- outerbounds/command_groups/apps_cli.py +6 -2
- {outerbounds-0.3.183rc1.dist-info → outerbounds-0.3.185.dist-info}/METADATA +3 -3
- {outerbounds-0.3.183rc1.dist-info → outerbounds-0.3.185.dist-info}/RECORD +6 -29
- outerbounds-0.3.185.dist-info/entry_points.txt +3 -0
- outerbounds/_vendor/spinner/__init__.py +0 -4
- outerbounds/_vendor/spinner/spinners.py +0 -478
- outerbounds/_vendor/spinner.LICENSE +0 -21
- outerbounds/apps/__init__.py +0 -0
- outerbounds/apps/_state_machine.py +0 -472
- outerbounds/apps/app_cli.py +0 -1514
- outerbounds/apps/app_config.py +0 -296
- outerbounds/apps/artifacts.py +0 -0
- outerbounds/apps/capsule.py +0 -839
- outerbounds/apps/cli_to_config.py +0 -99
- outerbounds/apps/click_importer.py +0 -24
- outerbounds/apps/code_package/__init__.py +0 -3
- outerbounds/apps/code_package/code_packager.py +0 -610
- outerbounds/apps/code_package/examples.py +0 -125
- outerbounds/apps/config_schema.yaml +0 -269
- outerbounds/apps/config_schema_autogen.json +0 -336
- outerbounds/apps/dependencies.py +0 -115
- outerbounds/apps/deployer.py +0 -0
- outerbounds/apps/experimental/__init__.py +0 -110
- outerbounds/apps/perimeters.py +0 -45
- outerbounds/apps/secrets.py +0 -164
- outerbounds/apps/utils.py +0 -234
- outerbounds/apps/validations.py +0 -22
- outerbounds-0.3.183rc1.dist-info/entry_points.txt +0 -3
- {outerbounds-0.3.183rc1.dist-info → outerbounds-0.3.185.dist-info}/WHEEL +0 -0
outerbounds/apps/app_cli.py
DELETED
@@ -1,1514 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
import os
|
3
|
-
import sys
|
4
|
-
import random
|
5
|
-
from functools import wraps, partial
|
6
|
-
from typing import Dict, List, Any, Optional, Union
|
7
|
-
|
8
|
-
# IF this CLI is supposed to be reusable across Metaflow Too
|
9
|
-
# Then we need to ensure that the click IMPORT happens from metaflow
|
10
|
-
# so that any object class comparisons end up working as expected.
|
11
|
-
# since Metaflow lazy loads Click Modules, we need to ensure that the
|
12
|
-
# module's click Groups correlate to the same module otherwise Metaflow
|
13
|
-
# will not accept the cli as valid.
|
14
|
-
# But the BIGGEST Problem of adding a `metaflow._vendor` import is that
|
15
|
-
# It will run the remote-config check and that can raise a really dirty exception
|
16
|
-
# That will break the CLI.
|
17
|
-
# So we need to find a way to import click without having it try to check for remote-config
|
18
|
-
# or load the config from the environment.
|
19
|
-
# If we import click from metaflow over here then it might
|
20
|
-
# end up creating issues with click in general. So we need to figure a
|
21
|
-
# way to figure the right import of click dynamically. a neat way to handle that would be
|
22
|
-
# to have a function that can import the correct click based on the context in which stuff is being loaded.
|
23
|
-
from .click_importer import click
|
24
|
-
from .app_config import (
|
25
|
-
AppConfig,
|
26
|
-
AppConfigError,
|
27
|
-
CODE_PACKAGE_PREFIX,
|
28
|
-
CAPSULE_DEBUG,
|
29
|
-
AuthType,
|
30
|
-
)
|
31
|
-
from .perimeters import PerimeterExtractor
|
32
|
-
from .cli_to_config import build_config_from_options
|
33
|
-
from .utils import (
|
34
|
-
MultiStepSpinner,
|
35
|
-
)
|
36
|
-
from . import experimental
|
37
|
-
from .validations import deploy_validations
|
38
|
-
from .code_package import CodePackager
|
39
|
-
from .capsule import (
|
40
|
-
CapsuleDeployer,
|
41
|
-
list_and_filter_capsules,
|
42
|
-
CapsuleApi,
|
43
|
-
DEPLOYMENT_READY_CONDITIONS,
|
44
|
-
CapsuleApiException,
|
45
|
-
CapsuleDeploymentException,
|
46
|
-
)
|
47
|
-
from .dependencies import bake_deployment_image
|
48
|
-
import shlex
|
49
|
-
import time
|
50
|
-
import uuid
|
51
|
-
from datetime import datetime
|
52
|
-
|
53
|
-
|
54
|
-
class KeyValueDictPair(click.ParamType):
|
55
|
-
name = "KV-DICT-PAIR" # type: ignore
|
56
|
-
|
57
|
-
def convert(self, value, param, ctx):
|
58
|
-
# Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
|
59
|
-
if len(value.split("=", 1)) != 2:
|
60
|
-
self.fail(
|
61
|
-
f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
|
62
|
-
)
|
63
|
-
|
64
|
-
key, _value = value.split("=", 1)
|
65
|
-
try:
|
66
|
-
return {"key": key, "value": json.loads(_value)}
|
67
|
-
except json.JSONDecodeError:
|
68
|
-
return {"key": key, "value": _value}
|
69
|
-
except Exception as e:
|
70
|
-
self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
|
71
|
-
|
72
|
-
def __str__(self):
|
73
|
-
return repr(self)
|
74
|
-
|
75
|
-
def __repr__(self):
|
76
|
-
return "KV-PAIR"
|
77
|
-
|
78
|
-
|
79
|
-
class KeyValuePair(click.ParamType):
|
80
|
-
name = "KV-PAIR" # type: ignore
|
81
|
-
|
82
|
-
def convert(self, value, param, ctx):
|
83
|
-
# Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
|
84
|
-
if len(value.split("=", 1)) != 2:
|
85
|
-
self.fail(
|
86
|
-
f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
|
87
|
-
)
|
88
|
-
|
89
|
-
key, _value = value.split("=", 1)
|
90
|
-
try:
|
91
|
-
return {key: json.loads(_value)}
|
92
|
-
except json.JSONDecodeError:
|
93
|
-
return {key: _value}
|
94
|
-
except Exception as e:
|
95
|
-
self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
|
96
|
-
|
97
|
-
def __str__(self):
|
98
|
-
return repr(self)
|
99
|
-
|
100
|
-
def __repr__(self):
|
101
|
-
return "KV-PAIR"
|
102
|
-
|
103
|
-
|
104
|
-
class MountMetaflowArtifact(click.ParamType):
|
105
|
-
name = "MOUNT-METAFLOW-ARTIFACT" # type: ignore
|
106
|
-
|
107
|
-
def convert(self, value, param, ctx):
|
108
|
-
"""
|
109
|
-
Convert a string like "flow=MyFlow,artifact=my_model,path=/tmp/abc" or
|
110
|
-
"pathspec=MyFlow/123/foo/345/my_model,path=/tmp/abc" to a dict.
|
111
|
-
"""
|
112
|
-
artifact_dict = {}
|
113
|
-
parts = value.split(",")
|
114
|
-
|
115
|
-
for part in parts:
|
116
|
-
if "=" not in part:
|
117
|
-
self.fail(
|
118
|
-
f"Invalid format in part '{part}'. Expected 'key=value'", param, ctx
|
119
|
-
)
|
120
|
-
|
121
|
-
key, val = part.split("=", 1)
|
122
|
-
artifact_dict[key.strip()] = val.strip()
|
123
|
-
|
124
|
-
# Validate required fields
|
125
|
-
if "pathspec" in artifact_dict:
|
126
|
-
if "path" not in artifact_dict:
|
127
|
-
self.fail(
|
128
|
-
"When using 'pathspec', you must also specify 'path'", param, ctx
|
129
|
-
)
|
130
|
-
|
131
|
-
# Return as pathspec format
|
132
|
-
return {
|
133
|
-
"pathspec": artifact_dict["pathspec"],
|
134
|
-
"path": artifact_dict["path"],
|
135
|
-
}
|
136
|
-
elif (
|
137
|
-
"flow" in artifact_dict
|
138
|
-
and "artifact" in artifact_dict
|
139
|
-
and "path" in artifact_dict
|
140
|
-
):
|
141
|
-
# Return as flow/artifact format
|
142
|
-
result = {
|
143
|
-
"flow": artifact_dict["flow"],
|
144
|
-
"artifact": artifact_dict["artifact"],
|
145
|
-
"path": artifact_dict["path"],
|
146
|
-
}
|
147
|
-
|
148
|
-
# Add optional namespace if provided
|
149
|
-
if "namespace" in artifact_dict:
|
150
|
-
result["namespace"] = artifact_dict["namespace"]
|
151
|
-
|
152
|
-
return result
|
153
|
-
else:
|
154
|
-
self.fail(
|
155
|
-
"Invalid format. Must be either 'flow=X,artifact=Y,path=Z' or 'pathspec=X,path=Z'",
|
156
|
-
param,
|
157
|
-
ctx,
|
158
|
-
)
|
159
|
-
|
160
|
-
def __str__(self):
|
161
|
-
return repr(self)
|
162
|
-
|
163
|
-
def __repr__(self):
|
164
|
-
return "MOUNT-METAFLOW-ARTIFACT"
|
165
|
-
|
166
|
-
|
167
|
-
class MountSecret(click.ParamType):
|
168
|
-
name = "MOUNT-SECRET" # type: ignore
|
169
|
-
|
170
|
-
def convert(self, value, param, ctx):
|
171
|
-
"""
|
172
|
-
Convert a string like "id=my_secret,path=/tmp/secret" to a dict.
|
173
|
-
"""
|
174
|
-
secret_dict = {}
|
175
|
-
parts = value.split(",")
|
176
|
-
|
177
|
-
for part in parts:
|
178
|
-
if "=" not in part:
|
179
|
-
self.fail(
|
180
|
-
f"Invalid format in part '{part}'. Expected 'key=value'", param, ctx
|
181
|
-
)
|
182
|
-
|
183
|
-
key, val = part.split("=", 1)
|
184
|
-
secret_dict[key.strip()] = val.strip()
|
185
|
-
|
186
|
-
# Validate required fields
|
187
|
-
if "id" in secret_dict and "path" in secret_dict:
|
188
|
-
return {"id": secret_dict["id"], "path": secret_dict["path"]}
|
189
|
-
else:
|
190
|
-
self.fail("Invalid format. Must be 'key=X,path=Y'", param, ctx)
|
191
|
-
|
192
|
-
def __str__(self):
|
193
|
-
return repr(self)
|
194
|
-
|
195
|
-
def __repr__(self):
|
196
|
-
return "MOUNT-SECRET"
|
197
|
-
|
198
|
-
|
199
|
-
class CommaSeparatedList(click.ParamType):
|
200
|
-
name = "COMMA-SEPARATED-LIST" # type: ignore
|
201
|
-
|
202
|
-
def convert(self, value, param, ctx):
|
203
|
-
return value.split(",")
|
204
|
-
|
205
|
-
def __str__(self):
|
206
|
-
return repr(self)
|
207
|
-
|
208
|
-
def __repr__(self):
|
209
|
-
return "COMMA-SEPARATED-LIST"
|
210
|
-
|
211
|
-
|
212
|
-
KVPairType = KeyValuePair()
|
213
|
-
MetaflowArtifactType = MountMetaflowArtifact()
|
214
|
-
SecretMountType = MountSecret()
|
215
|
-
CommaSeparatedListType = CommaSeparatedList()
|
216
|
-
KVDictType = KeyValueDictPair()
|
217
|
-
|
218
|
-
|
219
|
-
class ColorTheme:
|
220
|
-
TIMESTAMP = "magenta"
|
221
|
-
LOADING_COLOR = "cyan"
|
222
|
-
BAD_COLOR = "red"
|
223
|
-
INFO_COLOR = "green"
|
224
|
-
DEBUG_COLOR = "yellow"
|
225
|
-
|
226
|
-
TL_HEADER_COLOR = "magenta"
|
227
|
-
ROW_COLOR = "bright_white"
|
228
|
-
|
229
|
-
INFO_KEY_COLOR = "green"
|
230
|
-
INFO_VALUE_COLOR = "bright_white"
|
231
|
-
|
232
|
-
|
233
|
-
NativeList = list
|
234
|
-
|
235
|
-
|
236
|
-
def _logger(
|
237
|
-
body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
|
238
|
-
):
|
239
|
-
if timestamp:
|
240
|
-
if timestamp is True:
|
241
|
-
dt = datetime.now()
|
242
|
-
else:
|
243
|
-
dt = timestamp
|
244
|
-
tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
245
|
-
click.secho(tstamp + " ", fg=ColorTheme.TIMESTAMP, nl=False)
|
246
|
-
if head:
|
247
|
-
click.secho(head, fg=ColorTheme.INFO_COLOR, nl=False)
|
248
|
-
click.secho(
|
249
|
-
body,
|
250
|
-
bold=system_msg,
|
251
|
-
fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
|
252
|
-
nl=nl,
|
253
|
-
)
|
254
|
-
|
255
|
-
|
256
|
-
def _logger_styled(
|
257
|
-
body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
|
258
|
-
):
|
259
|
-
message_parts = []
|
260
|
-
|
261
|
-
if timestamp:
|
262
|
-
if timestamp is True:
|
263
|
-
dt = datetime.now()
|
264
|
-
else:
|
265
|
-
dt = timestamp
|
266
|
-
tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
267
|
-
message_parts.append(click.style(tstamp + " ", fg=ColorTheme.TIMESTAMP))
|
268
|
-
|
269
|
-
if head:
|
270
|
-
message_parts.append(click.style(head, fg=ColorTheme.INFO_COLOR))
|
271
|
-
|
272
|
-
message_parts.append(
|
273
|
-
click.style(
|
274
|
-
body,
|
275
|
-
bold=system_msg,
|
276
|
-
fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
|
277
|
-
)
|
278
|
-
)
|
279
|
-
|
280
|
-
return "".join(message_parts)
|
281
|
-
|
282
|
-
|
283
|
-
def _spinner_logger(spinner, *msg):
|
284
|
-
spinner.log(*[_logger_styled(x, timestamp=True) for x in msg])
|
285
|
-
|
286
|
-
|
287
|
-
class CliState(object):
|
288
|
-
pass
|
289
|
-
|
290
|
-
|
291
|
-
def _pre_create_debug(
|
292
|
-
app_config: AppConfig,
|
293
|
-
capsule: CapsuleDeployer,
|
294
|
-
state_dir: str,
|
295
|
-
options: Dict[str, Any],
|
296
|
-
cli_parsed_config: Dict[str, Any],
|
297
|
-
):
|
298
|
-
if CAPSULE_DEBUG:
|
299
|
-
os.makedirs(state_dir, exist_ok=True)
|
300
|
-
debug_path = os.path.join(state_dir, f"debug_{time.time()}.json")
|
301
|
-
with open(
|
302
|
-
debug_path,
|
303
|
-
"w",
|
304
|
-
) as f:
|
305
|
-
f.write(
|
306
|
-
json.dumps(
|
307
|
-
{
|
308
|
-
"app_state": app_config.dump_state(), # This is the state of the app config after parsing the CLI options and right before the capsule deploy API is called
|
309
|
-
"capsule_input": capsule.create_input(), # This is the input that is passed to the capsule deploy API
|
310
|
-
"deploy_response": capsule._capsule_deploy_response, # This is the response from the capsule deploy API
|
311
|
-
"cli_options": options, # These are the actual options passing down to the CLI
|
312
|
-
"cli_parsed_config": cli_parsed_config, # This is the config object that is created after parsing options from the CLI
|
313
|
-
},
|
314
|
-
indent=2,
|
315
|
-
default=str,
|
316
|
-
)
|
317
|
-
)
|
318
|
-
|
319
|
-
|
320
|
-
def _post_create_debug(capsule: CapsuleDeployer, state_dir: str):
|
321
|
-
if CAPSULE_DEBUG:
|
322
|
-
debug_path = os.path.join(
|
323
|
-
state_dir, f"debug_deploy_response_{time.time()}.json"
|
324
|
-
)
|
325
|
-
with open(debug_path, "w") as f:
|
326
|
-
f.write(json.dumps(capsule._capsule_deploy_response, indent=2, default=str))
|
327
|
-
|
328
|
-
|
329
|
-
def _bake_image(app_config: AppConfig, cache_dir: str, logger):
|
330
|
-
baking_status = bake_deployment_image(
|
331
|
-
app_config=app_config,
|
332
|
-
cache_file_path=os.path.join(cache_dir, "image_cache"),
|
333
|
-
logger=logger,
|
334
|
-
)
|
335
|
-
app_config.set_state(
|
336
|
-
"image",
|
337
|
-
baking_status.resolved_image,
|
338
|
-
)
|
339
|
-
app_config.set_state("python_path", baking_status.python_path)
|
340
|
-
logger("🐳 Using The Docker Image : %s" % app_config.get_state("image"))
|
341
|
-
|
342
|
-
|
343
|
-
def print_table(data, headers):
|
344
|
-
"""Print data in a formatted table."""
|
345
|
-
|
346
|
-
if not data:
|
347
|
-
return
|
348
|
-
|
349
|
-
# Calculate column widths
|
350
|
-
col_widths = [len(h) for h in headers]
|
351
|
-
|
352
|
-
# Calculate actual widths based on data
|
353
|
-
for row in data:
|
354
|
-
for i, cell in enumerate(row):
|
355
|
-
col_widths[i] = max(col_widths[i], len(str(cell)))
|
356
|
-
|
357
|
-
# Print header
|
358
|
-
header_row = " | ".join(
|
359
|
-
[headers[i].ljust(col_widths[i]) for i in range(len(headers))]
|
360
|
-
)
|
361
|
-
click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
|
362
|
-
click.secho(header_row, fg=ColorTheme.TL_HEADER_COLOR, bold=True)
|
363
|
-
click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
|
364
|
-
|
365
|
-
# Print data rows
|
366
|
-
for row in data:
|
367
|
-
formatted_row = " | ".join(
|
368
|
-
[str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
|
369
|
-
)
|
370
|
-
click.secho(formatted_row, fg=ColorTheme.ROW_COLOR, bold=True)
|
371
|
-
click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
|
372
|
-
|
373
|
-
|
374
|
-
@click.group()
|
375
|
-
def cli():
|
376
|
-
"""Outerbounds CLI tool."""
|
377
|
-
pass
|
378
|
-
|
379
|
-
|
380
|
-
@cli.group(
|
381
|
-
help="Commands related to Deploying/Running/Managing Apps on Outerbounds Platform."
|
382
|
-
)
|
383
|
-
@click.pass_context
|
384
|
-
def app(ctx):
|
385
|
-
"""App-related commands."""
|
386
|
-
metaflow_set_context = getattr(ctx, "obj", None)
|
387
|
-
ctx.obj = CliState()
|
388
|
-
ctx.obj.trace_id = str(uuid.uuid4())
|
389
|
-
ctx.obj.app_state_dir = os.path.join(os.curdir, ".ob_apps")
|
390
|
-
profile = os.environ.get("METAFLOW_PROFILE", "")
|
391
|
-
config_dir = os.path.expanduser(
|
392
|
-
os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
|
393
|
-
)
|
394
|
-
perimeter, api_server = PerimeterExtractor.for_ob_cli(
|
395
|
-
config_dir=config_dir, profile=profile
|
396
|
-
)
|
397
|
-
if perimeter is None or api_server is None:
|
398
|
-
raise AppConfigError(
|
399
|
-
"Perimeter not found in the environment, Found perimeter: %s, api_server: %s"
|
400
|
-
% (perimeter, api_server)
|
401
|
-
)
|
402
|
-
ctx.obj.perimeter = perimeter
|
403
|
-
ctx.obj.api_url = api_server
|
404
|
-
os.makedirs(ctx.obj.app_state_dir, exist_ok=True)
|
405
|
-
|
406
|
-
|
407
|
-
def parse_commands(app_config: AppConfig, cli_command_input):
|
408
|
-
# There can be two modes:
|
409
|
-
# 1. User passes command via `--` in the CLI
|
410
|
-
# 2. User passes the `commands` key in the config.
|
411
|
-
base_commands = []
|
412
|
-
if len(cli_command_input) > 0:
|
413
|
-
# TODO: we can be a little more fancy here by allowing the user to just call
|
414
|
-
# `outerbounds app deploy -- foo.py` and figure out if we need to stuff python
|
415
|
-
# in front of the command or not. But for sake of dumb simplicity, we can just
|
416
|
-
# assume what ever the user called on local needs to be called remotely, we can
|
417
|
-
# just ask them to add the outerbounds command in front of it.
|
418
|
-
# So the dev ex would be :
|
419
|
-
# `python foo.py` -> `outerbounds app deploy -- python foo.py`
|
420
|
-
if type(cli_command_input) == str:
|
421
|
-
base_commands.append(cli_command_input)
|
422
|
-
else:
|
423
|
-
base_commands.append(shlex.join(cli_command_input))
|
424
|
-
elif app_config.get("commands", None) is not None:
|
425
|
-
base_commands.extend(app_config.get("commands"))
|
426
|
-
return base_commands
|
427
|
-
|
428
|
-
|
429
|
-
def deployment_instance_options(func):
|
430
|
-
# These parameters influence how the CLI behaves for each instance of a launched deployment.
|
431
|
-
@click.option(
|
432
|
-
"--readiness-condition",
|
433
|
-
type=click.Choice(DEPLOYMENT_READY_CONDITIONS.enums()),
|
434
|
-
help=DEPLOYMENT_READY_CONDITIONS.__doc__,
|
435
|
-
default=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
|
436
|
-
)
|
437
|
-
@click.option(
|
438
|
-
"--status-file",
|
439
|
-
type=str,
|
440
|
-
help="The path to the file where the final status of the deployment will be written.",
|
441
|
-
default=None,
|
442
|
-
)
|
443
|
-
@click.option(
|
444
|
-
"--readiness-wait-time",
|
445
|
-
type=int,
|
446
|
-
help="The time (in seconds) to monitor the deployment for readiness after the readiness condition is met.",
|
447
|
-
default=4,
|
448
|
-
)
|
449
|
-
@click.option(
|
450
|
-
"--deployment-timeout",
|
451
|
-
"max_wait_time",
|
452
|
-
type=int,
|
453
|
-
help="The maximum time (in seconds) to wait for the deployment to reach readiness before timing out.",
|
454
|
-
default=600,
|
455
|
-
)
|
456
|
-
@click.option(
|
457
|
-
"--no-loader",
|
458
|
-
is_flag=True,
|
459
|
-
help="Do not use the loading spinner for the deployment.",
|
460
|
-
default=False,
|
461
|
-
)
|
462
|
-
@wraps(func)
|
463
|
-
def wrapper(*args, **kwargs):
|
464
|
-
return func(*args, **kwargs)
|
465
|
-
|
466
|
-
return wrapper
|
467
|
-
|
468
|
-
|
469
|
-
def common_deploy_options(func):
|
470
|
-
@click.option(
|
471
|
-
"--name",
|
472
|
-
type=str,
|
473
|
-
help="The name of the app to deploy.",
|
474
|
-
)
|
475
|
-
@click.option("--port", type=int, help="Port where the app is hosted.")
|
476
|
-
@click.option(
|
477
|
-
"--tag",
|
478
|
-
"tags",
|
479
|
-
multiple=True,
|
480
|
-
type=KVPairType,
|
481
|
-
help="The tags of the app to deploy. Format KEY=VALUE. Example --tag foo=bar --tag x=y",
|
482
|
-
default=None,
|
483
|
-
)
|
484
|
-
@click.option(
|
485
|
-
"--image",
|
486
|
-
type=str,
|
487
|
-
help="The Docker image to deploy with the App",
|
488
|
-
default=None,
|
489
|
-
)
|
490
|
-
@click.option(
|
491
|
-
"--cpu",
|
492
|
-
type=str,
|
493
|
-
help="CPU resource request and limit",
|
494
|
-
default=None,
|
495
|
-
)
|
496
|
-
@click.option(
|
497
|
-
"--memory",
|
498
|
-
type=str,
|
499
|
-
help="Memory resource request and limit",
|
500
|
-
default=None,
|
501
|
-
)
|
502
|
-
@click.option(
|
503
|
-
"--gpu",
|
504
|
-
type=str,
|
505
|
-
help="GPU resource request and limit",
|
506
|
-
default=None,
|
507
|
-
)
|
508
|
-
@click.option(
|
509
|
-
"--disk",
|
510
|
-
type=str,
|
511
|
-
help="Storage resource request and limit",
|
512
|
-
default=None,
|
513
|
-
)
|
514
|
-
@click.option(
|
515
|
-
"--health-check-enabled",
|
516
|
-
type=bool,
|
517
|
-
help="Enable health checks",
|
518
|
-
default=None,
|
519
|
-
)
|
520
|
-
@click.option(
|
521
|
-
"--health-check-path",
|
522
|
-
type=str,
|
523
|
-
help="Health check path",
|
524
|
-
default=None,
|
525
|
-
)
|
526
|
-
@click.option(
|
527
|
-
"--health-check-initial-delay",
|
528
|
-
type=int,
|
529
|
-
help="Initial delay seconds for health check",
|
530
|
-
default=None,
|
531
|
-
)
|
532
|
-
@click.option(
|
533
|
-
"--health-check-period",
|
534
|
-
type=int,
|
535
|
-
help="Period seconds for health check",
|
536
|
-
default=None,
|
537
|
-
)
|
538
|
-
@click.option(
|
539
|
-
"--compute-pools",
|
540
|
-
type=CommaSeparatedListType,
|
541
|
-
help="The compute pools to deploy the app to. Example: --compute-pools default,large",
|
542
|
-
default=None,
|
543
|
-
)
|
544
|
-
@click.option(
|
545
|
-
"--auth-type",
|
546
|
-
type=click.Choice(AuthType.enums()),
|
547
|
-
help="The type of authentication to use for the app.",
|
548
|
-
default=None,
|
549
|
-
)
|
550
|
-
@click.option(
|
551
|
-
"--public-access/--private-access",
|
552
|
-
"auth_public",
|
553
|
-
type=bool,
|
554
|
-
help="Whether the app is public or not.",
|
555
|
-
default=None,
|
556
|
-
)
|
557
|
-
@click.option(
|
558
|
-
"--no-deps",
|
559
|
-
is_flag=True,
|
560
|
-
help="Do not any dependencies. Directly used the image provided",
|
561
|
-
default=False,
|
562
|
-
)
|
563
|
-
@click.option(
|
564
|
-
"--min-replicas",
|
565
|
-
type=int,
|
566
|
-
help="Minimum number of replicas to deploy",
|
567
|
-
default=None,
|
568
|
-
)
|
569
|
-
@click.option(
|
570
|
-
"--max-replicas",
|
571
|
-
type=int,
|
572
|
-
help="Maximum number of replicas to deploy",
|
573
|
-
default=None,
|
574
|
-
)
|
575
|
-
@click.option(
|
576
|
-
"--description",
|
577
|
-
type=str,
|
578
|
-
help="The description of the app to deploy.",
|
579
|
-
default=None,
|
580
|
-
)
|
581
|
-
@click.option(
|
582
|
-
"--app-type",
|
583
|
-
type=str,
|
584
|
-
help="The type of app to deploy.",
|
585
|
-
default=None,
|
586
|
-
)
|
587
|
-
@click.option(
|
588
|
-
"--force-upgrade",
|
589
|
-
is_flag=True,
|
590
|
-
help="Force upgrade the app even if it is currently being upgraded.",
|
591
|
-
default=False,
|
592
|
-
)
|
593
|
-
@wraps(func)
|
594
|
-
def wrapper(*args, **kwargs):
|
595
|
-
return func(*args, **kwargs)
|
596
|
-
|
597
|
-
return wrapper
|
598
|
-
|
599
|
-
|
600
|
-
def common_run_options(func):
|
601
|
-
"""Common options for running and deploying apps."""
|
602
|
-
|
603
|
-
@click.option(
|
604
|
-
"--config-file",
|
605
|
-
type=str,
|
606
|
-
help="The config file to use for the App (YAML or JSON)",
|
607
|
-
default=None,
|
608
|
-
)
|
609
|
-
@click.option(
|
610
|
-
"--secret",
|
611
|
-
"secrets",
|
612
|
-
multiple=True,
|
613
|
-
type=str,
|
614
|
-
help="Secrets to deploy with the App",
|
615
|
-
default=None,
|
616
|
-
)
|
617
|
-
@click.option(
|
618
|
-
"--env",
|
619
|
-
"envs",
|
620
|
-
multiple=True,
|
621
|
-
type=KVPairType,
|
622
|
-
help="Environment variables to deploy with the App. Use format KEY=VALUE",
|
623
|
-
default=None,
|
624
|
-
)
|
625
|
-
@click.option(
|
626
|
-
"--package-src-path",
|
627
|
-
type=str,
|
628
|
-
help="The path to the source code to deploy with the App.",
|
629
|
-
default=None,
|
630
|
-
)
|
631
|
-
@click.option(
|
632
|
-
"--package-suffixes",
|
633
|
-
type=CommaSeparatedListType,
|
634
|
-
help="The suffixes of the source code to deploy with the App.",
|
635
|
-
default=None,
|
636
|
-
)
|
637
|
-
@click.option(
|
638
|
-
"--dep-from-requirements",
|
639
|
-
type=str,
|
640
|
-
help="Path to requirements.txt file for dependencies",
|
641
|
-
default=None,
|
642
|
-
)
|
643
|
-
@click.option(
|
644
|
-
"--dep-from-pyproject",
|
645
|
-
type=str,
|
646
|
-
help="Path to pyproject.toml file for dependencies",
|
647
|
-
default=None,
|
648
|
-
)
|
649
|
-
# TODO: [FIX ME]: Get better CLI abstraction for pypi/conda dependencies
|
650
|
-
@wraps(func)
|
651
|
-
def wrapper(*args, **kwargs):
|
652
|
-
return func(*args, **kwargs)
|
653
|
-
|
654
|
-
return wrapper
|
655
|
-
|
656
|
-
|
657
|
-
def _package_necessary_things(app_config: AppConfig, logger):
|
658
|
-
# Packaging has a few things to be thought through:
|
659
|
-
# 1. if `entrypoint_path` exists then should we package the directory
|
660
|
-
# where the entrypoint lives. For example : if the user calls
|
661
|
-
# `outerbounds app deploy foo/bar.py` should we package `foo` dir
|
662
|
-
# or should we package the cwd from which foo/bar.py is being called.
|
663
|
-
# 2. if the src path is used with the config file then how should we view
|
664
|
-
# that path ?
|
665
|
-
# 3. It becomes interesting when users call the deployment with config files
|
666
|
-
# where there is a `src_path` and then is the src_path relative to the config file
|
667
|
-
# or is it relative to where the caller command is sitting. Ideally it should work
|
668
|
-
# like Kustomizations where its relative to where the yaml file sits for simplicity
|
669
|
-
# of understanding relationships between config files. Ideally users can pass the src_path
|
670
|
-
# from the command line and that will aliviate any need to package any other directories for
|
671
|
-
#
|
672
|
-
|
673
|
-
package_dir = app_config.get_state("packaging_directory")
|
674
|
-
if package_dir is None:
|
675
|
-
app_config.set_state("code_package_url", None)
|
676
|
-
app_config.set_state("code_package_key", None)
|
677
|
-
return
|
678
|
-
from metaflow.metaflow_config import DEFAULT_DATASTORE
|
679
|
-
|
680
|
-
package = app_config.get_state("package") or {}
|
681
|
-
suffixes = package.get("suffixes", None)
|
682
|
-
|
683
|
-
packager = CodePackager(
|
684
|
-
datastore_type=DEFAULT_DATASTORE, code_package_prefix=CODE_PACKAGE_PREFIX
|
685
|
-
)
|
686
|
-
package_url, package_key = packager.store(
|
687
|
-
paths_to_include=[package_dir], file_suffixes=suffixes
|
688
|
-
)
|
689
|
-
app_config.set_state("code_package_url", package_url)
|
690
|
-
app_config.set_state("code_package_key", package_key)
|
691
|
-
logger("💾 Code Package Saved to : %s" % app_config.get_state("code_package_url"))
|
692
|
-
|
693
|
-
|
694
|
-
@app.command(help="Deploy an app to the Outerbounds Platform.")
|
695
|
-
@common_deploy_options
|
696
|
-
@common_run_options
|
697
|
-
@deployment_instance_options
|
698
|
-
@experimental.wrapping_cli_options
|
699
|
-
@click.pass_context
|
700
|
-
@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
|
701
|
-
def deploy(
|
702
|
-
ctx,
|
703
|
-
command,
|
704
|
-
readiness_condition=None,
|
705
|
-
max_wait_time=None,
|
706
|
-
readiness_wait_time=None,
|
707
|
-
status_file=None,
|
708
|
-
no_loader=False,
|
709
|
-
**options,
|
710
|
-
):
|
711
|
-
"""Deploy an app to the Outerbounds Platform."""
|
712
|
-
from functools import partial
|
713
|
-
|
714
|
-
if not ctx.obj.perimeter:
|
715
|
-
raise AppConfigError("OB_CURRENT_PERIMETER is not set")
|
716
|
-
_current_instance_debug_dir = None
|
717
|
-
logger = partial(_logger, timestamp=True)
|
718
|
-
try:
|
719
|
-
_cli_parsed_config = build_config_from_options(options)
|
720
|
-
# Create configuration
|
721
|
-
if options["config_file"]:
|
722
|
-
# Load from file
|
723
|
-
app_config = AppConfig.from_file(options["config_file"])
|
724
|
-
|
725
|
-
# Update with any CLI options using the unified method
|
726
|
-
app_config.update_from_cli_options(options)
|
727
|
-
else:
|
728
|
-
# Create from CLI options
|
729
|
-
app_config = AppConfig(_cli_parsed_config)
|
730
|
-
|
731
|
-
# Validate the configuration
|
732
|
-
app_config.validate()
|
733
|
-
logger(
|
734
|
-
f"🚀 Deploying {app_config.get('name')} to the Outerbounds platform...",
|
735
|
-
color=ColorTheme.INFO_COLOR,
|
736
|
-
system_msg=True,
|
737
|
-
)
|
738
|
-
|
739
|
-
packaging_directory = None
|
740
|
-
package_src_path = app_config.get("package", {}).get("src_path", None)
|
741
|
-
if package_src_path:
|
742
|
-
if os.path.isfile(package_src_path):
|
743
|
-
raise AppConfigError("src_path must be a directory, not a file")
|
744
|
-
elif os.path.isdir(package_src_path):
|
745
|
-
packaging_directory = os.path.abspath(package_src_path)
|
746
|
-
else:
|
747
|
-
raise AppConfigError(f"src_path '{package_src_path}' does not exist")
|
748
|
-
else:
|
749
|
-
# If src_path is None then we assume then we can assume for the moment
|
750
|
-
# that we can package the current working directory.
|
751
|
-
packaging_directory = os.getcwd()
|
752
|
-
|
753
|
-
app_config.set_state("packaging_directory", packaging_directory)
|
754
|
-
logger(
|
755
|
-
"📦 Packaging Directory : %s" % app_config.get_state("packaging_directory"),
|
756
|
-
)
|
757
|
-
# TODO: Construct the command needed to run the app
|
758
|
-
# If we are constructing the directory with the src_path
|
759
|
-
# then we need to add the command from the option otherwise
|
760
|
-
# we use the command from the entrypoint path and whatever follows `--`
|
761
|
-
# is the command to run.
|
762
|
-
|
763
|
-
# Set some defaults for the deploy command
|
764
|
-
app_config.set_deploy_defaults(packaging_directory)
|
765
|
-
|
766
|
-
if options.get("no_deps") == True:
|
767
|
-
# Setting this in the state will make it skip the fast-bakery step
|
768
|
-
# of building an image.
|
769
|
-
app_config.set_state("skip_dependencies", True)
|
770
|
-
else:
|
771
|
-
# Check if the user has set the dependencies in the app config
|
772
|
-
dependencies = app_config.get("dependencies", {})
|
773
|
-
if len(dependencies) == 0:
|
774
|
-
# The user has not set any dependencies, so we can sniff the packaging directory
|
775
|
-
# for a dependencies file.
|
776
|
-
requirements_file = os.path.join(
|
777
|
-
packaging_directory, "requirements.txt"
|
778
|
-
)
|
779
|
-
pyproject_toml = os.path.join(packaging_directory, "pyproject.toml")
|
780
|
-
if os.path.exists(pyproject_toml):
|
781
|
-
app_config.set_state(
|
782
|
-
"dependencies", {"from_pyproject_toml": pyproject_toml}
|
783
|
-
)
|
784
|
-
logger(
|
785
|
-
"📦 Using dependencies from pyproject.toml: %s" % pyproject_toml
|
786
|
-
)
|
787
|
-
elif os.path.exists(requirements_file):
|
788
|
-
app_config.set_state(
|
789
|
-
"dependencies", {"from_requirements_file": requirements_file}
|
790
|
-
)
|
791
|
-
logger(
|
792
|
-
"📦 Using dependencies from requirements.txt: %s"
|
793
|
-
% requirements_file
|
794
|
-
)
|
795
|
-
|
796
|
-
# Print the configuration
|
797
|
-
# 1. validate that the secrets for the app exist
|
798
|
-
# 2. TODO: validate that the compute pool specified in the app exists.
|
799
|
-
# 3. Building Docker image if necessary (based on parameters)
|
800
|
-
# - We will bake images with fastbakery and pass it to the deploy command
|
801
|
-
# TODO: validation logic can be wrapped in try catch so that we can provide
|
802
|
-
# better error messages.
|
803
|
-
cache_dir = os.path.join(
|
804
|
-
ctx.obj.app_state_dir, app_config.get("name", "default")
|
805
|
-
)
|
806
|
-
|
807
|
-
def _non_spinner_logger(*msg, **kwargs):
|
808
|
-
for m in msg:
|
809
|
-
logger(m, **kwargs)
|
810
|
-
|
811
|
-
deploy_validations(
|
812
|
-
app_config,
|
813
|
-
cache_dir=cache_dir,
|
814
|
-
logger=logger,
|
815
|
-
)
|
816
|
-
image_spinner = None
|
817
|
-
img_logger = _non_spinner_logger
|
818
|
-
if not no_loader:
|
819
|
-
image_spinner = MultiStepSpinner(
|
820
|
-
text=lambda: _logger_styled(
|
821
|
-
"🍞 Baking Docker Image",
|
822
|
-
timestamp=True,
|
823
|
-
),
|
824
|
-
color=ColorTheme.LOADING_COLOR,
|
825
|
-
)
|
826
|
-
img_logger = partial(_spinner_logger, image_spinner)
|
827
|
-
image_spinner.start()
|
828
|
-
|
829
|
-
_bake_image(app_config, cache_dir, img_logger)
|
830
|
-
if image_spinner:
|
831
|
-
image_spinner.stop()
|
832
|
-
|
833
|
-
base_commands = parse_commands(app_config, command)
|
834
|
-
|
835
|
-
app_config.set_state("commands", base_commands)
|
836
|
-
|
837
|
-
# TODO: Handle the case where packaging_directory is None
|
838
|
-
# This would involve:
|
839
|
-
# 1. Packaging the code:
|
840
|
-
# - We need to package the code and throw the tarball to some object store
|
841
|
-
_package_necessary_things(app_config, logger)
|
842
|
-
|
843
|
-
app_config.set_state("perimeter", ctx.obj.perimeter)
|
844
|
-
|
845
|
-
capsule_spinner = None
|
846
|
-
capsule_logger = _non_spinner_logger
|
847
|
-
# 2. Convert to the IR that the backend accepts
|
848
|
-
capsule = CapsuleDeployer(
|
849
|
-
app_config,
|
850
|
-
ctx.obj.api_url,
|
851
|
-
debug_dir=_current_instance_debug_dir,
|
852
|
-
success_terminal_state_condition=readiness_condition,
|
853
|
-
create_timeout=max_wait_time,
|
854
|
-
readiness_wait_time=readiness_wait_time,
|
855
|
-
logger_fn=capsule_logger,
|
856
|
-
)
|
857
|
-
_current_instance_debug_dir = os.path.join(
|
858
|
-
cache_dir, f"debug_deployment_instance_{time.time()}"
|
859
|
-
)
|
860
|
-
if CAPSULE_DEBUG:
|
861
|
-
os.makedirs(_current_instance_debug_dir, exist_ok=True)
|
862
|
-
if not no_loader:
|
863
|
-
capsule_spinner = MultiStepSpinner(
|
864
|
-
text=lambda: _logger_styled(
|
865
|
-
"💊 Waiting for %s %s to be ready to serve traffic"
|
866
|
-
% (capsule.capsule_type.lower(), capsule.identifier),
|
867
|
-
timestamp=True,
|
868
|
-
),
|
869
|
-
color=ColorTheme.LOADING_COLOR,
|
870
|
-
)
|
871
|
-
capsule_logger = partial(_spinner_logger, capsule_spinner)
|
872
|
-
capsule_spinner.start()
|
873
|
-
|
874
|
-
currently_present_capsules = list_and_filter_capsules(
|
875
|
-
capsule.capsule_api,
|
876
|
-
None,
|
877
|
-
None,
|
878
|
-
capsule.name,
|
879
|
-
None,
|
880
|
-
None,
|
881
|
-
None,
|
882
|
-
)
|
883
|
-
|
884
|
-
force_upgrade = app_config.get_state("force_upgrade", False)
|
885
|
-
|
886
|
-
_pre_create_debug(app_config, capsule, cache_dir, options, _cli_parsed_config)
|
887
|
-
|
888
|
-
if len(currently_present_capsules) > 0:
|
889
|
-
# Only update the capsule if there is no upgrade in progress
|
890
|
-
# Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
|
891
|
-
_curr_cap = currently_present_capsules[0]
|
892
|
-
this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
|
893
|
-
"updateInProgress", False
|
894
|
-
)
|
895
|
-
|
896
|
-
if this_capsule_is_being_updated and not force_upgrade:
|
897
|
-
_upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
|
898
|
-
message = f"{capsule.capsule_type} is currently being upgraded"
|
899
|
-
if _upgrader:
|
900
|
-
message = (
|
901
|
-
f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
|
902
|
-
"If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
|
903
|
-
)
|
904
|
-
raise AppConfigError(message)
|
905
|
-
capsule_logger(
|
906
|
-
f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
|
907
|
-
color=ColorTheme.INFO_COLOR,
|
908
|
-
system_msg=True,
|
909
|
-
)
|
910
|
-
else:
|
911
|
-
capsule_logger(
|
912
|
-
f"🚀 Deploying {capsule.capsule_type.lower()} to the platform....",
|
913
|
-
color=ColorTheme.INFO_COLOR,
|
914
|
-
system_msg=True,
|
915
|
-
)
|
916
|
-
# 3. Throw the job into the platform and report deployment status
|
917
|
-
capsule.create()
|
918
|
-
_post_create_debug(capsule, cache_dir)
|
919
|
-
|
920
|
-
# We only get the `capsule_response` if the deployment is has reached
|
921
|
-
# a successful terminal state.
|
922
|
-
final_status = capsule.wait_for_terminal_state()
|
923
|
-
if capsule_spinner:
|
924
|
-
capsule_spinner.stop()
|
925
|
-
|
926
|
-
logger(
|
927
|
-
f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed successfully! You can access it at {capsule.status.out_of_cluster_url}",
|
928
|
-
color=ColorTheme.INFO_COLOR,
|
929
|
-
system_msg=True,
|
930
|
-
)
|
931
|
-
|
932
|
-
if CAPSULE_DEBUG:
|
933
|
-
logger(
|
934
|
-
f"[debug] 💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployment status [on completion]: {final_status}",
|
935
|
-
color=ColorTheme.DEBUG_COLOR,
|
936
|
-
)
|
937
|
-
logger(
|
938
|
-
f"[debug] 💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) debug info saved to `{_current_instance_debug_dir}`",
|
939
|
-
color=ColorTheme.DEBUG_COLOR,
|
940
|
-
)
|
941
|
-
final_status["debug_dir"] = _current_instance_debug_dir
|
942
|
-
|
943
|
-
if status_file:
|
944
|
-
# Create the file if it doesn't exist
|
945
|
-
with open(status_file, "w") as f:
|
946
|
-
f.write(json.dumps(final_status, indent=4))
|
947
|
-
logger(
|
948
|
-
f"📝 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployment status written to {status_file}",
|
949
|
-
color=ColorTheme.INFO_COLOR,
|
950
|
-
system_msg=True,
|
951
|
-
)
|
952
|
-
|
953
|
-
except Exception as e:
|
954
|
-
message = getattr(e, "message", str(e))
|
955
|
-
logger(
|
956
|
-
f"Deployment failed: [{e.__class__.__name__}]: {message}",
|
957
|
-
bad=True,
|
958
|
-
system_msg=True,
|
959
|
-
)
|
960
|
-
if CAPSULE_DEBUG:
|
961
|
-
if _current_instance_debug_dir is not None:
|
962
|
-
logger(
|
963
|
-
f"[debug] 💊 debug info saved to `{_current_instance_debug_dir}`",
|
964
|
-
color=ColorTheme.DEBUG_COLOR,
|
965
|
-
)
|
966
|
-
raise e
|
967
|
-
exit(1)
|
968
|
-
|
969
|
-
|
970
|
-
def _parse_capsule_table(filtered_capsules):
|
971
|
-
headers = ["Name", "ID", "Ready", "App Type", "Port", "Tags", "URL"]
|
972
|
-
table_data = []
|
973
|
-
|
974
|
-
for capsule in filtered_capsules:
|
975
|
-
spec = capsule.get("spec", {})
|
976
|
-
status = capsule.get("status", {}) or {}
|
977
|
-
cap_id = capsule.get("id")
|
978
|
-
display_name = spec.get("displayName", "")
|
979
|
-
ready = str(status.get("readyToServeTraffic", False))
|
980
|
-
auth_type = spec.get("authConfig", {}).get("authType", "")
|
981
|
-
port = str(spec.get("port", ""))
|
982
|
-
tags_str = ", ".join(
|
983
|
-
[f"{tag['key']}={tag['value']}" for tag in spec.get("tags", [])]
|
984
|
-
)
|
985
|
-
access_info = status.get("accessInfo", {}) or {}
|
986
|
-
url = access_info.get("outOfClusterURL", None)
|
987
|
-
|
988
|
-
table_data.append(
|
989
|
-
[
|
990
|
-
display_name,
|
991
|
-
cap_id,
|
992
|
-
ready,
|
993
|
-
auth_type,
|
994
|
-
port,
|
995
|
-
tags_str,
|
996
|
-
f"https://{url}" if url else "URL not available",
|
997
|
-
]
|
998
|
-
)
|
999
|
-
return headers, table_data
|
1000
|
-
|
1001
|
-
|
1002
|
-
@app.command(help="List apps in the Outerbounds Platform.")
|
1003
|
-
@click.option("--project", type=str, help="Filter apps by project")
|
1004
|
-
@click.option("--branch", type=str, help="Filter apps by branch")
|
1005
|
-
@click.option("--name", type=str, help="Filter apps by name")
|
1006
|
-
@click.option(
|
1007
|
-
"--tag",
|
1008
|
-
"tags",
|
1009
|
-
type=KVDictType,
|
1010
|
-
help="Filter apps by tag. Format KEY=VALUE. Example --tag foo=bar --tag x=y. If multiple tags are provided, the app must match all of them.",
|
1011
|
-
multiple=True,
|
1012
|
-
)
|
1013
|
-
@click.option(
|
1014
|
-
"--format",
|
1015
|
-
type=click.Choice(["json", "text"]),
|
1016
|
-
help="Format the output",
|
1017
|
-
default="text",
|
1018
|
-
)
|
1019
|
-
@click.option(
|
1020
|
-
"--auth-type", type=click.Choice(AuthType.enums()), help="Filter apps by Auth type"
|
1021
|
-
)
|
1022
|
-
@click.pass_context
|
1023
|
-
def list(ctx, project, branch, name, tags, format, auth_type):
|
1024
|
-
"""List apps in the Outerbounds Platform."""
|
1025
|
-
capsule_api = CapsuleApi(
|
1026
|
-
ctx.obj.api_url,
|
1027
|
-
ctx.obj.perimeter,
|
1028
|
-
)
|
1029
|
-
filtered_capsules = list_and_filter_capsules(
|
1030
|
-
capsule_api, project, branch, name, tags, auth_type, None
|
1031
|
-
)
|
1032
|
-
if format == "json":
|
1033
|
-
click.echo(json.dumps(filtered_capsules, indent=4))
|
1034
|
-
else:
|
1035
|
-
headers, table_data = _parse_capsule_table(filtered_capsules)
|
1036
|
-
print_table(table_data, headers)
|
1037
|
-
|
1038
|
-
|
1039
|
-
@app.command(help="Delete an app/apps from the Outerbounds Platform.")
|
1040
|
-
@click.option("--name", type=str, help="Filter app to delete by name")
|
1041
|
-
@click.option("--id", "cap_id", type=str, help="Filter app to delete by id")
|
1042
|
-
@click.option("--project", type=str, help="Filter apps to delete by project")
|
1043
|
-
@click.option("--branch", type=str, help="Filter apps to delete by branch")
|
1044
|
-
@click.option(
|
1045
|
-
"--tag",
|
1046
|
-
"tags",
|
1047
|
-
multiple=True,
|
1048
|
-
type=KVDictType,
|
1049
|
-
help="Filter apps to delete by tag. Format KEY=VALUE. Example --tag foo=bar --tag x=y. If multiple tags are provided, the app must match all of them.",
|
1050
|
-
)
|
1051
|
-
@click.option("--auto-approve", is_flag=True, help="Do not prompt for confirmation")
|
1052
|
-
@click.pass_context
|
1053
|
-
def delete(ctx, name, cap_id, project, branch, tags, auto_approve):
|
1054
|
-
|
1055
|
-
"""Delete an app/apps from the Outerbounds Platform."""
|
1056
|
-
# Atleast one of the args need to be provided
|
1057
|
-
if not any(
|
1058
|
-
[
|
1059
|
-
name is not None,
|
1060
|
-
cap_id is not None,
|
1061
|
-
project is not None,
|
1062
|
-
branch is not None,
|
1063
|
-
len(tags) != 0,
|
1064
|
-
]
|
1065
|
-
):
|
1066
|
-
raise AppConfigError(
|
1067
|
-
"Atleast one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
|
1068
|
-
)
|
1069
|
-
|
1070
|
-
capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
|
1071
|
-
filtered_capsules = list_and_filter_capsules(
|
1072
|
-
capsule_api, project, branch, name, tags, None, cap_id
|
1073
|
-
)
|
1074
|
-
|
1075
|
-
headers, table_data = _parse_capsule_table(filtered_capsules)
|
1076
|
-
click.secho("The following apps will be deleted:", fg="red", bold=True)
|
1077
|
-
print_table(table_data, headers)
|
1078
|
-
|
1079
|
-
# Confirm the deletion
|
1080
|
-
if not auto_approve:
|
1081
|
-
confirm = click.prompt(
|
1082
|
-
click.style(
|
1083
|
-
"💊 Are you sure you want to delete these apps?", fg="red", bold=True
|
1084
|
-
),
|
1085
|
-
default="no",
|
1086
|
-
type=click.Choice(["yes", "no"]),
|
1087
|
-
)
|
1088
|
-
if confirm == "no":
|
1089
|
-
exit(1)
|
1090
|
-
|
1091
|
-
def item_show_func(x):
|
1092
|
-
if not x:
|
1093
|
-
return None
|
1094
|
-
name = x.get("spec", {}).get("displayName", "")
|
1095
|
-
id = x.get("id", "")
|
1096
|
-
return click.style(
|
1097
|
-
"💊 deleting %s [%s]" % (name, id),
|
1098
|
-
fg=ColorTheme.BAD_COLOR,
|
1099
|
-
bold=True,
|
1100
|
-
)
|
1101
|
-
|
1102
|
-
with click.progressbar(
|
1103
|
-
filtered_capsules,
|
1104
|
-
label=click.style("💊 Deleting apps...", fg=ColorTheme.BAD_COLOR, bold=True),
|
1105
|
-
fill_char=click.style("█", fg=ColorTheme.BAD_COLOR, bold=True),
|
1106
|
-
empty_char=click.style("░", fg=ColorTheme.BAD_COLOR, bold=True),
|
1107
|
-
item_show_func=item_show_func,
|
1108
|
-
) as bar:
|
1109
|
-
for capsule in bar:
|
1110
|
-
capsule_api.delete(capsule.get("id"))
|
1111
|
-
time.sleep(0.5 + random.random() * 2) # delay to avoid rate limiting
|
1112
|
-
|
1113
|
-
|
1114
|
-
@app.command(help="Run an app locally (for testing).")
|
1115
|
-
@common_run_options
|
1116
|
-
@click.pass_context
|
1117
|
-
def run(ctx, **options):
|
1118
|
-
"""Run an app locally for testing."""
|
1119
|
-
try:
|
1120
|
-
# Create configuration
|
1121
|
-
if options["config_file"]:
|
1122
|
-
# Load from file
|
1123
|
-
app_config = AppConfig.from_file(options["config_file"])
|
1124
|
-
|
1125
|
-
# Update with any CLI options using the unified method
|
1126
|
-
app_config.update_from_cli_options(options)
|
1127
|
-
else:
|
1128
|
-
# Create from CLI options
|
1129
|
-
config_dict = build_config_from_options(options)
|
1130
|
-
app_config = AppConfig(config_dict)
|
1131
|
-
|
1132
|
-
# Validate the configuration
|
1133
|
-
app_config.validate()
|
1134
|
-
|
1135
|
-
# Print the configuration
|
1136
|
-
click.echo("Running App with configuration:")
|
1137
|
-
click.echo(app_config.to_yaml())
|
1138
|
-
|
1139
|
-
# TODO: Implement local run logic
|
1140
|
-
# This would involve:
|
1141
|
-
# 1. Setting up the environment
|
1142
|
-
# 2. Running the app locally
|
1143
|
-
# 3. Reporting status
|
1144
|
-
|
1145
|
-
click.echo(f"App '{app_config.config['name']}' running locally!")
|
1146
|
-
|
1147
|
-
except AppConfigError as e:
|
1148
|
-
click.echo(f"Error in app configuration: {e}", err=True)
|
1149
|
-
ctx.exit(1)
|
1150
|
-
except Exception as e:
|
1151
|
-
click.echo(f"Error running app: {e}", err=True)
|
1152
|
-
ctx.exit(1)
|
1153
|
-
|
1154
|
-
|
1155
|
-
@app.command(
|
1156
|
-
help="Get detailed information about an app from the Outerbounds Platform."
|
1157
|
-
)
|
1158
|
-
@click.option("--name", type=str, help="Get info for app by name")
|
1159
|
-
@click.option("--id", "cap_id", type=str, help="Get info for app by id")
|
1160
|
-
@click.option(
|
1161
|
-
"--format",
|
1162
|
-
type=click.Choice(["json", "text"]),
|
1163
|
-
help="Format the output",
|
1164
|
-
default="text",
|
1165
|
-
)
|
1166
|
-
@click.pass_context
|
1167
|
-
def info(ctx, name, cap_id, format):
|
1168
|
-
"""Get detailed information about an app from the Outerbounds Platform."""
|
1169
|
-
# Require either name or id
|
1170
|
-
if not any([name is not None, cap_id is not None]):
|
1171
|
-
raise AppConfigError(
|
1172
|
-
"Either --name or --id must be provided to get app information."
|
1173
|
-
)
|
1174
|
-
|
1175
|
-
# Ensure only one is provided
|
1176
|
-
if name is not None and cap_id is not None:
|
1177
|
-
raise AppConfigError("Please provide either --name or --id, not both.")
|
1178
|
-
|
1179
|
-
capsule_api = CapsuleApi(
|
1180
|
-
ctx.obj.api_url,
|
1181
|
-
ctx.obj.perimeter,
|
1182
|
-
)
|
1183
|
-
|
1184
|
-
# First, find the capsule using list_and_filter_capsules
|
1185
|
-
filtered_capsules = list_and_filter_capsules(
|
1186
|
-
capsule_api, None, None, name, None, None, cap_id
|
1187
|
-
)
|
1188
|
-
|
1189
|
-
if len(filtered_capsules) == 0:
|
1190
|
-
identifier = name if name else cap_id
|
1191
|
-
identifier_type = "name" if name else "id"
|
1192
|
-
raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
|
1193
|
-
|
1194
|
-
if len(filtered_capsules) > 1:
|
1195
|
-
raise AppConfigError(
|
1196
|
-
f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want info for."
|
1197
|
-
)
|
1198
|
-
|
1199
|
-
# Get the capsule info
|
1200
|
-
capsule = filtered_capsules[0]
|
1201
|
-
capsule_id = capsule.get("id")
|
1202
|
-
|
1203
|
-
# Get detailed capsule info and workers
|
1204
|
-
try:
|
1205
|
-
detailed_capsule_info = capsule_api.get(capsule_id)
|
1206
|
-
workers_info = capsule_api.get_workers(capsule_id)
|
1207
|
-
|
1208
|
-
if format == "json":
|
1209
|
-
# Output in JSON format for piping to jq
|
1210
|
-
info_data = {"capsule": detailed_capsule_info, "workers": workers_info}
|
1211
|
-
click.echo(json.dumps(info_data, indent=4))
|
1212
|
-
else:
|
1213
|
-
# Output in text format
|
1214
|
-
_display_capsule_info_text(detailed_capsule_info, workers_info)
|
1215
|
-
|
1216
|
-
except Exception as e:
|
1217
|
-
raise AppConfigError(f"Error retrieving information for app {capsule_id}: {e}")
|
1218
|
-
|
1219
|
-
|
1220
|
-
def _display_capsule_info_text(capsule_info, workers_info):
|
1221
|
-
"""Display capsule information in a human-readable text format."""
|
1222
|
-
spec = capsule_info.get("spec", {})
|
1223
|
-
status = capsule_info.get("status", {}) or {}
|
1224
|
-
metadata = capsule_info.get("metadata", {}) or {}
|
1225
|
-
|
1226
|
-
info_color = ColorTheme.INFO_COLOR
|
1227
|
-
tl_color = ColorTheme.TL_HEADER_COLOR
|
1228
|
-
|
1229
|
-
def _key_style(key: str, value: str):
|
1230
|
-
return "%s: %s" % (
|
1231
|
-
click.style(
|
1232
|
-
key,
|
1233
|
-
fg=ColorTheme.INFO_KEY_COLOR,
|
1234
|
-
),
|
1235
|
-
click.style(str(value), fg=ColorTheme.INFO_VALUE_COLOR, bold=True),
|
1236
|
-
)
|
1237
|
-
|
1238
|
-
# Basic Info
|
1239
|
-
click.secho("=== App Information ===", fg=tl_color, bold=True)
|
1240
|
-
click.secho(_key_style("Name", spec.get("displayName", "N/A")), fg=info_color)
|
1241
|
-
click.secho(_key_style("ID", capsule_info.get("id", "N/A")), fg=info_color)
|
1242
|
-
click.secho(
|
1243
|
-
_key_style("Version", capsule_info.get("version", "N/A")), fg=info_color
|
1244
|
-
)
|
1245
|
-
click.secho(
|
1246
|
-
_key_style(
|
1247
|
-
"Ready to Serve Traffic", str(status.get("readyToServeTraffic", False))
|
1248
|
-
),
|
1249
|
-
fg=info_color,
|
1250
|
-
)
|
1251
|
-
click.secho(
|
1252
|
-
_key_style("Update In Progress", str(status.get("updateInProgress", False))),
|
1253
|
-
fg=info_color,
|
1254
|
-
)
|
1255
|
-
click.secho(
|
1256
|
-
_key_style(
|
1257
|
-
"Currently Served Version", str(status.get("currentlyServedVersion", "N/A"))
|
1258
|
-
),
|
1259
|
-
fg=info_color,
|
1260
|
-
)
|
1261
|
-
|
1262
|
-
# URLs
|
1263
|
-
access_info = status.get("accessInfo", {}) or {}
|
1264
|
-
out_cluster_url = access_info.get("outOfClusterURL")
|
1265
|
-
in_cluster_url = access_info.get("inClusterURL")
|
1266
|
-
|
1267
|
-
if out_cluster_url:
|
1268
|
-
click.secho(
|
1269
|
-
_key_style("External URL", f"https://{out_cluster_url}"), fg=info_color
|
1270
|
-
)
|
1271
|
-
if in_cluster_url:
|
1272
|
-
click.secho(
|
1273
|
-
_key_style("Internal URL", f"https://{in_cluster_url}"), fg=info_color
|
1274
|
-
)
|
1275
|
-
|
1276
|
-
# Resource Configuration
|
1277
|
-
click.secho("\n=== Resource Configuration ===", fg=tl_color, bold=True)
|
1278
|
-
resource_config = spec.get("resourceConfig", {})
|
1279
|
-
click.secho(_key_style("CPU", resource_config.get("cpu", "N/A")), fg=info_color)
|
1280
|
-
click.secho(
|
1281
|
-
_key_style("Memory", resource_config.get("memory", "N/A")), fg=info_color
|
1282
|
-
)
|
1283
|
-
click.secho(
|
1284
|
-
_key_style("Ephemeral Storage", resource_config.get("ephemeralStorage", "N/A")),
|
1285
|
-
fg=info_color,
|
1286
|
-
)
|
1287
|
-
if resource_config.get("gpu"):
|
1288
|
-
click.secho(_key_style("GPU", resource_config.get("gpu")), fg=info_color)
|
1289
|
-
|
1290
|
-
# Autoscaling
|
1291
|
-
click.secho("\n=== Autoscaling Configuration ===", fg=tl_color, bold=True)
|
1292
|
-
autoscaling_config = spec.get("autoscalingConfig", {})
|
1293
|
-
click.secho(
|
1294
|
-
_key_style("Min Replicas", str(autoscaling_config.get("minReplicas", "N/A"))),
|
1295
|
-
fg=info_color,
|
1296
|
-
)
|
1297
|
-
click.secho(
|
1298
|
-
_key_style("Max Replicas", str(autoscaling_config.get("maxReplicas", "N/A"))),
|
1299
|
-
fg=info_color,
|
1300
|
-
)
|
1301
|
-
click.secho(
|
1302
|
-
_key_style("Available Replicas", str(status.get("availableReplicas", "N/A"))),
|
1303
|
-
fg=info_color,
|
1304
|
-
)
|
1305
|
-
|
1306
|
-
# Auth Configuration
|
1307
|
-
click.secho("\n=== Authentication Configuration ===", fg=tl_color, bold=True)
|
1308
|
-
auth_config = spec.get("authConfig", {})
|
1309
|
-
click.secho(
|
1310
|
-
_key_style("Auth Type", auth_config.get("authType", "N/A")), fg=info_color
|
1311
|
-
)
|
1312
|
-
click.secho(
|
1313
|
-
_key_style("Public Access", str(auth_config.get("publicToDeployment", "N/A"))),
|
1314
|
-
fg=info_color,
|
1315
|
-
)
|
1316
|
-
|
1317
|
-
# Tags
|
1318
|
-
tags = spec.get("tags", [])
|
1319
|
-
if tags:
|
1320
|
-
click.secho("\n=== Tags ===", fg=tl_color, bold=True)
|
1321
|
-
for tag in tags:
|
1322
|
-
click.secho(
|
1323
|
-
_key_style(str(tag.get("key", "N/A")), str(tag.get("value", "N/A"))),
|
1324
|
-
fg=info_color,
|
1325
|
-
)
|
1326
|
-
|
1327
|
-
# Metadata
|
1328
|
-
click.secho("\n=== Metadata ===", fg=tl_color, bold=True)
|
1329
|
-
click.secho(
|
1330
|
-
_key_style("Created At", metadata.get("createdAt", "N/A")), fg=info_color
|
1331
|
-
)
|
1332
|
-
click.secho(
|
1333
|
-
_key_style("Last Modified At", metadata.get("lastModifiedAt", "N/A")),
|
1334
|
-
fg=info_color,
|
1335
|
-
)
|
1336
|
-
click.secho(
|
1337
|
-
_key_style("Last Modified By", metadata.get("lastModifiedBy", "N/A")),
|
1338
|
-
fg=info_color,
|
1339
|
-
)
|
1340
|
-
|
1341
|
-
# Workers Information
|
1342
|
-
click.secho("\n=== Workers Information ===", fg=tl_color, bold=True)
|
1343
|
-
if not workers_info:
|
1344
|
-
click.secho("No workers found", fg=info_color)
|
1345
|
-
else:
|
1346
|
-
click.secho(_key_style("Total Workers", str(len(workers_info))), fg=tl_color)
|
1347
|
-
|
1348
|
-
# Create a table for workers
|
1349
|
-
workers_headers = [
|
1350
|
-
"Worker ID",
|
1351
|
-
"Phase",
|
1352
|
-
"Version",
|
1353
|
-
"Activity",
|
1354
|
-
"Activity Data Available",
|
1355
|
-
]
|
1356
|
-
workers_table_data = []
|
1357
|
-
|
1358
|
-
for worker in workers_info:
|
1359
|
-
worker_id = worker.get("workerId", "N/A")
|
1360
|
-
phase = worker.get("phase", "N/A")
|
1361
|
-
version = worker.get("version", "N/A")
|
1362
|
-
activity = str(worker.get("activity", "N/A"))
|
1363
|
-
activity_data_available = str(worker.get("activityDataAvailable", False))
|
1364
|
-
|
1365
|
-
workers_table_data.append(
|
1366
|
-
[
|
1367
|
-
worker_id[:20] + "..." if len(worker_id) > 23 else worker_id,
|
1368
|
-
phase,
|
1369
|
-
version[:10] + "..." if len(version) > 13 else version,
|
1370
|
-
activity,
|
1371
|
-
activity_data_available,
|
1372
|
-
]
|
1373
|
-
)
|
1374
|
-
|
1375
|
-
print_table(workers_table_data, workers_headers)
|
1376
|
-
|
1377
|
-
|
1378
|
-
@app.command(help="Get logs for an app worker from the Outerbounds Platform.")
|
1379
|
-
@click.option("--name", type=str, help="Get logs for app by name")
|
1380
|
-
@click.option("--id", "cap_id", type=str, help="Get logs for app by id")
|
1381
|
-
@click.option("--worker-id", type=str, help="Get logs for specific worker")
|
1382
|
-
@click.option("--file", type=str, help="Save logs to file")
|
1383
|
-
@click.option(
|
1384
|
-
"--previous",
|
1385
|
-
is_flag=True,
|
1386
|
-
help="Get logs from previous container instance",
|
1387
|
-
default=False,
|
1388
|
-
)
|
1389
|
-
@click.pass_context
|
1390
|
-
def logs(ctx, name, cap_id, worker_id, file, previous):
|
1391
|
-
"""Get logs for an app worker from the Outerbounds Platform."""
|
1392
|
-
# Require either name or id
|
1393
|
-
if not any([name is not None, cap_id is not None]):
|
1394
|
-
raise AppConfigError("Either --name or --id must be provided to get app logs.")
|
1395
|
-
|
1396
|
-
# Ensure only one is provided
|
1397
|
-
if name is not None and cap_id is not None:
|
1398
|
-
raise AppConfigError("Please provide either --name or --id, not both.")
|
1399
|
-
|
1400
|
-
capsule_api = CapsuleApi(
|
1401
|
-
ctx.obj.api_url,
|
1402
|
-
ctx.obj.perimeter,
|
1403
|
-
)
|
1404
|
-
|
1405
|
-
# First, find the capsule using list_and_filter_capsules
|
1406
|
-
filtered_capsules = list_and_filter_capsules(
|
1407
|
-
capsule_api, None, None, name, None, None, cap_id
|
1408
|
-
)
|
1409
|
-
|
1410
|
-
if len(filtered_capsules) == 0:
|
1411
|
-
identifier = name if name else cap_id
|
1412
|
-
identifier_type = "name" if name else "id"
|
1413
|
-
raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
|
1414
|
-
|
1415
|
-
if len(filtered_capsules) > 1:
|
1416
|
-
raise AppConfigError(
|
1417
|
-
f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want logs for."
|
1418
|
-
)
|
1419
|
-
|
1420
|
-
capsule = filtered_capsules[0]
|
1421
|
-
capsule_id = capsule.get("id")
|
1422
|
-
|
1423
|
-
# Get workers
|
1424
|
-
try:
|
1425
|
-
workers_info = capsule_api.get_workers(capsule_id)
|
1426
|
-
except Exception as e:
|
1427
|
-
raise AppConfigError(f"Error retrieving workers for app {capsule_id}: {e}")
|
1428
|
-
|
1429
|
-
if not workers_info:
|
1430
|
-
raise AppConfigError(f"No workers found for app {capsule_id}")
|
1431
|
-
|
1432
|
-
# If worker_id not provided, show interactive selection
|
1433
|
-
if not worker_id:
|
1434
|
-
if len(workers_info) == 1:
|
1435
|
-
# Only one worker, use it automatically
|
1436
|
-
selected_worker = workers_info[0]
|
1437
|
-
worker_id = selected_worker.get("workerId")
|
1438
|
-
worker_phase = selected_worker.get("phase", "N/A")
|
1439
|
-
worker_version = selected_worker.get("version", "N/A")[:10]
|
1440
|
-
click.echo(
|
1441
|
-
f"📋 Using the only available worker: {worker_id[:20]}... (phase: {worker_phase}, version: {worker_version}...)"
|
1442
|
-
)
|
1443
|
-
else:
|
1444
|
-
# Multiple workers, show selection
|
1445
|
-
click.secho(
|
1446
|
-
"📋 Multiple workers found. Please select one:",
|
1447
|
-
fg=ColorTheme.INFO_COLOR,
|
1448
|
-
bold=True,
|
1449
|
-
)
|
1450
|
-
|
1451
|
-
# Display workers in a table format for better readability
|
1452
|
-
headers = ["#", "Worker ID", "Phase", "Version", "Activity"]
|
1453
|
-
table_data = []
|
1454
|
-
|
1455
|
-
for i, worker in enumerate(workers_info, 1):
|
1456
|
-
w_id = worker.get("workerId", "N/A")
|
1457
|
-
phase = worker.get("phase", "N/A")
|
1458
|
-
version = worker.get("version", "N/A")
|
1459
|
-
activity = str(worker.get("activity", "N/A"))
|
1460
|
-
|
1461
|
-
table_data.append(
|
1462
|
-
[
|
1463
|
-
str(i),
|
1464
|
-
w_id[:30] + "..." if len(w_id) > 33 else w_id,
|
1465
|
-
phase,
|
1466
|
-
version[:15] + "..." if len(version) > 18 else version,
|
1467
|
-
activity,
|
1468
|
-
]
|
1469
|
-
)
|
1470
|
-
|
1471
|
-
print_table(table_data, headers)
|
1472
|
-
|
1473
|
-
# Create choices for the prompt
|
1474
|
-
worker_choices = []
|
1475
|
-
for i, worker in enumerate(workers_info, 1):
|
1476
|
-
worker_choices.append(str(i))
|
1477
|
-
|
1478
|
-
selected_index = click.prompt(
|
1479
|
-
click.style(
|
1480
|
-
"Select worker number", fg=ColorTheme.INFO_COLOR, bold=True
|
1481
|
-
),
|
1482
|
-
type=click.Choice(worker_choices),
|
1483
|
-
)
|
1484
|
-
|
1485
|
-
# Get the selected worker
|
1486
|
-
selected_worker = workers_info[int(selected_index) - 1]
|
1487
|
-
worker_id = selected_worker.get("workerId")
|
1488
|
-
|
1489
|
-
# Get logs for the selected worker
|
1490
|
-
try:
|
1491
|
-
logs_response = capsule_api.logs(capsule_id, worker_id, previous=previous)
|
1492
|
-
except Exception as e:
|
1493
|
-
raise AppConfigError(f"Error retrieving logs for worker {worker_id}: {e}")
|
1494
|
-
|
1495
|
-
# Format logs content
|
1496
|
-
logs_content = "\n".join([log.get("message", "") for log in logs_response])
|
1497
|
-
|
1498
|
-
# Display or save logs
|
1499
|
-
if file:
|
1500
|
-
try:
|
1501
|
-
with open(file, "w") as f:
|
1502
|
-
f.write(logs_content)
|
1503
|
-
click.echo(f"📁 Logs saved to {file}")
|
1504
|
-
except Exception as e:
|
1505
|
-
raise AppConfigError(f"Error saving logs to file {file}: {e}")
|
1506
|
-
else:
|
1507
|
-
if logs_content.strip():
|
1508
|
-
click.echo(logs_content)
|
1509
|
-
else:
|
1510
|
-
click.echo("📝 No logs available for this worker.")
|
1511
|
-
|
1512
|
-
|
1513
|
-
# if __name__ == "__main__":
|
1514
|
-
# cli()
|