outerbounds 0.3.179rc5__py3-none-any.whl → 0.3.180rc0__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,22 @@ 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
+
91
162
  def print_table(data, headers):
92
163
  """Print data in a formatted table."""
164
+
93
165
  if not data:
94
166
  return
95
167
 
@@ -105,17 +177,17 @@ def print_table(data, headers):
105
177
  header_row = " | ".join(
106
178
  [headers[i].ljust(col_widths[i]) for i in range(len(headers))]
107
179
  )
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")
180
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
181
+ click.secho(header_row, fg=ColorTheme.TL_HEADER_COLOR, bold=True)
182
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
111
183
 
112
184
  # Print data rows
113
185
  for row in data:
114
186
  formatted_row = " | ".join(
115
187
  [str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
116
188
  )
117
- click.secho(formatted_row, fg="green", bold=True)
118
- click.secho("-" * len(header_row), fg="yellow")
189
+ click.secho(formatted_row, fg=ColorTheme.ROW_COLOR, bold=True)
190
+ click.secho("-" * len(header_row), fg=ColorTheme.TL_HEADER_COLOR)
119
191
 
120
192
 
121
193
  @click.group()
@@ -173,6 +245,33 @@ def parse_commands(app_config: AppConfig, cli_command_input):
173
245
  return base_commands
174
246
 
175
247
 
248
+ def deployment_instance_options(func):
249
+ # These parameters influence how the CLI behaves for each instance of a launched deployment.
250
+ @click.option(
251
+ "--readiness-condition",
252
+ type=click.Choice(DEPLOYMENT_READY_CONDITIONS.enums()),
253
+ help=DEPLOYMENT_READY_CONDITIONS.__doc__,
254
+ default=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
255
+ )
256
+ @click.option(
257
+ "--readiness-wait-time",
258
+ type=int,
259
+ help="The time (in seconds) to monitor the deployment for readiness after the readiness condition is met.",
260
+ default=4,
261
+ )
262
+ @click.option(
263
+ "--max-wait-time",
264
+ type=int,
265
+ help="The maximum time (in seconds) to wait for the deployment to be ready.",
266
+ default=600,
267
+ )
268
+ @wraps(func)
269
+ def wrapper(*args, **kwargs):
270
+ return func(*args, **kwargs)
271
+
272
+ return wrapper
273
+
274
+
176
275
  def common_deploy_options(func):
177
276
  @click.option(
178
277
  "--name",
@@ -291,6 +390,12 @@ def common_deploy_options(func):
291
390
  help="The type of app to deploy.",
292
391
  default=None,
293
392
  )
393
+ @click.option(
394
+ "--force-upgrade",
395
+ is_flag=True,
396
+ help="Force upgrade the app even if it is currently being upgraded.",
397
+ default=False,
398
+ )
294
399
  @wraps(func)
295
400
  def wrapper(*args, **kwargs):
296
401
  return func(*args, **kwargs)
@@ -395,10 +500,18 @@ def _package_necessary_things(app_config: AppConfig, logger):
395
500
  @app.command(help="Deploy an app to the Outerbounds Platform.")
396
501
  @common_deploy_options
397
502
  @common_run_options
503
+ @deployment_instance_options
398
504
  @experimental.wrapping_cli_options
399
505
  @click.pass_context
400
506
  @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
401
- def deploy(ctx, command, **options):
507
+ def deploy(
508
+ ctx,
509
+ command,
510
+ readiness_condition=None,
511
+ max_wait_time=None,
512
+ readiness_wait_time=None,
513
+ **options,
514
+ ):
402
515
  """Deploy an app to the Outerbounds Platform."""
403
516
  from functools import partial
404
517
 
@@ -423,7 +536,7 @@ def deploy(ctx, command, **options):
423
536
  app_config.validate()
424
537
  logger(
425
538
  f"🚀 Deploying {app_config.get('name')} to the Outerbounds platform...",
426
- color=LOGGER_COLOR,
539
+ color=ColorTheme.INFO_COLOR,
427
540
  system_msg=True,
428
541
  )
429
542
 
@@ -494,11 +607,31 @@ def deploy(ctx, command, **options):
494
607
  cache_dir = os.path.join(
495
608
  ctx.obj.app_state_dir, app_config.get("name", "default")
496
609
  )
610
+
497
611
  deploy_validations(
498
612
  app_config,
499
613
  cache_dir=cache_dir,
500
614
  logger=logger,
501
615
  )
616
+ with MultiStepSpinner(
617
+ text=lambda: _logger_styled(
618
+ "🍞 Baking Docker Image",
619
+ timestamp=True,
620
+ ),
621
+ color=ColorTheme.LOADING_COLOR,
622
+ ) as spinner:
623
+ _ctx_lgr = partial(_spinner_logger, spinner)
624
+ baking_status = bake_deployment_image(
625
+ app_config=app_config,
626
+ cache_file_path=os.path.join(cache_dir, "image_cache"),
627
+ logger=_ctx_lgr,
628
+ )
629
+ app_config.set_state(
630
+ "image",
631
+ baking_status.resolved_image,
632
+ )
633
+ app_config.set_state("python_path", baking_status.python_path)
634
+ _ctx_lgr("🐳 Using The Docker Image : %s" % app_config.get_state("image"))
502
635
 
503
636
  base_commands = parse_commands(app_config, command)
504
637
 
@@ -513,22 +646,74 @@ def deploy(ctx, command, **options):
513
646
  app_config.set_state("perimeter", ctx.obj.perimeter)
514
647
 
515
648
  # 2. Convert to the IR that the backend accepts
516
- capsule = CapsuleDeployer(app_config, ctx.obj.api_url, debug_dir=cache_dir)
649
+ capsule = CapsuleDeployer(
650
+ app_config,
651
+ ctx.obj.api_url,
652
+ debug_dir=cache_dir,
653
+ success_terminal_state_condition=readiness_condition,
654
+ create_timeout=max_wait_time,
655
+ readiness_wait_time=readiness_wait_time,
656
+ )
657
+ currently_present_capsules = list_and_filter_capsules(
658
+ capsule.capsule_api,
659
+ None,
660
+ None,
661
+ capsule.name,
662
+ None,
663
+ None,
664
+ None,
665
+ )
666
+
667
+ force_upgrade = app_config.get_state("force_upgrade", False)
517
668
 
518
669
  _pre_create_debug(app_config, capsule, cache_dir)
670
+
671
+ if len(currently_present_capsules) > 0:
672
+ # Only update the capsule if there is no upgrade in progress
673
+ # Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
674
+ _curr_cap = currently_present_capsules[0]
675
+ this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
676
+ "updateInProgress", False
677
+ )
678
+
679
+ if this_capsule_is_being_updated and not force_upgrade:
680
+ _upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
681
+ message = f"{capsule.capsule_type} is currently being upgraded"
682
+ if _upgrader:
683
+ message = (
684
+ f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
685
+ "If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
686
+ )
687
+ raise AppConfigError(message)
688
+ logger(
689
+ f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
690
+ color=ColorTheme.INFO_COLOR,
691
+ system_msg=True,
692
+ )
693
+ else:
694
+ logger(
695
+ f"🚀 Deploying {capsule.capsule_type.lower()} to the platform....",
696
+ color=ColorTheme.INFO_COLOR,
697
+ system_msg=True,
698
+ )
519
699
  # 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
