outerbounds 0.3.92__py3-none-any.whl → 0.3.94__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -17,7 +17,7 @@ def cli(**kwargs):
17
17
  pass
18
18
 
19
19
 
20
- @click.group(help="Manage perimeters")
20
+ @click.group(help="Manage apps")
21
21
  def app(**kwargs):
22
22
  pass
23
23
 
@@ -56,6 +56,21 @@ def app(**kwargs):
56
56
  type=click.Choice(["json", ""]),
57
57
  )
58
58
  def start(config_dir=None, profile=None, port=-1, name="", output=""):
59
+ if len(name) == 0 or len(name) >= 20:
60
+ click.secho(
61
+ "App name should not be more than 20 characters long.",
62
+ fg="red",
63
+ err=True,
64
+ )
65
+ return
66
+ elif not name.isalnum() or not name.islower():
67
+ click.secho(
68
+ "App name can only contain lowercase alphanumeric characters.",
69
+ fg="red",
70
+ err=True,
71
+ )
72
+ return
73
+
59
74
  start_app_response = OuterboundsCommandResponse()
60
75
 
61
76
  validate_workstation_step = CommandStatus(
@@ -70,10 +85,10 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
70
85
  "List of workstations fetched.",
71
86
  )
72
87
 
73
- validate_port_exists = CommandStatus(
74
- "ValidatePortExists",
88
+ validate_request = CommandStatus(
89
+ "ValidateRequest",
75
90
  OuterboundsCommandStatus.OK,
76
- "Port exists on workstation",
91
+ "Start app request is valid.",
77
92
  )
78
93
 
79
94
  start_app_step = CommandStatus(
@@ -127,12 +142,36 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
127
142
  for workstation in workstations_json:
128
143
  if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
129
144
  if "named_ports" in workstation["spec"]:
145
+ try:
146
+ ensure_app_start_request_is_valid(
147
+ workstation["spec"]["named_ports"], port, name
148
+ )
149
+ except ValueError as e:
150
+ click.secho(str(e), fg="red", err=True)
151
+ validate_request.update(
152
+ OuterboundsCommandStatus.FAIL,
153
+ str(e),
154
+ "",
155
+ )
156
+ start_app_response.add_step(validate_request)
157
+ if output == "json":
158
+ click.echo(
159
+ json.dumps(start_app_response.as_dict(), indent=4)
160
+ )
161
+ return
162
+
163
+ start_app_response.add_step(validate_request)
164
+
130
165
  for named_port in workstation["spec"]["named_ports"]:
131
166
  if int(named_port["port"]) == port:
132
- start_app_response.add_step(validate_port_exists)
133
- if named_port["enabled"] and named_port["name"] == name:
167
+ if named_port["enabled"]:
168
+ already_running_message = (
169
+ f"App {name} already running on port {port}!"
170
+ if named_port["name"] == name
171
+ else f"App {named_port['name']} already running on port {port} is now renamed to {name}!"
172
+ )
134
173
  click.secho(
135
- f"App {name} started on port {port}!",
174
+ already_running_message,
136
175
  fg="green",
137
176
  err=True,
138
177
  )
@@ -167,7 +206,12 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
167
206
  fg="green",
168
207
  err=True,
169
208
  )
170
- except:
209
+ click.secho(
210
+ f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{name}/",
211
+ fg="green",
212
+ err=True,
213
+ )
214
+ except Exception:
171
215
  click.secho(
172
216
  f"Failed to start app {name} on port {port}!",
173
217
  fg="red",
@@ -187,20 +231,6 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
187
231
  )
188
232
  )
189
233
  return
190
-
191
- click.secho(
192
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
193
- fg="red",
194
- err=True,
195
- )
196
- validate_port_exists.update(
197
- OuterboundsCommandStatus.FAIL,
198
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
199
- "",
200
- )
201
- start_app_response.add_step(validate_port_exists)
202
- if output == "json":
203
- click.echo(json.dumps(start_app_response.as_dict(), indent=4))
204
234
  except Exception as e:
205
235
  click.secho(f"Failed to start app {name} on port {port}!", fg="red", err=True)
206
236
  start_app_step.update(
@@ -229,10 +259,18 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
229
259
  )
230
260
  @click.option(
231
261
  "--port",
232
- required=True,
262
+ required=False,
263
+ default=-1,
233
264
  help="Port number where you want to start your app.",
234
265
  type=int,
235
266
  )
