outerbounds 0.3.55rc3__py3-none-any.whl → 0.3.133__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. outerbounds/_vendor/PyYAML.LICENSE +20 -0
  2. outerbounds/_vendor/__init__.py +0 -0
  3. outerbounds/_vendor/_yaml/__init__.py +34 -0
  4. outerbounds/_vendor/click/__init__.py +73 -0
  5. outerbounds/_vendor/click/_compat.py +626 -0
  6. outerbounds/_vendor/click/_termui_impl.py +717 -0
  7. outerbounds/_vendor/click/_textwrap.py +49 -0
  8. outerbounds/_vendor/click/_winconsole.py +279 -0
  9. outerbounds/_vendor/click/core.py +2998 -0
  10. outerbounds/_vendor/click/decorators.py +497 -0
  11. outerbounds/_vendor/click/exceptions.py +287 -0
  12. outerbounds/_vendor/click/formatting.py +301 -0
  13. outerbounds/_vendor/click/globals.py +68 -0
  14. outerbounds/_vendor/click/parser.py +529 -0
  15. outerbounds/_vendor/click/py.typed +0 -0
  16. outerbounds/_vendor/click/shell_completion.py +580 -0
  17. outerbounds/_vendor/click/termui.py +787 -0
  18. outerbounds/_vendor/click/testing.py +479 -0
  19. outerbounds/_vendor/click/types.py +1073 -0
  20. outerbounds/_vendor/click/utils.py +580 -0
  21. outerbounds/_vendor/click.LICENSE +28 -0
  22. outerbounds/_vendor/vendor_any.txt +2 -0
  23. outerbounds/_vendor/yaml/__init__.py +471 -0
  24. outerbounds/_vendor/yaml/_yaml.cpython-311-darwin.so +0 -0
  25. outerbounds/_vendor/yaml/composer.py +146 -0
  26. outerbounds/_vendor/yaml/constructor.py +862 -0
  27. outerbounds/_vendor/yaml/cyaml.py +177 -0
  28. outerbounds/_vendor/yaml/dumper.py +138 -0
  29. outerbounds/_vendor/yaml/emitter.py +1239 -0
  30. outerbounds/_vendor/yaml/error.py +94 -0
  31. outerbounds/_vendor/yaml/events.py +104 -0
  32. outerbounds/_vendor/yaml/loader.py +62 -0
  33. outerbounds/_vendor/yaml/nodes.py +51 -0
  34. outerbounds/_vendor/yaml/parser.py +629 -0
  35. outerbounds/_vendor/yaml/reader.py +208 -0
  36. outerbounds/_vendor/yaml/representer.py +378 -0
  37. outerbounds/_vendor/yaml/resolver.py +245 -0
  38. outerbounds/_vendor/yaml/scanner.py +1555 -0
  39. outerbounds/_vendor/yaml/serializer.py +127 -0
  40. outerbounds/_vendor/yaml/tokens.py +129 -0
  41. outerbounds/command_groups/apps_cli.py +450 -0
  42. outerbounds/command_groups/cli.py +9 -5
  43. outerbounds/command_groups/local_setup_cli.py +249 -33
  44. outerbounds/command_groups/perimeters_cli.py +231 -33
  45. outerbounds/command_groups/tutorials_cli.py +111 -0
  46. outerbounds/command_groups/workstations_cli.py +88 -15
  47. outerbounds/utils/kubeconfig.py +2 -2
  48. outerbounds/utils/metaflowconfig.py +111 -21
  49. outerbounds/utils/schema.py +8 -2
  50. outerbounds/utils/utils.py +19 -0
  51. outerbounds/vendor.py +159 -0
  52. {outerbounds-0.3.55rc3.dist-info → outerbounds-0.3.133.dist-info}/METADATA +17 -6
  53. outerbounds-0.3.133.dist-info/RECORD +59 -0
  54. {outerbounds-0.3.55rc3.dist-info → outerbounds-0.3.133.dist-info}/WHEEL +1 -1
  55. outerbounds-0.3.55rc3.dist-info/RECORD +0 -15
  56. {outerbounds-0.3.55rc3.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")