700
  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
- )
701
+ _post_create_debug(capsule, cache_dir)
702
+
703
+ with MultiStepSpinner(
704
+ text=lambda: _logger_styled(
705
+ "💊 Waiting for %s %s to be ready to serve traffic"
706
+ % (capsule.capsule_type.lower(), capsule.identifier),
707
+ timestamp=True,
708
+ ),
709
+ color=ColorTheme.LOADING_COLOR,
710
+ ) as spinner:
711
+ capsule.wait_for_terminal_state(logger=partial(_spinner_logger, spinner))
712
+ logger(
713
+ f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed successfully! You can access it at {capsule.status.out_of_cluster_url}",
714
+ color=ColorTheme.INFO_COLOR,
715
+ system_msg=True,
716
+ )
532
717
 
533
718
  except AppConfigError as e:
534
719
  click.echo(f"Error in app configuration: {e}", err=True)
@@ -593,9 +778,12 @@ def _parse_capsule_table(filtered_capsules):
593
778
  @click.pass_context
594
779
  def list(ctx, project, branch, name, tags, format, auth_type):
595
780
  """List apps in the Outerbounds Platform."""
596
-
781
+ capsule_api = CapsuleApi(
782
+ ctx.obj.api_url,
783
+ ctx.obj.perimeter,
784
+ )
597
785
  filtered_capsules = list_and_filter_capsules(
598
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, auth_type, None
786
+ capsule_api, project, branch, name, tags, auth_type, None
599
787
  )
