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.
- 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 +618 -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.180rc1.dist-info}/METADATA +3 -3
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc1.dist-info}/RECORD +15 -12
- outerbounds/command_groups/flowprojects_cli.py +0 -137
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc1.dist-info}/WHEEL +0 -0
- {outerbounds-0.3.179rc5.dist-info → outerbounds-0.3.180rc1.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,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=
|
109
|
-
click.secho(header_row, fg=
|
110
|
-
click.secho("-" * len(header_row), fg=
|
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=
|
118
|
-
click.secho("-" * len(header_row), fg=
|
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(
|
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=
|
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(
|
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
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
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
|
-
|
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
|
-
|
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(
|
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=
|
666
|
-
fill_char=click.style("█", fg=
|
667
|
-
empty_char=click.style("░", fg=
|
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()
|