outerbounds 0.3.171__py3-none-any.whl → 0.3.173rc0__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.
@@ -0,0 +1,125 @@
1
+ """
2
+ Examples demonstrating how to use the code packager abstraction.
3
+
4
+ This file provides usage examples for the code packager classes.
5
+ These examples are for documentation purposes and are not meant to be run directly.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from io import BytesIO
11
+ from typing import List, Dict, Any, Callable, Optional
12
+
13
+ from .code_packager import CodePackager
14
+
15
+
16
+ def basic_usage_example(datastore_type: str = "s3") -> None:
17
+ """
18
+ Basic usage example with ContentAddressedStore.
19
+
20
+ This example shows how to:
21
+ 1. Define paths to include in a package
22
+ 2. Create a package using CodePackager's default packaging
23
+ 3. Store the package using ContentAddressedStore directly
24
+ 4. Generate a download command
25
+
26
+ Parameters
27
+ ----------
28
+ datastore_type : str, default "s3"
29
+ The type of datastore to use: "s3", "azure", "gs", or "local"
30
+ """
31
+ # Define the paths to include in the package
32
+ paths_to_include = ["./"]
33
+
34
+ # Define which file suffixes to include
35
+ file_suffixes = [".py", ".md"]
36
+
37
+ # Create metadata for the package
38
+ metadata = {"example": True, "timestamp": "2023-01-01T00:00:00Z"}
39
+
40
+ # Initialize the packager with datastore configuration
41
+ packager = CodePackager(
42
+ datastore_type=datastore_type,
43
+ code_package_prefix="my-custom-prefix", # Optional
44
+ )
45
+
46
+ # Store the package with packaging parameters
47
+ package_url, package_key = packager.store(
48
+ paths_to_include=paths_to_include,
49
+ file_suffixes=file_suffixes,
50
+ metadata=metadata,
51
+ )
52
+
53
+ # Generate a download command
54
+ download_cmd = CodePackager.get_download_cmd(
55
+ package_url=package_url,
56
+ datastore_type=datastore_type,
57
+ target_file="my_package.tar",
58
+ )
59
+
60
+ # Generate complete package commands for downloading and setup
61
+ package_commands = packager.get_package_commands(
62
+ code_package_url=package_url,
63
+ target_file="my_package.tar",
64
+ working_dir="my_app",
65
+ )
66
+
67
+ # Print some information
68
+ print(f"Package URL: {package_url}")
69
+ print(f"Package Key: {package_key}")
70
+ print(f"Download Command: {download_cmd}")
71
+ print(f"Complete package commands: {package_commands}")
72
+
73
+
74
+ def usage_with_custom_package_function(datastore_type: str = "s3") -> None:
75
+ """
76
+ Example of using the CodePackager with a custom package creation function.
77
+
78
+ Parameters
79
+ ----------
80
+ datastore_type : str, default "s3"
81
+ The type of datastore to use: "s3", "azure", "gs", or "local"
82
+ """
83
+
84
+ # Define a custom package function
85
+ def create_custom_package():
86
+ # This is a simple example - in real use, you might create a more complex package
87
+ from io import BytesIO
88
+ import tarfile
89
+ import time
90
+
91
+ buf = BytesIO()
92
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
93
+ # Add a simple file to the tarball
94
+ content = b"print('Hello, custom package!')"
95
+ info = tarfile.TarInfo(name="hello.py")
96
+ info.size = len(content)
97
+ info.mtime = int(time.time())
98
+ file_object = BytesIO(content)
99
+ tar.addfile(info, file_object)
100
+
101
+ return bytearray(buf.getvalue())
102
+
103
+ # Initialize the packager with datastore configuration
104
+ packager = CodePackager(datastore_type=datastore_type)
105
+
106
+ # Store the package with the custom package function
107
+ package_url, package_key = packager.store(package_create_fn=create_custom_package)
108
+
109
+ # Generate a download command
110
+ download_cmd = CodePackager.get_download_cmd(
111
+ package_url=package_url,
112
+ datastore_type=datastore_type,
113
+ target_file="custom_package.tar",
114
+ )
115
+
116
+ # Generate complete package commands
117
+ package_commands = packager.get_package_commands(
118
+ code_package_url=package_url,
119
+ )
120
+
121
+ # Print some information
122
+ print(f"Package URL: {package_url}")
123
+ print(f"Package Key: {package_key}")
124
+ print(f"Download Command: {download_cmd}")
125
+ print(f"Complete commands: {package_commands}")
@@ -0,0 +1,194 @@
1
+
2
+ title: Outerbounds App Configuration Schema
3
+ description: |
4
+ Schema for defining Outerbounds Apps configuration. This schema is what we will end up using on the CLI/programmatic interface.
5
+ All the properties in this schema will then translate into an IR that will have resolved information from the
6
+ version: 1.0.0
7
+ type: object
8
+ required:
9
+ - name
10
+ - port
11
+ properties:
12
+ name: # Only used in `deploy` command
13
+ allow_union: true # Allow overriding name from the CLI.
14
+ type: string
15
+ description: The name of the app to deploy.
16
+ maxLength: 20 # todo: check if we should allow a larger length.
17
+ example: "myapp"
18
+ port: # Only used in `deploy` command
19
+ allow_union: false
20
+ type: integer
21
+ description: Port where the app is hosted. When deployed this will be port on which we will deploy the app.
22
+ minimum: 1
23
+ maximum: 65535
24
+ example: 8000
25
+ tags: # Only used in `deploy` command
26
+ allow_union: true
27
+ type: array
28
+ description: The tags of the app to deploy.
29
+ items:
30
+ type: string
31
+ example: ["production", "v1.0"]
32
+ image: # Only used in `deploy` command
33
+ allow_union: true # We will overrwite the image if specified on the CLI.
34
+ type: string
35
+ description: The Docker image to deploy with the App.
36
+ example: "python:3.10-slim"
37
+ secrets: # Used in `run` command
38
+ allow_union: true
39
+ type: array
40
+ description: Outerbounds integrations to attach to the app. You can use the value you set in the `@secrets` decorator in your code.
41
+ items:
42
+ type: string
43
+ example: ["outerbounds.hf-token"]
44
+ environment: # Used in `run` command
45
+ # Todo: So this part might not be best on the CLI. We should probably have a better way to handle this.
46
+ # In simplicity, we can just JSON dump anything that looks like a dict/list/
47
+ allow_union: true
48
+ type: object
49
+ description: Environment variables to deploy with the App.
50
+ additionalProperties:
51
+ oneOf:
52
+ - type: string
53
+ - type: number
54
+ - type: boolean
55
+ - type: object
56
+ - type: array # When users give arrays, or objects, we need to JSON dump them. Users need to be aware of this.
57
+ example:
58
+ DEBUG: true
59
+ DATABASE_CONFIG: {"host": "localhost", "port": 5432}
60
+ ALLOWED_ORIGINS: ["http://localhost:3000", "https://myapp.com"]
61
+ dependencies: # Used in `run` command
62
+ allow_union: false
63
+ type: object
64
+ description: |
65
+ The dependencies to attach to the app. Only one of the properties can be specified.
66
+ properties:
67
+ from_requirements_file:
68
+ type: string
69
+ description: The path to the requirements.txt file to attach to the app.
70
+ example: "requirements.txt"
71
+ from_pyproject_toml:
72
+ type: string
73
+ description: The path to the pyproject.toml file to attach to the app.
74
+ example: "pyproject.toml"
75
+ python:
76
+ type: string
77
+ description: |
78
+ The Python version to use for the app.
79
+ example: "3.10"
80
+ pypi:
81
+ type: object
82
+ description: |
83
+ A dictionary of pypi dependencies to attach to the app.
84
+ The key is the package name and the value is the version.
85
+ example:
86
+ numpy: 1.23.0
87
+ pandas: ""
88
+ conda:
89
+ type: object
90
+ description: |
91
+ A dictionary of pypi dependencies to attach to the app.
92
+ The key is the package name and the value is the version.
93
+ example:
94
+ numpy: 1.23.0
95
+ pandas: ""
96
+ package:
97
+ allow_union: false
98
+ type: object
99
+ description: |
100
+ Configurations associated with packaging the app.
101
+ properties:
102
+ src_path:
103
+ type: string
104
+ description: The path to the source code to deploy with the App.
105
+ example: "./"
106
+ suffixes:
107
+ type: array
108
+ description: |
109
+ A list of suffixes to add to the source code to deploy with the App.
110
+ items:
111
+ type: string
112
+ example: [".py", ".ipynb"]
113
+
114
+ commands: # Used in `run` command
115
+ allow_union: false
116
+ type: array
117
+ description: A list of commands to run the app with. Cannot be configured from the CLI. Only used in `run` command.
118
+ items:
119
+ type: string
120
+ example: ["python app.py", "python app.py --foo bar"]
121
+ resources: # Only used in `deploy` command
122
+ allow_union: true # You can override CPU/Memory/GPU/Storage from the CLI.
123
+ type: object
124
+ properties:
125
+ cpu:
126
+ type: string
127
+ description: CPU resource request and limit.
128
+ example: "500m"
129
+ default: "1"
130
+ memory:
131
+ type: string
132
+ description: Memory resource request and limit.
133
+ example: "512Mi"
134
+ default: "4Gi"
135
+ gpu:
136
+ type: string
137
+ description: GPU resource request and limit.
138
+ example: "1"
139
+ storage:
140
+ type: string
141
+ description: Storage resource request and limit.
142
+ example: "1Gi"
143
+ default: "10Gi"
144
+ health_check: # Can be used in `run` command
145
+ type: object
146
+ # `allow_union` property means that any object in this field will be done a union with the config file if something is provided on commanline.
147
+ # If it is set to false, then we should throw an exception if the CLI is trying to override something specified in the config file.
148
+ # We will only allow unions in certains options. The rest will not allow any unions and only need to be specified in one place.
149
+ allow_union: false
150
+ properties:
151
+ enabled:
152
+ type: boolean
153
+ description: Whether to enable health checks.
154
+ example: true
155
+ default: false
156
+ path:
157
+ type: string
158
+ description: The path for health checks.
159
+ example: "/health"
160
+ initial_delay_seconds:
161
+ type: integer
162
+ description: Number of seconds to wait before performing the first health check.
163
+ example: 10
164
+ period_seconds:
165
+ type: integer
166
+ description: How often to perform the health check.
167
+ example: 30
168
+ compute_pools: # Only used in `deploy` command
169
+ allow_union: true
170
+ type: array
171
+ description: |
172
+ A list of compute pools to deploy the app to.
173
+ items:
174
+ type: string
175
+ example: ["default", "large"]
176
+ auth: # Only used in `deploy` command
177
+ allow_union: false
178
+ type: object
179
+ description: |
180
+ Auth related configurations.
181
+ properties:
182
+ type:
183
+ type: string
184
+ description: |
185
+ The type of authentication to use for the app.
186
+ enum: [API, SSO]
187
+ public:
188
+ type: boolean
189
+ description: |
190
+ Whether the app is public or not.
191
+ default: true
192
+ # There is an allowed perimeters property
193
+ # But that needs a little more thought on how
194
+ # to expose.
@@ -0,0 +1,115 @@
1
+ import copy
2
+ import json
3
+ import os
4
+ import shutil
5
+ import sys
6
+ import tempfile
7
+ from hashlib import sha256
8
+ from typing import List, Optional, Callable, Any
9
+ from .app_config import AppConfig
10
+ from .utils import TODOException
11
+ from metaflow.metaflow_config import (
12
+ get_pinned_conda_libs,
13
+ DEFAULT_DATASTORE,
14
+ KUBERNETES_CONTAINER_IMAGE,
15
+ )
16
+ from collections import namedtuple
17
+
18
+ BakingStatus = namedtuple(
19
+ "BakingStatus", ["image_should_be_baked", "python_path", "resolved_image"]
20
+ )
21
+
22
+
23
+ class ImageBakingException(Exception):
24
+ pass
25
+
26
+
27
+ def _safe_open_file(path: str):
28
+ if not os.path.exists(path):
29
+ raise TODOException(f"File does not exist: {path}")
30
+ try:
31
+ with open(path, "r") as f:
32
+ return f.read()
33
+ except Exception as e:
34
+ raise TODOException(f"Failed to open file: {e}")
35
+
36
+
37
+ def bake_deployment_image(
38
+ app_config: AppConfig,
39
+ cache_file_path: str,
40
+ logger: Optional[Callable[[str], Any]] = None,
41
+ ) -> BakingStatus:
42
+ # When do we bake an image?
43
+ # 1. When the user has specified something like `pypi`/`conda`
44
+ # 2, When the user has specified something like `from_requirements`/ `from_pyproject`
45
+ # TODO: add parsers for the pyproject/requirements stuff.
46
+ from metaflow.ob_internal import bake_image
47
+ from metaflow.plugins.pypi.parsers import (
48
+ requirements_txt_parser,
49
+ pyproject_toml_parser,
50
+ )
51
+
52
+ image = app_config.get("image", KUBERNETES_CONTAINER_IMAGE)
53
+ python_version = "%d.%d.%d" % sys.version_info[:3]
54
+
55
+ dependencies = app_config.get_state("dependencies", {})
56
+ pypi_packages = {}
57
+ conda_packages = {}
58
+
59
+ parsed_packages = {}
60
+
61
+ if dependencies.get("from_requirements_file"):
62
+ parsed_packages = requirements_txt_parser(
63
+ _safe_open_file(dependencies.get("from_requirements_file"))
64
+ )
65
+ pypi_packages = parsed_packages.get("packages", {})
66
+ python_version = parsed_packages.get("python_version", python_version)
67
+
68
+ elif dependencies.get("from_pyproject_toml"):
69
+ parsed_packages = pyproject_toml_parser(
70
+ _safe_open_file(dependencies.get("from_pyproject_toml"))
71
+ )
72
+ pypi_packages = parsed_packages.get("packages", {})
73
+ python_version = parsed_packages.get("python_version", python_version)
74
+
75
+ elif "pypi" in dependencies:
76
+ pypi_packages = dependencies.get("pypi", {})
77
+
78
+ if "conda" in dependencies:
79
+ conda_packages = dependencies.get("conda", {})
80
+ if "python" in dependencies:
81
+ python_version = dependencies.get("python", python_version)
82
+
83
+ python_packages_exist = len(pypi_packages) > 0 or len(conda_packages) > 0
84
+ if (not python_packages_exist) or app_config.get_state("skip_dependencies", False):
85
+ # Inform the user that no dependencies are being used.
86
+ if app_config.get_state("skip_dependencies", False):
87
+ logger(
88
+ "⏭️ Skipping baking dependencies into the image based on the --no-deps flag."
89
+ )
90
+ # TODO: Handle this a little more nicely.
91
+ return BakingStatus(
92
+ image_should_be_baked=False, resolved_image=image, python_path="python"
93
+ )
94
+
95
+ pinned_conda_libs = get_pinned_conda_libs(python_version, DEFAULT_DATASTORE)
96
+ pypi_packages.update(pinned_conda_libs)
97
+ _reference = app_config.get("name", "default")
98
+ # `image` cannot be None. If by change it is none, FB will fart.
99
+ fb_response = bake_image(
100
+ cache_file_path=cache_file_path,
101
+ pypi_packages=pypi_packages,
102
+ conda_packages=conda_packages,
103
+ ref=_reference,
104
+ python=python_version,
105
+ base_image=image,
106
+ logger=logger,
107
+ )
108
+ if fb_response.failure:
109
+ raise ImageBakingException(f"Failed to bake image: {fb_response.response}")
110
+
111
+ return BakingStatus(
112
+ image_should_be_baked=True,
113
+ resolved_image=fb_response.container_image,
114
+ python_path=fb_response.python_path,
115
+ )
File without changes
@@ -0,0 +1,164 @@
1
+ from typing import Dict
2
+
3
+ import base64
4
+ import json
5
+ import requests
6
+ import random
7
+ import time
8
+ import sys
9
+
10
+ from .utils import safe_requests_wrapper, TODOException
11
+
12
+
13
+ class OuterboundsSecretsException(Exception):
14
+ pass
15
+
16
+
17
+ class SecretNotFound(OuterboundsSecretsException):
18
+ pass
19
+
20
+
21
+ class OuterboundsSecretsApiResponse:
22
+ def __init__(self, response):
23
+ self.response = response
24
+
25
+ @property
26
+ def secret_resource_id(self):
27
+ return self.response["secret_resource_id"]
28
+
29
+ @property
30
+ def secret_backend_type(self):
31
+ return self.response["secret_backend_type"]
32
+
33
+
34
+ class SecretRetriever:
35
+ def get_secret_as_dict(self, secret_id, options={}, role=None):
36
+ """
37
+ Supports a special way of specifying secrets sources in outerbounds using the format:
38
+ @secrets(sources=["outerbounds.<integrations_name>"])
39
+
40
+ When invoked it makes a requests to the integrations secrets metadata endpoint on the
41
+ keywest server to get the cloud resource id for a secret. It then uses that to invoke
42
+ secrets manager on the core oss and returns the secrets.
43
+ """
44
+ headers = {"Content-Type": "application/json", "Connection": "keep-alive"}
45
+ perimeter, integrations_url = self._get_secret_configs()
46
+ integration_name = secret_id
47
+ request_payload = {
48
+ "perimeter_name": perimeter,
49
+ "integration_name": integration_name,
50
+ }
51
+ response = self._make_request(integrations_url, headers, request_payload)
52
+ secret_resource_id = response.secret_resource_id
53
+ secret_backend_type = response.secret_backend_type
54
+
55
+ from metaflow.plugins.secrets.secrets_decorator import (
56
+ get_secrets_backend_provider,
57
+ )
58
+
59
+ secrets_provider = get_secrets_backend_provider(secret_backend_type)
60
+ secret_dict = secrets_provider.get_secret_as_dict(
61
+ secret_resource_id, options={}, role=role
62
+ )
63
+
64
+ # Outerbounds stores secrets as binaries. Hence we expect the returned secret to be
65
+ # {<cloud-secret-name>: <base64 encoded full secret>}. We decode the secret here like:
66
+ # 1. decode the base64 encoded full secret
67
+ # 2. load the decoded secret as a json
68
+ # 3. decode the base64 encoded values in the dict
69
+ # 4. return the decoded dict
70
+ binary_secret = next(iter(secret_dict.values()))
71
+ return self._decode_secret(binary_secret)
72
+
73
+ def _is_base64_encoded(self, data):
74
+ try:
75
+ if isinstance(data, str):
76
+ # Check if the string can be base64 decoded
77
+ base64.b64decode(data).decode("utf-8")
78
+ return True
79
+ return False
80
+ except Exception:
81
+ return False
82
+
83
+ def _decode_secret(self, secret):
84
+ try:
85
+ result = {}
86
+ secret_str = secret
87
+ if self._is_base64_encoded(secret):
88
+ # we check if the secret string is base64 encoded because the returned secret from
89
+ # AWS secret manager is base64 encoded while the secret from GCP is not
90
+ secret_str = base64.b64decode(secret).decode("utf-8")
91
+
92
+ secret_dict = json.loads(secret_str)
93
+ for key, value in secret_dict.items():
94
+ result[key] = base64.b64decode(value).decode("utf-8")
95
+
96
+ return result
97
+ except Exception as e:
98
+ raise OuterboundsSecretsException(f"Error decoding secret: {e}")
99
+
100
+ def _get_secret_configs(self):
101
+ from metaflow_extensions.outerbounds.remote_config import init_config
102
+ from os import environ
103
+
104
+ conf = init_config()
105
+ if "OBP_PERIMETER" in conf:
106
+ perimeter = conf["OBP_PERIMETER"]
107
+ else:
108
+ # if the perimeter is not in metaflow config, try to get it from the environment
109
+ perimeter = environ.get("OBP_PERIMETER", "")
110
+
111
+ if "OBP_INTEGRATIONS_URL" in conf:
112
+ integrations_url = conf["OBP_INTEGRATIONS_URL"]
113
+ else:
114
+ # if the integrations is not in metaflow config, try to get it from the environment
115
+ integrations_url = environ.get("OBP_INTEGRATIONS_URL", "")
116
+
117
+ if not perimeter:
118
+ raise OuterboundsSecretsException(
119
+ "No perimeter set. Please make sure to run `outerbounds configure <...>` command which can be found on the Ourebounds UI or reach out to your Outerbounds support team."
120
+ )
121
+
122
+ if not integrations_url:
123
+ raise OuterboundsSecretsException(
124
+ "No integrations url set. Please notify your Outerbounds support team about this issue."
125
+ )
126
+
127
+ integrations_secrets_metadata_url = f"{integrations_url}/secrets/metadata"
128
+ return perimeter, integrations_secrets_metadata_url
129
+
130
+ def _make_request(self, url, headers: Dict, payload: Dict):
131
+ try:
132
+ from metaflow.metaflow_config import SERVICE_HEADERS
133
+
134
+ request_headers = {**headers, **(SERVICE_HEADERS or {})}
135
+ except ImportError:
136
+ raise TODOException(
137
+ "Failed to create capsule: No Metaflow service headers found"
138
+ )
139
+
140
+ response = safe_requests_wrapper(
141
+ requests.get,
142
+ url,
143
+ data=json.dumps(payload),
144
+ headers=request_headers,
145
+ conn_error_retries=5,
146
+ retryable_status_codes=[409],
147
+ )
148
+ self._handle_error_response(response)
149
+ return OuterboundsSecretsApiResponse(response.json())
150
+
151
+ @staticmethod
152
+ def _handle_error_response(response: requests.Response):
153
+ if response.status_code >= 500:
154
+ raise OuterboundsSecretsException(
155
+ f"Server error: {response.text}. Please reach out to your Outerbounds support team."
156
+ )
157
+ status_code = response.status_code
158
+ if status_code == 404:
159
+ raise SecretNotFound(f"Secret not found: {response.text}")
160
+
161
+ if status_code >= 400:
162
+ raise OuterboundsSecretsException(
163
+ f"status_code={status_code}\t\n\t\t{response.text}"
164
+ )