600
788
  if format == "json":
601
789
  click.echo(json.dumps(filtered_capsules, indent=4))
@@ -634,8 +822,9 @@ def delete(ctx, name, cap_id, project, branch, tags):
634
822
  "Atleast one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
635
823
  )
636
824
 
825
+ capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
637
826
  filtered_capsules = list_and_filter_capsules(
638
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, None, cap_id
827
+ capsule_api, project, branch, name, tags, None, cap_id
639
828
  )
640
829
 
641
830
  headers, table_data = _parse_capsule_table(filtered_capsules)
@@ -658,18 +847,22 @@ def delete(ctx, name, cap_id, project, branch, tags):
658
847
  return None
659
848
  name = x.get("spec", {}).get("displayName", "")
660
849
  id = x.get("id", "")
661
- return click.style("💊 deleting %s [%s]" % (name, id), fg="red", bold=True)
850
+ return click.style(
851
+ "💊 deleting %s [%s]" % (name, id),
852
+ fg=ColorTheme.BAD_COLOR,
853
+ bold=True,
854
+ )
662
855
 
663
856
  with click.progressbar(
664
857
  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),
858
+ label=click.style("💊 Deleting apps...", fg=ColorTheme.BAD_COLOR, bold=True),
859
+ fill_char=click.style("█", fg=ColorTheme.BAD_COLOR, bold=True),
860
+ empty_char=click.style("░", fg=ColorTheme.BAD_COLOR, bold=True),
668
861
  item_show_func=item_show_func,
669
862
  ) as bar:
670
- capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
671
863
  for capsule in bar:
672
864
  capsule_api.delete(capsule.get("id"))
865
+ time.sleep(0.5 + random.random() * 2) # delay to avoid rate limiting
673
866
 
674
867
 
675
868
  @app.command(help="Run an app locally (for testing).")
@@ -713,5 +906,363 @@ def run(ctx, **options):
713
906
  ctx.exit(1)
714
907
 
715
908
 
