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.
- outerbounds/_vendor/spinner/__init__.py +4 -0
- outerbounds/_vendor/spinner/spinners.py +478 -0
- outerbounds/_vendor/spinner.LICENSE +21 -0
- outerbounds/apps/_state_machine.py +358 -0
- outerbounds/apps/app_cli.py +587 -36
- outerbounds/apps/capsule.py +324 -68
- outerbounds/apps/cli_to_config.py +9 -1
- outerbounds/apps/config_schema.yaml +5 -0
- outerbounds/apps/utils.py +132 -0
- outerbounds/apps/validations.py +0 -12
- outerbounds/command_groups/cli.py +0 -2
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc0.dist-info}/METADATA +3 -3
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc0.dist-info}/RECORD +15 -12
- outerbounds/command_groups/flowprojects_cli.py +0 -137
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc0.dist-info}/WHEEL +0 -0
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc0.dist-info}/entry_points.txt +0 -0
outerbounds/apps/app_cli.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
import json
|
2
2
|
import os
|
3
3
|
import sys
|
4
|
-
|
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
|
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
|
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
|
-
|
41
|
-
|
42
|
-
|
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=
|
82
|
+
click.secho(tstamp + " ", fg=ColorTheme.TIMESTAMP, nl=False)
|
57
83
|
if head:
|
58
|
-
click.secho(head, fg=
|
84
|
+
click.secho(head, fg=ColorTheme.INFO_COLOR, nl=False)
|
59
85
|
click.secho(
|
60
86
|
body,
|
61
87
|
bold=system_msg,
|
62
|
-
fg=
|
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=
|
109
|
-
click.secho(header_row, fg=
|
110
|
-
click.secho("-" * len(header_row), fg=
|
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=
|
118
|
-
click.secho("-" * len(header_row), fg=
|
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(
|
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=
|
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(
|
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
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
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
|
-
|
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
|
-
|
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(
|
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=
|
666
|
-
fill_char=click.style("█", fg=
|
667
|
-
empty_char=click.style("░", fg=
|
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()
|