outerbounds 0.3.55rc8__py3-none-any.whl → 0.3.133__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/PyYAML.LICENSE +20 -0
- outerbounds/_vendor/__init__.py +0 -0
- outerbounds/_vendor/_yaml/__init__.py +34 -0
- outerbounds/_vendor/click/__init__.py +73 -0
- outerbounds/_vendor/click/_compat.py +626 -0
- outerbounds/_vendor/click/_termui_impl.py +717 -0
- outerbounds/_vendor/click/_textwrap.py +49 -0
- outerbounds/_vendor/click/_winconsole.py +279 -0
- outerbounds/_vendor/click/core.py +2998 -0
- outerbounds/_vendor/click/decorators.py +497 -0
- outerbounds/_vendor/click/exceptions.py +287 -0
- outerbounds/_vendor/click/formatting.py +301 -0
- outerbounds/_vendor/click/globals.py +68 -0
- outerbounds/_vendor/click/parser.py +529 -0
- outerbounds/_vendor/click/py.typed +0 -0
- outerbounds/_vendor/click/shell_completion.py +580 -0
- outerbounds/_vendor/click/termui.py +787 -0
- outerbounds/_vendor/click/testing.py +479 -0
- outerbounds/_vendor/click/types.py +1073 -0
- outerbounds/_vendor/click/utils.py +580 -0
- outerbounds/_vendor/click.LICENSE +28 -0
- outerbounds/_vendor/vendor_any.txt +2 -0
- outerbounds/_vendor/yaml/__init__.py +471 -0
- outerbounds/_vendor/yaml/_yaml.cpython-311-darwin.so +0 -0
- outerbounds/_vendor/yaml/composer.py +146 -0
- outerbounds/_vendor/yaml/constructor.py +862 -0
- outerbounds/_vendor/yaml/cyaml.py +177 -0
- outerbounds/_vendor/yaml/dumper.py +138 -0
- outerbounds/_vendor/yaml/emitter.py +1239 -0
- outerbounds/_vendor/yaml/error.py +94 -0
- outerbounds/_vendor/yaml/events.py +104 -0
- outerbounds/_vendor/yaml/loader.py +62 -0
- outerbounds/_vendor/yaml/nodes.py +51 -0
- outerbounds/_vendor/yaml/parser.py +629 -0
- outerbounds/_vendor/yaml/reader.py +208 -0
- outerbounds/_vendor/yaml/representer.py +378 -0
- outerbounds/_vendor/yaml/resolver.py +245 -0
- outerbounds/_vendor/yaml/scanner.py +1555 -0
- outerbounds/_vendor/yaml/serializer.py +127 -0
- outerbounds/_vendor/yaml/tokens.py +129 -0
- outerbounds/command_groups/apps_cli.py +450 -0
- outerbounds/command_groups/cli.py +9 -5
- outerbounds/command_groups/local_setup_cli.py +247 -36
- outerbounds/command_groups/perimeters_cli.py +212 -32
- outerbounds/command_groups/tutorials_cli.py +111 -0
- outerbounds/command_groups/workstations_cli.py +2 -2
- outerbounds/utils/kubeconfig.py +2 -2
- outerbounds/utils/metaflowconfig.py +93 -16
- outerbounds/utils/schema.py +2 -2
- outerbounds/utils/utils.py +19 -0
- outerbounds/vendor.py +159 -0
- {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/METADATA +17 -6
- outerbounds-0.3.133.dist-info/RECORD +59 -0
- {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/WHEEL +1 -1
- outerbounds-0.3.55rc8.dist-info/RECORD +0 -15
- {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,127 @@
|
|
1
|
+
__all__ = ["Serializer", "SerializerError"]
|
2
|
+
|
3
|
+
from .error import YAMLError
|
4
|
+
from .events import *
|
5
|
+
from .nodes import *
|
6
|
+
|
7
|
+
|
8
|
+
class SerializerError(YAMLError):
|
9
|
+
pass
|
10
|
+
|
11
|
+
|
12
|
+
class Serializer:
|
13
|
+
|
14
|
+
ANCHOR_TEMPLATE = "id%03d"
|
15
|
+
|
16
|
+
def __init__(
|
17
|
+
self,
|
18
|
+
encoding=None,
|
19
|
+
explicit_start=None,
|
20
|
+
explicit_end=None,
|
21
|
+
version=None,
|
22
|
+
tags=None,
|
23
|
+
):
|
24
|
+
self.use_encoding = encoding
|
25
|
+
self.use_explicit_start = explicit_start
|
26
|
+
self.use_explicit_end = explicit_end
|
27
|
+
self.use_version = version
|
28
|
+
self.use_tags = tags
|
29
|
+
self.serialized_nodes = {}
|
30
|
+
self.anchors = {}
|
31
|
+
self.last_anchor_id = 0
|
32
|
+
self.closed = None
|
33
|
+
|
34
|
+
def open(self):
|
35
|
+
if self.closed is None:
|
36
|
+
self.emit(StreamStartEvent(encoding=self.use_encoding))
|
37
|
+
self.closed = False
|
38
|
+
elif self.closed:
|
39
|
+
raise SerializerError("serializer is closed")
|
40
|
+
else:
|
41
|
+
raise SerializerError("serializer is already opened")
|
42
|
+
|
43
|
+
def close(self):
|
44
|
+
if self.closed is None:
|
45
|
+
raise SerializerError("serializer is not opened")
|
46
|
+
elif not self.closed:
|
47
|
+
self.emit(StreamEndEvent())
|
48
|
+
self.closed = True
|
49
|
+
|
50
|
+
# def __del__(self):
|
51
|
+
# self.close()
|
52
|
+
|
53
|
+
def serialize(self, node):
|
54
|
+
if self.closed is None:
|
55
|
+
raise SerializerError("serializer is not opened")
|
56
|
+
elif self.closed:
|
57
|
+
raise SerializerError("serializer is closed")
|
58
|
+
self.emit(
|
59
|
+
DocumentStartEvent(
|
60
|
+
explicit=self.use_explicit_start,
|
61
|
+
version=self.use_version,
|
62
|
+
tags=self.use_tags,
|
63
|
+
)
|
64
|
+
)
|
65
|
+
self.anchor_node(node)
|
66
|
+
self.serialize_node(node, None, None)
|
67
|
+
self.emit(DocumentEndEvent(explicit=self.use_explicit_end))
|
68
|
+
self.serialized_nodes = {}
|
69
|
+
self.anchors = {}
|
70
|
+
self.last_anchor_id = 0
|
71
|
+
|
72
|
+
def anchor_node(self, node):
|
73
|
+
if node in self.anchors:
|
74
|
+
if self.anchors[node] is None:
|
75
|
+
self.anchors[node] = self.generate_anchor(node)
|
76
|
+
else:
|
77
|
+
self.anchors[node] = None
|
78
|
+
if isinstance(node, SequenceNode):
|
79
|
+
for item in node.value:
|
80
|
+
self.anchor_node(item)
|
81
|
+
elif isinstance(node, MappingNode):
|
82
|
+
for key, value in node.value:
|
83
|
+
self.anchor_node(key)
|
84
|
+
self.anchor_node(value)
|
85
|
+
|
86
|
+
def generate_anchor(self, node):
|
87
|
+
self.last_anchor_id += 1
|
88
|
+
return self.ANCHOR_TEMPLATE % self.last_anchor_id
|
89
|
+
|
90
|
+
def serialize_node(self, node, parent, index):
|
91
|
+
alias = self.anchors[node]
|
92
|
+
if node in self.serialized_nodes:
|
93
|
+
self.emit(AliasEvent(alias))
|
94
|
+
else:
|
95
|
+
self.serialized_nodes[node] = True
|
96
|
+
self.descend_resolver(parent, index)
|
97
|
+
if isinstance(node, ScalarNode):
|
98
|
+
detected_tag = self.resolve(ScalarNode, node.value, (True, False))
|
99
|
+
default_tag = self.resolve(ScalarNode, node.value, (False, True))
|
100
|
+
implicit = (node.tag == detected_tag), (node.tag == default_tag)
|
101
|
+
self.emit(
|
102
|
+
ScalarEvent(alias, node.tag, implicit, node.value, style=node.style)
|
103
|
+
)
|
104
|
+
elif isinstance(node, SequenceNode):
|
105
|
+
implicit = node.tag == self.resolve(SequenceNode, node.value, True)
|
106
|
+
self.emit(
|
107
|
+
SequenceStartEvent(
|
108
|
+
alias, node.tag, implicit, flow_style=node.flow_style
|
109
|
+
)
|
110
|
+
)
|
111
|
+
index = 0
|
112
|
+
for item in node.value:
|
113
|
+
self.serialize_node(item, node, index)
|
114
|
+
index += 1
|
115
|
+
self.emit(SequenceEndEvent())
|
116
|
+
elif isinstance(node, MappingNode):
|
117
|
+
implicit = node.tag == self.resolve(MappingNode, node.value, True)
|
118
|
+
self.emit(
|
119
|
+
MappingStartEvent(
|
120
|
+
alias, node.tag, implicit, flow_style=node.flow_style
|
121
|
+
)
|
122
|
+
)
|
123
|
+
for key, value in node.value:
|
124
|
+
self.serialize_node(key, node, None)
|
125
|
+
self.serialize_node(value, node, key)
|
126
|
+
self.emit(MappingEndEvent())
|
127
|
+
self.ascend_resolver()
|
@@ -0,0 +1,129 @@
|
|
1
|
+
class Token(object):
|
2
|
+
def __init__(self, start_mark, end_mark):
|
3
|
+
self.start_mark = start_mark
|
4
|
+
self.end_mark = end_mark
|
5
|
+
|
6
|
+
def __repr__(self):
|
7
|
+
attributes = [key for key in self.__dict__ if not key.endswith("_mark")]
|
8
|
+
attributes.sort()
|
9
|
+
arguments = ", ".join(
|
10
|
+
["%s=%r" % (key, getattr(self, key)) for key in attributes]
|
11
|
+
)
|
12
|
+
return "%s(%s)" % (self.__class__.__name__, arguments)
|
13
|
+
|
14
|
+
|
15
|
+
# class BOMToken(Token):
|
16
|
+
# id = '<byte order mark>'
|
17
|
+
|
18
|
+
|
19
|
+
class DirectiveToken(Token):
|
20
|
+
id = "<directive>"
|
21
|
+
|
22
|
+
def __init__(self, name, value, start_mark, end_mark):
|
23
|
+
self.name = name
|
24
|
+
self.value = value
|
25
|
+
self.start_mark = start_mark
|
26
|
+
self.end_mark = end_mark
|
27
|
+
|
28
|
+
|
29
|
+
class DocumentStartToken(Token):
|
30
|
+
id = "<document start>"
|
31
|
+
|
32
|
+
|
33
|
+
class DocumentEndToken(Token):
|
34
|
+
id = "<document end>"
|
35
|
+
|
36
|
+
|
37
|
+
class StreamStartToken(Token):
|
38
|
+
id = "<stream start>"
|
39
|
+
|
40
|
+
def __init__(self, start_mark=None, end_mark=None, encoding=None):
|
41
|
+
self.start_mark = start_mark
|
42
|
+
self.end_mark = end_mark
|
43
|
+
self.encoding = encoding
|
44
|
+
|
45
|
+
|
46
|
+
class StreamEndToken(Token):
|
47
|
+
id = "<stream end>"
|
48
|
+
|
49
|
+
|
50
|
+
class BlockSequenceStartToken(Token):
|
51
|
+
id = "<block sequence start>"
|
52
|
+
|
53
|
+
|
54
|
+
class BlockMappingStartToken(Token):
|
55
|
+
id = "<block mapping start>"
|
56
|
+
|
57
|
+
|
58
|
+
class BlockEndToken(Token):
|
59
|
+
id = "<block end>"
|
60
|
+
|
61
|
+
|
62
|
+
class FlowSequenceStartToken(Token):
|
63
|
+
id = "["
|
64
|
+
|
65
|
+
|
66
|
+
class FlowMappingStartToken(Token):
|
67
|
+
id = "{"
|
68
|
+
|
69
|
+
|
70
|
+
class FlowSequenceEndToken(Token):
|
71
|
+
id = "]"
|
72
|
+
|
73
|
+
|
74
|
+
class FlowMappingEndToken(Token):
|
75
|
+
id = "}"
|
76
|
+
|
77
|
+
|
78
|
+
class KeyToken(Token):
|
79
|
+
id = "?"
|
80
|
+
|
81
|
+
|
82
|
+
class ValueToken(Token):
|
83
|
+
id = ":"
|
84
|
+
|
85
|
+
|
86
|
+
class BlockEntryToken(Token):
|
87
|
+
id = "-"
|
88
|
+
|
89
|
+
|
90
|
+
class FlowEntryToken(Token):
|
91
|
+
id = ","
|
92
|
+
|
93
|
+
|
94
|
+
class AliasToken(Token):
|
95
|
+
id = "<alias>"
|
96
|
+
|
97
|
+
def __init__(self, value, start_mark, end_mark):
|
98
|
+
self.value = value
|
99
|
+
self.start_mark = start_mark
|
100
|
+
self.end_mark = end_mark
|
101
|
+
|
102
|
+
|
103
|
+
class AnchorToken(Token):
|
104
|
+
id = "<anchor>"
|
105
|
+
|
106
|
+
def __init__(self, value, start_mark, end_mark):
|
107
|
+
self.value = value
|
108
|
+
self.start_mark = start_mark
|
109
|
+
self.end_mark = end_mark
|
110
|
+
|
111
|
+
|
112
|
+
class TagToken(Token):
|
113
|
+
id = "<tag>"
|
114
|
+
|
115
|
+
def __init__(self, value, start_mark, end_mark):
|
116
|
+
self.value = value
|
117
|
+
self.start_mark = start_mark
|
118
|
+
self.end_mark = end_mark
|
119
|
+
|
120
|
+
|
121
|
+
class ScalarToken(Token):
|
122
|
+
id = "<scalar>"
|
123
|
+
|
124
|
+
def __init__(self, value, plain, start_mark, end_mark, style=None):
|
125
|
+
self.value = value
|
126
|
+
self.plain = plain
|
127
|
+
self.start_mark = start_mark
|
128
|
+
self.end_mark = end_mark
|
129
|
+
self.style = style
|
@@ -0,0 +1,450 @@
|
|
1
|
+
import os
|
2
|
+
from os import path
|
3
|
+
from outerbounds._vendor import click
|
4
|
+
import requests
|
5
|
+
import time
|
6
|
+
import random
|
7
|
+
|
8
|
+
from ..utils import metaflowconfig
|
9
|
+
|
10
|
+
APP_READY_POLL_TIMEOUT_SECONDS = 300
|
11
|
+
# Even after our backend validates that the app routes are ready, it takes a few seconds for
|
12
|
+
# the app to be accessible via the browser. Till we hunt down this delay, add an extra buffer.
|
13
|
+
APP_READY_EXTRA_BUFFER_SECONDS = 30
|
14
|
+
|
15
|
+
|
16
|
+
@click.group()
|
17
|
+
def cli(**kwargs):
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
@click.group(help="Manage apps")
|
22
|
+
def app(**kwargs):
|
23
|
+
pass
|
24
|
+
|
25
|
+
|
26
|
+
@app.command(help="Start an app using a port and a name")
|
27
|
+
@click.option(
|
28
|
+
"-d",
|
29
|
+
"--config-dir",
|
30
|
+
default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
|
31
|
+
help="Path to Metaflow configuration directory",
|
32
|
+
show_default=True,
|
33
|
+
)
|
34
|
+
@click.option(
|
35
|
+
"-p",
|
36
|
+
"--profile",
|
37
|
+
default=os.environ.get("METAFLOW_PROFILE", ""),
|
38
|
+
help="The named metaflow profile in which your workstation exists",
|
39
|
+
)
|
40
|
+
@click.option(
|
41
|
+
"--port",
|
42
|
+
required=True,
|
43
|
+
help="Port number where you want to start your app",
|
44
|
+
type=int,
|
45
|
+
)
|
46
|
+
@click.option(
|
47
|
+
"--name",
|
48
|
+
required=True,
|
49
|
+
help="Name of your app",
|
50
|
+
type=str,
|
51
|
+
)
|
52
|
+
def start(config_dir=None, profile=None, port=-1, name=""):
|
53
|
+
if len(name) == 0 or len(name) >= 20:
|
54
|
+
click.secho(
|
55
|
+
"App name should not be more than 20 characters long.",
|
56
|
+
fg="red",
|
57
|
+
err=True,
|
58
|
+
)
|
59
|
+
return
|
60
|
+
elif not name.isalnum() or not name.islower():
|
61
|
+
click.secho(
|
62
|
+
"App name can only contain lowercase alphanumeric characters.",
|
63
|
+
fg="red",
|
64
|
+
err=True,
|
65
|
+
)
|
66
|
+
return
|
67
|
+
|
68
|
+
if "WORKSTATION_ID" not in os.environ:
|
69
|
+
click.secho(
|
70
|
+
"All outerbounds app commands can only be run from a workstation.",
|
71
|
+
fg="red",
|
72
|
+
err=True,
|
73
|
+
)
|
74
|
+
return
|
75
|
+
|
76
|
+
workstation_id = os.environ["WORKSTATION_ID"]
|
77
|
+
|
78
|
+
try:
|
79
|
+
try:
|
80
|
+
metaflow_token = metaflowconfig.get_metaflow_token_from_config(
|
81
|
+
config_dir, profile
|
82
|
+
)
|
83
|
+
api_url = metaflowconfig.get_sanitized_url_from_config(
|
84
|
+
config_dir, profile, "OBP_API_SERVER"
|
85
|
+
)
|
86
|
+
|
87
|
+
workstations_response = requests.get(
|
88
|
+
f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
|
89
|
+
)
|
90
|
+
workstations_response.raise_for_status()
|
91
|
+
except:
|
92
|
+
click.secho("Failed to list workstations!", fg="red", err=True)
|
93
|
+
return
|
94
|
+
|
95
|
+
workstations_json = workstations_response.json()["workstations"]
|
96
|
+
for workstation in workstations_json:
|
97
|
+
if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
|
98
|
+
if "named_ports" in workstation["spec"]:
|
99
|
+
try:
|
100
|
+
ensure_app_start_request_is_valid(
|
101
|
+
workstation["spec"]["named_ports"], port, name
|
102
|
+
)
|
103
|
+
except ValueError as e:
|
104
|
+
click.secho(str(e), fg="red", err=True)
|
105
|
+
return
|
106
|
+
|
107
|
+
for named_port in workstation["spec"]["named_ports"]:
|
108
|
+
if int(named_port["port"]) == port:
|
109
|
+
if named_port["enabled"] and named_port["name"] == name:
|
110
|
+
click.secho(
|
111
|
+
f"App {name} already running on port {port}!",
|
112
|
+
fg="green",
|
113
|
+
err=True,
|
114
|
+
)
|
115
|
+
click.secho(
|
116
|
+
f"App URL: {api_url.replace('api', 'ui')}/apps/{workstation_id}/{name}/",
|
117
|
+
fg="green",
|
118
|
+
err=True,
|
119
|
+
)
|
120
|
+
return
|
121
|
+
else:
|
122
|
+
try:
|
123
|
+
response = requests.put(
|
124
|
+
f"{api_url}/v1/workstations/update/{workstation_id}/namedports",
|
125
|
+
headers={"x-api-key": metaflow_token},
|
126
|
+
json={
|
127
|
+
"port": port,
|
128
|
+
"name": name,
|
129
|
+
"enabled": True,
|
130
|
+
},
|
131
|
+
)
|
132
|
+
|
133
|
+
response.raise_for_status()
|
134
|
+
poll_success = wait_for_app_port_to_be_accessible(
|
135
|
+
api_url,
|
136
|
+
metaflow_token,
|
137
|
+
workstation_id,
|
138
|
+
name,
|
139
|
+
APP_READY_POLL_TIMEOUT_SECONDS,
|
140
|
+
)
|
141
|
+
if poll_success:
|
142
|
+
click.secho(
|
143
|
+
f"App {name} started on port {port}!",
|
144
|
+
fg="green",
|
145
|
+
err=True,
|
146
|
+
)
|
147
|
+
click.secho(
|
148
|
+
f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{name}/",
|
149
|
+
fg="green",
|
150
|
+
err=True,
|
151
|
+
)
|
152
|
+
else:
|
153
|
+
click.secho(
|
154
|
+
f"The app could not be deployed in {APP_READY_POLL_TIMEOUT_SECONDS / 60} minutes. Please try again later.",
|
155
|
+
fg="red",
|
156
|
+
err=True,
|
157
|
+
)
|
158
|
+
return
|
159
|
+
except Exception:
|
160
|
+
click.secho(
|
161
|
+
f"Failed to start app {name} on port {port}!",
|
162
|
+
fg="red",
|
163
|
+
err=True,
|
164
|
+
)
|
165
|
+
return
|
166
|
+
except Exception as e:
|
167
|
+
click.secho(f"Failed to start app {name} on port {port}!", fg="red", err=True)
|
168
|
+
|
169
|
+
|
170
|
+
@app.command(help="Stop an app using its port number")
|
171
|
+
@click.option(
|
172
|
+
"-d",
|
173
|
+
"--config-dir",
|
174
|
+
default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
|
175
|
+
help="Path to Metaflow configuration directory",
|
176
|
+
show_default=True,
|
177
|
+
)
|
178
|
+
@click.option(
|
179
|
+
"-p",
|
180
|
+
"--profile",
|
181
|
+
default=os.environ.get("METAFLOW_PROFILE", ""),
|
182
|
+
help="The named metaflow profile in which your workstation exists",
|
183
|
+
)
|
184
|
+
@click.option(
|
185
|
+
"--port",
|
186
|
+
required=False,
|
187
|
+
default=-1,
|
188
|
+
help="Port number where you want to start your app.",
|
189
|
+
type=int,
|
190
|
+
)
|
191
|
+
@click.option(
|
192
|
+
"--name",
|
193
|
+
required=False,
|
194
|
+
help="Name of your app",
|
195
|
+
default="",
|
196
|
+
type=str,
|
197
|
+
)
|
198
|
+
def stop(config_dir=None, profile=None, port=-1, name=""):
|
199
|
+
if port == -1 and not name:
|
200
|
+
click.secho(
|
201
|
+
"Please provide either a port number or a name to stop the app.",
|
202
|
+
fg="red",
|
203
|
+
err=True,
|
204
|
+
)
|
205
|
+
return
|
206
|
+
elif port > 0 and name:
|
207
|
+
click.secho(
|
208
|
+
"Please provide either a port number or a name to stop the app, not both.",
|
209
|
+
fg="red",
|
210
|
+
err=True,
|
211
|
+
)
|
212
|
+
return
|
213
|
+
|
214
|
+
if "WORKSTATION_ID" not in os.environ:
|
215
|
+
click.secho(
|
216
|
+
"All outerbounds app commands can only be run from a workstation.",
|
217
|
+
fg="red",
|
218
|
+
err=True,
|
219
|
+
)
|
220
|
+
|
221
|
+
return
|
222
|
+
|
223
|
+
try:
|
224
|
+
try:
|
225
|
+
metaflow_token = metaflowconfig.get_metaflow_token_from_config(
|
226
|
+
config_dir, profile
|
227
|
+
)
|
228
|
+
api_url = metaflowconfig.get_sanitized_url_from_config(
|
229
|
+
config_dir, profile, "OBP_API_SERVER"
|
230
|
+
)
|
231
|
+
|
232
|
+
workstations_response = requests.get(
|
233
|
+
f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
|
234
|
+
)
|
235
|
+
workstations_response.raise_for_status()
|
236
|
+
except:
|
237
|
+
click.secho("Failed to list workstations!", fg="red", err=True)
|
238
|
+
return
|
239
|
+
|
240
|
+
app_found = False
|
241
|
+
workstations_json = workstations_response.json()["workstations"]
|
242
|
+
for workstation in workstations_json:
|
243
|
+
if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
|
244
|
+
if "named_ports" in workstation["spec"]:
|
245
|
+
for named_port in workstation["spec"]["named_ports"]:
|
246
|
+
if (
|
247
|
+
int(named_port["port"]) == port
|
248
|
+
or named_port["name"] == name
|
249
|
+
):
|
250
|
+
app_found = True
|
251
|
+
if named_port["enabled"]:
|
252
|
+
try:
|
253
|
+
response = requests.put(
|
254
|
+
f"{api_url}/v1/workstations/update/{os.environ['WORKSTATION_ID']}/namedports",
|
255
|
+
headers={"x-api-key": metaflow_token},
|
256
|
+
json={
|
257
|
+
"port": named_port["port"],
|
258
|
+
"name": named_port["name"],
|
259
|
+
"enabled": False,
|
260
|
+
},
|
261
|
+
)
|
262
|
+
response.raise_for_status()
|
263
|
+
click.secho(
|
264
|
+
f"App {named_port['name']} stopped on port {named_port['port']}!",
|
265
|
+
fg="green",
|
266
|
+
err=True,
|
267
|
+
)
|
268
|
+
except Exception as e:
|
269
|
+
click.secho(
|
270
|
+
f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
|
271
|
+
fg="red",
|
272
|
+
err=True,
|
273
|
+
)
|
274
|
+
return
|
275
|
+
|
276
|
+
if app_found:
|
277
|
+
already_stopped_message = (
|
278
|
+
f"No deployed app named {name} found."
|
279
|
+
if name
|
280
|
+
else f"There is no app deployed on port {port}"
|
281
|
+
)
|
282
|
+
click.secho(
|
283
|
+
already_stopped_message,
|
284
|
+
fg="green",
|
285
|
+
err=True,
|
286
|
+
)
|
287
|
+
return
|
288
|
+
|
289
|
+
err_message = (
|
290
|
+
(f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}")
|
291
|
+
if port != -1
|
292
|
+
else f"App {name} not found on workstation {os.environ['WORKSTATION_ID']}"
|
293
|
+
)
|
294
|
+
|
295
|
+
click.secho(
|
296
|
+
err_message,
|
297
|
+
fg="red",
|
298
|
+
err=True,
|
299
|
+
)
|
300
|
+
except Exception as e:
|
301
|
+
click.secho(f"Failed to stop app on port {port}!", fg="red", err=True)
|
302
|
+
|
303
|
+
|
304
|
+
@app.command(help="List all apps on the workstation")
|
305
|
+
@click.option(
|
306
|
+
"-d",
|
307
|
+
"--config-dir",
|
308
|
+
default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
|
309
|
+
help="Path to Metaflow configuration directory",
|
310
|
+
show_default=True,
|
311
|
+
)
|
312
|
+
@click.option(
|
313
|
+
"-p",
|
314
|
+
"--profile",
|
315
|
+
default=os.environ.get("METAFLOW_PROFILE", ""),
|
316
|
+
help="The named metaflow profile in which your workstation exists",
|
317
|
+
)
|
318
|
+
def list(config_dir=None, profile=None):
|
319
|
+
if "WORKSTATION_ID" not in os.environ:
|
320
|
+
click.secho(
|
321
|
+
"All outerbounds app commands can only be run from a workstation.",
|
322
|
+
fg="red",
|
323
|
+
err=True,
|
324
|
+
)
|
325
|
+
|
326
|
+
return
|
327
|
+
|
328
|
+
try:
|
329
|
+
try:
|
330
|
+
metaflow_token = metaflowconfig.get_metaflow_token_from_config(
|
331
|
+
config_dir, profile
|
332
|
+
)
|
333
|
+
api_url = metaflowconfig.get_sanitized_url_from_config(
|
334
|
+
config_dir, profile, "OBP_API_SERVER"
|
335
|
+
)
|
336
|
+
|
337
|
+
workstations_response = requests.get(
|
338
|
+
f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
|
339
|
+
)
|
340
|
+
workstations_response.raise_for_status()
|
341
|
+
except:
|
342
|
+
click.secho("Failed to list workstations!", fg="red", err=True)
|
343
|
+
return
|
344
|
+
|
345
|
+
workstations_json = workstations_response.json()["workstations"]
|
346
|
+
for workstation in workstations_json:
|
347
|
+
if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
|
348
|
+
if "named_ports" in workstation["spec"]:
|
349
|
+
for named_port in workstation["spec"]["named_ports"]:
|
350
|
+
if named_port["enabled"]:
|
351
|
+
click.secho(
|
352
|
+
f"App Name: {named_port['name']}", fg="green", err=True
|
353
|
+
)
|
354
|
+
click.secho(
|
355
|
+
f"App Port on Workstation: {named_port['port']}",
|
356
|
+
fg="green",
|
357
|
+
err=True,
|
358
|
+
)
|
359
|
+
click.secho(f"App Status: Deployed", fg="green", err=True)
|
360
|
+
click.secho(
|
361
|
+
f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{named_port['name']}/",
|
362
|
+
fg="green",
|
363
|
+
err=True,
|
364
|
+
)
|
365
|
+
else:
|
366
|
+
click.secho(
|
367
|
+
f"App Port on Workstation: {named_port['port']}",
|
368
|
+
fg="yellow",
|
369
|
+
err=True,
|
370
|
+
)
|
371
|
+
click.secho(
|
372
|
+
f"App Status: Not Deployed", fg="yellow", err=True
|
373
|
+
)
|
374
|
+
|
375
|
+
click.echo("\n", err=True)
|
376
|
+
except Exception as e:
|
377
|
+
click.secho(f"Failed to list apps!", fg="red", err=True)
|
378
|
+
|
379
|
+
|
380
|
+
def ensure_app_start_request_is_valid(existing_named_ports, port: int, name: str):
|
381
|
+
existing_apps_by_port = {np["port"]: np for np in existing_named_ports}
|
382
|
+
|
383
|
+
if port not in existing_apps_by_port:
|
384
|
+
raise ValueError(f"Port {port} not found on workstation")
|
385
|
+
|
386
|
+
for existing_named_port in existing_named_ports:
|
387
|
+
if (
|
388
|
+
name == existing_named_port["name"]
|
389
|
+
and existing_named_port["port"] != port
|
390
|
+
and existing_named_port["enabled"]
|
391
|
+
):
|
392
|
+
raise ValueError(
|
393
|
+
f"App with name '{name}' is already deployed on port {existing_named_port['port']}"
|
394
|
+
)
|
395
|
+
|
396
|
+
|
397
|
+
def wait_for_app_port_to_be_accessible(
|
398
|
+
api_url, metaflow_token, workstation_id, app_name, poll_timeout_seconds
|
399
|
+
) -> bool:
|
400
|
+
num_retries_per_request = 3
|
401
|
+
start_time = time.time()
|
402
|
+
retry_delay = 1.0
|
403
|
+
poll_interval = 10
|
404
|
+
wait_message = f"App {app_name} is currently being deployed..."
|
405
|
+
while time.time() - start_time < poll_timeout_seconds:
|
406
|
+
for _ in range(num_retries_per_request):
|
407
|
+
try:
|
408
|
+
workstations_response = requests.get(
|
409
|
+
f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
|
410
|
+
)
|
411
|
+
workstations_response.raise_for_status()
|
412
|
+
if is_app_ready(workstations_response.json(), workstation_id, app_name):
|
413
|
+
click.secho(
|
414
|
+
wait_message,
|
415
|
+
fg="yellow",
|
416
|
+
err=True,
|
417
|
+
)
|
418
|
+
time.sleep(APP_READY_EXTRA_BUFFER_SECONDS)
|
419
|
+
return True
|
420
|
+
else:
|
421
|
+
click.secho(
|
422
|
+
wait_message,
|
423
|
+
fg="yellow",
|
424
|
+
err=True,
|
425
|
+
)
|
426
|
+
time.sleep(poll_interval)
|
427
|
+
except (
|
428
|
+
requests.exceptions.ConnectionError,
|
429
|
+
requests.exceptions.ReadTimeout,
|
430
|
+
):
|
431
|
+
time.sleep(retry_delay)
|
432
|
+
retry_delay *= 2 # Double the delay for the next attempt
|
433
|
+
retry_delay += random.uniform(0, 1) # Add jitter
|
434
|
+
retry_delay = min(retry_delay, 10)
|
435
|
+
return False
|
436
|
+
|
437
|
+
|
438
|
+
def is_app_ready(response_json: dict, workstation_id: str, app_name: str) -> bool:
|
439
|
+
"""Checks if the app is ready in the given workstation's response."""
|
440
|
+
workstations = response_json.get("workstations", [])
|
441
|
+
for workstation in workstations:
|
442
|
+
if workstation.get("instance_id") == workstation_id:
|
443
|
+
hosted_apps = workstation.get("status", {}).get("hosted_apps", [])
|
444
|
+
for hosted_app in hosted_apps:
|
445
|
+
if hosted_app.get("name") == app_name:
|
446
|
+
return bool(hosted_app.get("ready"))
|
447
|
+
return False
|
448
|
+
|
449
|
+
|
450
|
+
cli.add_command(app, name="app")
|