267
+ @click.option(
268
+ "--name",
269
+ required=False,
270
+ help="Name of your app",
271
+ default="",
272
+ type=str,
273
+ )
236
274
  @click.option(
237
275
  "-o",
238
276
  "--output",
@@ -240,7 +278,22 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
240
278
  help="Show output in the specified format.",
241
279
  type=click.Choice(["json", ""]),
242
280
  )
243
- def stop(config_dir=None, profile=None, port=-1, output=""):
281
+ def stop(config_dir=None, profile=None, port=-1, name="", output=""):
282
+ if port == -1 and not name:
283
+ click.secho(
284
+ "Please provide either a port number or a name to stop the app.",
285
+ fg="red",
286
+ err=True,
287
+ )
288
+ return
289
+ elif port > 0 and name:
290
+ click.secho(
291
+ "Please provide either a port number or a name to stop the app, not both.",
292
+ fg="red",
293
+ err=True,
294
+ )
295
+ return
296
+
244
297
  stop_app_response = OuterboundsCommandResponse()
245
298
 
246
299
  validate_workstation_step = CommandStatus(
@@ -313,11 +366,21 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
313
366
  if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
314
367
  if "named_ports" in workstation["spec"]:
315
368
  for named_port in workstation["spec"]["named_ports"]:
316
- if int(named_port["port"]) == port:
369
+ if (
370
+ int(named_port["port"]) == port
371
+ or named_port["name"] == name
372
+ ):
317
373
  stop_app_response.add_step(validate_port_exists)
318
374
  if not named_port["enabled"]:
375
+ already_stopped_message = (
376
+ f"No deployed app named {named_port['name']} found."
377
+ if name
378
+ else f"There is no app deployed on port {port}"
379
+ )
319
380
  click.secho(
320
- f"App stopped on port {port}!", fg="green", err=True
381
+ already_stopped_message,
382
+ fg="green",
383
+ err=True,
321
384
  )
322
385
  stop_app_response.add_step(stop_app_step)
323
386
  if output == "json":
@@ -333,26 +396,26 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
333
396
  f"{api_url}/v1/workstations/update/{os.environ['WORKSTATION_ID']}/namedports",
334
397
  headers={"x-api-key": metaflow_token},
335
398
  json={
336
- "port": port,
399
+ "port": named_port["port"],
337
400
  "name": named_port["name"],
338
401
  "enabled": False,
339
402
  },
340
403
  )
341
404
  response.raise_for_status()
342
405
  click.secho(
343
- f"App stopped on port {port}!",
406
+ f"App {named_port['name']} stopped on port {named_port['port']}!",
344
407
  fg="green",
345
408
  err=True,
346
409
  )
347
- except:
410
+ except Exception as e:
348
411
  click.secho(
349
- f"Failed to stop app on port {port}!",
412
+ f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
350
413
  fg="red",
351
414
  err=True,
352
415
  )
353
416
  stop_app_step.update(
354
417
  OuterboundsCommandStatus.FAIL,
355
- f"Failed to stop app on port {port}!",
418
+ f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
356
419
  "",
357
420
  )
358
421
 
@@ -365,14 +428,21 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
365
428
  )
366
429
  return
367
430
 
431
+ err_message = (
432
+ (f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}")
433
+ if port != -1
434
+ else f"App {name} not found on workstation {os.environ['WORKSTATION_ID']}"
435
+ )
436
+
368
437
  click.secho(
369
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
438
+ err_message,
370
439
  fg="red",
371
440
  err=True,
372
441
  )
442
+
373
443
  validate_port_exists.update(
374
444
  OuterboundsCommandStatus.FAIL,
375
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
445
+ err_message,
376
446
  "",
377
447
  )
378
448
  stop_app_response.add_step(validate_port_exists)
@@ -502,4 +572,21 @@ def list(config_dir=None, profile=None, output=""):
502
572
  click.echo(json.dumps(list_app_response.as_dict(), indent=4))
503
573
 
504
574
 
575
+ def ensure_app_start_request_is_valid(existing_named_ports, port: int, name: str):
576
+ existing_apps_by_port = {np["port"]: np for np in existing_named_ports}
577
+
578
+ if port not in existing_apps_by_port:
579
+ raise ValueError(f"Port {port} not found on workstation")
580
+
581
+ for existing_named_port in existing_named_ports:
582
+ if (
583
+ name == existing_named_port["name"]
584
+ and existing_named_port["port"] != port
585
+ and existing_named_port["enabled"]
586
+ ):
587
+ raise ValueError(
588
+ f"App with name '{name}' is already deployed on port {existing_named_port['port']}"
589
+ )
590
+
591
+
505
592
  cli.add_command(app, name="app")
