outerbounds 0.3.179rc5__py3-none-any.whl → 0.3.180rc1__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.
@@ -1,7 +1,8 @@
1
1
  import json
2
2
  import os
3
3
  import sys
4
- from functools import wraps
4
+ import random
5
+ from functools import wraps, partial
5
6
  from typing import Dict, List, Any, Optional, Union
6
7
 
7
8
  # IF this CLI is supposed to be reusable across Metaflow Too
@@ -15,6 +16,10 @@ from typing import Dict, List, Any, Optional, Union
15
16
  # That will break the CLI.
16
17
  # So we need to find a way to import click without having it try to check for remote-config
17
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.
18
23
  from metaflow._vendor import click
19
24
  from outerbounds._vendor import yaml
20
25
  from outerbounds.utils import metaflowconfig
@@ -27,19 +32,40 @@ from .app_config import (
27
32
  )
28
33
  from .perimeters import PerimeterExtractor
29
34
  from .cli_to_config import build_config_from_options
30
- from .utils import CommaSeparatedListType, KVPairType, KVDictType
35
+ from .utils import (
36
+ CommaSeparatedListType,
37
+ KVPairType,
38
+ KVDictType,
39
+ MultiStepSpinner,
40
+ )
31
41
  from . import experimental
32
42
  from .validations import deploy_validations
33
43
  from .code_package import CodePackager
34
- from .capsule import CapsuleDeployer, list_and_filter_capsules, CapsuleApi
44
+ from .capsule import (
45
+ CapsuleDeployer,
46
+ list_and_filter_capsules,
47
+ CapsuleApi,
48
+ DEPLOYMENT_READY_CONDITIONS,
49
+ )
50
+ from .dependencies import bake_deployment_image
35
51
  import shlex
36
52
  import time
37
53
  import uuid
38
54
  from datetime import datetime
39
55
 
40
- LOGGER_TIMESTAMP = "magenta"
41
- LOGGER_COLOR = "green"
42
- LOGGER_BAD_COLOR = "red"
56
+
57
+ class ColorTheme:
58
+ TIMESTAMP = "magenta"
59
+ LOADING_COLOR = "cyan"
60
+ BAD_COLOR = "red"
61
+ INFO_COLOR = "green"
62
+
63
+ TL_HEADER_COLOR = "magenta"
64
+ ROW_COLOR = "bright_white"
65
+
66
+ INFO_KEY_COLOR = "green"
67
+ INFO_VALUE_COLOR = "bright_white"
68
+
43
69
 
44
70
  NativeList = list
45
71
 
