ob-metaflow-extensions 1.1.151__py2.py3-none-any.whl → 1.4.33__py2.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.
Files changed (87) hide show
  1. metaflow_extensions/outerbounds/__init__.py +1 -1
  2. metaflow_extensions/outerbounds/plugins/__init__.py +17 -3
  3. metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -0
  4. metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +146 -0
  5. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +10 -0
  6. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +506 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/__init__.py +0 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/__init__.py +4 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/spinners.py +478 -0
  10. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +1200 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +146 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py +0 -0
  13. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +958 -0
  14. metaflow_extensions/outerbounds/plugins/apps/core/click_importer.py +24 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/code_package/__init__.py +3 -0
  16. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +618 -0
  17. metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py +125 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +12 -0
  19. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
  20. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +868 -0
  21. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +288 -0
  22. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +139 -0
  23. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +398 -0
  24. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +1088 -0
  25. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +337 -0
  26. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +115 -0
  27. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +303 -0
  28. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +89 -0
  29. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +87 -0
  30. metaflow_extensions/outerbounds/plugins/apps/core/secrets.py +164 -0
  31. metaflow_extensions/outerbounds/plugins/apps/core/utils.py +233 -0
  32. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +17 -0
  33. metaflow_extensions/outerbounds/plugins/aws/__init__.py +4 -0
  34. metaflow_extensions/outerbounds/plugins/aws/assume_role.py +3 -0
  35. metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +78 -0
  36. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/coreweave.py +9 -77
  37. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/external_chckpt.py +85 -0
  38. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/nebius.py +7 -78
  39. metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
  40. metaflow_extensions/outerbounds/plugins/fast_bakery/docker_environment.py +17 -3
  41. metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery.py +1 -0
  42. metaflow_extensions/outerbounds/plugins/kubernetes/kubernetes_client.py +18 -44
  43. metaflow_extensions/outerbounds/plugins/kubernetes/pod_killer.py +374 -0
  44. metaflow_extensions/outerbounds/plugins/nim/card.py +1 -6
  45. metaflow_extensions/outerbounds/plugins/nim/{__init__.py → nim_decorator.py} +13 -49
  46. metaflow_extensions/outerbounds/plugins/nim/nim_manager.py +294 -233
  47. metaflow_extensions/outerbounds/plugins/nim/utils.py +36 -0
  48. metaflow_extensions/outerbounds/plugins/nvcf/constants.py +2 -2
  49. metaflow_extensions/outerbounds/plugins/nvct/nvct_decorator.py +32 -8
  50. metaflow_extensions/outerbounds/plugins/nvct/nvct_runner.py +1 -1
  51. metaflow_extensions/outerbounds/plugins/ollama/__init__.py +171 -16
  52. metaflow_extensions/outerbounds/plugins/ollama/constants.py +1 -0
  53. metaflow_extensions/outerbounds/plugins/ollama/exceptions.py +22 -0
  54. metaflow_extensions/outerbounds/plugins/ollama/ollama.py +1710 -114
  55. metaflow_extensions/outerbounds/plugins/ollama/status_card.py +292 -0
  56. metaflow_extensions/outerbounds/plugins/optuna/__init__.py +48 -0
  57. metaflow_extensions/outerbounds/plugins/profilers/simple_card_decorator.py +96 -0
  58. metaflow_extensions/outerbounds/plugins/s3_proxy/__init__.py +7 -0
  59. metaflow_extensions/outerbounds/plugins/s3_proxy/binary_caller.py +132 -0
  60. metaflow_extensions/outerbounds/plugins/s3_proxy/constants.py +11 -0
  61. metaflow_extensions/outerbounds/plugins/s3_proxy/exceptions.py +13 -0
  62. metaflow_extensions/outerbounds/plugins/s3_proxy/proxy_bootstrap.py +59 -0
  63. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_api.py +93 -0
  64. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_decorator.py +250 -0
  65. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_manager.py +225 -0
  66. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +6 -3
  67. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +13 -7
  68. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +8 -2
  69. metaflow_extensions/outerbounds/plugins/torchtune/__init__.py +163 -0
  70. metaflow_extensions/outerbounds/plugins/vllm/__init__.py +255 -0
  71. metaflow_extensions/outerbounds/plugins/vllm/constants.py +1 -0
  72. metaflow_extensions/outerbounds/plugins/vllm/exceptions.py +1 -0
  73. metaflow_extensions/outerbounds/plugins/vllm/status_card.py +352 -0
  74. metaflow_extensions/outerbounds/plugins/vllm/vllm_manager.py +621 -0
  75. metaflow_extensions/outerbounds/remote_config.py +27 -3
  76. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +86 -2
  77. metaflow_extensions/outerbounds/toplevel/ob_internal.py +4 -0
  78. metaflow_extensions/outerbounds/toplevel/plugins/optuna/__init__.py +1 -0
  79. metaflow_extensions/outerbounds/toplevel/plugins/torchtune/__init__.py +1 -0
  80. metaflow_extensions/outerbounds/toplevel/plugins/vllm/__init__.py +1 -0
  81. metaflow_extensions/outerbounds/toplevel/s3_proxy.py +88 -0
  82. {ob_metaflow_extensions-1.1.151.dist-info → ob_metaflow_extensions-1.4.33.dist-info}/METADATA +2 -2
  83. ob_metaflow_extensions-1.4.33.dist-info/RECORD +134 -0
  84. metaflow_extensions/outerbounds/plugins/nim/utilities.py +0 -5
  85. ob_metaflow_extensions-1.1.151.dist-info/RECORD +0 -74
  86. {ob_metaflow_extensions-1.1.151.dist-info → ob_metaflow_extensions-1.4.33.dist-info}/WHEEL +0 -0
  87. {ob_metaflow_extensions-1.1.151.dist-info → ob_metaflow_extensions-1.4.33.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1200 @@
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 .config import auto_cli_options, CoreConfig
32
+ from .perimeters import PerimeterExtractor
33
+ from .utils import (
34
+ MultiStepSpinner,
35
+ )
36
+ from .code_package import CodePackager
37
+ from .capsule import (
38
+ CapsuleDeployer,
39
+ list_and_filter_capsules,
40
+ CapsuleApi,
41
+ DEPLOYMENT_READY_CONDITIONS,
42
+ CapsuleApiException,
43
+ CapsuleDeploymentException,
44
+ )
45
+ from .dependencies import bake_deployment_image
46
+ import shlex
47
+ import time
48
+ import uuid
49
+ from datetime import datetime
50
+
51
+
52
+ class KeyValueDictPair(click.ParamType):
53
+ name = "KV-DICT-PAIR" # type: ignore
54
+
55
+ def convert(self, value, param, ctx):
56
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
57
+ if len(value.split("=", 1)) != 2:
58
+ self.fail(
59
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
60
+ )
61
+
62
+ key, _value = value.split("=", 1)
63
+ try:
64
+ return {"key": key, "value": json.loads(_value)}
65
+ except json.JSONDecodeError:
66
+ return {"key": key, "value": _value}
67
+ except Exception as e:
68
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
69
+
70
+ def __str__(self):
71
+ return repr(self)
72
+
73
+ def __repr__(self):
74
+ return "KV-PAIR"
75
+
76
+
77
+ class KeyValuePair(click.ParamType):
78
+ name = "KV-PAIR" # type: ignore
79
+
80
+ def convert(self, value, param, ctx):
81
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
82
+ if len(value.split("=", 1)) != 2:
83
+ self.fail(
84
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
85
+ )
86
+
87
+ key, _value = value.split("=", 1)
88
+ try:
89
+ return {key: json.loads(_value)}
90
+ except json.JSONDecodeError:
91
+ return {key: _value}
92
+ except Exception as e:
93
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
94
+
95
+ def __str__(self):
96
+ return repr(self)
97
+
98
+ def __repr__(self):
99
+ return "KV-PAIR"
100
+
101
+
102
+ class CommaSeparatedList(click.ParamType):
103
+ name = "COMMA-SEPARATED-LIST" # type: ignore
104
+
105
+ def convert(self, value, param, ctx):
106
+ return value.split(",")
107
+
108
+ def __str__(self):
109
+ return repr(self)
110
+
111
+ def __repr__(self):
112
+ return "COMMA-SEPARATED-LIST"
113
+
114
+
115
+ KVPairType = KeyValuePair() # used for --tag and --env
116
+ CommaSeparatedListType = CommaSeparatedList() # used for --compute-pools
117
+ KVDictType = KeyValueDictPair() # only Used for the list/delete commands for tags
118
+
119
+
120
+ class ColorTheme:
121
+ TIMESTAMP = "magenta"
122
+ LOADING_COLOR = "cyan"
123
+ BAD_COLOR = "red"
124
+ INFO_COLOR = "green"
125
+ DEBUG_COLOR = "yellow"
126
+
127
+ TL_HEADER_COLOR = "magenta"
128
+ # Use a color that is readable in both light and dark mode.
129
+ # "white" can be hard to see in light mode, so use "black" for rows.
130
+ # Alternatively, "reset" (default terminal color) is safest.
131
+ ROW_COLOR = "reset"
132
+
133
+ INFO_KEY_COLOR = "green"
134
+ INFO_VALUE_COLOR = "reset"
135
+
136
+
137
+ NativeList = list
138
+
139
+
140
+ def _logger(
141
+ body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
142
+ ):
143
+ if timestamp:
144
+ if timestamp is True:
145
+ dt = datetime.now()
146
+ else:
147
+ dt = timestamp
148
+ tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
149
+ click.secho(tstamp + " ", fg=ColorTheme.TIMESTAMP, nl=False)
150
+ if head:
151
+ click.secho(head, fg=ColorTheme.INFO_COLOR, nl=False)
152
+ click.secho(
153
+ body,
154
+ bold=system_msg,
155
+ fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
156
+ nl=nl,
157
+ )
158
+
159
+
160
+ def _logger_styled(
161
+ body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
162
+ ):
163
+ message_parts = []
164
+
165
+ if timestamp:
166
+ if timestamp is True:
167
+ dt = datetime.now()
168
+ else:
169
+ dt = timestamp
170
+ tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
171
+ message_parts.append(click.style(tstamp + " ", fg=ColorTheme.TIMESTAMP))
172
+
173
+ if head:
174
+ message_parts.append(click.style(head, fg=ColorTheme.INFO_COLOR))
175
+
176
+ message_parts.append(
177
+ click.style(
178
+ body,
179
+ bold=system_msg,
180
+ fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
181
+ )
182
+ )
183
+
184
+ return "".join(message_parts)
185
+
186
+
187
+ def _spinner_logger(spinner, *msg, **kwargs):
188
+ spinner.log(*[_logger_styled(x, timestamp=True, **kwargs) for x in msg])
189
+
190
+
191
+ class CliState(object):
192
+ pass
193
+
194
+
195
+ def _pre_create_debug(
196
+ app_config: AppConfig,
197
+ capsule: CapsuleDeployer,
198
+ state_dir: str,
199
+ options: Dict[str, Any],
200
+ ):
201
+ if CAPSULE_DEBUG:
202
+ os.makedirs(state_dir, exist_ok=True)
203
+ debug_path = os.path.join(state_dir, f"debug_{time.time()}.json")
204
+ with open(
205
+ debug_path,
206
+ "w",
207
+ ) as f:
208
+ f.write(
209
+ json.dumps(
210
+ {
211
+ "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
212
+ "capsule_input": capsule.create_input(), # This is the input that is passed to the capsule deploy API
213
+ "deploy_response": capsule._capsule_deploy_response, # This is the response from the capsule deploy API
214
+ "cli_options": options, # These are the actual options passing down to the CLI
215
+ },
216
+ indent=2,
217
+ default=str,
218
+ )
219
+ )
220
+
221
+
222
+ def _post_create_debug(capsule: CapsuleDeployer, state_dir: str):
223
+ if CAPSULE_DEBUG:
224
+ debug_path = os.path.join(
225
+ state_dir, f"debug_deploy_response_{time.time()}.json"
226
+ )
227
+ with open(debug_path, "w") as f:
228
+ f.write(json.dumps(capsule._capsule_deploy_response, indent=2, default=str))
229
+
230
+
231
+ def _bake_image(app_config: AppConfig, cache_dir: str, logger):
232
+ baking_status = bake_deployment_image(
233
+ app_config=app_config,
234
+ cache_file_path=os.path.join(cache_dir, "image_cache"),
235
+ logger=logger,
236
+ )
237
+ app_config.set_state(
238
+ "image",
239
+ baking_status.resolved_image,
240
+ )
241
+ app_config.set_state("python_path", baking_status.python_path)
242
+ logger("🐳 Using the docker image : %s" % app_config.get_state("image"))
243
+
244
+
245
+ def print_table(data, headers):
246
+ """Print data in a formatted table."""
247
+
248
+ if not data:
249
+ return
250
+
251
+ # Calculate column widths
252
+ col_widths = [len(h) for h in headers]
253
+
254
+ # Calculate actual widths based on data
255
+ for row in data:
256
+ for i, cell in enumerate(row):
257
+ col_widths[i] = max(col_widths[i], len(str(cell)))
258
+
259
+ # Print header
260
+ header_row = " | ".join(
261
+ [headers[i].ljust(col_widths[i]) for i in range(len(headers))]
262
+ )
263
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
264
+ click.secho(header_row, fg=ColorTheme.TL_HEADER_COLOR, bold=True)
265
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
266
+
267
+ # Print data rows
268
+ for row in data:
269
+ formatted_row = " | ".join(
270
+ [str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
271
+ )
272
+ click.secho(formatted_row, fg=ColorTheme.ROW_COLOR, bold=True)
273
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
274
+
275
+
276
+ @click.group()
277
+ def cli():
278
+ """Outerbounds CLI tool."""
279
+ pass
280
+
281
+
282
+ @cli.group(
283
+ help="Commands related to Deploying/Running/Managing Apps on Outerbounds Platform."
284
+ )
285
+ @click.pass_context
286
+ def app(ctx):
287
+ """App-related commands."""
288
+ metaflow_set_context = getattr(ctx, "obj", None)
289
+ ctx.obj = CliState()
290
+ ctx.obj.trace_id = str(uuid.uuid4())
291
+ ctx.obj.app_state_dir = os.path.join(os.curdir, ".ob_apps")
292
+ profile = os.environ.get("METAFLOW_PROFILE", "")
293
+ config_dir = os.path.expanduser(
294
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
295
+ )
296
+ perimeter, api_server = PerimeterExtractor.for_ob_cli(
297
+ config_dir=config_dir, profile=profile
298
+ )
299
+ if perimeter is None or api_server is None:
300
+ raise AppConfigError(
301
+ "Perimeter not found in the environment, Found perimeter: %s, api_server: %s"
302
+ % (perimeter, api_server)
303
+ )
304
+ ctx.obj.perimeter = perimeter
305
+ ctx.obj.api_url = api_server
306
+ os.makedirs(ctx.obj.app_state_dir, exist_ok=True)
307
+
308
+
309
+ def parse_cli_commands(cli_command_input):
310
+ # There can be two modes:
311
+ # 1. User passes command via `--` in the CLI
312
+ # 2. User passes the `commands` key in the config.
313
+ # This function parses the command for mode 1.
314
+ base_commands = []
315
+ if len(cli_command_input) > 0:
316
+ if type(cli_command_input) == str:
317
+ base_commands.append(cli_command_input)
318
+ else:
319
+ base_commands.append(shlex.join(cli_command_input))
320
+
321
+ return base_commands
322
+
323
+
324
+ def deployment_instance_options(func):
325
+ # These parameters influence how the CLI behaves for each instance of a launched deployment.
326
+ @click.option(
327
+ "--readiness-condition",
328
+ type=click.Choice(DEPLOYMENT_READY_CONDITIONS.enums()),
329
+ help=DEPLOYMENT_READY_CONDITIONS.__doc__,
330
+ default=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
331
+ )
332
+ @click.option(
333
+ "--status-file",
334
+ type=str,
335
+ help="The path to the file where the final status of the deployment will be written.",
336
+ default=None,
337
+ )
338
+ @click.option(
339
+ "--readiness-wait-time",
340
+ type=int,
341
+ help="The time (in seconds) to monitor the deployment for readiness after the readiness condition is met.",
342
+ default=15,
343
+ )
344
+ @click.option(
345
+ "--deployment-timeout",
346
+ "max_wait_time",
347
+ type=int,
348
+ help="The maximum time (in seconds) to wait for the deployment to reach readiness before timing out.",
349
+ default=600,
350
+ )
351
+ @click.option(
352
+ "--no-loader",
353
+ is_flag=True,
354
+ help="Do not use the loading spinner for the deployment.",
355
+ default=False,
356
+ )
357
+ @wraps(func)
358
+ def wrapper(*args, **kwargs):
359
+ return func(*args, **kwargs)
360
+
361
+ return wrapper
362
+
363
+
364
+ def _package_necessary_things(app_config: AppConfig, logger):
365
+ # Packaging has a few things to be thought through:
366
+ # 1. if `entrypoint_path` exists then should we package the directory
367
+ # where the entrypoint lives. For example : if the user calls
368
+ # `outerbounds app deploy foo/bar.py` should we package `foo` dir
369
+ # or should we package the cwd from which foo/bar.py is being called.
370
+ # 2. if the src path is used with the config file then how should we view
371
+ # that path ?
372
+ # 3. It becomes interesting when users call the deployment with config files
373
+ # where there is a `src_path` and then is the src_path relative to the config file
374
+ # or is it relative to where the caller command is sitting. Ideally it should work
375
+ # like Kustomizations where its relative to where the yaml file sits for simplicity
376
+ # of understanding relationships between config files. Ideally users can pass the src_path
377
+ # from the command line and that will alleviate any need to package any other directories for
378
+ #
379
+
380
+ package_dirs = app_config.get_state("packaging_directories")
381
+ if package_dirs is None:
382
+ app_config.set_state("code_package_url", None)
383
+ app_config.set_state("code_package_key", None)
384
+ return
385
+ from metaflow.metaflow_config import DEFAULT_DATASTORE
386
+
387
+ package = app_config.get_state("package") or {}
388
+ suffixes = package.get("suffixes", None)
389
+
390
+ packager = CodePackager(
391
+ datastore_type=DEFAULT_DATASTORE, code_package_prefix=CODE_PACKAGE_PREFIX
392
+ )
393
+ package_url, package_key = packager.store(
394
+ paths_to_include=package_dirs, file_suffixes=suffixes
395
+ )
396
+ app_config.set_state("code_package_url", package_url)
397
+ app_config.set_state("code_package_key", package_key)
398
+ logger("💾 Code package saved to : %s" % app_config.get_state("code_package_url"))
399
+
400
+
401
+ def _sniff_pyproject_and_requirements(packaging_directories: List[str]):
402
+ pyproject_path = None
403
+ requirements_path = None
404
+ for directory in packaging_directories:
405
+ pyproject_toml = os.path.join(directory, "pyproject.toml")
406
+ requirements_txt = os.path.join(directory, "requirements.txt")
407
+ if os.path.exists(pyproject_toml):
408
+ pyproject_path = pyproject_toml
409
+ elif os.path.exists(requirements_txt):
410
+ requirements_path = requirements_txt
411
+ return pyproject_path, requirements_path
412
+
413
+
414
+ @app.command(help="Deploy an app to the Outerbounds Platform.")
415
+ @click.option(
416
+ "--config-file",
417
+ type=str,
418
+ help="The config file to use for the App (YAML or JSON)",
419
+ default=None,
420
+ )
421
+ @deployment_instance_options
422
+ @auto_cli_options()
423
+ @click.pass_context
424
+ @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
425
+ def deploy(
426
+ ctx,
427
+ command,
428
+ readiness_condition=None,
429
+ max_wait_time=None,
430
+ readiness_wait_time=None,
431
+ status_file=None,
432
+ no_loader=False,
433
+ **options,
434
+ ):
435
+ """Deploy an app to the Outerbounds Platform."""
436
+ from functools import partial
437
+
438
+ if not ctx.obj.perimeter:
439
+ raise AppConfigError("OB_CURRENT_PERIMETER is not set")
440
+ _current_instance_debug_dir = None
441
+ logger = partial(_logger, timestamp=True)
442
+
443
+ base_commands = parse_cli_commands(command)
444
+ options["commands"] = base_commands
445
+ try:
446
+ # Create configuration
447
+ if options["config_file"]:
448
+ # Load from file
449
+ app_config = AppConfig.from_file(options["config_file"])
450
+
451
+ # Update with any CLI options using the unified method
452
+ app_config.update_from_cli_options(options)
453
+ else:
454
+ # Create from CLI options
455
+ app_config = AppConfig.from_cli(options)
456
+
457
+ # Validate the configuration
458
+ app_config.commit()
459
+ logger(
460
+ f"🚀 Deploying {app_config.get('name')} to the Outerbounds platform...",
461
+ color=ColorTheme.INFO_COLOR,
462
+ system_msg=True,
463
+ )
464
+
465
+ package_src_paths = app_config.get("package", {}).get("src_paths", [])
466
+ if package_src_paths is None:
467
+ package_src_paths = []
468
+
469
+ if len(package_src_paths) == 0:
470
+ # If src_paths is None then we assume then we can assume for the moment
471
+ # that we can package the current working directory.
472
+ package_src_paths = [os.getcwd()]
473
+
474
+ app_config.set_state("packaging_directories", package_src_paths)
475
+ logger(
476
+ "📦 Packaging directories : %s"
477
+ % ", ".join(app_config.get_state("packaging_directories")),
478
+ )
479
+
480
+ if app_config.get("no_deps", False):
481
+ # Setting this in the state will make it skip the fast-bakery step
482
+ # of building an image.
483
+ app_config.set_state("skip_dependencies", True)
484
+ else:
485
+ # Check if the user has set the dependencies in the app config
486
+ dependencies = app_config.get("dependencies", {})
487
+
488
+ if all(
489
+ [
490
+ dependencies.get("from_pyproject_toml", None) is None,
491
+ dependencies.get("from_requirements_file", None) is None,
492
+ dependencies.get("pypi", None) is None,
493
+ dependencies.get("conda", None) is None,
494
+ ]
495
+ ):
496
+ python_version = dependencies.get(
497
+ "python"
498
+ ) # python gets a default value so it's always set.
499
+ # The user has not set any dependencies, so we can sniff the packaging directory
500
+ # for a dependencies file.
501
+ pyproject_toml, requirements_file = _sniff_pyproject_and_requirements(
502
+ package_src_paths
503
+ )
504
+ if pyproject_toml:
505
+ app_config.set_state(
506
+ "dependencies",
507
+ {
508
+ "from_pyproject_toml": pyproject_toml,
509
+ "python": python_version,
510
+ },
511
+ )
512
+ logger(
513
+ "📦 Using dependencies from pyproject.toml: %s" % pyproject_toml
514
+ )
515
+ elif requirements_file:
516
+ app_config.set_state(
517
+ "dependencies",
518
+ {
519
+ "from_requirements_file": requirements_file,
520
+ "python": python_version,
521
+ },
522
+ )
523
+ logger(
524
+ "📦 Using dependencies from requirements.txt: %s"
525
+ % requirements_file
526
+ )
527
+
528
+ # Print the configuration
529
+ # 1. validate that the secrets for the app exist
530
+ # 2. TODO: validate that the compute pool specified in the app exists.
531
+ # 3. Building Docker image if necessary (based on parameters)
532
+ # - We will bake images with fastbakery and pass it to the deploy command
533
+ cache_dir = os.path.join(
534
+ ctx.obj.app_state_dir, app_config.get("name", "default")
535
+ )
536
+
537
+ def _non_spinner_logger(*msg, **kwargs):
538
+ for m in msg:
539
+ logger(m, **kwargs)
540
+
541
+ image_spinner = None
542
+ img_logger = _non_spinner_logger
543
+ if not no_loader:
544
+ image_spinner = MultiStepSpinner(
545
+ text=lambda: _logger_styled(
546
+ "🍞 Baking Docker Image",
547
+ timestamp=True,
548
+ ),
549
+ color=ColorTheme.LOADING_COLOR,
550
+ )
551
+ img_logger = partial(_spinner_logger, image_spinner)
552
+ image_spinner.start()
553
+
554
+ _bake_image(app_config, cache_dir, img_logger)
555
+ if image_spinner:
556
+ image_spinner.stop()
557
+
558
+ # TODO: Handle the case where packaging_directory is None
559
+ # This would involve:
560
+ # 1. Packaging the code:
561
+ # - We need to package the code and throw the tarball to some object store
562
+ _package_necessary_things(app_config, logger)
563
+
564
+ app_config.set_state("perimeter", ctx.obj.perimeter)
565
+
566
+ capsule_spinner = None
567
+ capsule_logger = _non_spinner_logger
568
+
569
+ if CAPSULE_DEBUG:
570
+ _current_instance_debug_dir = os.path.join(
571
+ cache_dir, f"debug_deployment_instance_{time.time()}"
572
+ )
573
+ os.makedirs(_current_instance_debug_dir, exist_ok=True)
574
+ # 2. Convert to the IR that the backend accepts
575
+ capsule = CapsuleDeployer(
576
+ app_config,
577
+ ctx.obj.api_url,
578
+ debug_dir=_current_instance_debug_dir,
579
+ success_terminal_state_condition=readiness_condition,
580
+ create_timeout=max_wait_time,
581
+ readiness_wait_time=readiness_wait_time,
582
+ logger_fn=capsule_logger,
583
+ )
584
+ if not no_loader:
585
+ capsule_spinner = MultiStepSpinner(
586
+ text=lambda: _logger_styled(
587
+ "💊 Waiting for %s %s to be ready to serve traffic"
588
+ % (capsule.capsule_type.lower(), capsule.identifier),
589
+ timestamp=True,
590
+ ),
591
+ color=ColorTheme.LOADING_COLOR,
592
+ )
593
+ capsule_logger = partial(_spinner_logger, capsule_spinner)
594
+ capsule_spinner.start()
595
+
596
+ currently_present_capsules = list_and_filter_capsules(
597
+ capsule.capsule_api,
598
+ None,
599
+ None,
600
+ capsule.name,
601
+ None,
602
+ None,
603
+ None,
604
+ )
605
+
606
+ force_upgrade = app_config.get_state("force_upgrade", False)
607
+
608
+ _pre_create_debug(
609
+ app_config,
610
+ capsule,
611
+ _current_instance_debug_dir,
612
+ options,
613
+ )
614
+
615
+ if len(currently_present_capsules) > 0:
616
+ # Only update the capsule if there is no upgrade in progress
617
+ # Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
618
+ _curr_cap = currently_present_capsules[0]
619
+ this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
620
+ "updateInProgress", False
621
+ )
622
+
623
+ if this_capsule_is_being_updated and not force_upgrade:
624
+ _upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
625
+ message = f"{capsule.capsule_type} is currently being upgraded"
626
+ if _upgrader:
627
+ message = (
628
+ f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
629
+ "If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
630
+ )
631
+ raise AppConfigError(message)
632
+ capsule_logger(
633
+ f"🚀 {'Upgrading' if not force_upgrade else 'Force upgrading'} {capsule.capsule_type.lower()} `{capsule.name}`....",
634
+ color=ColorTheme.INFO_COLOR,
635
+ system_msg=True,
636
+ )
637
+ else:
638
+ capsule_logger(
639
+ f"🚀 Deploying {capsule.capsule_type.lower()} to the platform....",
640
+ color=ColorTheme.INFO_COLOR,
641
+ system_msg=True,
642
+ )
643
+ # 3. Throw the job into the platform and report deployment status
644
+ capsule.create()
645
+ _post_create_debug(capsule, _current_instance_debug_dir)
646
+
647
+ # We only get the `capsule_response` if the deployment is has reached
648
+ # a successful terminal state.
649
+ final_status = capsule.wait_for_terminal_state()
650
+ if capsule_spinner:
651
+ capsule_spinner.stop()
652
+
653
+ logger(
654
+ f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed! {capsule.capsule_type} available on the URL: {capsule.url}",
655
+ color=ColorTheme.INFO_COLOR,
656
+ system_msg=True,
657
+ )
658
+
659
+ if CAPSULE_DEBUG:
660
+ logger(
661
+ f"[debug] 💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployment status [on completion]: {final_status}",
662
+ color=ColorTheme.DEBUG_COLOR,
663
+ )
664
+ logger(
665
+ f"[debug] 💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) debug info saved to `{_current_instance_debug_dir}`",
666
+ color=ColorTheme.DEBUG_COLOR,
667
+ )
668
+ final_status["debug_dir"] = _current_instance_debug_dir
669
+
670
+ if status_file:
671
+ # Create the file if it doesn't exist
672
+ with open(status_file, "w") as f:
673
+ f.write(json.dumps(final_status, indent=4))
674
+ logger(
675
+ f"📝 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployment status written to {status_file}",
676
+ color=ColorTheme.INFO_COLOR,
677
+ system_msg=True,
678
+ )
679
+
680
+ except Exception as e:
681
+ message = getattr(e, "message", str(e))
682
+ logger(
683
+ f"Deployment failed: [{e.__class__.__name__}]: {message}",
684
+ bad=True,
685
+ system_msg=True,
686
+ )
687
+ if CAPSULE_DEBUG:
688
+ if _current_instance_debug_dir is not None:
689
+ logger(
690
+ f"[debug] 💊 debug info saved to `{_current_instance_debug_dir}`",
691
+ color=ColorTheme.DEBUG_COLOR,
692
+ )
693
+ raise e
694
+ exit(1)
695
+
696
+
697
+ def _parse_capsule_table(filtered_capsules):
698
+ headers = ["Name", "ID", "Ready", "App Type", "Port", "Tags", "URL"]
699
+ table_data = []
700
+
701
+ for capsule in filtered_capsules:
702
+ spec = capsule.get("spec", {})
703
+ status = capsule.get("status", {}) or {}
704
+ cap_id = capsule.get("id")
705
+ display_name = spec.get("displayName", "")
706
+ ready = str(status.get("readyToServeTraffic", False))
707
+ auth_type = spec.get("authConfig", {}).get("authType", "")
708
+ port = str(spec.get("port", ""))
709
+ tags_str = ", ".join(
710
+ [f"{tag['key']}={tag['value']}" for tag in spec.get("tags", [])]
711
+ )
712
+ access_info = status.get("accessInfo", {}) or {}
713
+ url = access_info.get("outOfClusterURL", None)
714
+
715
+ table_data.append(
716
+ [
717
+ display_name,
718
+ cap_id,
719
+ ready,
720
+ auth_type,
721
+ port,
722
+ tags_str,
723
+ f"https://{url}" if url else "URL not available",
724
+ ]
725
+ )
726
+ return headers, table_data
727
+
728
+
729
+ @app.command(help="List apps in the Outerbounds Platform.")
730
+ @click.option("--project", type=str, help="Filter apps by project")
731
+ @click.option("--branch", type=str, help="Filter apps by branch")
732
+ @click.option("--name", type=str, help="Filter apps by name")
733
+ @click.option(
734
+ "--tag",
735
+ "tags",
736
+ type=KVDictType,
737
+ 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.",
738
+ multiple=True,
739
+ )
740
+ @click.option(
741
+ "--format",
742
+ type=click.Choice(["json", "text"]),
743
+ help="Format the output",
744
+ default="text",
745
+ )
746
+ @click.option(
747
+ "--auth-type", type=click.Choice(AuthType.enums()), help="Filter apps by Auth type"
748
+ )
749
+ @click.pass_context
750
+ def list(ctx, project, branch, name, tags, format, auth_type):
751
+ """List apps in the Outerbounds Platform."""
752
+ capsule_api = CapsuleApi(
753
+ ctx.obj.api_url,
754
+ ctx.obj.perimeter,
755
+ )
756
+ filtered_capsules = list_and_filter_capsules(
757
+ capsule_api, project, branch, name, tags, auth_type, None
758
+ )
759
+ if format == "json":
760
+ click.echo(json.dumps(filtered_capsules, indent=4))
761
+ else:
762
+ headers, table_data = _parse_capsule_table(filtered_capsules)
763
+ print_table(table_data, headers)
764
+
765
+
766
+ @app.command(help="Delete an app/apps from the Outerbounds Platform.")
767
+ @click.option("--name", type=str, help="Filter app to delete by name")
768
+ @click.option("--id", "cap_id", type=str, help="Filter app to delete by id")
769
+ @click.option("--project", type=str, help="Filter apps to delete by project")
770
+ @click.option("--branch", type=str, help="Filter apps to delete by branch")
771
+ @click.option(
772
+ "--tag",
773
+ "tags",
774
+ multiple=True,
775
+ type=KVDictType,
776
+ 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.",
777
+ )
778
+ @click.option("--auto-approve", is_flag=True, help="Do not prompt for confirmation")
779
+ @click.pass_context
780
+ def delete(ctx, name, cap_id, project, branch, tags, auto_approve):
781
+
782
+ """Delete an app/apps from the Outerbounds Platform."""
783
+ # At least one of the args need to be provided
784
+ if not any(
785
+ [
786
+ name is not None,
787
+ cap_id is not None,
788
+ project is not None,
789
+ branch is not None,
790
+ len(tags) != 0,
791
+ ]
792
+ ):
793
+ raise AppConfigError(
794
+ "At least one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
795
+ )
796
+
797
+ capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
798
+ filtered_capsules = list_and_filter_capsules(
799
+ capsule_api, project, branch, name, tags, None, cap_id
800
+ )
801
+
802
+ headers, table_data = _parse_capsule_table(filtered_capsules)
803
+ click.secho("The following apps will be deleted:", fg="red", bold=True)
804
+ print_table(table_data, headers)
805
+
806
+ # Confirm the deletion
807
+ if not auto_approve:
808
+ confirm = click.prompt(
809
+ click.style(
810
+ "💊 Are you sure you want to delete these apps?", fg="red", bold=True
811
+ ),
812
+ default="no",
813
+ type=click.Choice(["yes", "no"]),
814
+ )
815
+ if confirm == "no":
816
+ exit(1)
817
+
818
+ def item_show_func(x):
819
+ if not x:
820
+ return None
821
+ name = x.get("spec", {}).get("displayName", "")
822
+ id = x.get("id", "")
823
+ return click.style(
824
+ "💊 deleting %s [%s]" % (name, id),
825
+ fg=ColorTheme.BAD_COLOR,
826
+ bold=True,
827
+ )
828
+
829
+ with click.progressbar(
830
+ filtered_capsules,
831
+ label=click.style("💊 Deleting apps...", fg=ColorTheme.BAD_COLOR, bold=True),
832
+ fill_char=click.style("█", fg=ColorTheme.BAD_COLOR, bold=True),
833
+ empty_char=click.style("░", fg=ColorTheme.BAD_COLOR, bold=True),
834
+ item_show_func=item_show_func,
835
+ ) as bar:
836
+ for capsule in bar:
837
+ capsule_api.delete(capsule.get("id"))
838
+ time.sleep(0.5 + random.random() * 2) # delay to avoid rate limiting
839
+
840
+
841
+ @app.command(
842
+ help="Get detailed information about an app from the Outerbounds Platform."
843
+ )
844
+ @click.option("--name", type=str, help="Get info for app by name")
845
+ @click.option("--id", "cap_id", type=str, help="Get info for app by id")
846
+ @click.option(
847
+ "--format",
848
+ type=click.Choice(["json", "text"]),
849
+ help="Format the output",
850
+ default="text",
851
+ )
852
+ @click.pass_context
853
+ def info(ctx, name, cap_id, format):
854
+ """Get detailed information about an app from the Outerbounds Platform."""
855
+ # Require either name or id
856
+ if not any([name is not None, cap_id is not None]):
857
+ raise AppConfigError(
858
+ "Either --name or --id must be provided to get app information."
859
+ )
860
+
861
+ # Ensure only one is provided
862
+ if name is not None and cap_id is not None:
863
+ raise AppConfigError("Please provide either --name or --id, not both.")
864
+
865
+ capsule_api = CapsuleApi(
866
+ ctx.obj.api_url,
867
+ ctx.obj.perimeter,
868
+ )
869
+
870
+ # First, find the capsule using list_and_filter_capsules
871
+ filtered_capsules = list_and_filter_capsules(
872
+ capsule_api, None, None, name, None, None, cap_id
873
+ )
874
+
875
+ if len(filtered_capsules) == 0:
876
+ identifier = name if name else cap_id
877
+ identifier_type = "name" if name else "id"
878
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
879
+
880
+ if len(filtered_capsules) > 1:
881
+ raise AppConfigError(
882
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want info for."
883
+ )
884
+
885
+ # Get the capsule info
886
+ capsule = filtered_capsules[0]
887
+ capsule_id = capsule.get("id")
888
+
889
+ # Get detailed capsule info and workers
890
+ try:
891
+ detailed_capsule_info = capsule_api.get(capsule_id)
892
+ workers_info = capsule_api.get_workers(capsule_id)
893
+
894
+ if format == "json":
895
+ # Output in JSON format for piping to jq
896
+ info_data = {"capsule": detailed_capsule_info, "workers": workers_info}
897
+ click.echo(json.dumps(info_data, indent=4))
898
+ else:
899
+ # Output in text format
900
+ _display_capsule_info_text(detailed_capsule_info, workers_info)
901
+
902
+ except Exception as e:
903
+ raise AppConfigError(f"Error retrieving information for app {capsule_id}: {e}")
904
+
905
+
906
+ def _display_capsule_info_text(capsule_info, workers_info):
907
+ """Display capsule information in a human-readable text format."""
908
+ spec = capsule_info.get("spec", {})
909
+ status = capsule_info.get("status", {}) or {}
910
+ metadata = capsule_info.get("metadata", {}) or {}
911
+
912
+ info_color = ColorTheme.INFO_COLOR
913
+ tl_color = ColorTheme.TL_HEADER_COLOR
914
+
915
+ def _key_style(key: str, value: str):
916
+ return "%s: %s" % (
917
+ click.style(
918
+ key,
919
+ fg=ColorTheme.INFO_KEY_COLOR,
920
+ ),
921
+ click.style(str(value), fg=ColorTheme.INFO_VALUE_COLOR, bold=True),
922
+ )
923
+
924
+ # Basic Info
925
+ click.secho("=== App Information ===", fg=tl_color, bold=True)
926
+ click.secho(_key_style("Name", spec.get("displayName", "N/A")), fg=info_color)
927
+ click.secho(_key_style("ID", capsule_info.get("id", "N/A")), fg=info_color)
928
+ click.secho(
929
+ _key_style("Version", capsule_info.get("version", "N/A")), fg=info_color
930
+ )
931
+ click.secho(
932
+ _key_style(
933
+ "Ready to Serve Traffic", str(status.get("readyToServeTraffic", False))
934
+ ),
935
+ fg=info_color,
936
+ )
937
+ click.secho(
938
+ _key_style("Update In Progress", str(status.get("updateInProgress", False))),
939
+ fg=info_color,
940
+ )
941
+ click.secho(
942
+ _key_style(
943
+ "Currently Served Version", str(status.get("currentlyServedVersion", "N/A"))
944
+ ),
945
+ fg=info_color,
946
+ )
947
+
948
+ # URLs
949
+ access_info = status.get("accessInfo", {}) or {}
950
+ out_cluster_url = access_info.get("outOfClusterURL")
951
+ in_cluster_url = access_info.get("inClusterURL")
952
+
953
+ if out_cluster_url:
954
+ click.secho(
955
+ _key_style("External URL", f"https://{out_cluster_url}"), fg=info_color
956
+ )
957
+ if in_cluster_url:
958
+ click.secho(
959
+ _key_style("Internal URL", f"https://{in_cluster_url}"), fg=info_color
960
+ )
961
+
962
+ # Resource Configuration
963
+ click.secho("\n=== Resource Configuration ===", fg=tl_color, bold=True)
964
+ resource_config = spec.get("resourceConfig", {})
965
+ click.secho(_key_style("CPU", resource_config.get("cpu", "N/A")), fg=info_color)
966
+ click.secho(
967
+ _key_style("Memory", resource_config.get("memory", "N/A")), fg=info_color
968
+ )
969
+ click.secho(
970
+ _key_style("Ephemeral Storage", resource_config.get("ephemeralStorage", "N/A")),
971
+ fg=info_color,
972
+ )
973
+ if resource_config.get("gpu"):
974
+ click.secho(_key_style("GPU", resource_config.get("gpu")), fg=info_color)
975
+
976
+ # Autoscaling
977
+ click.secho("\n=== Autoscaling Configuration ===", fg=tl_color, bold=True)
978
+ autoscaling_config = spec.get("autoscalingConfig", {})
979
+ click.secho(
980
+ _key_style("Min Replicas", str(autoscaling_config.get("minReplicas", "N/A"))),
981
+ fg=info_color,
982
+ )
983
+ click.secho(
984
+ _key_style("Max Replicas", str(autoscaling_config.get("maxReplicas", "N/A"))),
985
+ fg=info_color,
986
+ )
987
+ click.secho(
988
+ _key_style("Available Replicas", str(status.get("availableReplicas", "N/A"))),
989
+ fg=info_color,
990
+ )
991
+
992
+ # Auth Configuration
993
+ click.secho("\n=== Authentication Configuration ===", fg=tl_color, bold=True)
994
+ auth_config = spec.get("authConfig", {})
995
+ click.secho(
996
+ _key_style("Auth Type", auth_config.get("authType", "N/A")), fg=info_color
997
+ )
998
+ click.secho(
999
+ _key_style("Public Access", str(auth_config.get("publicToDeployment", "N/A"))),
1000
+ fg=info_color,
1001
+ )
1002
+
1003
+ # Tags
1004
+ tags = spec.get("tags", [])
1005
+ if tags:
1006
+ click.secho("\n=== Tags ===", fg=tl_color, bold=True)
1007
+ for tag in tags:
1008
+ click.secho(
1009
+ _key_style(str(tag.get("key", "N/A")), str(tag.get("value", "N/A"))),
1010
+ fg=info_color,
1011
+ )
1012
+
1013
+ # Metadata
1014
+ click.secho("\n=== Metadata ===", fg=tl_color, bold=True)
1015
+ click.secho(
1016
+ _key_style("Created At", metadata.get("createdAt", "N/A")), fg=info_color
1017
+ )
1018
+ click.secho(
1019
+ _key_style("Last Modified At", metadata.get("lastModifiedAt", "N/A")),
1020
+ fg=info_color,
1021
+ )
1022
+ click.secho(
1023
+ _key_style("Last Modified By", metadata.get("lastModifiedBy", "N/A")),
1024
+ fg=info_color,
1025
+ )
1026
+
1027
+ # Workers Information
1028
+ click.secho("\n=== Workers Information ===", fg=tl_color, bold=True)
1029
+ if not workers_info:
1030
+ click.secho("No workers found", fg=info_color)
1031
+ else:
1032
+ click.secho(_key_style("Total Workers", str(len(workers_info))), fg=tl_color)
1033
+
1034
+ # Create a table for workers
1035
+ workers_headers = [
1036
+ "Worker ID",
1037
+ "Phase",
1038
+ "Version",
1039
+ "Activity",
1040
+ "Activity Data Available",
1041
+ ]
1042
+ workers_table_data = []
1043
+
1044
+ for worker in workers_info:
1045
+ worker_id = worker.get("workerId", "N/A")
1046
+ phase = worker.get("phase", "N/A")
1047
+ version = worker.get("version", "N/A")
1048
+ activity = str(worker.get("activity", "N/A"))
1049
+ activity_data_available = str(worker.get("activityDataAvailable", False))
1050
+
1051
+ workers_table_data.append(
1052
+ [
1053
+ worker_id[:20] + "..." if len(worker_id) > 23 else worker_id,
1054
+ phase,
1055
+ version[:10] + "..." if len(version) > 13 else version,
1056
+ activity,
1057
+ activity_data_available,
1058
+ ]
1059
+ )
1060
+
1061
+ print_table(workers_table_data, workers_headers)
1062
+
1063
+
1064
+ @app.command(help="Get logs for an app worker from the Outerbounds Platform.")
1065
+ @click.option("--name", type=str, help="Get logs for app by name")
1066
+ @click.option("--id", "cap_id", type=str, help="Get logs for app by id")
1067
+ @click.option("--worker-id", type=str, help="Get logs for specific worker")
1068
+ @click.option("--file", type=str, help="Save logs to file")
1069
+ @click.option(
1070
+ "--previous",
1071
+ is_flag=True,
1072
+ help="Get logs from previous container instance",
1073
+ default=False,
1074
+ )
1075
+ @click.pass_context
1076
+ def logs(ctx, name, cap_id, worker_id, file, previous):
1077
+ """Get logs for an app worker from the Outerbounds Platform."""
1078
+ # Require either name or id
1079
+ if not any([name is not None, cap_id is not None]):
1080
+ raise AppConfigError("Either --name or --id must be provided to get app logs.")
1081
+
1082
+ # Ensure only one is provided
1083
+ if name is not None and cap_id is not None:
1084
+ raise AppConfigError("Please provide either --name or --id, not both.")
1085
+
1086
+ capsule_api = CapsuleApi(
1087
+ ctx.obj.api_url,
1088
+ ctx.obj.perimeter,
1089
+ )
1090
+
1091
+ # First, find the capsule using list_and_filter_capsules
1092
+ filtered_capsules = list_and_filter_capsules(
1093
+ capsule_api, None, None, name, None, None, cap_id
1094
+ )
1095
+
1096
+ if len(filtered_capsules) == 0:
1097
+ identifier = name if name else cap_id
1098
+ identifier_type = "name" if name else "id"
1099
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
1100
+
1101
+ if len(filtered_capsules) > 1:
1102
+ raise AppConfigError(
1103
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want logs for."
1104
+ )
1105
+
1106
+ capsule = filtered_capsules[0]
1107
+ capsule_id = capsule.get("id")
1108
+
1109
+ # Get workers
1110
+ try:
1111
+ workers_info = capsule_api.get_workers(capsule_id)
1112
+ except Exception as e:
1113
+ raise AppConfigError(f"Error retrieving workers for app {capsule_id}: {e}")
1114
+
1115
+ if not workers_info:
1116
+ raise AppConfigError(f"No workers found for app {capsule_id}")
1117
+
1118
+ # If worker_id not provided, show interactive selection
1119
+ if not worker_id:
1120
+ if len(workers_info) == 1:
1121
+ # Only one worker, use it automatically
1122
+ selected_worker = workers_info[0]
1123
+ worker_id = selected_worker.get("workerId")
1124
+ worker_phase = selected_worker.get("phase", "N/A")
1125
+ worker_version = selected_worker.get("version", "N/A")[:10]
1126
+ click.echo(
1127
+ f"📋 Using the only available worker: {worker_id[:20]}... (phase: {worker_phase}, version: {worker_version}...)"
1128
+ )
1129
+ else:
1130
+ # Multiple workers, show selection
1131
+ click.secho(
1132
+ "📋 Multiple workers found. Please select one:",
1133
+ fg=ColorTheme.INFO_COLOR,
1134
+ bold=True,
1135
+ )
1136
+
1137
+ # Display workers in a table format for better readability
1138
+ headers = ["#", "Worker ID", "Phase", "Version", "Activity"]
1139
+ table_data = []
1140
+
1141
+ for i, worker in enumerate(workers_info, 1):
1142
+ w_id = worker.get("workerId", "N/A")
1143
+ phase = worker.get("phase", "N/A")
1144
+ version = worker.get("version", "N/A")
1145
+ activity = str(worker.get("activity", "N/A"))
1146
+
1147
+ table_data.append(
1148
+ [
1149
+ str(i),
1150
+ w_id[:30] + "..." if len(w_id) > 33 else w_id,
1151
+ phase,
1152
+ version[:15] + "..." if len(version) > 18 else version,
1153
+ activity,
1154
+ ]
1155
+ )
1156
+
1157
+ print_table(table_data, headers)
1158
+
1159
+ # Create choices for the prompt
1160
+ worker_choices = []
1161
+ for i, worker in enumerate(workers_info, 1):
1162
+ worker_choices.append(str(i))
1163
+
1164
+ selected_index = click.prompt(
1165
+ click.style(
1166
+ "Select worker number", fg=ColorTheme.INFO_COLOR, bold=True
1167
+ ),
1168
+ type=click.Choice(worker_choices),
1169
+ )
1170
+
1171
+ # Get the selected worker
1172
+ selected_worker = workers_info[int(selected_index) - 1]
1173
+ worker_id = selected_worker.get("workerId")
1174
+
1175
+ # Get logs for the selected worker
1176
+ try:
1177
+ logs_response = capsule_api.logs(capsule_id, worker_id, previous=previous)
1178
+ except Exception as e:
1179
+ raise AppConfigError(f"Error retrieving logs for worker {worker_id}: {e}")
1180
+
1181
+ # Format logs content
1182
+ logs_content = "\n".join([log.get("message", "") for log in logs_response])
1183
+
1184
+ # Display or save logs
1185
+ if file:
1186
+ try:
1187
+ with open(file, "w") as f:
1188
+ f.write(logs_content)
1189
+ click.echo(f"📁 Logs saved to {file}")
1190
+ except Exception as e:
1191
+ raise AppConfigError(f"Error saving logs to file {file}: {e}")
1192
+ else:
1193
+ if logs_content.strip():
1194
+ click.echo(logs_content)
1195
+ else:
1196
+ click.echo("📝 No logs available for this worker.")
1197
+
1198
+
1199
+ # if __name__ == "__main__":
1200
+ # cli()