outerbounds 0.3.58__py3-none-any.whl → 0.3.60__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,12 @@
1
1
  import click
2
2
  from . import local_setup_cli
3
3
  from . import workstations_cli
4
+ from . import perimeters_cli
4
5
 
5
6
 
6
7
  @click.command(
7
- cls=click.CommandCollection, sources=[local_setup_cli.cli, workstations_cli.cli]
8
+ cls=click.CommandCollection,
9
+ sources=[local_setup_cli.cli, workstations_cli.cli, perimeters_cli.cli],
8
10
  )
9
11
  def cli(**kwargs):
10
12
  pass
@@ -43,6 +43,8 @@ BAD_EXTENSION_MESSAGE = (
43
43
  "Mis-installation of the Outerbounds Platform extension package has been detected."
44
44
  )
45
45
 
46
+ PERIMETER_CONFIG_URL_KEY = "OB_CURRENT_PERIMETER_MF_CONFIG_URL"
47
+
46
48
 
47
49
  class Narrator:
48
50
  def __init__(self, verbose):
@@ -235,20 +237,21 @@ class ConfigEntrySpec:
235
237
  def get_config_specs():
236
238
  return [
237
239
  ConfigEntrySpec(
238
- "METAFLOW_DATASTORE_SYSROOT_S3", "s3://[a-z0-9\-]+/metaflow[/]?"
240
+ "METAFLOW_DATASTORE_SYSROOT_S3",
241
+ r"s3://[a-z0-9\-]+/metaflow(-[a-z0-9\-]+)?[/]?",
242
+ ),
243
+ ConfigEntrySpec(
244
+ "METAFLOW_DATATOOLS_S3ROOT", r"s3://[a-z0-9\-]+/data(-[a-z0-9\-]+)?[/]?"
239
245
  ),
240
- ConfigEntrySpec("METAFLOW_DATATOOLS_S3ROOT", "s3://[a-z0-9\-]+/data[/]?"),
241
246
  ConfigEntrySpec("METAFLOW_DEFAULT_AWS_CLIENT_PROVIDER", "obp", expected="obp"),
242
247
  ConfigEntrySpec("METAFLOW_DEFAULT_DATASTORE", "s3", expected="s3"),
243
248
  ConfigEntrySpec("METAFLOW_DEFAULT_METADATA", "service", expected="service"),
244
- ConfigEntrySpec(
245
- "METAFLOW_KUBERNETES_NAMESPACE", "jobs\-default", expected="jobs-default"
246
- ),
247
- ConfigEntrySpec("METAFLOW_KUBERNETES_SANDBOX_INIT_SCRIPT", "eval \$\(.*"),
248
- ConfigEntrySpec("METAFLOW_SERVICE_AUTH_KEY", "[a-zA-Z0-9!_\-\.]+"),
249
- ConfigEntrySpec("METAFLOW_SERVICE_URL", "https://metadata\..*"),
250
- ConfigEntrySpec("METAFLOW_UI_URL", "https://ui\..*"),
251
- ConfigEntrySpec("OBP_AUTH_SERVER", "auth\..*"),
249
+ ConfigEntrySpec("METAFLOW_KUBERNETES_NAMESPACE", r"jobs-.*"),
250
+ ConfigEntrySpec("METAFLOW_KUBERNETES_SANDBOX_INIT_SCRIPT", r"eval \$\(.*"),
251
+ ConfigEntrySpec("METAFLOW_SERVICE_AUTH_KEY", r"[a-zA-Z0-9!_\-\.]+"),
252
+ ConfigEntrySpec("METAFLOW_SERVICE_URL", r"https://metadata\..*"),
253
+ ConfigEntrySpec("METAFLOW_UI_URL", r"https://ui\..*"),
254
+ ConfigEntrySpec("OBP_AUTH_SERVER", r"auth\..*"),
252
255
  ]
253
256
 
254
257
 
@@ -261,7 +264,12 @@ def check_metaflow_config(narrator: Narrator) -> CommandStatus:
261
264
  mitigation="",
262
265
  )
263
266
 
264
- config = metaflowconfig.init_config()
267
+ profile = os.environ.get("METAFLOW_PROFILE")
268
+ config_dir = os.path.expanduser(
269
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
270
+ )
271
+
272
+ config = metaflowconfig.init_config(config_dir, profile)
265
273
  for spec in get_config_specs():
266
274
  narrator.announce_check("config entry " + spec.name)
267
275
  if spec.name not in config:
@@ -304,7 +312,12 @@ def check_metaflow_token(narrator: Narrator) -> CommandStatus:
304
312
  mitigation="",
305
313
  )
306
314
 
307
- config = metaflowconfig.init_config()
315
+ profile = os.environ.get("METAFLOW_PROFILE")
316
+ config_dir = os.path.expanduser(
317
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
318
+ )
319
+
320
+ config = metaflowconfig.init_config(config_dir, profile)
308
321
  try:
309
322
  if "OBP_AUTH_SERVER" in config:
310
323
  k8s_response = requests.get(
@@ -363,7 +376,13 @@ def check_workstation_api_accessible(narrator: Narrator) -> CommandStatus:
363
376
  )
364
377
 
365
378
  try:
366
- config = metaflowconfig.init_config()
379
+ profile = os.environ.get("METAFLOW_PROFILE")
380
+ config_dir = os.path.expanduser(
381
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
382
+ )
383
+
384
+ config = metaflowconfig.init_config(config_dir, profile)
385
+
367
386
  missing_keys = []
368
387
  if "METAFLOW_SERVICE_AUTH_KEY" not in config:
369
388
  missing_keys.append("METAFLOW_SERVICE_AUTH_KEY")
@@ -422,7 +441,13 @@ def check_kubeconfig_valid_for_workstations(narrator: Narrator) -> CommandStatus
422
441
  )
423
442
 
424
443
  try:
425
- config = metaflowconfig.init_config()
444
+ profile = os.environ.get("METAFLOW_PROFILE")
445
+ config_dir = os.path.expanduser(
446
+ os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
447
+ )
448
+
449
+ config = metaflowconfig.init_config(config_dir, profile)
450
+
426
451
  missing_keys = []
427
452
  if "METAFLOW_SERVICE_AUTH_KEY" not in config:
428
453
  missing_keys.append("METAFLOW_SERVICE_AUTH_KEY")
@@ -585,6 +610,13 @@ class ConfigurationWriter:
585
610
  self.decoded_config = None
586
611
  self.out_dir = out_dir
587
612
  self.profile = profile