@@ -53,17 +79,48 @@ def _logger(
53
79
  else:
54
80
  dt = timestamp
55
81
  tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
56
- click.secho(tstamp + " ", fg=LOGGER_TIMESTAMP, nl=False)
82
+ click.secho(tstamp + " ", fg=ColorTheme.TIMESTAMP, nl=False)
57
83
  if head:
58
- click.secho(head, fg=LOGGER_COLOR, nl=False)
84
+ click.secho(head, fg=ColorTheme.INFO_COLOR, nl=False)
59
85
  click.secho(
60
86
  body,
61
87
  bold=system_msg,
62
- fg=LOGGER_BAD_COLOR if bad else color if color is not None else None,
88
+ fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
63
89
  nl=nl,
64
90
  )
65
91
 
66
92
 
93
+ def _logger_styled(
94
+ body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
95
+ ):
96
+ message_parts = []
97
+
98
+ if timestamp:
99
+ if timestamp is True:
100
+ dt = datetime.now()
101
+ else:
102
+ dt = timestamp
103
+ tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
104
+ message_parts.append(click.style(tstamp + " ", fg=ColorTheme.TIMESTAMP))
105
+
106
+ if head:
107
+ message_parts.append(click.style(head, fg=ColorTheme.INFO_COLOR))
108
+
109
+ message_parts.append(
110
+ click.style(
111
+ body,
112
+ bold=system_msg,
113
+ fg=ColorTheme.BAD_COLOR if bad else color if color is not None else None,
114
+ )
115
+ )
116
+
117
+ return "".join(message_parts)
118
+
119
+
120
+ def _spinner_logger(spinner, *msg):
121
+ spinner.log(*[_logger_styled(x, timestamp=True) for x in msg])
122
+
123
+
67
124
  class CliState(object):
68
125
  pass
69
126
 
@@ -81,6 +138,7 @@ def _pre_create_debug(app_config: AppConfig, capsule: CapsuleDeployer, state_dir
81
138
  {
82
139
  "app_state": app_config.dump_state(),
83
140
  "capsule_input": capsule.create_input(),
141
+ "deploy_response": capsule._capsule_deploy_response,
84
142
  },
85
143
  default_flow_style=False,
86
144
  indent=2,
@@ -88,8 +146,36 @@ def _pre_create_debug(app_config: AppConfig, capsule: CapsuleDeployer, state_dir
88
146
  )
89
147
 
90
148
 
149
+ def _post_create_debug(capsule: CapsuleDeployer, state_dir: str):
150
+ if CAPSULE_DEBUG:
151
+ debug_path = os.path.join(
152
+ state_dir, f"debug_deploy_response_{time.time()}.yaml"
153
+ )
154
+ with open(debug_path, "w") as f:
155
+ f.write(
156
+ yaml.dump(
157
+ capsule._capsule_deploy_response, default_flow_style=False, indent=2
158
+ )
159
+ )
160
+
161
+
162
+ def _bake_image(app_config: AppConfig, cache_dir: str, logger):
163
+ baking_status = bake_deployment_image(
164
+ app_config=app_config,
165
+ cache_file_path=os.path.join(cache_dir, "image_cache"),
166
+ logger=logger,
167
+ )
168
+ app_config.set_state(
169
+ "image",
170
+ baking_status.resolved_image,
171
+ )
172
+ app_config.set_state("python_path", baking_status.python_path)
173
+ logger("🐳 Using The Docker Image : %s" % app_config.get_state("image"))
174
+
175
+
91
176
  def print_table(data, headers):
92
177
  """Print data in a formatted table."""
178
+
93
179
  if not data:
94
180
  return
95
181
 
@@ -105,17 +191,17 @@ def print_table(data, headers):
105
191
  header_row = " | ".join(
106
192
  [headers[i].ljust(col_widths[i]) for i in range(len(headers))]
107
193
  )
108
- click.secho("-" * len(header_row), fg="yellow")
109
- click.secho(header_row, fg="yellow", bold=True)
110
- click.secho("-" * len(header_row), fg="yellow")
194
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
195
+ click.secho(header_row, fg=ColorTheme.TL_HEADER_COLOR, bold=True)
196
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
111
197
 
112
198
  # Print data rows
113
199
  for row in data:
114
200
  formatted_row = " | ".join(
115
201
  [str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
116
202
  )
117
- click.secho(formatted_row, fg="green", bold=True)
118
- click.secho("-" * len(header_row), fg="yellow")
203
+ click.secho(formatted_row, fg=ColorTheme.ROW_COLOR, bold=True)
204
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
119
205
 
120
206
 
121
207
  @click.group()
@@ -173,6 +259,39 @@ def parse_commands(app_config: AppConfig, cli_command_input):
173
259
  return base_commands
174
260
 
175
261
 
262
+ def deployment_instance_options(func):
263
+ # These parameters influence how the CLI behaves for each instance of a launched deployment.
264
+ @click.option(
265
+ "--readiness-condition",
266
+ type=click.Choice(DEPLOYMENT_READY_CONDITIONS.enums()),
267
+ help=DEPLOYMENT_READY_CONDITIONS.__doc__,
268
+ default=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
269
+ )
270
+ @click.option(
271
+ "--readiness-wait-time",
272
+ type=int,
273
+ help="The time (in seconds) to monitor the deployment for readiness after the readiness condition is met.",
274
+ default=4,
275
+ )
276
+ @click.option(
277
+ "--max-wait-time",
278
+ type=int,
279
+ help="The maximum time (in seconds) to wait for the deployment to be ready.",
280
+ default=600,
281
+ )
282
+ @click.option(
283
+ "--no-loader",
284
+ is_flag=True,
285
+ help="Do not use the loading spinner for the deployment.",
286
+ default=False,
287
+ )
288
+ @wraps(func)
289
+ def wrapper(*args, **kwargs):
290
+ return func(*args, **kwargs)
291
+
292
+ return wrapper
293
+
294
+
176
295
  def common_deploy_options(func):
177
296
  @click.option(
178
297
  "--name",
@@ -291,6 +410,12 @@ def common_deploy_options(func):
291
410
  help="The type of app to deploy.",
292
411
  default=None,
293
412
  )
413
+ @click.option(
414
+ "--force-upgrade",
415
+ is_flag=True,
416
+ help="Force upgrade the app even if it is currently being upgraded.",
417
+ default=False,
418
+ )
294
419
  @wraps(func)
295
420
  def wrapper(*args, **kwargs):
296
421
  return func(*args, **kwargs)
@@ -395,10 +520,19 @@ def _package_necessary_things(app_config: AppConfig, logger):
395
520
  @app.command(help="Deploy an app to the Outerbounds Platform.")
396
521
  @common_deploy_options
397
522
  @common_run_options
523
+ @deployment_instance_options
398
524
  @experimental.wrapping_cli_options
399
525
  @click.pass_context
400
526
  @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
401
- def deploy(ctx, command, **options):
527
+ def deploy(
528
+ ctx,
529
+ command,
530
+ readiness_condition=None,
531
+ max_wait_time=None,
532
+ readiness_wait_time=None,
533
+ no_loader=False,
534
+ **options,
535
+ ):
402
536
  """Deploy an app to the Outerbounds Platform."""
403
537
  from functools import partial
404
538
 
@@ -423,7 +557,7 @@ def deploy(ctx, command, **options):
423
557
  app_config.validate()
424
558
  logger(
425
559
  f"🚀 Deploying {app_config.get('name')} to the Outerbounds platform...",
426
- color=LOGGER_COLOR,
560
+ color=ColorTheme.INFO_COLOR,
427
561
  system_msg=True,
428
562
  )
429
563
 
@@ -494,11 +628,32 @@ def deploy(ctx, command, **options):
494
628
  cache_dir = os.path.join(
495
629
  ctx.obj.app_state_dir, app_config.get("name", "default")
496
630
  )
631
+
632
+ def _non_spinner_logger(*msg):
633
+ for m in msg:
634
+ logger(m)
635
+
497
636
  deploy_validations(
498
637
  app_config,
499
638
  cache_dir=cache_dir,
500
639
  logger=logger,
501
640
  )
641
+ image_spinner = None
642
+ img_logger = _non_spinner_logger
643
+ if not no_loader:
644
+ image_spinner = MultiStepSpinner(
645
+ text=lambda: _logger_styled(
646
+ "🍞 Baking Docker Image",
647
+ timestamp=True,
648
+ ),
649
+ color=ColorTheme.LOADING_COLOR,
650
+ )
651
+ img_logger = partial(_spinner_logger, image_spinner)
652
+ image_spinner.start()
653
+
654
+ _bake_image(app_config, cache_dir, img_logger)
655
+ if image_spinner:
656
+ image_spinner.stop()
502
657
 
503
658
  base_commands = parse_commands(app_config, command)
504
659
 
@@ -513,22 +668,83 @@ def deploy(ctx, command, **options):
513
668
  app_config.set_state("perimeter", ctx.obj.perimeter)
514
669
 
515
670
  # 2. Convert to the IR that the backend accepts
516
- capsule = CapsuleDeployer(app_config, ctx.obj.api_url, debug_dir=cache_dir)
671
+ capsule = CapsuleDeployer(
672
+ app_config,
673
+ ctx.obj.api_url,
674
+ debug_dir=cache_dir,
675
+ success_terminal_state_condition=readiness_condition,
676
+ create_timeout=max_wait_time,
677
+ readiness_wait_time=readiness_wait_time,
678
+ )
679
+ currently_present_capsules = list_and_filter_capsules(
680
+ capsule.capsule_api,
681
+ None,
682
+ None,
683
+ capsule.name,
684
+ None,
685
+ None,
686
+ None,
687
+ )
688
+
689
+ force_upgrade = app_config.get_state("force_upgrade", False)
517
690
 
518
691
  _pre_create_debug(app_config, capsule, cache_dir)
692
+
693
+ if len(currently_present_capsules) > 0:
694
+ # Only update the capsule if there is no upgrade in progress
695
+ # Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
696
+ _curr_cap = currently_present_capsules[0]
697
+ this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
698
+ "updateInProgress", False
699
+ )
700
+
701
+ if this_capsule_is_being_updated and not force_upgrade:
702
+ _upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
703
+ message = f"{capsule.capsule_type} is currently being upgraded"
704
+ if _upgrader:
705
+ message = (
706
+ f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
707
+ "If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
708
+ )
709
+ raise AppConfigError(message)
710
+ logger(
711
+ f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
712
+ color=ColorTheme.INFO_COLOR,
713
+ system_msg=True,
714
+ )
715
+ else:
716
+ logger(
717
+ f"🚀 Deploying {capsule.capsule_type.lower()} to the platform....",
718
+ color=ColorTheme.INFO_COLOR,
719
+ system_msg=True,
720
+ )
519
721
  # 3. Throw the job into the platform and report deployment status
520
- logger(
521
- f"🚀 Deploying {capsule.capsule_type.lower()} to the platform...",
522
- color=LOGGER_COLOR,
523
- system_msg=True,
524
- )
525
722
  capsule.create()
526
- capsule.wait_for_terminal_state(logger=logger)
527
- logger(
528
- f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed successfully! You can access it at {capsule.status.out_of_cluster_url}",
529
- color=LOGGER_COLOR,
530
- system_msg=True,
531
- )
723
+ _post_create_debug(capsule, cache_dir)
724
+
725
+ capsule_spinner = None
726
+ capsule_logger = _non_spinner_logger
727
+ if not no_loader:
728
+ capsule_spinner = MultiStepSpinner(
729
+ text=lambda: _logger_styled(
730
+ "💊 Waiting for %s %s to be ready to serve traffic"
731
+ % (capsule.capsule_type.lower(), capsule.identifier),
732
+ timestamp=True,
733
+ ),
734
+ color=ColorTheme.LOADING_COLOR,
735
+ )
736
+ capsule_logger = partial(_spinner_logger, capsule_spinner)
737
+ capsule_spinner.start()
738
+
739
+ capsule.wait_for_terminal_state(logger=capsule_logger)
740
+ if capsule_spinner:
741
+ capsule_spinner.stop()
742
+
743
+ logger(
744
+ f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed successfully! You can access it at {capsule.status.out_of_cluster_url}",
745
+ color=ColorTheme.INFO_COLOR,
746
+ system_msg=True,
747
+ )
532
748
 
533
749
  except AppConfigError as e:
534
750
  click.echo(f"Error in app configuration: {e}", err=True)
@@ -593,9 +809,12 @@ def _parse_capsule_table(filtered_capsules):
593
809
  @click.pass_context
594
810
  def list(ctx, project, branch, name, tags, format, auth_type):
595
811
  """List apps in the Outerbounds Platform."""
596
-
812
+ capsule_api = CapsuleApi(
813
+ ctx.obj.api_url,
814
+ ctx.obj.perimeter,
815
+ )
597
816
  filtered_capsules = list_and_filter_capsules(
598
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, auth_type, None
817
+ capsule_api, project, branch, name, tags, auth_type, None
599
818
  )
600
819
  if format == "json":
601
820
  click.echo(json.dumps(filtered_capsules, indent=4))
@@ -634,8 +853,9 @@ def delete(ctx, name, cap_id, project, branch, tags):
634
853
  "Atleast one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
635
854
  )
636
855
 
856
+ capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
637
857
  filtered_capsules = list_and_filter_capsules(
638
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, None, cap_id
858
+ capsule_api, project, branch, name, tags, None, cap_id
639
859
  )
640
860
 
641
861
  headers, table_data = _parse_capsule_table(filtered_capsules)
@@ -658,18 +878,22 @@ def delete(ctx, name, cap_id, project, branch, tags):
658
878
  return None
659
879
  name = x.get("spec", {}).get("displayName", "")
660
880
  id = x.get("id", "")
661
- return click.style("💊 deleting %s [%s]" % (name, id), fg="red", bold=True)
881
+ return click.style(
882
+ "💊 deleting %s [%s]" % (name, id),
883
+ fg=ColorTheme.BAD_COLOR,
884
+ bold=True,
885
+ )
662
886
 
663
887
  with click.progressbar(
664
888
  filtered_capsules,
665
- label=click.style("💊 Deleting apps...", fg="red", bold=True),
666
- fill_char=click.style("█", fg="red", bold=True),
667
- empty_char=click.style("░", fg="red", bold=True),
889
+ label=click.style("💊 Deleting apps...", fg=ColorTheme.BAD_COLOR, bold=True),
890
+ fill_char=click.style("█", fg=ColorTheme.BAD_COLOR, bold=True),
891
+ empty_char=click.style("░", fg=ColorTheme.BAD_COLOR, bold=True),
668
892
  item_show_func=item_show_func,
669
893
  ) as bar:
670
- capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
671
894
  for capsule in bar:
672
895
  capsule_api.delete(capsule.get("id"))
896
+ time.sleep(0.5 + random.random() * 2) # delay to avoid rate limiting
673
897
 
674
898
 
675
899
  @app.command(help="Run an app locally (for testing).")
@@ -713,5 +937,363 @@ def run(ctx, **options):
713
937
  ctx.exit(1)
714
938
 
715
939
 
940
+ @app.command(
941
+ help="Get detailed information about an app from the Outerbounds Platform."
942
+ )
943
+ @click.option("--name", type=str, help="Get info for app by name")
944
+ @click.option("--id", "cap_id", type=str, help="Get info for app by id")
945
+ @click.option(
946
+ "--format",
947
+ type=click.Choice(["json", "text"]),
948
+ help="Format the output",
949
+ default="text",
950
+ )
951
+ @click.pass_context
952
+ def info(ctx, name, cap_id, format):
953
+ """Get detailed information about an app from the Outerbounds Platform."""
954
+ # Require either name or id
955
+ if not any([name is not None, cap_id is not None]):
956
+ raise AppConfigError(
957
+ "Either --name or --id must be provided to get app information."
958
+ )
959
+
960
+ # Ensure only one is provided
961
+ if name is not None and cap_id is not None:
962
+ raise AppConfigError("Please provide either --name or --id, not both.")
963
+
964
+ capsule_api = CapsuleApi(
965
+ ctx.obj.api_url,
966
+ ctx.obj.perimeter,
967
+ )
968
+
969
+ # First, find the capsule using list_and_filter_capsules
970
+ filtered_capsules = list_and_filter_capsules(
971
+ capsule_api, None, None, name, None, None, cap_id
972
+ )
973
+
974
+ if len(filtered_capsules) == 0:
975
+ identifier = name if name else cap_id
976
+ identifier_type = "name" if name else "id"
977
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
978
+
979
+ if len(filtered_capsules) > 1:
980
+ raise AppConfigError(
981
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want info for."
982
+ )
983
+
984
+ # Get the capsule info
985
+ capsule = filtered_capsules[0]
986
+ capsule_id = capsule.get("id")
987
+
988
+ # Get detailed capsule info and workers
989
+ try:
990
+ detailed_capsule_info = capsule_api.get(capsule_id)
991
+ workers_info = capsule_api.get_workers(capsule_id)
992
+
993
+ if format == "json":
994
+ # Output in JSON format for piping to jq
995
+ info_data = {"capsule": detailed_capsule_info, "workers": workers_info}
996
+ click.echo(json.dumps(info_data, indent=4))
997
+ else:
998
+ # Output in text format
999
+ _display_capsule_info_text(detailed_capsule_info, workers_info)
1000
+
1001
+ except Exception as e:
1002
+ raise AppConfigError(f"Error retrieving information for app {capsule_id}: {e}")
1003
+
1004
+
1005
+ def _display_capsule_info_text(capsule_info, workers_info):
1006
+ """Display capsule information in a human-readable text format."""
1007
+ spec = capsule_info.get("spec", {})
1008
+ status = capsule_info.get("status", {}) or {}
1009
+ metadata = capsule_info.get("metadata", {}) or {}
1010
+
1011
+ info_color = ColorTheme.INFO_COLOR
1012
+ tl_color = ColorTheme.TL_HEADER_COLOR
1013
+
1014
+ def _key_style(key: str, value: str):
1015
+ return "%s: %s" % (
1016
+ click.style(
1017
+ key,
1018
+ fg=ColorTheme.INFO_KEY_COLOR,
1019
+ ),
1020
+ click.style(str(value), fg=ColorTheme.INFO_VALUE_COLOR, bold=True),
1021
+ )
1022
+
1023
+ # Basic Info
1024
+ click.secho("=== App Information ===", fg=tl_color, bold=True)
1025
+ click.secho(_key_style("Name", spec.get("displayName", "N/A")), fg=info_color)
1026
+ click.secho(_key_style("ID", capsule_info.get("id", "N/A")), fg=info_color)
1027
+ click.secho(
1028
+ _key_style("Version", capsule_info.get("version", "N/A")), fg=info_color
1029
+ )
1030
+ click.secho(
1031
+ _key_style(
1032
+ "Ready to Serve Traffic", str(status.get("readyToServeTraffic", False))
1033
+ ),
1034
+ fg=info_color,
1035
+ )
1036
+ click.secho(
1037
+ _key_style("Update In Progress", str(status.get("updateInProgress", False))),
1038
+ fg=info_color,
1039
+ )
1040
+ click.secho(
1041
+ _key_style(
1042
+ "Currently Served Version", str(status.get("currentlyServedVersion", "N/A"))
1043
+ ),
1044
+ fg=info_color,
1045
+ )
1046
+
1047
+ # URLs
1048
+ access_info = status.get("accessInfo", {}) or {}
1049
+ out_cluster_url = access_info.get("outOfClusterURL")
1050
+ in_cluster_url = access_info.get("inClusterURL")
1051
+
1052
+ if out_cluster_url:
1053
+ click.secho(
1054
+ _key_style("External URL", f"https://{out_cluster_url}"), fg=info_color
1055
+ )
1056
+ if in_cluster_url:
1057
+ click.secho(
1058
+ _key_style("Internal URL", f"https://{in_cluster_url}"), fg=info_color
1059
+ )
1060
+
1061
+ # Resource Configuration
1062
+ click.secho("\n=== Resource Configuration ===", fg=tl_color, bold=True)
1063
+ resource_config = spec.get("resourceConfig", {})
1064
+ click.secho(_key_style("CPU", resource_config.get("cpu", "N/A")), fg=info_color)
1065
+ click.secho(
1066
+ _key_style("Memory", resource_config.get("memory", "N/A")), fg=info_color
1067
+ )
1068
+ click.secho(
1069
+ _key_style("Ephemeral Storage", resource_config.get("ephemeralStorage", "N/A")),
1070
+ fg=info_color,
1071
+ )
1072
+ if resource_config.get("gpu"):
1073
+ click.secho(_key_style("GPU", resource_config.get("gpu")), fg=info_color)
1074
+
1075
+ # Autoscaling
1076
+ click.secho("\n=== Autoscaling Configuration ===", fg=tl_color, bold=True)
1077
+ autoscaling_config = spec.get("autoscalingConfig", {})
1078
+ click.secho(
1079
+ _key_style("Min Replicas", str(autoscaling_config.get("minReplicas", "N/A"))),
1080
+ fg=info_color,
1081
+ )
1082
+ click.secho(
1083
+ _key_style("Max Replicas", str(autoscaling_config.get("maxReplicas", "N/A"))),
1084
+ fg=info_color,
1085
+ )
1086
+ click.secho(
1087
+ _key_style("Available Replicas", str(status.get("availableReplicas", "N/A"))),
1088
+ fg=info_color,
1089
+ )
1090
+
1091
+ # Auth Configuration
1092
+ click.secho("\n=== Authentication Configuration ===", fg=tl_color, bold=True)
1093
+ auth_config = spec.get("authConfig", {})
1094
+ click.secho(
1095
+ _key_style("Auth Type", auth_config.get("authType", "N/A")), fg=info_color
1096
+ )
1097
+ click.secho(
1098
+ _key_style("Public Access", str(auth_config.get("publicToDeployment", "N/A"))),
1099
+ fg=info_color,
1100
+ )
1101
+
1102
+ # Tags
1103
+ tags = spec.get("tags", [])
1104
+ if tags:
1105
+ click.secho("\n=== Tags ===", fg=tl_color, bold=True)
1106
+ for tag in tags:
1107
+ click.secho(
1108
+ _key_style(str(tag.get("key", "N/A")), str(tag.get("value", "N/A"))),
1109
+ fg=info_color,
1110
+ )
1111
+
1112
+ # Metadata
1113
+ click.secho("\n=== Metadata ===", fg=tl_color, bold=True)
1114
+ click.secho(
1115
+ _key_style("Created At", metadata.get("createdAt", "N/A")), fg=info_color
1116
+ )
1117
+ click.secho(
1118
+ _key_style("Last Modified At", metadata.get("lastModifiedAt", "N/A")),
1119
+ fg=info_color,
1120
+ )
1121
+ click.secho(
1122
+ _key_style("Last Modified By", metadata.get("lastModifiedBy", "N/A")),
1123
+ fg=info_color,
1124
+ )
1125
+
1126
+ # Workers Information
1127
+ click.secho("\n=== Workers Information ===", fg=tl_color, bold=True)
1128
+ if not workers_info:
1129
+ click.secho("No workers found", fg=info_color)
1130
+ else:
1131
+ click.secho(_key_style("Total Workers", str(len(workers_info))), fg=tl_color)
1132
+
1133
+ # Create a table for workers
1134
+ workers_headers = [
1135
+ "Worker ID",
1136
+ "Phase",
1137
+ "Version",
1138
+ "Activity",
1139
+ "Activity Data Available",
1140
+ ]
1141
+ workers_table_data = []
1142
+
1143
+ for worker in workers_info:
1144
+ worker_id = worker.get("workerId", "N/A")
1145
+ phase = worker.get("phase", "N/A")
1146
+ version = worker.get("version", "N/A")
1147
+ activity = str(worker.get("activity", "N/A"))
1148
+ activity_data_available = str(worker.get("activityDataAvailable", False))
1149
+
1150
+ workers_table_data.append(
1151
+ [
1152
+ worker_id[:20] + "..." if len(worker_id) > 23 else worker_id,
1153
+ phase,
1154
+ version[:10] + "..." if len(version) > 13 else version,
1155
+ activity,
1156
+ activity_data_available,
1157
+ ]
1158
+ )
1159
+
1160
+ print_table(workers_table_data, workers_headers)
1161
+
1162
+
1163
+ @app.command(help="Get logs for an app worker from the Outerbounds Platform.")
1164
+ @click.option("--name", type=str, help="Get logs for app by name")
1165
+ @click.option("--id", "cap_id", type=str, help="Get logs for app by id")
1166
+ @click.option("--worker-id", type=str, help="Get logs for specific worker")
1167
+ @click.option("--file", type=str, help="Save logs to file")
1168
+ @click.option(
1169
+ "--previous",
1170
+ is_flag=True,
1171
+ help="Get logs from previous container instance",
1172
+ default=False,
1173
+ )
1174
+ @click.pass_context
1175
+ def logs(ctx, name, cap_id, worker_id, file, previous):
1176
+ """Get logs for an app worker from the Outerbounds Platform."""
1177
+ # Require either name or id
1178
+ if not any([name is not None, cap_id is not None]):
1179
+ raise AppConfigError("Either --name or --id must be provided to get app logs.")
1180
+
1181
+ # Ensure only one is provided
1182
+ if name is not None and cap_id is not None:
1183
+ raise AppConfigError("Please provide either --name or --id, not both.")
1184
+
1185
+ capsule_api = CapsuleApi(
1186
+ ctx.obj.api_url,
1187
+ ctx.obj.perimeter,
1188
+ )
1189
+
1190
+ # First, find the capsule using list_and_filter_capsules
1191
+ filtered_capsules = list_and_filter_capsules(
1192
+ capsule_api, None, None, name, None, None, cap_id
1193
+ )
1194
+
1195
+ if len(filtered_capsules) == 0:
1196
+ identifier = name if name else cap_id
1197
+ identifier_type = "name" if name else "id"
1198
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
1199
+
1200
+ if len(filtered_capsules) > 1:
1201
+ raise AppConfigError(
1202
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want logs for."
1203
+ )
1204
+
1205
+ capsule = filtered_capsules[0]
1206
+ capsule_id = capsule.get("id")
1207
+
1208
+ # Get workers
1209
+ try:
1210
+ workers_info = capsule_api.get_workers(capsule_id)
1211
+ except Exception as e:
1212
+ raise AppConfigError(f"Error retrieving workers for app {capsule_id}: {e}")
1213
+
1214
+ if not workers_info:
1215
+ raise AppConfigError(f"No workers found for app {capsule_id}")
1216
+
1217
+ # If worker_id not provided, show interactive selection
1218
+ if not worker_id:
1219
+ if len(workers_info) == 1:
1220
+ # Only one worker, use it automatically
1221
+ selected_worker = workers_info[0]
1222
+ worker_id = selected_worker.get("workerId")
1223
+ worker_phase = selected_worker.get("phase", "N/A")
1224
+ worker_version = selected_worker.get("version", "N/A")[:10]
1225
+ click.echo(
1226
+ f"📋 Using the only available worker: {worker_id[:20]}... (phase: {worker_phase}, version: {worker_version}...)"
1227
+ )
1228
+ else:
1229
+ # Multiple workers, show selection
1230
+ click.secho(
1231
+ "📋 Multiple workers found. Please select one:",
1232
+ fg=ColorTheme.INFO_COLOR,
1233
+ bold=True,
1234
+ )
1235
+
1236
+ # Display workers in a table format for better readability
1237
+ headers = ["#", "Worker ID", "Phase", "Version", "Activity"]
1238
+ table_data = []
1239
+
1240
+ for i, worker in enumerate(workers_info, 1):
1241
+ w_id = worker.get("workerId", "N/A")
1242
+ phase = worker.get("phase", "N/A")
1243
+ version = worker.get("version", "N/A")
1244
+ activity = str(worker.get("activity", "N/A"))
1245
+
1246
+ table_data.append(
1247
+ [
1248
+ str(i),
1249
+ w_id[:30] + "..." if len(w_id) > 33 else w_id,
1250
+ phase,
1251
+ version[:15] + "..." if len(version) > 18 else version,
1252
+ activity,
1253
+ ]
1254
+ )
1255
+
1256
+ print_table(table_data, headers)
1257
+
1258
+ # Create choices for the prompt
1259
+ worker_choices = []
1260
+ for i, worker in enumerate(workers_info, 1):
1261
+ worker_choices.append(str(i))
1262
+
1263
+ selected_index = click.prompt(
1264
+ click.style(
1265
+ "Select worker number", fg=ColorTheme.INFO_COLOR, bold=True
1266
+ ),
1267
+ type=click.Choice(worker_choices),
1268
+ )
1269
+
1270
+ # Get the selected worker
1271
+ selected_worker = workers_info[int(selected_index) - 1]
1272
+ worker_id = selected_worker.get("workerId")
1273
+
1274
+ # Get logs for the selected worker
1275
+ try:
1276
+ logs_response = capsule_api.logs(capsule_id, worker_id, previous=previous)
1277
+ except Exception as e:
1278
+ raise AppConfigError(f"Error retrieving logs for worker {worker_id}: {e}")
1279
+
1280
+ # Format logs content
1281
+ logs_content = "\n".join([log.get("message", "") for log in logs_response])
1282
+
1283
+ # Display or save logs
1284
+ if file:
1285
+ try:
1286
+ with open(file, "w") as f:
1287
+ f.write(logs_content)
1288
+ click.echo(f"📁 Logs saved to {file}")
1289
+ except Exception as e:
1290
+ raise AppConfigError(f"Error saving logs to file {file}: {e}")
1291
+ else:
1292
+ if logs_content.strip():
1293
+ click.echo(logs_content)
1294
+ else:
1295
+ click.echo("📝 No logs available for this worker.")
1296
+
1297
+
716
1298
  # if __name__ == "__main__":
717
1299
  # cli()