909
+ @app.command(
910
+ help="Get detailed information about an app from the Outerbounds Platform."
911
+ )
912
+ @click.option("--name", type=str, help="Get info for app by name")
913
+ @click.option("--id", "cap_id", type=str, help="Get info for app by id")
914
+ @click.option(
915
+ "--format",
916
+ type=click.Choice(["json", "text"]),
917
+ help="Format the output",
918
+ default="text",
919
+ )
920
+ @click.pass_context
921
+ def info(ctx, name, cap_id, format):
922
+ """Get detailed information about an app from the Outerbounds Platform."""
923
+ # Require either name or id
924
+ if not any([name is not None, cap_id is not None]):
925
+ raise AppConfigError(
926
+ "Either --name or --id must be provided to get app information."
927
+ )
928
+
929
+ # Ensure only one is provided
930
+ if name is not None and cap_id is not None:
931
+ raise AppConfigError("Please provide either --name or --id, not both.")
932
+
933
+ capsule_api = CapsuleApi(
934
+ ctx.obj.api_url,
935
+ ctx.obj.perimeter,
936
+ )
937
+
938
+ # First, find the capsule using list_and_filter_capsules
939
+ filtered_capsules = list_and_filter_capsules(
940
+ capsule_api, None, None, name, None, None, cap_id
941
+ )
942
+
943
+ if len(filtered_capsules) == 0:
944
+ identifier = name if name else cap_id
945
+ identifier_type = "name" if name else "id"
946
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
947
+
948
+ if len(filtered_capsules) > 1:
949
+ raise AppConfigError(
950
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want info for."
951
+ )
952
+
953
+ # Get the capsule info
954
+ capsule = filtered_capsules[0]
955
+ capsule_id = capsule.get("id")
956
+
957
+ # Get detailed capsule info and workers
958
+ try:
959
+ detailed_capsule_info = capsule_api.get(capsule_id)
960
+ workers_info = capsule_api.get_workers(capsule_id)
961
+
962
+ if format == "json":
963
+ # Output in JSON format for piping to jq
964
+ info_data = {"capsule": detailed_capsule_info, "workers": workers_info}
965
+ click.echo(json.dumps(info_data, indent=4))
966
+ else:
967
+ # Output in text format
968
+ _display_capsule_info_text(detailed_capsule_info, workers_info)
969
+
970
+ except Exception as e:
971
+ raise AppConfigError(f"Error retrieving information for app {capsule_id}: {e}")
972
+
973
+
974
+ def _display_capsule_info_text(capsule_info, workers_info):
975
+ """Display capsule information in a human-readable text format."""
976
+ spec = capsule_info.get("spec", {})
977
+ status = capsule_info.get("status", {}) or {}
978
+ metadata = capsule_info.get("metadata", {}) or {}
979
+
980
+ info_color = ColorTheme.INFO_COLOR
981
+ tl_color = ColorTheme.TL_HEADER_COLOR
982
+
983
+ def _key_style(key: str, value: str):
984
+ return "%s: %s" % (
985
+ click.style(
986
+ key,
987
+ fg=ColorTheme.INFO_KEY_COLOR,
988
+ ),
989
+ click.style(str(value), fg=ColorTheme.INFO_VALUE_COLOR, bold=True),
990
+ )
991
+
992
+ # Basic Info
993
+ click.secho("=== App Information ===", fg=tl_color, bold=True)
994
+ click.secho(_key_style("Name", spec.get("displayName", "N/A")), fg=info_color)
995
+ click.secho(_key_style("ID", capsule_info.get("id", "N/A")), fg=info_color)
996
+ click.secho(
997
+ _key_style("Version", capsule_info.get("version", "N/A")), fg=info_color
998
+ )
999
+ click.secho(
1000
+ _key_style(
1001
+ "Ready to Serve Traffic", str(status.get("readyToServeTraffic", False))
1002
+ ),
1003
+ fg=info_color,
1004
+ )
1005
+ click.secho(
1006
+ _key_style("Update In Progress", str(status.get("updateInProgress", False))),
1007
+ fg=info_color,
1008
+ )
1009
+ click.secho(
1010
+ _key_style(
1011
+ "Currently Served Version", str(status.get("currentlyServedVersion", "N/A"))
1012
+ ),
1013
+ fg=info_color,
1014
+ )
1015
+
1016
+ # URLs
1017
+ access_info = status.get("accessInfo", {}) or {}
1018
+ out_cluster_url = access_info.get("outOfClusterURL")
1019
+ in_cluster_url = access_info.get("inClusterURL")
1020
+
1021
+ if out_cluster_url:
1022
+ click.secho(
1023
+ _key_style("External URL", f"https://{out_cluster_url}"), fg=info_color
1024
+ )
1025
+ if in_cluster_url:
1026
+ click.secho(
1027
+ _key_style("Internal URL", f"https://{in_cluster_url}"), fg=info_color
1028
+ )
1029
+
1030
+ # Resource Configuration
1031
+ click.secho("\n=== Resource Configuration ===", fg=tl_color, bold=True)
1032
+ resource_config = spec.get("resourceConfig", {})
1033
+ click.secho(_key_style("CPU", resource_config.get("cpu", "N/A")), fg=info_color)
1034
+ click.secho(
1035
+ _key_style("Memory", resource_config.get("memory", "N/A")), fg=info_color
1036
+ )
1037
+ click.secho(
1038
+ _key_style("Ephemeral Storage", resource_config.get("ephemeralStorage", "N/A")),
1039
+ fg=info_color,
1040
+ )
1041
+ if resource_config.get("gpu"):
1042
+ click.secho(_key_style("GPU", resource_config.get("gpu")), fg=info_color)
1043
+
1044
+ # Autoscaling
1045
+ click.secho("\n=== Autoscaling Configuration ===", fg=tl_color, bold=True)
1046
+ autoscaling_config = spec.get("autoscalingConfig", {})
1047
+ click.secho(
1048
+ _key_style("Min Replicas", str(autoscaling_config.get("minReplicas", "N/A"))),
1049
+ fg=info_color,
1050
+ )
1051
+ click.secho(
1052
+ _key_style("Max Replicas", str(autoscaling_config.get("maxReplicas", "N/A"))),
1053
+ fg=info_color,
1054
+ )
1055
+ click.secho(
1056
+ _key_style("Available Replicas", str(status.get("availableReplicas", "N/A"))),
1057
+ fg=info_color,
1058
+ )
1059
+
1060
+ # Auth Configuration
1061
+ click.secho("\n=== Authentication Configuration ===", fg=tl_color, bold=True)
1062
+ auth_config = spec.get("authConfig", {})
1063
+ click.secho(
1064
+ _key_style("Auth Type", auth_config.get("authType", "N/A")), fg=info_color
1065
+ )
1066
+ click.secho(
1067
+ _key_style("Public Access", str(auth_config.get("publicToDeployment", "N/A"))),
1068
+ fg=info_color,
1069
+ )
1070
+
1071
+ # Tags
1072
+ tags = spec.get("tags", [])
1073
+ if tags:
1074
+ click.secho("\n=== Tags ===", fg=tl_color, bold=True)
1075
+ for tag in tags:
1076
+ click.secho(
1077
+ _key_style(str(tag.get("key", "N/A")), str(tag.get("value", "N/A"))),
1078
+ fg=info_color,
1079
+ )
1080
+
1081
+ # Metadata
1082
+ click.secho("\n=== Metadata ===", fg=tl_color, bold=True)
1083
+ click.secho(
1084
+ _key_style("Created At", metadata.get("createdAt", "N/A")), fg=info_color
1085
+ )
1086
+ click.secho(
1087
+ _key_style("Last Modified At", metadata.get("lastModifiedAt", "N/A")),
1088
+ fg=info_color,
1089
+ )
1090
+ click.secho(
1091
+ _key_style("Last Modified By", metadata.get("lastModifiedBy", "N/A")),
1092
+ fg=info_color,
1093
+ )
1094
+
1095
+ # Workers Information
1096
+ click.secho("\n=== Workers Information ===", fg=tl_color, bold=True)
1097
+ if not workers_info:
1098
+ click.secho("No workers found", fg=info_color)
1099
+ else:
1100
+ click.secho(_key_style("Total Workers", str(len(workers_info))), fg=tl_color)
1101
+
1102
+ # Create a table for workers
1103
+ workers_headers = [
1104
+ "Worker ID",
1105
+ "Phase",
1106
+ "Version",
1107
+ "Activity",
1108
+ "Activity Data Available",
1109
+ ]
1110
+ workers_table_data = []
1111
+
1112
+ for worker in workers_info:
1113
+ worker_id = worker.get("workerId", "N/A")
1114
+ phase = worker.get("phase", "N/A")
1115
+ version = worker.get("version", "N/A")
1116
+ activity = str(worker.get("activity", "N/A"))
1117
+ activity_data_available = str(worker.get("activityDataAvailable", False))
1118
+
1119
+ workers_table_data.append(
1120
+ [
1121
+ worker_id[:20] + "..." if len(worker_id) > 23 else worker_id,
1122
+ phase,
1123
+ version[:10] + "..." if len(version) > 13 else version,
1124
+ activity,
1125
+ activity_data_available,
1126
+ ]
1127
+ )
1128
+
1129
+ print_table(workers_table_data, workers_headers)
1130
+
1131
+
1132
+ @app.command(help="Get logs for an app worker from the Outerbounds Platform.")
1133
+ @click.option("--name", type=str, help="Get logs for app by name")
1134
+ @click.option("--id", "cap_id", type=str, help="Get logs for app by id")
1135
+ @click.option("--worker-id", type=str, help="Get logs for specific worker")
1136
+ @click.option("--file", type=str, help="Save logs to file")
1137
+ @click.option(
1138
+ "--previous",
1139
+ is_flag=True,
1140
+ help="Get logs from previous container instance",
1141
+ default=False,
1142
+ )
1143
+ @click.pass_context
1144
+ def logs(ctx, name, cap_id, worker_id, file, previous):
1145
+ """Get logs for an app worker from the Outerbounds Platform."""
1146
+ # Require either name or id
1147
+ if not any([name is not None, cap_id is not None]):
1148
+ raise AppConfigError("Either --name or --id must be provided to get app logs.")
1149
+
1150
+ # Ensure only one is provided
1151
+ if name is not None and cap_id is not None:
1152
+ raise AppConfigError("Please provide either --name or --id, not both.")
1153
+
1154
+ capsule_api = CapsuleApi(
1155
+ ctx.obj.api_url,
1156
+ ctx.obj.perimeter,
1157
+ )
1158
+
1159
+ # First, find the capsule using list_and_filter_capsules
1160
+ filtered_capsules = list_and_filter_capsules(
1161
+ capsule_api, None, None, name, None, None, cap_id
1162
+ )
1163
+
1164
+ if len(filtered_capsules) == 0:
1165
+ identifier = name if name else cap_id
1166
+ identifier_type = "name" if name else "id"
1167
+ raise AppConfigError(f"No app found with {identifier_type}: {identifier}")
1168
+
1169
+ if len(filtered_capsules) > 1:
1170
+ raise AppConfigError(
1171
+ f"Multiple apps found with name: {name}. Please use --id to specify exactly which app you want logs for."
1172
+ )
1173
+
1174
+ capsule = filtered_capsules[0]
1175
+ capsule_id = capsule.get("id")
1176
+
1177
+ # Get workers
1178
+ try:
1179
+ workers_info = capsule_api.get_workers(capsule_id)
1180
+ except Exception as e:
1181
+ raise AppConfigError(f"Error retrieving workers for app {capsule_id}: {e}")
1182
+
1183
+ if not workers_info:
1184
+ raise AppConfigError(f"No workers found for app {capsule_id}")
1185
+
1186
+ # If worker_id not provided, show interactive selection
1187
+ if not worker_id:
1188
+ if len(workers_info) == 1:
1189
+ # Only one worker, use it automatically
1190
+ selected_worker = workers_info[0]
1191
+ worker_id = selected_worker.get("workerId")
1192
+ worker_phase = selected_worker.get("phase", "N/A")
1193
+ worker_version = selected_worker.get("version", "N/A")[:10]
1194
+ click.echo(
1195
+ f"📋 Using the only available worker: {worker_id[:20]}... (phase: {worker_phase}, version: {worker_version}...)"
1196
+ )
1197
+ else:
1198
+ # Multiple workers, show selection
1199
+ click.secho(
1200
+ "📋 Multiple workers found. Please select one:",
1201
+ fg=ColorTheme.INFO_COLOR,
1202
+ bold=True,
1203
+ )
1204
+
1205
+ # Display workers in a table format for better readability
1206
+ headers = ["#", "Worker ID", "Phase", "Version", "Activity"]
1207
+ table_data = []
1208
+
1209
+ for i, worker in enumerate(workers_info, 1):
1210
+ w_id = worker.get("workerId", "N/A")
1211
+ phase = worker.get("phase", "N/A")
1212
+ version = worker.get("version", "N/A")
1213
+ activity = str(worker.get("activity", "N/A"))
1214
+
1215
+ table_data.append(
1216
+ [
1217
+ str(i),
1218
+ w_id[:30] + "..." if len(w_id) > 33 else w_id,
1219
+ phase,
1220
+ version[:15] + "..." if len(version) > 18 else version,
1221
+ activity,
1222
+ ]
1223
+ )
1224
+
1225
+ print_table(table_data, headers)
1226
+
1227
+ # Create choices for the prompt
1228
+ worker_choices = []
1229
+ for i, worker in enumerate(workers_info, 1):
1230
+ worker_choices.append(str(i))
1231
+
1232
+ selected_index = click.prompt(
1233
+ click.style(
1234
+ "Select worker number", fg=ColorTheme.INFO_COLOR, bold=True
1235
+ ),
1236
+ type=click.Choice(worker_choices),
1237
+ )
1238
+
1239
+ # Get the selected worker
1240
+ selected_worker = workers_info[int(selected_index) - 1]
1241
+ worker_id = selected_worker.get("workerId")
1242
+
1243
+ # Get logs for the selected worker
1244
+ try:
1245
+ logs_response = capsule_api.logs(capsule_id, worker_id, previous=previous)
1246
+ except Exception as e:
1247
+ raise AppConfigError(f"Error retrieving logs for worker {worker_id}: {e}")
1248
+
1249
+ # Format logs content
1250
+ logs_content = "\n".join([log.get("message", "") for log in logs_response])
1251
+
1252
+ # Display or save logs
1253
+ if file:
1254
+ try:
1255
+ with open(file, "w") as f:
1256
+ f.write(logs_content)
1257
+ click.echo(f"📁 Logs saved to {file}")
1258
+ except Exception as e:
1259
+ raise AppConfigError(f"Error saving logs to file {file}: {e}")
1260
+ else:
1261
+ if logs_content.strip():
1262
+ click.echo(logs_content)
1263
+ else:
1264
+ click.echo("📝 No logs available for this worker.")
1265
+
1266
+
716
1267
  # if __name__ == "__main__":
717
1268
  # cli()