@@ -1,8 +1,5 @@
1
1
  from outerbounds._vendor import click
2
- from . import local_setup_cli
3
- from . import workstations_cli
4
- from . import perimeters_cli
5
- from . import apps_cli
2
+ from . import local_setup_cli, workstations_cli, perimeters_cli, apps_cli, tutorials_cli
6
3
 
7
4
 
8
5
  @click.command(
@@ -12,6 +9,7 @@ from . import apps_cli
12
9
  workstations_cli.cli,
13
10
  perimeters_cli.cli,
14
11
  apps_cli.cli,
12
+ tutorials_cli.cli,
15
13
  ],
16
14
  )
17
15
  def cli(**kwargs):
@@ -0,0 +1,111 @@
1
+ import os
2
+ from outerbounds._vendor import click
3
+ import requests
4
+
5
+ import tarfile
6
+ import hashlib
7
+ import tempfile
8
+
9
+
10
+ @click.group()
11
+ def cli(**kwargs):
12
+ pass
13
+
14
+
15
+ @click.group(help="Manage tutorials curated by Outerbounds.", hidden=True)
16
+ def tutorials(**kwargs):
17
+ pass
18
+
19
+
20
+ @tutorials.command(help="Pull Outerbounds tutorials.")
21
+ @click.option(
22
+ "--url",
23
+ required=True,
24
+ help="URL to pull the tutorials from.",
25
+ type=str,
26
+ )
27
+ @click.option(
28
+ "--destination-dir",
29
+ help="Show output in the specified format.",
30
+ type=str,
31
+ required=True,
32
+ )
33
+ @click.option(
34
+ "--force-overwrite",
35
+ is_flag=True,
36
+ help="Overwrite all existing files across all tutorials.",
37
+ type=bool,
38
+ required=False,
39
+ default=False,
40
+ )
41
+ def pull(url="", destination_dir="", force_overwrite=False):
42
+ try:
43
+ secure_download_and_extract(
44
+ url, destination_dir, force_overwrite=force_overwrite
45
+ )
46
+ click.secho("Tutorials pulled successfully.", fg="green", err=True)
47
+ except Exception as e:
48
+ print(e)
49
+ click.secho(f"Failed to pull tutorials: {e}", fg="red", err=True)
50
+
51
+
52
+ def secure_download_and_extract(
53
+ url, dest_dir, expected_hash=None, force_overwrite=False
54
+ ):
55
+ """
56
+ Download a tar.gz file from a URL, verify its integrity, and extract its contents.
57
+
58
+ :param url: URL of the tar.gz file to download
59
+ :param dest_dir: Destination directory to extract the contents
60
+ :param expected_hash: Expected SHA256 hash of the file (optional)
61
+ """
62
+
63
+ with tempfile.TemporaryDirectory() as temp_dir:
64
+ temp_file = os.path.join(
65
+ temp_dir, hashlib.md5(url.encode()).hexdigest() + ".tar.gz"
66
+ )
67
+
68
+ # Download the file
69
+ try:
70
+ response = requests.get(url, stream=True, verify=True)
71
+ response.raise_for_status()
72
+
73
+ with open(temp_file, "wb") as f:
74
+ for chunk in response.iter_content(chunk_size=8192):
75
+ f.write(chunk)
76
+ except requests.exceptions.RequestException as e:
77
+ raise Exception(f"Failed to download file: {e}")
78
+
79
+ if expected_hash:
80
+ with open(temp_file, "rb") as f:
81
+ file_hash = hashlib.sha256(f.read()).hexdigest()
82
+ if file_hash != expected_hash:
83
+ raise Exception("File integrity check failed")
84
+
85
+ os.makedirs(dest_dir, exist_ok=True)
86
+
87
+ try:
88
+ with tarfile.open(temp_file, "r:gz") as tar:
89
+ # Keep track of new journeys to extract.
90
+ to_extract = []
91
+ members = tar.getmembers()
92
+ for member in members:
93
+ member_path = os.path.join(dest_dir, member.name)
94
+ # Check for any files trying to write outside the destination
95
+ if not os.path.abspath(member_path).startswith(
96
+ os.path.abspath(dest_dir)
97
+ ):
98
+ raise Exception("Attempted path traversal in tar file")
99
+ if not os.path.exists(member_path):
100
+ # The user might have modified the existing files, leave them untouched.
101
+ to_extract.append(member)
102
+
103
+ if force_overwrite:
104
+ tar.extractall(path=dest_dir)
105
+ else:
106
+ tar.extractall(path=dest_dir, members=to_extract)
107
+ except tarfile.TarError as e:
108
+ raise Exception(f"Failed to extract tar file: {e}")
109
+
110
+
111
+ cli.add_command(tutorials, name="tutorials")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: outerbounds
3
- Version: 0.3.92
3
+ Version: 0.3.94
4
4
  Summary: More Data Science, Less Administration