613
+ self.selected_perimeter = None
614
+
615
+ ob_config_dir = path.expanduser(os.getenv("OBP_CONFIG_DIR", out_dir))
616
+ self.ob_config_path = path.join(
617
+ ob_config_dir,
618
+ "ob_config_{}.json".format(profile) if profile else "ob_config.json",
619
+ )
588
620
 
589
621
  def decode(self):
590
622
  self.decoded_config = deserialize(self.encoded_config)
@@ -592,6 +624,9 @@ class ConfigurationWriter:
592
624
  def process_decoded_config(self):
593
625
  config_type = self.decoded_config.get("OB_CONFIG_TYPE", "inline")
594
626
  if config_type == "inline":
627
+ if "OBP_PERIMETER" in self.decoded_config:
628
+ self.selected_perimeter = self.decoded_config["OBP_PERIMETER"]
629
+
595
630
  if "OBP_METAFLOW_CONFIG_URL" in self.decoded_config:
596
631
  self.decoded_config = {
597
632
  "OBP_METAFLOW_CONFIG_URL": self.decoded_config[
@@ -648,6 +683,18 @@ class ConfigurationWriter:
648
683
  with open(config_path, "w") as fd:
649
684
  json.dump(self.existing, fd, indent=4)
650
685
 
686
+ # Every time a config is initialized, we should also reset the corresponding ob_config[_profile].json
687
+ remote_config = metaflowconfig.init_config(self.out_dir, self.profile)
688
+ if self.selected_perimeter and "OBP_METAFLOW_CONFIG_URL" in self.decoded_config:
689
+ with open(self.ob_config_path, "w") as fd:
690
+ ob_config_dict = {
691
+ "OB_CURRENT_PERIMETER": self.selected_perimeter,
692
+ PERIMETER_CONFIG_URL_KEY: self.decoded_config[
693
+ "OBP_METAFLOW_CONFIG_URL"
694
+ ],
695
+ }
696
+ json.dump(ob_config_dict, fd, indent=4)
697
+
651
698
  def confirm_overwrite_config(self, config_path):
652
699
  if os.path.exists(config_path):
653
700
  if not click.confirm(
@@ -670,6 +717,64 @@ class ConfigurationWriter:
670
717
  return True
671
718
 
672
719
 
720
+ def get_gha_jwt(audience: str):
721
+ # These are specific environment variables that are set by GitHub Actions.
722
+ if (
723
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN" in os.environ
724
+ and "ACTIONS_ID_TOKEN_REQUEST_URL" in os.environ
725
+ ):
726
+ try:
727
+ response = requests.get(
728
+ url=os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"],
729
+ headers={
730
+ "Authorization": f"Bearer {os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}"
731
+ },
732
+ params={"audience": audience},
733
+ )
734
+ response.raise_for_status()
735
+ return response.json()["value"]
736
+ except Exception as e:
737
+ click.secho(
738
+ "Failed to fetch JWT token from GitHub Actions. Please make sure you are permission 'id-token: write' is set on the GHA jobs level.",
739
+ fg="red",
740
+ )
741
+ sys.exit(1)
742
+
743
+ click.secho(
744
+ "The --github-actions flag was set, but we didn't not find '$ACTIONS_ID_TOKEN_REQUEST_TOKEN' and '$ACTIONS_ID_TOKEN_REQUEST_URL' environment variables. Please make sure you are running this command in a GitHub Actions environment and with correct permissions as per https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers",
745
+ fg="red",
746
+ )
747
+ sys.exit(1)
748
+
749
+
750
+ def get_origin_token(
751
+ service_principal_name: str,
752
+ deployment: str,
753
+ perimeter: str,
754
+ token: str,
755
+ auth_server: str,
756
+ ):
757
+ try:
758
+ response = requests.get(
759
+ f"{auth_server}/generate/service-principal",
760
+ headers={"x-api-key": token},
761
+ data=json.dumps(
762
+ {
763
+ "servicePrincipalName": service_principal_name,
764
+ "deploymentName": deployment,
765
+ "perimeter": perimeter,
766
+ }
767
+ ),
768
+ )
769
+ response.raise_for_status()
770
+ return response.json()["token"]
771
+ except Exception as e:
772
+ click.secho(
773
+ f"Failed to get origin token from {auth_server}. Error: {str(e)}", fg="red"
774
+ )
775
+ sys.exit(1)
776
+
777
+
673
778
  @click.group(help="The Outerbounds Platform CLI", no_args_is_help=True)
674
779
  def cli(**kwargs):
675
780
  pass
@@ -778,3 +883,116 @@ def configure(
778
883
  except Exception as e:
779
884
  click.secho("Writing the configuration file '{}' failed.".format(writer.path()))
780
885
  click.secho("Error: {}".format(str(e)))
886
+
887
+
888
+ @cli.command(
889
+ help="Authenticate service principals using JWT minted by their IDPs and configure Metaflow"
890
+ )
891
+ @click.option(
892
+ "-n",
893
+ "--name",
894
+ default="",
895
+ help="The name of service principals to authenticate",
896
+ required=True,
897
+ )
898
+ @click.option(
899
+ "--deployment-domain",
900
+ default="",
901
+ help="The full domain of the target Outerbounds Platform deployment (eg. 'foo.obp.outerbounds.com')",
902
+ required=True,
903
+ )
904
+ @click.option(
905
+ "-p",
906
+ "--perimeter",
907
+ default="default",
908
+ help="The name of the perimeter to authenticate the service principal in",
909
+ )
910
+ @click.option(
911
+ "-t",
912
+ "--jwt-token",
913
+ default="",
914
+ help="The JWT token that will be used to authenticate against the OBP Auth Server.",
915
+ )
916
+ @click.option(
917
+ "--github-actions",
918
+ is_flag=True,
919
+ help="Set if the command is being run in a GitHub Actions environment. If both --jwt-token and --github-actions are specified the --github-actions flag will be ignored.",
920
+ )
921
+ @click.option(
922
+ "-d",
923
+ "--config-dir",
924
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
925
+ help="Path to Metaflow configuration directory",
926
+ show_default=True,
927
+ )
928
+ @click.option(
929
+ "--profile",
930
+ default="",
931
+ help="Configure a named profile. Activate the profile by setting "
932
+ "`METAFLOW_PROFILE` environment variable.",
933
+ )
934
+ @click.option(
935
+ "-e",
936
+ "--echo",
937
+ is_flag=True,
938
+ help="Print decoded configuration to stdout",
939
+ )
940
+ @click.option(
941
+ "-f",
942
+ "--force",
943
+ is_flag=True,
944
+ help="Force overwrite of existing configuration",
945
+ )
946
+ def service_principal_configure(
947
+ name: str,
948
+ deployment_domain: str,
949
+ perimeter: str,
950
+ jwt_token="",
951
+ github_actions=False,
952
+ config_dir=None,
953
+ profile=None,
954
+ echo=None,
955
+ force=False,
956
+ ):
957
+ audience = f"https://{deployment_domain}"
958
+ if jwt_token == "" and github_actions:
959
+ jwt_token = get_gha_jwt(audience)
960
+
961
+ if jwt_token == "":
962
+ click.secho(
963
+ "No JWT token provided. Please provider either a valid jwt token or set --github-actions",
964
+ fg="red",
965
+ )
966
+ sys.exit(1)
967
+
968
+ auth_server = f"https://auth.{deployment_domain}"
969
+ deployment_name = deployment_domain.split(".")[0]
970
+ origin_token = get_origin_token(
971
+ name, deployment_name, perimeter, jwt_token, auth_server
972
+ )
973
+
974
+ api_server = f"https://api.{deployment_domain}"
975
+ metaflow_config = metaflowconfig.get_remote_metaflow_config_for_perimeter(
976
+ origin_token, perimeter, api_server
977
+ )
978
+
979
+ writer = ConfigurationWriter(serialize(metaflow_config), config_dir, profile)
980
+ try:
981
+ writer.decode()
982
+ except:
983
+ click.secho("Decoding the configuration text failed.", fg="red")
984
+ sys.exit(1)
985
+ try:
986
+ writer.process_decoded_config()
987
+ except DecodedConfigProcessingError as e:
988
+ click.secho("Resolving the configuration remotely failed.", fg="red")
989
+ click.secho(str(e), fg="magenta")
990
+ sys.exit(1)
991
+ try:
992
+ if echo == True:
993
+ writer.display()
994
+ if force or writer.confirm_overwrite():
995
+ writer.write_config()
996
+ except Exception as e:
997
+ click.secho("Writing the configuration file '{}' failed.".format(writer.path()))
998
+ click.secho("Error: {}".format(str(e)))
@@ -0,0 +1,400 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import zlib
9
+ from base64 import b64decode, b64encode
10
+ from importlib.machinery import PathFinder
11
+ from os import path
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, List
14
+
15
+ import boto3
16
+ import click
17
+ import requests
18
+ from requests.exceptions import HTTPError
19
+
20
+ from ..utils import kubeconfig, metaflowconfig
21
+ from ..utils.schema import (
22
+ CommandStatus,
23
+ OuterboundsCommandResponse,
24
+ OuterboundsCommandStatus,
25
+ )
26
+
27
+ from .local_setup_cli import PERIMETER_CONFIG_URL_KEY
28
+
29
+
30
+ @click.group()
31
+ def cli(**kwargs):
32
+ pass
33
+
34
+
35
+ @click.group(help="Manage perimeters")
36
+ def perimeter(**kwargs):
37
+ pass
38
+
39
+
40
+ @perimeter.command(help="Switch current perimeter")
41
+ @click.option(
42
+ "-d",
43
+ "--config-dir",
44
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
45
+ help="Path to Metaflow configuration directory",
46
+ show_default=True,
47
+ )
48
+ @click.option(
49
+ "-p",
50
+ "--profile",
51
+ default=os.environ.get("METAFLOW_PROFILE", ""),
52
+ help="The named metaflow profile in which your workstation exists",
53
+ )
54
+ @click.option(
55
+ "-o",
56
+ "--output",
57
+ default="",
58
+ help="Show output in the specified format.",
59
+ type=click.Choice(["json", ""]),
60
+ )
61
+ @click.option("--id", default="", type=str, help="Perimeter name to switch to")
62
+ @click.option(
63
+ "-f",
64
+ "--force",
65
+ is_flag=True,
66
+ help="Force change the existing perimeter",
67
+ default=False,
68
+ )
69
+ def switch(config_dir=None, profile=None, output="", id=None, force=False):
70
+ switch_perimeter_response = OuterboundsCommandResponse()
71
+
72
+ switch_perimeter_step = CommandStatus(
73
+ "SwitchPerimeter",
74
+ OuterboundsCommandStatus.OK,
75
+ "Perimeter was successfully switched!",
76
+ )
77
+
78
+ perimeters = get_perimeters_from_api_or_fail_command(
79
+ config_dir, profile, output, switch_perimeter_response, switch_perimeter_step
80
+ )
81
+ confirm_user_has_access_to_perimeter_or_fail(
82
+ id, perimeters, output, switch_perimeter_response, switch_perimeter_step
83
+ )
84
+
85
+ path_to_config = get_ob_config_file_path(config_dir, profile)
86
+
87
+ import fcntl
88
+
89
+ try:
90
+ if os.path.exists(path_to_config):
91
+ if not force:
92
+ fd = os.open(path_to_config, os.O_WRONLY)
93
+ # Try to acquire an exclusive lock
94
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
95
+ else:
96
+ click.secho(
97
+ "Force flag is set. Perimeter will be switched, but can have unintended consequences on other running processes.",
98
+ fg="yellow",
99
+ err=True,
100
+ )
101
+
102
+ ob_config_dict = {
103
+ "OB_CURRENT_PERIMETER": str(id),
104
+ PERIMETER_CONFIG_URL_KEY: perimeters[id]["remote_config_url"],
105
+ }
106
+
107
+ # Now that we have the lock, we can safely write to the file
108
+ with open(path_to_config, "w") as file:
109
+ json.dump(ob_config_dict, file, indent=4)
110
+
111
+ click.secho("Perimeter switched to {}".format(id), fg="green", err=True)
112
+ except BlockingIOError:
113
+ # This exception is raised if the file is already locked (non-blocking mode)
114
+ # Note that its the metaflow package (the extension actually) that acquires a shared read lock
115
+ # on the file whenever a process imports metaflow.
116
+ # In the future we might want to get smarter about it and show which process is holding the lock.
117
+ click.secho(
118
+ "Can't switch perimeter while Metaflow is in use. Please make sure there are no running python processes or notebooks using metaflow.",
119
+ fg="red",
120
+ err=True,
121
+ )
122
+ switch_perimeter_step.update(
123
+ status=OuterboundsCommandStatus.FAIL,
124
+ reason="Can't switch perimeter while Metaflow is in use.",
125
+ mitigation="Please make sure there are no running python processes or notebooks using metaflow.",
126
+ )
127
+
128
+ switch_perimeter_response.add_step(switch_perimeter_step)
129
+ if output == "json":
130
+ click.echo(json.dumps(switch_perimeter_response.as_dict(), indent=4))
131
+ return
132
+
133
+
134
+ @perimeter.command(help="Show current perimeter")
135
+ @click.option(
136
+ "-d",
137
+ "--config-dir",
138
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
139
+ help="Path to Metaflow configuration directory",
140
+ show_default=True,
141
+ )
142
+ @click.option(
143
+ "-p",
144
+ "--profile",
145
+ default=os.environ.get("METAFLOW_PROFILE", ""),
146
+ help="Configure a named profile. Activate the profile by setting "
147
+ "`METAFLOW_PROFILE` environment variable.",
148
+ )
149
+ @click.option(
150
+ "-o",
151
+ "--output",
152
+ default="",
153
+ help="Show output in the specified format.",
154
+ type=click.Choice(["json", ""]),
155
+ )
156
+ def show_current(config_dir=None, profile=None, output=""):
157
+ show_current_perimeter_response = OuterboundsCommandResponse()
158
+
159
+ show_current_perimeter_step = CommandStatus(
160
+ "ShowCurrentPerimeter",
161
+ OuterboundsCommandStatus.OK,
162
+ "Current Perimeter Fetch Successful.",
163
+ )
164
+
165
+ ob_config_dict = get_ob_config_or_fail_command(
166
+ config_dir,
167
+ profile,
168
+ output,
169
+ show_current_perimeter_response,
170
+ show_current_perimeter_step,
171
+ )
172
+
173
+ perimeters = get_perimeters_from_api_or_fail_command(
174
+ config_dir,
175
+ profile,
176
+ output,
177
+ show_current_perimeter_response,
178
+ show_current_perimeter_step,
179
+ )
180
+ confirm_user_has_access_to_perimeter_or_fail(
181
+ ob_config_dict["OB_CURRENT_PERIMETER"],
182
+ perimeters,
183
+ output,
184
+ show_current_perimeter_response,
185
+ show_current_perimeter_step,
186
+ )
187
+
188
+ click.secho(
189
+ "Current Perimeter: {}".format(ob_config_dict["OB_CURRENT_PERIMETER"]),
190
+ fg="green",
191
+ err=True,
192
+ )
193
+
194
+ show_current_perimeter_response.add_or_update_data(
195
+ "current_perimeter", ob_config_dict["OB_CURRENT_PERIMETER"]
196
+ )
197
+
198
+ if output == "json":
199
+ click.echo(json.dumps(show_current_perimeter_response.as_dict(), indent=4))
200
+
201
+
202
+ @perimeter.command(help="List all available perimeters")
203
+ @click.option(
204
+ "-d",
205
+ "--config-dir",
206
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
207
+ help="Path to Metaflow configuration directory",
208
+ show_default=True,
209
+ )
210
+ @click.option(
211
+ "-p",
212
+ "--profile",
213
+ default=os.environ.get("METAFLOW_PROFILE", ""),
214
+ help="The named metaflow profile in which your workstation exists",
215
+ )
216
+ @click.option(
217
+ "-o",
218
+ "--output",
219
+ default="",
220
+ help="Show output in the specified format.",
221
+ type=click.Choice(["json", ""]),
222
+ )
223
+ def list(config_dir=None, profile=None, output=""):
224
+ list_perimeters_response = OuterboundsCommandResponse()
225
+
226
+ list_perimeters_step = CommandStatus(
227
+ "ListPerimeters", OuterboundsCommandStatus.OK, "Perimeter Fetch Successful."
228
+ )
229
+
230
+ if "WORKSTATION_ID" in os.environ and (
231
+ "OBP_DEFAULT_PERIMETER" not in os.environ
232
+ or "OBP_DEFAULT_PERIMETER_URL" not in os.environ
233
+ ):
234
+ list_perimeters_response.update(
235
+ OuterboundsCommandStatus.NOT_SUPPORTED,
236
+ 500,
237
+ "Perimeters are not supported on old workstations.",
238
+ )
239
+ click.secho(
240
+ "Perimeters are not supported on old workstations.", err=True, fg="red"
241
+ )
242
+ if output == "json":
243
+ click.echo(json.dumps(list_perimeters_response.as_dict(), indent=4))
244
+ return
245
+
246
+ ob_config_dict = get_ob_config_or_fail_command(
247
+ config_dir, profile, output, list_perimeters_response, list_perimeters_step
248
+ )
249
+ active_perimeter = ob_config_dict["OB_CURRENT_PERIMETER"]
250
+
251
+ perimeters = get_perimeters_from_api_or_fail_command(
252
+ config_dir, profile, output, list_perimeters_response, list_perimeters_step
253
+ )
254
+
255
+ perimeter_list = []
256
+ for perimeter in perimeters.values():
257
+ status = "OK"
258
+ perimeter_list.append(
259
+ {
260
+ "id": perimeter["perimeter"],
261
+ "active": perimeter["perimeter"] == active_perimeter,
262
+ "status": status,
263
+ }
264
+ )
265
+ if perimeter["perimeter"] != active_perimeter:
266
+ click.secho("Perimeter: {}".format(perimeter["perimeter"]), err=True)
267
+ else:
268
+ click.secho(
269
+ "Perimeter: {} (active)".format(perimeter["perimeter"]),
270
+ fg="green",
271
+ err=True,
272
+ )
273
+
274
+ list_perimeters_response.add_or_update_data("perimeters", perimeter_list)
275
+
276
+ if output == "json":
277
+ click.echo(json.dumps(list_perimeters_response.as_dict(), indent=4))
278
+
279
+
280
+ def get_list_perimeters_api_response(config_dir, profile):
281
+ metaflow_token = metaflowconfig.get_metaflow_token_from_config(config_dir, profile)
282
+ api_url = metaflowconfig.get_sanitized_url_from_config(
283
+ config_dir, profile, "OBP_API_SERVER"
284
+ )
285
+ perimeters_response = requests.get(
286
+ f"{api_url}/v1/me/perimeters?privilege=Execute",
287
+ headers={"x-api-key": metaflow_token},
288
+ )
289
+ perimeters_response.raise_for_status()
290
+ return perimeters_response.json()["perimeters"]
291
+
292
+
293
+ def get_ob_config_file_path(config_dir: str, profile: str) -> str:
294
+ # If OBP_CONFIG_DIR is set, use that, otherwise use METAFLOW_HOME
295
+ # If neither are set, use ~/.metaflowconfig
296
+ obp_config_dir = path.expanduser(os.environ.get("OBP_CONFIG_DIR", config_dir))
297
+
298
+ ob_config_filename = f"ob_config_{profile}.json" if profile else "ob_config.json"
299
+ return os.path.expanduser(os.path.join(obp_config_dir, ob_config_filename))
300
+
301
+
302
+ def get_perimeters_from_api_or_fail_command(
303
+ config_dir: str,
304
+ profile: str,
305
+ output: str,
306
+ command_response: OuterboundsCommandResponse,
307
+ command_step: CommandStatus,
308
+ ) -> Dict[str, Dict[str, str]]:
309
+ try:
310
+ perimeters = get_list_perimeters_api_response(config_dir, profile)
311
+ except:
312
+ click.secho(
313
+ "Failed to fetch perimeters from API.",
314
+ fg="red",
315
+ err=True,
316
+ )
317
+ command_step.update(
318
+ status=OuterboundsCommandStatus.FAIL,
319
+ reason="Failed to fetch perimeters from API",
320
+ mitigation="",
321
+ )
322
+ command_response.add_step(command_step)
323
+ if output == "json":
324
+ click.echo(json.dumps(command_response.as_dict(), indent=4))
325
+ sys.exit(1)
326
+ return {p["perimeter"]: p for p in perimeters}
327
+
328
+
329
+ def get_ob_config_or_fail_command(
330
+ config_dir: str,
331
+ profile: str,
332
+ output: str,
333
+ command_response: OuterboundsCommandResponse,
334
+ command_step: CommandStatus,
335
+ ) -> Dict[str, str]:
336
+ path_to_config = get_ob_config_file_path(config_dir, profile)
337
+
338
+ if not os.path.exists(path_to_config):
339
+ click.secho(
340
+ "Config file not found at {}".format(path_to_config), fg="red", err=True
341
+ )
342
+ command_step.update(
343
+ status=OuterboundsCommandStatus.FAIL,
344
+ reason="Config file not found",
345
+ mitigation="Please make sure the config file exists at {}".format(
346
+ path_to_config
347
+ ),
348
+ )
349
+ command_response.add_step(command_step)
350
+ if output == "json":
351
+ click.echo(json.dumps(command_response.as_dict(), indent=4))
352
+ sys.exit(1)
353
+
354
+ with open(path_to_config, "r") as file:
355
+ ob_config_dict = json.load(file)
356
+
357
+ if "OB_CURRENT_PERIMETER" not in ob_config_dict:
358
+ click.secho(
359
+ "OB_CURRENT_PERIMETER not found in Config file: {}".format(path_to_config),
360
+ fg="red",
361
+ err=True,
362
+ )
363
+ command_step.update(
364
+ status=OuterboundsCommandStatus.FAIL,
365
+ reason="OB_CURRENT_PERIMETER not found in Config file: {}",
366
+ mitigation="",
367
+ )
368
+ command_response.add_step(command_step)
369
+ if output == "json":
370
+ click.echo(json.dumps(command_response.as_dict(), indent=4))
371
+ sys.exit(1)
372
+
373
+ return ob_config_dict
374
+
375
+
376
+ def confirm_user_has_access_to_perimeter_or_fail(
377
+ perimeter_id: str,
378
+ perimeters: Dict[str, Any],
379
+ output: str,
380
+ command_response: OuterboundsCommandResponse,
381
+ command_step: CommandStatus,
382
+ ):
383
+ if perimeter_id not in perimeters:
384
+ click.secho(
385
+ f"You do not have access to perimeter {perimeter_id} or it does not exist.",
386
+ fg="red",
387
+ err=True,
388
+ )
389
+ command_step.update(
390
+ status=OuterboundsCommandStatus.FAIL,
391
+ reason=f"You do not have access to perimeter {perimeter_id} or it does not exist.",
392
+ mitigation="",
393
+ )
394
+ command_response.add_step(command_step)
395
+ if output == "json":
396
+ click.echo(json.dumps(command_response.as_dict(), indent=4))
397
+ sys.exit(1)
398
+
399
+
400
+ cli.add_command(perimeter, name="perimeter")
@@ -19,6 +19,10 @@ from ..utils.schema import (
19
19
  OuterboundsCommandStatus,
20
20
  )
21
21
  from tempfile import NamedTemporaryFile
22
+ from .perimeters_cli import (
23
+ get_perimeters_from_api_or_fail_command,
24
+ confirm_user_has_access_to_perimeter_or_fail,
25
+ )
22
26
 
23
27
  KUBECTL_INSTALL_MITIGATION = "Please install kubectl manually from https://kubernetes.io/docs/tasks/tools/#kubectl"
24
28
 
@@ -89,7 +93,7 @@ def generate_workstation_token(config_dir=None, profile=None):
89
93
  @click.option(
90
94
  "-p",
91
95
  "--profile",
92
- default="",
96
+ default=os.environ.get("METAFLOW_PROFILE", ""),
93
97
  help="The named metaflow profile in which your workstation exists",
94
98
  )
95
99
  @click.option(
@@ -110,7 +114,6 @@ def configure_cloud_workstation(config_dir=None, profile=None, binary=None, outp
110
114
  kubeconfig_configure_step = CommandStatus(
111
115
  "ConfigureKubeConfig", OuterboundsCommandStatus.OK, "Kubeconfig is configured"
112
116
  )
113
-
114
117
  try:
115
118
  metaflow_token = metaflowconfig.get_metaflow_token_from_config(
116
119
  config_dir, profile
@@ -191,10 +194,25 @@ def configure_cloud_workstation(config_dir=None, profile=None, binary=None, outp
191
194
  @click.option(
192
195
  "-p",
193
196
  "--profile",
194
- default="",
197
+ default=os.environ.get("METAFLOW_PROFILE", ""),
195
198
  help="The named metaflow profile in which your workstation exists",
196
199
  )
197
- def list_workstations(config_dir=None, profile=None):
200
+ @click.option(
201
+ "-o",
202
+ "--output",
203
+ default="json",
204
+ help="Show output in the specified format.",
205
+ type=click.Choice(["json"]),
206
+ )
207
+ def list_workstations(config_dir=None, profile=None, output="json"):
208
+ list_response = OuterboundsCommandResponse()
209
+ list_step = CommandStatus(
210
+ "listWorkstations",
211
+ OuterboundsCommandStatus.OK,
212
+ "Workstation list successfully fetched!",
213
+ )
214
+ list_response.add_or_update_data("workstations", [])
215
+
198
216
  try:
199
217
  metaflow_token = metaflowconfig.get_metaflow_token_from_config(
200
218
  config_dir, profile
@@ -205,17 +223,23 @@ def list_workstations(config_dir=None, profile=None):
205
223
  workstations_response = requests.get(
206
224
  f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
207
225
  )
208
- try:
209
- workstations_response.raise_for_status()
210
- click.echo(json.dumps(workstations_response.json(), indent=4))
211
- except HTTPError:
212
- click.secho("Failed to generate workstation token.", fg="red")
213
- click.secho(
214
- "Error: {}".format(json.dumps(workstations_response.json(), indent=4))
215
- )
226
+ workstations_response.raise_for_status()
227
+ list_response.add_or_update_data(
228
+ "workstations", workstations_response.json()["workstations"]
229
+ )
230
+ if output == "json":
231
+ click.echo(json.dumps(list_response.as_dict(), indent=4))
216
232
  except Exception as e:
217
- click.secho("Failed to list workstations", fg="red")
218
- click.secho("Error: {}".format(str(e)))
233
+ list_step.update(
234
+ OuterboundsCommandStatus.FAIL, "Failed to list workstations", ""
235
+ )
236
+ list_response.add_step(list_step)
237
+ if output == "json":
238
+ list_response.add_or_update_data("error", str(e))
239
+ click.echo(json.dumps(list_response.as_dict(), indent=4))
240
+ else:
241
+ click.secho("Failed to list workstations", fg="red", err=True)
242
+ click.secho("Error: {}".format(str(e)), fg="red", err=True)
219
243
 
220
244
 
221
245
  @cli.command(help="Hibernate workstation", hidden=True)
@@ -235,7 +259,7 @@ def list_workstations(config_dir=None, profile=None):
235
259
  @click.option(
236
260
  "-w",
237
261
  "--workstation",
238
- default="",
262
+ default=os.environ.get("METAFLOW_PROFILE", ""),
239
263
  help="The ID of the workstation to hibernate",
240
264
  )
241
265
  def hibernate_workstation(config_dir=None, profile=None, workstation=None):
@@ -243,6 +267,8 @@ def hibernate_workstation(config_dir=None, profile=None, workstation=None):
243
267
  click.secho("Please specify a workstation ID", fg="red")
244
268
  return
245
269
  try:
270
+ if not profile:
271
+ profile = metaflowconfig.get_metaflow_profile()
246
272
  metaflow_token = metaflowconfig.get_metaflow_token_from_config(
247
273
  config_dir, profile
248
274
  )
@@ -267,7 +293,7 @@ def hibernate_workstation(config_dir=None, profile=None, workstation=None):
267
293
  )
268
294
  except Exception as e:
269
295
  click.secho("Failed to hibernate workstation", fg="red")
270
- click.secho("Error: {}".format(str(e)))
296
+ click.secho("Error: {}".format(str(e)), fg="red")
271
297
 
272
298
 
273
299
  @cli.command(help="Restart workstation to the int", hidden=True)
@@ -281,7 +307,7 @@ def hibernate_workstation(config_dir=None, profile=None, workstation=None):
281
307
  @click.option(
282
308
  "-p",
283
309
  "--profile",
284
- default="",
310
+ default=os.environ.get("METAFLOW_PROFILE", ""),
285
311
  help="The named metaflow profile in which your workstation exists",
286
312
  )
287
313
  @click.option(
@@ -319,7 +345,7 @@ def restart_workstation(config_dir=None, profile=None, workstation=None):
319
345
  )
320
346
  except Exception as e:
321
347
  click.secho("Failed to restart workstation", fg="red")
322
- click.secho("Error: {}".format(str(e)))
348
+ click.secho("Error: {}".format(str(e)), fg="red")
323
349
 
324
350
 
325
351
  @cli.command(help="Install dependencies needed by workstations", hidden=True)
@@ -486,8 +512,85 @@ def add_to_path(program_path, platform):
486
512
  with open(path_to_rc_file, "a+") as f: # Open bashrc file
487
513
  if program_path not in f.read():
488
514
  f.write("\n# Added by Outerbounds\n")
489
- f.write(program_path)
515
+ f.write(f"export PATH=$PATH:{program_path}")
490
516
 
491
517
 
492
518
  def to_windows_path(path):
493
519
  return os.path.normpath(path).replace(os.sep, "\\")
520
+
521
+
522
+ @cli.command(help="Show relevant links for a deployment & perimeter", hidden=True)
523
+ @click.option(
524
+ "-d",
525
+ "--config-dir",
526
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
527
+ help="Path to Metaflow configuration directory",
528
+ show_default=True,
529
+ )
530
+ @click.option(
531
+ "-p",
532
+ "--profile",
533
+ default="",
534
+ help="The named metaflow profile in which your workstation exists",
535
+ )
536
+ @click.option(
537
+ "--perimeter-id",
538
+ default="",
539
+ help="The id of the perimeter to use",
540
+ )
541
+ @click.option(
542
+ "-o",
543
+ "--output",
544
+ default="",
545
+ help="Show output in the specified format.",
546
+ type=click.Choice(["json", ""]),
547
+ )
548
+ def show_relevant_links(config_dir=None, profile=None, perimeter_id="", output=""):
549
+ show_links_response = OuterboundsCommandResponse()
550
+ show_links_step = CommandStatus(
551
+ "showRelevantLinks",
552
+ OuterboundsCommandStatus.OK,
553
+ "Relevant links successfully fetched!",
554
+ )
555
+ show_links_response.add_or_update_data("links", [])
556
+ links = []
557
+ try:
558
+ if not perimeter_id:
559
+ metaflow_config = metaflowconfig.init_config(config_dir, profile)
560
+ else:
561
+ perimeters_dict = get_perimeters_from_api_or_fail_command(
562
+ config_dir, profile, output, show_links_response, show_links_step
563
+ )
564
+ confirm_user_has_access_to_perimeter_or_fail(
565
+ perimeter_id,
566
+ perimeters_dict,
567
+ output,
568
+ show_links_response,
569
+ show_links_step,
570
+ )
571
+
572
+ metaflow_config = metaflowconfig.init_config_from_url(
573
+ config_dir, profile, perimeters_dict[perimeter_id]["remote_config_url"]
574
+ )
575
+
576
+ links.append(
577
+ {
578
+ "id": "metaflow-ui-url",
579
+ "url": metaflow_config["METAFLOW_UI_URL"],
580
+ "label": "Metaflow UI URL",
581
+ }
582
+ )
583
+ show_links_response.add_or_update_data("links", links)
584
+ if output == "json":
585
+ click.echo(json.dumps(show_links_response.as_dict(), indent=4))
586
+ except Exception as e:
587
+ show_links_step.update(
588
+ OuterboundsCommandStatus.FAIL, "Failed to show relevant links", ""
589
+ )
590
+ show_links_response.add_step(show_links_step)
591
+ if output == "json":
592
+ show_links_response.add_or_update_data("error", str(e))
593
+ click.echo(json.dumps(show_links_response.as_dict(), indent=4))
594
+ else:
595
+ click.secho("Failed to show relevant links", fg="red", err=True)
596
+ click.secho("Error: {}".format(str(e)), fg="red", err=True)
@@ -1,15 +1,45 @@
1
+ import click
1
2
  import json
2
3
  import os
3
4
  import requests
5
+ from os import path
6
+ import requests
7
+ from typing import Dict
8
+ import sys
9
+
4
10
 
11
+ def init_config(config_dir, profile) -> Dict[str, str]:
12
+ config = read_metaflow_config_from_filesystem(config_dir, profile)
5
13
 
6
- def init_config() -> dict:
7
- profile = os.environ.get("METAFLOW_PROFILE")
8
- config_dir = os.path.expanduser(
9
- os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
14
+ # This is new remote-metaflow config; fetch it from the URL
15
+ if "OBP_METAFLOW_CONFIG_URL" in config:
16
+ remote_config = init_config_from_url(
17
+ config_dir, profile, config["OBP_METAFLOW_CONFIG_URL"]
18
+ )
19
+ remote_config["OBP_METAFLOW_CONFIG_URL"] = config["OBP_METAFLOW_CONFIG_URL"]
20
+ return remote_config
21
+ # Legacy config, use from filesystem
22
+ return config
23
+
24
+
25
+ def init_config_from_url(config_dir, profile, url) -> Dict[str, str]:
26
+ config = read_metaflow_config_from_filesystem(config_dir, profile)
27
+
28
+ if config is None or "METAFLOW_SERVICE_AUTH_KEY" not in config:
29
+ raise Exception("METAFLOW_SERVICE_AUTH_KEY not found in config file")
30
+
31
+ config_response = requests.get(
32
+ url,
33
+ headers={"x-api-key": f'{config["METAFLOW_SERVICE_AUTH_KEY"]}'},
10
34
  )
35
+ config_response.raise_for_status()
36
+ remote_config = config_response.json()["config"]
37
+ return remote_config
11
38
 
39
+
40
+ def read_metaflow_config_from_filesystem(config_dir, profile) -> Dict[str, str]:
12
41
  config_filename = f"config_{profile}.json" if profile else "config.json"
42
+
13
43
  path_to_config = os.path.join(config_dir, config_filename)
14
44
 
15
45
  if os.path.exists(path_to_config):
@@ -17,22 +47,6 @@ def init_config() -> dict:
17
47
  config = json.load(json_file)
18
48
  else:
19
49
  raise Exception("Unable to locate metaflow config at '%s')" % (path_to_config))
20
-
21
- # This is new remote-metaflow config; fetch it from the URL
22
- if "OBP_METAFLOW_CONFIG_URL" in config:
23
- if config is None or "METAFLOW_SERVICE_AUTH_KEY" not in config:
24
- raise Exception("METAFLOW_SERVICE_AUTH_KEY not found in config file")
25
-
26
- config_response = requests.get(
27
- config["OBP_METAFLOW_CONFIG_URL"],
28
- headers={"x-api-key": f'{config["METAFLOW_SERVICE_AUTH_KEY"]}'},
29
- )
30
- config_response.raise_for_status()
31
- remote_config = config_response.json()["config"]
32
- remote_config["METAFLOW_SERVICE_AUTH_KEY"] = config["METAFLOW_SERVICE_AUTH_KEY"]
33
- return remote_config
34
-
35
- # Legacy config, use from filesystem
36
50
  return config
37
51
 
38
52
 
@@ -44,13 +58,10 @@ def get_metaflow_token_from_config(config_dir: str, profile: str) -> str:
44
58
  config_dir (str): Path to the config directory
45
59
  profile (str): The named metaflow profile
46
60
  """
47
- config_filename = f"config_{profile}.json" if profile else "config.json"
48
- config_path = os.path.join(config_dir, config_filename)
49
- with open(config_path) as json_file:
50
- config = json.load(json_file)
51
- if config is None or "METAFLOW_SERVICE_AUTH_KEY" not in config:
52
- raise Exception("METAFLOW_SERVICE_AUTH_KEY not found in config file")
53
- return config["METAFLOW_SERVICE_AUTH_KEY"]
61
+ config = init_config(config_dir, profile)
62
+ if config is None or "METAFLOW_SERVICE_AUTH_KEY" not in config:
63
+ raise Exception("METAFLOW_SERVICE_AUTH_KEY not found in config file")
64
+ return config["METAFLOW_SERVICE_AUTH_KEY"]
54
65
 
55
66
 
56
67
  def get_sanitized_url_from_config(config_dir: str, profile: str, key: str) -> str:
@@ -62,16 +73,32 @@ def get_sanitized_url_from_config(config_dir: str, profile: str, key: str) -> st
62
73
  profile (str): The named metaflow profile
63
74
  key (str): The key to look up in the config file
64
75
  """
65
- config_filename = f"config_{profile}.json" if profile else "config.json"
66
- config_path = os.path.join(config_dir, config_filename)
67
-
68
- with open(config_path) as json_file:
69
- config = json.load(json_file)
70
- if key not in config:
71
- raise Exception(f"Key {key} not found in config file {config_path}")
72
- url_in_config = config[key]
73
- if not url_in_config.startswith("https://"):
74
- url_in_config = f"https://{url_in_config}"
75
-
76
- url_in_config = url_in_config.rstrip("/")
77
- return url_in_config
76
+ config = init_config(config_dir, profile)
77
+ if key not in config:
78
+ raise Exception(f"Key {key} not found in config")
79
+ url_in_config = config[key]
80
+ if not url_in_config.startswith("https://"):
81
+ url_in_config = f"https://{url_in_config}"
82
+
83
+ url_in_config = url_in_config.rstrip("/")
84
+ return url_in_config
85
+
86
+
87
+ def get_remote_metaflow_config_for_perimeter(
88
+ origin_token: str, perimeter: str, api_server: str
89
+ ):
90
+ try:
91
+ response = requests.get(
92
+ f"{api_server}/v1/perimeters/{perimeter}/metaflowconfigs/default",
93
+ headers={"x-api-key": origin_token},
94
+ )
95
+ response.raise_for_status()
96
+ config = response.json()["config"]
97
+ config["METAFLOW_SERVICE_AUTH_KEY"] = origin_token
98
+ return config
99
+ except Exception as e:
100
+ click.secho(
101
+ f"Failed to get metaflow config from {api_server}. Error: {str(e)}",
102
+ fg="red",
103
+ )
104
+ sys.exit(1)
@@ -5,6 +5,7 @@ class OuterboundsCommandStatus(Enum):
5
5
  OK = "OK"
6
6
  FAIL = "FAIL"
7
7
  WARN = "WARN"
8
+ NOT_SUPPORTED = "NOT_SUPPORTED"
8
9
 
9
10
 
10
11
  class CommandStatus:
@@ -37,10 +38,19 @@ class OuterboundsCommandResponse:
37
38
  self._message = ""
38
39
  self._steps = []
39
40
  self.metadata = {}
41
+ self._data = {}
42
+
43
+ def update(self, status, code, message):
44
+ self.status = status
45
+ self._code = code
46
+ self._message = message
40
47
 
41
48
  def add_or_update_metadata(self, key, value):
42
49
  self.metadata[key] = value
43
50
 
51
+ def add_or_update_data(self, key, value):
52
+ self._data[key] = value
53
+
44
54
  def add_step(self, step: CommandStatus):
45
55
  self._steps.append(step)
46
56
  self._process_step_status(step)
@@ -59,10 +69,11 @@ class OuterboundsCommandResponse:
59
69
  self._message = "We found one or more warnings with your installation."
60
70
 
61
71
  def as_dict(self):
72
+ self._data["steps"] = [step.as_dict() for step in self._steps]
62
73
  return {
63
74
  "status": self.status.value,
64
75
  "code": self._code,
65
76
  "message": self._message,
66
- "steps": [step.as_dict() for step in self._steps],
67
77
  "metadata": self.metadata,
78
+ "data": self._data,
68
79
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: outerbounds
3
- Version: 0.3.58
3
+ Version: 0.3.60
4
4
  Summary: More Data Science, Less Administration
5
5
  License: Proprietary
6
6
  Keywords: data science,machine learning,MLOps
@@ -23,9 +23,9 @@ Requires-Dist: click (>=8.1.3,<9.0.0)
23
23
  Requires-Dist: google-api-core (>=2.16.1,<3.0.0) ; extra == "gcp"
24
24
  Requires-Dist: google-auth (>=2.27.0,<3.0.0) ; extra == "gcp"
25
25
  Requires-Dist: google-cloud-storage (>=2.14.0,<3.0.0) ; extra == "gcp"
26
- Requires-Dist: ob-metaflow (==2.11.4.9)
27
- Requires-Dist: ob-metaflow-extensions (==1.1.49)
28
- Requires-Dist: ob-metaflow-stubs (==2.11.4.9)
26
+ Requires-Dist: ob-metaflow (==2.11.9.1)
27
+ Requires-Dist: ob-metaflow-extensions (==1.1.51)
28
+ Requires-Dist: ob-metaflow-stubs (==2.11.9.1)
29
29
  Requires-Dist: opentelemetry-distro (==0.41b0)
30
30
  Requires-Dist: opentelemetry-exporter-otlp-proto-http (==1.20.0)
31
31
  Requires-Dist: opentelemetry-instrumentation-requests (==0.41b0)
@@ -0,0 +1,15 @@
1
+ outerbounds/__init__.py,sha256=GPdaubvAYF8pOFWJ3b-sPMKCpyfpteWVMZWkmaYhxRw,32
2
+ outerbounds/cli_main.py,sha256=e9UMnPysmc7gbrimq2I4KfltggyU7pw59Cn9aEguVcU,74
3
+ outerbounds/command_groups/__init__.py,sha256=QPWtj5wDRTINDxVUL7XPqG3HoxHNvYOg08EnuSZB2Hc,21
4
+ outerbounds/command_groups/cli.py,sha256=H4LxcYTmsY9DQUrReSRLjvbg9s9Ro7s-eUrcMqEJ_9A,261
5
+ outerbounds/command_groups/local_setup_cli.py,sha256=cqdZ_Jg6CFlaIFwI-LRb_13LQqN0MUQx8wBFi-okG28,35982
6
+ outerbounds/command_groups/perimeters_cli.py,sha256=ICH-StHHYXVAAYvVT8NfMxCDDtKnULnP_vCXrqKOZ48,12770
7
+ outerbounds/command_groups/workstations_cli.py,sha256=b5lt8_g2B0zCoUoNriTRv32IPB6E4mI2sUhubDT7Yjo,21966
8
+ outerbounds/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ outerbounds/utils/kubeconfig.py,sha256=l1mUP1j9VIq3fsffi5bJ1Nk-hYlwd1dIqkpj7DvVS1E,7936
10
+ outerbounds/utils/metaflowconfig.py,sha256=cQWD7zoVkOPXd6q2tqmqACjL0IN-0RgiQ45ojxXBYSM,3529
11
+ outerbounds/utils/schema.py,sha256=cNlgjmteLPbDzSEUSQDsq8txdhMGyezSmM83jU3aa0w,2329
12
+ outerbounds-0.3.60.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
13
+ outerbounds-0.3.60.dist-info/METADATA,sha256=51YozDJl2MV1cLz5uR0oceAyqNdyom6oSPwVFWw56jA,1407
14
+ outerbounds-0.3.60.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
15
+ outerbounds-0.3.60.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- outerbounds/__init__.py,sha256=GPdaubvAYF8pOFWJ3b-sPMKCpyfpteWVMZWkmaYhxRw,32
2
- outerbounds/cli_main.py,sha256=e9UMnPysmc7gbrimq2I4KfltggyU7pw59Cn9aEguVcU,74
3
- outerbounds/command_groups/__init__.py,sha256=QPWtj5wDRTINDxVUL7XPqG3HoxHNvYOg08EnuSZB2Hc,21
4
- outerbounds/command_groups/cli.py,sha256=61VsBlPG2ykP_786eCyllqeM8DMhPAOfj2FhktrSd7k,207
5
- outerbounds/command_groups/local_setup_cli.py,sha256=g_kkrlDGzYvZTm184pW6QwotpkcqBamB14kH_Kv8TbM,28685
6
- outerbounds/command_groups/workstations_cli.py,sha256=VgydQzCas3mlAFyzZuanjl1E8Zh7pBrbKbbP6t6N2WU,18237
7
- outerbounds/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- outerbounds/utils/kubeconfig.py,sha256=l1mUP1j9VIq3fsffi5bJ1Nk-hYlwd1dIqkpj7DvVS1E,7936
9
- outerbounds/utils/metaflowconfig.py,sha256=6u9D4x-pQVCPKnmGkTg9uSSHrq4mGnWQl7TurwyV2e8,2945
10
- outerbounds/utils/schema.py,sha256=nBuarFbdZu0LGhG0YkJ6pEIvdglfM_TO_W_Db2vksb0,2017
11
- outerbounds-0.3.58.dist-info/METADATA,sha256=ADmikJlmX_lGTzMKbqEBXBSi_X_pBFTE6iZiZYLCEUw,1407
12
- outerbounds-0.3.58.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
13
- outerbounds-0.3.58.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
14
- outerbounds-0.3.58.dist-info/RECORD,,