5
5
  License: Proprietary
6
6
  Keywords: data science,machine learning,MLOps
@@ -41,10 +41,11 @@ outerbounds/_vendor/yaml/serializer.py,sha256=8wFZRy9SsQSktF_f9OOroroqsh4qVUe53r
41
41
  outerbounds/_vendor/yaml/tokens.py,sha256=JBSu38wihGr4l73JwbfMA7Ks1-X84g8-NskTz7KwPmA,2578
42
42
  outerbounds/cli_main.py,sha256=e9UMnPysmc7gbrimq2I4KfltggyU7pw59Cn9aEguVcU,74
43
43
  outerbounds/command_groups/__init__.py,sha256=QPWtj5wDRTINDxVUL7XPqG3HoxHNvYOg08EnuSZB2Hc,21
44
- outerbounds/command_groups/apps_cli.py,sha256=XdGRitYWyKGDZXlGSbcP7U6F3DclqzgBG7ZGuZK8tzY,19361
45
- outerbounds/command_groups/cli.py,sha256=3NyxnczfANtxKh-KnJHGWAEC5akdXux_hylfGXTs2A0,362
44
+ outerbounds/command_groups/apps_cli.py,sha256=sgRPYMdL_c7UUcFqy1pTQno0Q2aT1LHT7POB_VUuKoE,22612
45
+ outerbounds/command_groups/cli.py,sha256=q0hdJO4biD3iEOdyJcxnRkeleA8AKAhx842kQ49I6kk,365
46
46
  outerbounds/command_groups/local_setup_cli.py,sha256=tuuqJRXQ_guEwOuQSIf9wkUU0yg8yAs31myGViAK15s,36364
47
47
  outerbounds/command_groups/perimeters_cli.py,sha256=mrJfFIRYFOjuiz-9h4OKg2JT8Utmbs72z6wvPzDss3s,18685
48
+ outerbounds/command_groups/tutorials_cli.py,sha256=UInFyiMqtscHFfi8YQwiY_6Sdw9quJOtRu5OukEBccw,3522
48
49
  outerbounds/command_groups/workstations_cli.py,sha256=V5Jbj1cVb4IRllI7fOgNgL6OekRpuFDv6CEhDb4xC6w,22016
49
50
  outerbounds/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
51
  outerbounds/utils/kubeconfig.py,sha256=yvcyRXGR4AhQuqUDqmbGxEOHw5ixMFV0AZIDg1LI_Qo,7981
@@ -52,7 +53,7 @@ outerbounds/utils/metaflowconfig.py,sha256=l2vJbgPkLISU-XPGZFaC8ZKmYFyJemlD6bwB-
52
53
  outerbounds/utils/schema.py,sha256=lMUr9kNgn9wy-sO_t_Tlxmbt63yLeN4b0xQXbDUDj4A,2331
53
54
  outerbounds/utils/utils.py,sha256=4Z8cszNob_8kDYCLNTrP-wWads_S_MdL3Uj3ju4mEsk,501
54
55
  outerbounds/vendor.py,sha256=gRLRJNXtZBeUpPEog0LOeIsl6GosaFFbCxUvR4bW6IQ,5093
55
- outerbounds-0.3.92.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
56
- outerbounds-0.3.92.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
57
- outerbounds-0.3.92.dist-info/METADATA,sha256=enOUPH2DJjaqvzpywbTNnD1aosIzZq8JlsBne9w84o8,1632
58
- outerbounds-0.3.92.dist-info/RECORD,,
56
+ outerbounds-0.3.94.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
57
+ outerbounds-0.3.94.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
58
+ outerbounds-0.3.94.dist-info/METADATA,sha256=ipH_wW6z5ITcDg1vBorq7clnY8ajMt-WMBgOcfNbxyA,1632
59
+ outerbounds-0.3.94.dist-info/RECORD,,