ob-metaflow-extensions 1.4.33__py2.py3-none-any.whl → 1.6.2__py2.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.
- metaflow_extensions/outerbounds/plugins/__init__.py +8 -1
- metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +8 -2
- metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +6 -6
- metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +1 -19
- metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +333 -0
- metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +150 -79
- metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +4 -1
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +4 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +103 -5
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +12 -1
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +100 -6
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +141 -2
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +74 -37
- metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +6 -6
- metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +2 -2
- metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +1102 -105
- metaflow_extensions/outerbounds/plugins/apps/core/exceptions.py +341 -0
- metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +42 -6
- metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +43 -3
- metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +10 -1
- metaflow_extensions/outerbounds/plugins/optuna/__init__.py +2 -1
- metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +37 -7
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +18 -8
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +6 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +39 -15
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +5 -2
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +2 -2
- metaflow_extensions/outerbounds/remote_config.py +20 -7
- metaflow_extensions/outerbounds/toplevel/apps/__init__.py +9 -0
- metaflow_extensions/outerbounds/toplevel/apps/exceptions.py +11 -0
- metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -1
- metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -1
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/METADATA +2 -2
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/RECORD +36 -34
- metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +0 -146
- metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +0 -1200
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/WHEEL +0 -0
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/top_level.txt +0 -0
|
@@ -1,36 +1,454 @@
|
|
|
1
1
|
from .config import TypedCoreConfig, TypedDict
|
|
2
2
|
from .perimeters import PerimeterExtractor
|
|
3
3
|
from .capsule import CapsuleApi
|
|
4
|
-
import
|
|
4
|
+
import time
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
5
7
|
from ._state_machine import DEPLOYMENT_READY_CONDITIONS, LogLine
|
|
6
8
|
from .app_config import AppConfig, AppConfigError
|
|
7
|
-
from .
|
|
9
|
+
from .code_package import CodePackager
|
|
10
|
+
from .config import PackagedCode, BakedImage
|
|
11
|
+
from .app_config import CODE_PACKAGE_PREFIX, AuthType
|
|
12
|
+
from .capsule import (
|
|
13
|
+
CapsuleDeployer,
|
|
14
|
+
list_and_filter_capsules,
|
|
15
|
+
_format_url_string,
|
|
16
|
+
)
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
CapsuleDeploymentException,
|
|
19
|
+
CapsuleApiException,
|
|
20
|
+
CapsuleCrashLoopException,
|
|
21
|
+
CapsuleReadinessException,
|
|
22
|
+
CapsuleConcurrentUpgradeException,
|
|
23
|
+
CapsuleDeletedDuringDeploymentException,
|
|
24
|
+
AppConcurrentUpgradeException,
|
|
25
|
+
AppCrashLoopException,
|
|
26
|
+
AppCreationFailedException,
|
|
27
|
+
AppDeletedDuringDeploymentException,
|
|
28
|
+
AppDeploymentException,
|
|
29
|
+
AppNotFoundException,
|
|
30
|
+
AppReadinessException,
|
|
31
|
+
AppUpgradeInProgressException,
|
|
32
|
+
CodePackagingException,
|
|
33
|
+
)
|
|
34
|
+
from .dependencies import ImageBakingException
|
|
8
35
|
from functools import partial
|
|
9
36
|
import sys
|
|
10
|
-
import
|
|
11
|
-
from typing import Type, Dict, List
|
|
37
|
+
from typing import Dict, List, Optional, Callable, Any
|
|
12
38
|
from datetime import datetime
|
|
13
39
|
|
|
14
40
|
|
|
41
|
+
def _resolve_fast_bakery_url():
|
|
42
|
+
config = PerimeterExtractor.config_during_programmatic_access()
|
|
43
|
+
fast_bakery_url = config.get("METAFLOW_FAST_BAKERY_URL")
|
|
44
|
+
default_container_image = config.get("METAFLOW_KUBERNETES_CONTAINER_IMAGE")
|
|
45
|
+
if fast_bakery_url is None:
|
|
46
|
+
raise ImageBakingException(
|
|
47
|
+
"METAFLOW_FAST_BAKERY_URL is not set. Please set the METAFLOW_FAST_BAKERY_URL environment variable or add it to your metaflow config. Please contact outerbounds support for assistance."
|
|
48
|
+
)
|
|
49
|
+
return fast_bakery_url, default_container_image
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bake_image(
|
|
53
|
+
pypi: Optional[Dict[str, str]] = None,
|
|
54
|
+
conda: Optional[Dict[str, str]] = None,
|
|
55
|
+
requirements_file: Optional[str] = None,
|
|
56
|
+
pyproject_toml: Optional[str] = None,
|
|
57
|
+
base_image: Optional[str] = None,
|
|
58
|
+
python: Optional[str] = None,
|
|
59
|
+
logger: Optional[Callable[[str], Any]] = None,
|
|
60
|
+
cache_name: Optional[str] = None,
|
|
61
|
+
) -> BakedImage:
|
|
62
|
+
"""
|
|
63
|
+
Bake a Docker image with the specified dependencies.
|
|
64
|
+
|
|
65
|
+
This is a composable building block that can be used standalone or
|
|
66
|
+
combined with AppDeployer to deploy apps with custom images.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
pypi : Dict[str, str], optional
|
|
71
|
+
Dictionary of PyPI packages to install. Keys are package names,
|
|
72
|
+
values are version specifiers. Example: {"flask": ">=2.0", "requests": ""}
|
|
73
|
+
Mutually exclusive with requirements_file and pyproject_toml.
|
|
74
|
+
conda : Dict[str, str], optional
|
|
75
|
+
Dictionary of Conda packages to install.
|
|
76
|
+
requirements_file : str, optional
|
|
77
|
+
Path to a requirements.txt file.
|
|
78
|
+
Mutually exclusive with pypi and pyproject_toml.
|
|
79
|
+
pyproject_toml : str, optional
|
|
80
|
+
Path to a pyproject.toml file.
|
|
81
|
+
Mutually exclusive with pypi and requirements_file.
|
|
82
|
+
base_image : str, optional
|
|
83
|
+
Base Docker image to build from. Defaults to the platform default image.
|
|
84
|
+
python : str, optional
|
|
85
|
+
Python version to use (e.g., "3.11.0"). If None (default), uses the Python
|
|
86
|
+
already present in the base_image and installs dependencies into it. If a
|
|
87
|
+
version is specified, a new Python environment at that version is created
|
|
88
|
+
inside the base image, and all dependencies are installed into it.
|
|
89
|
+
logger : Callable, optional
|
|
90
|
+
Logger function for progress messages.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
BakedImage
|
|
95
|
+
Named tuple containing:
|
|
96
|
+
- image: The baked Docker image URL
|
|
97
|
+
- python_path: Path to Python executable in the image
|
|
98
|
+
|
|
99
|
+
Raises
|
|
100
|
+
------
|
|
101
|
+
ImageBakingException
|
|
102
|
+
If baking fails or if invalid parameters are provided.
|
|
103
|
+
|
|
104
|
+
Examples
|
|
105
|
+
--------
|
|
106
|
+
Bake with PyPI packages:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
result = bake_image(pypi={"flask": ">=2.0", "requests": ""})
|
|
110
|
+
print(result.image)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Bake from requirements.txt:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
result = bake_image(requirements_file="./requirements.txt")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Bake from pyproject.toml:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
result = bake_image(pyproject_toml="./pyproject.toml")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Combine with AppDeployer:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from metaflow.apps import bake_image, AppDeployer
|
|
129
|
+
|
|
130
|
+
baked = bake_image(pypi={"flask": ">=2.0"})
|
|
131
|
+
deployer = AppDeployer(name="my-app", port=8080, image=baked.image)
|
|
132
|
+
deployed = deployer.deploy()
|
|
133
|
+
```
|
|
134
|
+
"""
|
|
135
|
+
from metaflow.ob_internal import internal_bake_image as _internal_bake # type: ignore
|
|
136
|
+
from metaflow.plugins.pypi.parsers import (
|
|
137
|
+
requirements_txt_parser,
|
|
138
|
+
pyproject_toml_parser,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
from metaflow.metaflow_config import (
|
|
142
|
+
DEFAULT_DATASTORE,
|
|
143
|
+
get_pinned_conda_libs,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Count how many dependency sources are provided
|
|
147
|
+
dep_sources = sum(
|
|
148
|
+
[
|
|
149
|
+
pypi is not None,
|
|
150
|
+
requirements_file is not None,
|
|
151
|
+
pyproject_toml is not None,
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
fast_bakery_url, default_base_image = _resolve_fast_bakery_url()
|
|
156
|
+
|
|
157
|
+
if dep_sources > 1:
|
|
158
|
+
raise ImageBakingException(
|
|
159
|
+
"Only one of pypi, requirements_file, or pyproject_toml can be specified."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Set defaults
|
|
163
|
+
_base_image = base_image or default_base_image
|
|
164
|
+
_python_version = python # Keep it None to use image python
|
|
165
|
+
_logger = logger or (lambda x: None)
|
|
166
|
+
_cache_name = cache_name or "default"
|
|
167
|
+
|
|
168
|
+
# Set up cache directory (internal - not exposed to users)
|
|
169
|
+
cache_dir = os.path.join(tempfile.gettempdir(), f"ob-bake-{_cache_name}")
|
|
170
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
171
|
+
cache_file_path = os.path.join(cache_dir, "image_cache")
|
|
172
|
+
|
|
173
|
+
# Collect packages
|
|
174
|
+
pypi_packages: Dict[str, str] = {}
|
|
175
|
+
conda_packages: Dict[str, str] = {}
|
|
176
|
+
|
|
177
|
+
# Parse from file if provided
|
|
178
|
+
if requirements_file:
|
|
179
|
+
if not os.path.exists(requirements_file):
|
|
180
|
+
raise ImageBakingException(
|
|
181
|
+
f"Requirements file not found: {requirements_file}"
|
|
182
|
+
)
|
|
183
|
+
with open(requirements_file, "r") as f:
|
|
184
|
+
parsed = requirements_txt_parser(f.read())
|
|
185
|
+
pypi_packages = parsed.get("packages", {})
|
|
186
|
+
_python_version = parsed.get("python_version", _python_version)
|
|
187
|
+
_logger(f"📦 Parsed {len(pypi_packages)} packages from {requirements_file}")
|
|
188
|
+
|
|
189
|
+
elif pyproject_toml:
|
|
190
|
+
if not os.path.exists(pyproject_toml):
|
|
191
|
+
raise ImageBakingException(f"pyproject.toml not found: {pyproject_toml}")
|
|
192
|
+
with open(pyproject_toml, "r") as f:
|
|
193
|
+
parsed = pyproject_toml_parser(f.read())
|
|
194
|
+
pypi_packages = parsed.get("packages", {})
|
|
195
|
+
_python_version = parsed.get("python_version", _python_version)
|
|
196
|
+
_logger(f"📦 Parsed {len(pypi_packages)} packages from {pyproject_toml}")
|
|
197
|
+
|
|
198
|
+
elif pypi:
|
|
199
|
+
pypi_packages = pypi.copy()
|
|
200
|
+
|
|
201
|
+
if conda:
|
|
202
|
+
conda_packages = conda.copy()
|
|
203
|
+
|
|
204
|
+
# Check if there are any packages to bake
|
|
205
|
+
if not pypi_packages and not conda_packages:
|
|
206
|
+
_logger("⚠️ No packages to bake. Returning base image.")
|
|
207
|
+
return BakedImage(image=_base_image, python_path="python")
|
|
208
|
+
|
|
209
|
+
# Add pinned conda libs required by the platform
|
|
210
|
+
pinned_libs = get_pinned_conda_libs(_python_version, DEFAULT_DATASTORE)
|
|
211
|
+
pypi_packages.update(pinned_libs)
|
|
212
|
+
|
|
213
|
+
_logger(f"🍞 Baking image with {len(pypi_packages)} PyPI packages...")
|
|
214
|
+
|
|
215
|
+
# Call the internal bake function
|
|
216
|
+
fb_response = _internal_bake(
|
|
217
|
+
cache_file_path=cache_file_path,
|
|
218
|
+
pypi_packages=pypi_packages,
|
|
219
|
+
conda_packages=conda_packages,
|
|
220
|
+
ref=_cache_name,
|
|
221
|
+
python=_python_version,
|
|
222
|
+
base_image=_base_image,
|
|
223
|
+
logger=_logger,
|
|
224
|
+
fast_bakery_url=fast_bakery_url,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if fb_response.failure:
|
|
228
|
+
raise ImageBakingException(f"Failed to bake image: {fb_response.response}")
|
|
229
|
+
|
|
230
|
+
_logger(f"🐳 Baked image: {fb_response.container_image}")
|
|
231
|
+
|
|
232
|
+
return BakedImage(
|
|
233
|
+
image=fb_response.container_image,
|
|
234
|
+
python_path=fb_response.python_path,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def package_code(
|
|
239
|
+
src_paths: List[str],
|
|
240
|
+
suffixes: Optional[List[str]] = None,
|
|
241
|
+
logger: Optional[Callable[[str], Any]] = None,
|
|
242
|
+
) -> PackagedCode:
|
|
243
|
+
"""
|
|
244
|
+
Package code for deployment to the Outerbounds Platform.
|
|
245
|
+
|
|
246
|
+
This is a composable building block that can be used standalone or
|
|
247
|
+
combined with AppDeployer to deploy apps with custom code packages.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
src_paths : List[str]
|
|
252
|
+
List of directories to include in the package. All paths must exist
|
|
253
|
+
and be directories.
|
|
254
|
+
suffixes : List[str], optional
|
|
255
|
+
File extensions to include (e.g., [".py", ".json", ".yaml"]).
|
|
256
|
+
If None, uses default suffixes: .py, .txt, .yaml, .yml, .json,
|
|
257
|
+
.html, .css, .js, .jsx, .ts, .tsx, .md, .rst
|
|
258
|
+
logger : Callable, optional
|
|
259
|
+
Logger function for progress messages. Receives a single string argument.
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
PackagedCode
|
|
264
|
+
Named tuple containing:
|
|
265
|
+
- url: The package URL in object storage
|
|
266
|
+
- key: Unique content-addressed key identifying this package
|
|
267
|
+
|
|
268
|
+
Raises
|
|
269
|
+
------
|
|
270
|
+
CodePackagingException
|
|
271
|
+
If packaging fails or if invalid paths are provided.
|
|
272
|
+
|
|
273
|
+
Examples
|
|
274
|
+
--------
|
|
275
|
+
Package a directory:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
pkg = package_code(src_paths=["./src"])
|
|
279
|
+
print(pkg.url)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Package multiple directories:
|
|
283
|
+
|
|
284
|
+
```python
|
|
285
|
+
pkg = package_code(src_paths=["./src", "./configs"])
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Package with specific file types:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
pkg = package_code(
|
|
292
|
+
src_paths=["./app"],
|
|
293
|
+
suffixes=[".py", ".yaml", ".json"]
|
|
294
|
+
)
|
|
295
|
+
```
|
|
296
|
+
"""
|
|
297
|
+
from metaflow.metaflow_config import DEFAULT_DATASTORE
|
|
298
|
+
|
|
299
|
+
_logger = logger or (lambda x: None)
|
|
300
|
+
|
|
301
|
+
# Validate paths
|
|
302
|
+
for path in src_paths:
|
|
303
|
+
if not os.path.exists(path):
|
|
304
|
+
raise CodePackagingException(f"Source path does not exist: {path}")
|
|
305
|
+
if not os.path.isdir(path):
|
|
306
|
+
raise CodePackagingException(f"Source path is not a directory: {path}")
|
|
307
|
+
|
|
308
|
+
_logger(f"📦 Packaging {len(src_paths)} directory(ies)...")
|
|
309
|
+
|
|
310
|
+
# Create packager and store
|
|
311
|
+
packager = CodePackager(
|
|
312
|
+
datastore_type=DEFAULT_DATASTORE,
|
|
313
|
+
code_package_prefix=CODE_PACKAGE_PREFIX,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
package_url, package_key = packager.store(
|
|
318
|
+
paths_to_include=src_paths,
|
|
319
|
+
file_suffixes=suffixes, # None uses defaults in CodePackager
|
|
320
|
+
)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
raise CodePackagingException(f"Failed to package code: {e}") from e
|
|
323
|
+
|
|
324
|
+
_logger(f"📦 Code package stored: {package_url}")
|
|
325
|
+
|
|
326
|
+
return PackagedCode(url=package_url, key=package_key)
|
|
327
|
+
|
|
328
|
+
|
|
15
329
|
class AppDeployer(TypedCoreConfig):
|
|
16
|
-
|
|
330
|
+
|
|
331
|
+
__examples__ = """
|
|
332
|
+
Examples
|
|
333
|
+
--------
|
|
334
|
+
Basic deployment with bake_image and package_code:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
from metaflow.apps import bake_image, package_code, AppDeployer
|
|
338
|
+
|
|
339
|
+
# Step 1: Bake dependencies into an image
|
|
340
|
+
baked = bake_image(pypi={"flask": ">=2.0", "requests": ""})
|
|
341
|
+
|
|
342
|
+
# Step 2: Package your application code
|
|
343
|
+
pkg = package_code(src_paths=["./src"])
|
|
344
|
+
|
|
345
|
+
# Step 3: Create deployer and deploy
|
|
346
|
+
deployer = AppDeployer(
|
|
347
|
+
name="my-flask-app",
|
|
348
|
+
port=8000,
|
|
349
|
+
image=baked.image,
|
|
350
|
+
code_package=pkg,
|
|
351
|
+
commands=["python server.py"],
|
|
352
|
+
replicas={"min": 1, "max": 3},
|
|
353
|
+
resources={"cpu": "1", "memory": "2048Mi"},
|
|
354
|
+
)
|
|
355
|
+
deployed = deployer.deploy()
|
|
356
|
+
print(deployed.public_url)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Deployment with API authentication:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
deployer = AppDeployer(
|
|
363
|
+
name="my-api",
|
|
364
|
+
port=8000,
|
|
365
|
+
image=baked.image,
|
|
366
|
+
code_package=pkg,
|
|
367
|
+
commands=["python api.py"],
|
|
368
|
+
auth={"type": "API"},
|
|
369
|
+
)
|
|
370
|
+
deployed = deployer.deploy()
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Deployment with environment variables and secrets:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
deployer = AppDeployer(
|
|
377
|
+
name="my-app",
|
|
378
|
+
port=8000,
|
|
379
|
+
image=baked.image,
|
|
380
|
+
code_package=pkg,
|
|
381
|
+
commands=["python app.py"],
|
|
382
|
+
environment={"DEBUG": "false", "LOG_LEVEL": "info"},
|
|
383
|
+
secrets=["my-api-keys"],
|
|
384
|
+
)
|
|
385
|
+
deployed = deployer.deploy()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Interacting with a deployed app:
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
# Get app info
|
|
392
|
+
info = deployed.info()
|
|
393
|
+
|
|
394
|
+
# Get logs from all workers
|
|
395
|
+
logs = deployed.logs()
|
|
396
|
+
|
|
397
|
+
# Scale to zero workers
|
|
398
|
+
deployed.scale_to_zero()
|
|
399
|
+
|
|
400
|
+
# Delete the app
|
|
401
|
+
deployed.delete()
|
|
402
|
+
```
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
__doc__ = (
|
|
406
|
+
"""Programmatic API For deploying Outerbounds Apps.\n"""
|
|
407
|
+
+ TypedCoreConfig.__doc__
|
|
408
|
+
+ __examples__
|
|
409
|
+
)
|
|
17
410
|
|
|
18
411
|
__init__ = TypedCoreConfig.__init__
|
|
19
412
|
|
|
20
413
|
_app_config: AppConfig
|
|
21
414
|
|
|
415
|
+
# What is `_state` ?
|
|
416
|
+
# `_state` is a dictionary that will hold all information that might need
|
|
417
|
+
# to be passed down without the user explicity setting them.
|
|
418
|
+
# Setting `_state` will ensure that values are explicity passed down from
|
|
419
|
+
# top level when the class is used under different context.
|
|
420
|
+
# So for example if we need to set some things like project/branches etc
|
|
421
|
+
# during metaflow context, we can do so easily. We also like to set state like
|
|
422
|
+
# perimeters at class level since users current cannot also switch perimeters within
|
|
423
|
+
# the same interpreter.
|
|
22
424
|
_state = {}
|
|
23
425
|
|
|
24
426
|
__state_items = [
|
|
427
|
+
# perimeter and api_url come from config setups
|
|
428
|
+
# need to happen before AppDeployer and need to
|
|
429
|
+
# come from _set_state
|
|
25
430
|
"perimeter",
|
|
26
431
|
"api_url",
|
|
432
|
+
# code package URL / code package key
|
|
433
|
+
# can come from CodePackager so its fine
|
|
434
|
+
# if its in _set_state
|
|
27
435
|
"code_package_url",
|
|
28
436
|
"code_package_key",
|
|
437
|
+
# Image can be explicitly set by the user
|
|
438
|
+
# or requre some external fast-bakery API
|
|
29
439
|
"image",
|
|
440
|
+
# project/branch have to come from _set_state
|
|
441
|
+
# if users do this through current. Otherwise
|
|
442
|
+
# can come from the
|
|
30
443
|
"project",
|
|
31
444
|
"branch",
|
|
32
445
|
]
|
|
33
446
|
|
|
447
|
+
def _init(self):
|
|
448
|
+
perimeter, api_url = PerimeterExtractor.during_programmatic_access()
|
|
449
|
+
self._set_state("perimeter", perimeter)
|
|
450
|
+
self._set_state("api_url", api_url)
|
|
451
|
+
|
|
34
452
|
@property
|
|
35
453
|
def _deploy_config(self) -> AppConfig:
|
|
36
454
|
if not hasattr(self, "_app_config"):
|
|
@@ -39,62 +457,217 @@ class AppDeployer(TypedCoreConfig):
|
|
|
39
457
|
|
|
40
458
|
# Things that need to be set before deploy
|
|
41
459
|
@classmethod
|
|
42
|
-
def _set_state(
|
|
43
|
-
cls
|
|
44
|
-
perimeter: str,
|
|
45
|
-
api_url: str,
|
|
46
|
-
code_package_url: str = None,
|
|
47
|
-
code_package_key: str = None,
|
|
48
|
-
name_prefix: str = None,
|
|
49
|
-
image: str = None,
|
|
50
|
-
max_entropy: int = 4,
|
|
51
|
-
default_tags: List[Dict[str, str]] = None,
|
|
52
|
-
project: str = None,
|
|
53
|
-
branch: str = None,
|
|
54
|
-
):
|
|
55
|
-
cls._state["perimeter"] = perimeter
|
|
56
|
-
cls._state["api_url"] = api_url
|
|
57
|
-
cls._state["code_package_url"] = code_package_url
|
|
58
|
-
cls._state["code_package_key"] = code_package_key
|
|
59
|
-
cls._state["name_prefix"] = name_prefix
|
|
60
|
-
cls._state["image"] = image
|
|
61
|
-
cls._state["max_entropy"] = max_entropy
|
|
62
|
-
cls._state["default_tags"] = default_tags
|
|
63
|
-
cls._state["project"] = project
|
|
64
|
-
cls._state["branch"] = branch
|
|
65
|
-
|
|
66
|
-
assert (
|
|
67
|
-
max_entropy > 0
|
|
68
|
-
), "max_entropy must be greater than 0. Since AppDeployer's deploy fn can be called many time inside a step itself."
|
|
460
|
+
def _set_state(cls, key, value):
|
|
461
|
+
cls._state[key] = value
|
|
69
462
|
|
|
70
463
|
def deploy(
|
|
71
464
|
self,
|
|
72
|
-
readiness_condition=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
|
|
465
|
+
readiness_condition: str = DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
|
|
73
466
|
max_wait_time=600,
|
|
74
467
|
readiness_wait_time=10,
|
|
75
468
|
logger_fn=partial(print, file=sys.stderr),
|
|
76
|
-
status_file=None,
|
|
77
|
-
no_loader=False,
|
|
78
469
|
**kwargs,
|
|
79
470
|
) -> "DeployedApp":
|
|
471
|
+
"""
|
|
472
|
+
Deploy the app to the Outerbounds Platform.
|
|
473
|
+
|
|
474
|
+
This method packages and deploys the configured app, waiting for it to reach
|
|
475
|
+
the specified readiness condition before returning.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
readiness_condition : str, optional
|
|
480
|
+
The condition that must be met for the deployment to be considered ready.
|
|
481
|
+
Default is ATLEAST_ONE_RUNNING.
|
|
482
|
+
|
|
483
|
+
Deployment ready conditions define what is considered a successful completion
|
|
484
|
+
of the current deployment instance. This allows users or platform designers
|
|
485
|
+
to configure the criteria for deployment readiness.
|
|
486
|
+
|
|
487
|
+
Why do we need deployment readiness conditions?
|
|
488
|
+
- Deployments might be taking place from a CI/CD-esque environment.
|
|
489
|
+
In these setups, the downstream build triggers might be depending on
|
|
490
|
+
a specific criteria for deployment completion. Having readiness conditions
|
|
491
|
+
allows the CI/CD systems to get a signal of when the deployment is ready.
|
|
492
|
+
- Users might be calling the deployment API under different conditions:
|
|
493
|
+
- Some users might want a cluster of workers ready before serving
|
|
494
|
+
traffic while others might want just one worker ready to start
|
|
495
|
+
serving traffic.
|
|
496
|
+
|
|
497
|
+
Available readiness conditions:
|
|
498
|
+
|
|
499
|
+
ATLEAST_ONE_RUNNING ("at_least_one_running")
|
|
500
|
+
At least min(min_replicas, 1) workers of the current deployment
|
|
501
|
+
instance's version have started running.
|
|
502
|
+
Usecase: Some endpoints may be deployed ephemerally and are considered
|
|
503
|
+
ready when at least one instance is running; additional instances are
|
|
504
|
+
for load management.
|
|
505
|
+
|
|
506
|
+
ALL_RUNNING ("all_running")
|
|
507
|
+
At least min_replicas number of workers are running for the deployment
|
|
508
|
+
to be considered ready.
|
|
509
|
+
Usecase: Operators may require that all replicas are available before
|
|
510
|
+
traffic is routed. Needed when inference endpoints may be under some
|
|
511
|
+
SLA or require a larger load.
|
|
512
|
+
|
|
513
|
+
FULLY_FINISHED ("fully_finished")
|
|
514
|
+
At least min_replicas number of workers are running for the deployment
|
|
515
|
+
and there are no pending or crashlooping workers from previous versions
|
|
516
|
+
lying around.
|
|
517
|
+
Usecase: Ensuring endpoint is fully available and no other versions are
|
|
518
|
+
running or endpoint has been fully scaled down.
|
|
519
|
+
|
|
520
|
+
ASYNC ("async")
|
|
521
|
+
The deployment will be assumed ready as soon as the server acknowledges
|
|
522
|
+
it has registered the app in the backend.
|
|
523
|
+
Usecase: Operators may only care that the URL is minted for the deployment
|
|
524
|
+
or the operator wants the deployment to eventually scale down to 0.
|
|
525
|
+
|
|
526
|
+
max_wait_time : int, optional
|
|
527
|
+
Maximum time in seconds to wait for the deployment to reach readiness.
|
|
528
|
+
Default is 600 (10 minutes).
|
|
529
|
+
|
|
530
|
+
readiness_wait_time : int, optional
|
|
531
|
+
Time in seconds to wait between readiness checks. Default is 10.
|
|
532
|
+
|
|
533
|
+
logger_fn : Callable, optional
|
|
534
|
+
Function to use for logging progress messages. Default prints to stderr.
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
DeployedApp
|
|
539
|
+
An object representing the deployed app with methods to interact with it
|
|
540
|
+
(logs, info, scale_to_zero, delete, etc.) and properties like public_url.
|
|
80
541
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
] # for now the name-prefix cannot be very large.
|
|
86
|
-
entropy = uuid.uuid4().hex[: self._state["max_entropy"]]
|
|
87
|
-
self._deploy_config._core_config.name = f"{name}-{entropy}"
|
|
542
|
+
Raises
|
|
543
|
+
------
|
|
544
|
+
CodePackagingException
|
|
545
|
+
If code_package is not provided or is not a valid PackagedCode instance.
|
|
88
546
|
|
|
89
|
-
|
|
547
|
+
AppConfigError
|
|
548
|
+
If the app configuration is invalid.
|
|
549
|
+
|
|
550
|
+
AppCreationFailedException
|
|
551
|
+
If the app deployment submission fails due to an API error.
|
|
552
|
+
Contains status_code and error_text attributes for debugging.
|
|
553
|
+
|
|
554
|
+
AppCrashLoopException
|
|
555
|
+
If a worker enters CrashLoopBackOff or Failed state during deployment.
|
|
556
|
+
Contains worker_id and logs attributes for debugging.
|
|
557
|
+
|
|
558
|
+
AppReadinessException
|
|
559
|
+
If the app fails to meet readiness conditions within max_wait_time.
|
|
560
|
+
|
|
561
|
+
AppUpgradeInProgressException
|
|
562
|
+
If an upgrade is already in progress when deployment starts.
|
|
563
|
+
Use force_upgrade=True to override. Contains upgrader attribute.
|
|
564
|
+
|
|
565
|
+
AppConcurrentUpgradeException
|
|
566
|
+
If another deployment was triggered while this deployment was in progress,
|
|
567
|
+
invalidating the current deployment. Contains expected_version and actual_version.
|
|
568
|
+
|
|
569
|
+
OuterboundsBackendUnhealthyException
|
|
570
|
+
If the Outerbounds backend is unreachable (network issues, DNS failures) or
|
|
571
|
+
returns server errors (HTTP 5xx). This indicates a platform-side issue, not a
|
|
572
|
+
problem with your configuration. Retry the deployment or contact Outerbounds support.
|
|
573
|
+
|
|
574
|
+
AppDeletedDuringDeploymentException
|
|
575
|
+
If the app was deleted by another process or user while this deployment was
|
|
576
|
+
in progress. This can occur when concurrent operations conflict.
|
|
577
|
+
|
|
578
|
+
Examples
|
|
579
|
+
--------
|
|
580
|
+
Basic deployment:
|
|
581
|
+
|
|
582
|
+
```python
|
|
583
|
+
from metaflow.apps import bake_image, package_code, AppDeployer
|
|
584
|
+
baked = bake_image(pypi={"flask": ">=2.0"})
|
|
585
|
+
pkg = package_code(src_paths=["./src"])
|
|
586
|
+
deployer = AppDeployer(
|
|
587
|
+
name="my-app",
|
|
588
|
+
port=8000,
|
|
589
|
+
image=baked.image,
|
|
590
|
+
code_package=pkg,
|
|
591
|
+
commands=["python server.py"],
|
|
592
|
+
)
|
|
593
|
+
deployed = deployer.deploy()
|
|
594
|
+
print(deployed.public_url)
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Wait for all replicas to be ready:
|
|
598
|
+
|
|
599
|
+
```python
|
|
600
|
+
deployed = deployer.deploy(
|
|
601
|
+
readiness_condition="all_running"
|
|
602
|
+
)
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Async deployment (don't wait for workers):
|
|
606
|
+
|
|
607
|
+
```python
|
|
608
|
+
deployed = deployer.deploy(
|
|
609
|
+
readiness_condition="async"
|
|
610
|
+
)
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Handling deployment errors:
|
|
614
|
+
|
|
615
|
+
```python
|
|
616
|
+
from metaflow.apps import AppDeployer
|
|
617
|
+
from metaflow.apps.exceptions import (
|
|
618
|
+
AppReadinessException,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
deployed = deployer.deploy()
|
|
623
|
+
except AppReadinessException as e:
|
|
624
|
+
print(f"App {e.app_id} failed to become ready in time but we can move forward")
|
|
625
|
+
deployed_app:DeployedApp = e.deployed_app
|
|
626
|
+
# use DeployedApp to do what ever you need
|
|
627
|
+
```
|
|
628
|
+
"""
|
|
629
|
+
if len(self._state.get("default_tags", [])) > 0:
|
|
90
630
|
self._deploy_config._core_config.tags = (
|
|
91
631
|
self._deploy_config._core_config.tags or []
|
|
92
632
|
) + self._state["default_tags"]
|
|
93
633
|
|
|
634
|
+
# Handle code_package if provided - extract url and key to state
|
|
635
|
+
code_package = getattr(self._deploy_config._core_config, "code_package", None)
|
|
636
|
+
if code_package is not None:
|
|
637
|
+
# Validate that code_package is a PackagedCode namedtuple
|
|
638
|
+
if not isinstance(code_package, PackagedCode):
|
|
639
|
+
raise CodePackagingException(
|
|
640
|
+
f"code_package must be a PackagedCode instance returned by package_code(). "
|
|
641
|
+
f"Got {type(code_package).__name__} instead.\n\n"
|
|
642
|
+
"Use package_code() to create a valid code package:\n\n"
|
|
643
|
+
" from metaflow.apps import package_code, AppDeployer\n\n"
|
|
644
|
+
" pkg = package_code(src_paths=['./src'])\n"
|
|
645
|
+
" deployer = AppDeployer(..., code_package=pkg)\n"
|
|
646
|
+
)
|
|
647
|
+
self._set_state("code_package_url", code_package.url)
|
|
648
|
+
self._set_state("code_package_key", code_package.key)
|
|
649
|
+
# Clear the code_package field to avoid serialization issues
|
|
650
|
+
self._deploy_config._core_config.code_package = None
|
|
651
|
+
|
|
652
|
+
# Verify code_package is present (either from code_package param or from state)
|
|
653
|
+
if (
|
|
654
|
+
self._state.get("code_package_url") is None
|
|
655
|
+
and self._deploy_config.get_state("code_package_url") is None
|
|
656
|
+
):
|
|
657
|
+
raise CodePackagingException(
|
|
658
|
+
"code_package is required for deployment. "
|
|
659
|
+
"Use package_code() to create a code package:\n\n"
|
|
660
|
+
" from metaflow.apps import package_code, AppDeployer\n\n"
|
|
661
|
+
" pkg = package_code(src_paths=['./src'])\n"
|
|
662
|
+
" deployer = AppDeployer(..., code_package=pkg)\n"
|
|
663
|
+
)
|
|
664
|
+
|
|
94
665
|
self._deploy_config.commit()
|
|
95
666
|
# Set any state that might have been passed down from the top level
|
|
96
667
|
for k in self.__state_items:
|
|
97
|
-
if self._deploy_config.get_state(k) is None
|
|
668
|
+
if self._deploy_config.get_state(k) is None and (
|
|
669
|
+
k in self._state and self._state[k] is not None
|
|
670
|
+
):
|
|
98
671
|
self._deploy_config.set_state(k, self._state[k])
|
|
99
672
|
|
|
100
673
|
capsule = CapsuleDeployer(
|
|
@@ -129,13 +702,10 @@ class AppDeployer(TypedCoreConfig):
|
|
|
129
702
|
|
|
130
703
|
if this_capsule_is_being_updated and not force_upgrade:
|
|
131
704
|
_upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
|
|
137
|
-
)
|
|
138
|
-
raise AppConfigError(message)
|
|
705
|
+
raise AppUpgradeInProgressException(
|
|
706
|
+
app_id=_curr_cap.get("id"),
|
|
707
|
+
upgrader=_upgrader,
|
|
708
|
+
)
|
|
139
709
|
|
|
140
710
|
logger_fn(
|
|
141
711
|
f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
|
|
@@ -145,19 +715,222 @@ class AppDeployer(TypedCoreConfig):
|
|
|
145
715
|
f"🚀 Deploying {capsule.capsule_type.lower()} `{capsule.name}`....",
|
|
146
716
|
)
|
|
147
717
|
|
|
148
|
-
|
|
149
|
-
|
|
718
|
+
try:
|
|
719
|
+
capsule.create()
|
|
720
|
+
except CapsuleApiException as e:
|
|
721
|
+
raise AppCreationFailedException(
|
|
722
|
+
app_name=capsule.name,
|
|
723
|
+
status_code=e.status_code,
|
|
724
|
+
error_text=e.text,
|
|
725
|
+
) from e
|
|
726
|
+
try:
|
|
727
|
+
final_status = capsule.wait_for_terminal_state()
|
|
728
|
+
except CapsuleCrashLoopException as e:
|
|
729
|
+
raise AppCrashLoopException(
|
|
730
|
+
app_id=e.capsule_id,
|
|
731
|
+
worker_id=e.worker_id,
|
|
732
|
+
logs=e.logs,
|
|
733
|
+
) from e
|
|
734
|
+
except CapsuleReadinessException as e:
|
|
735
|
+
raise AppReadinessException(
|
|
736
|
+
app_id=e.capsule_id,
|
|
737
|
+
) from e
|
|
738
|
+
except CapsuleConcurrentUpgradeException as e:
|
|
739
|
+
raise AppConcurrentUpgradeException(
|
|
740
|
+
app_id=e.capsule_id,
|
|
741
|
+
expected_version=e.expected_version,
|
|
742
|
+
actual_version=e.actual_version,
|
|
743
|
+
modified_by=e.modified_by,
|
|
744
|
+
modified_at=e.modified_at,
|
|
745
|
+
) from e
|
|
746
|
+
except CapsuleDeletedDuringDeploymentException as e:
|
|
747
|
+
raise AppDeletedDuringDeploymentException(
|
|
748
|
+
app_id=e.capsule_id,
|
|
749
|
+
) from e
|
|
750
|
+
|
|
150
751
|
return DeployedApp(
|
|
151
752
|
final_status["id"],
|
|
152
753
|
final_status["auth_type"],
|
|
153
|
-
final_status["public_url"],
|
|
754
|
+
_format_url_string(final_status["public_url"], True),
|
|
154
755
|
final_status["name"],
|
|
155
756
|
final_status["deployed_version"],
|
|
156
757
|
final_status["deployed_at"],
|
|
157
758
|
)
|
|
158
759
|
|
|
760
|
+
@classmethod
|
|
761
|
+
def list_deployments(
|
|
762
|
+
cls,
|
|
763
|
+
name: str = None,
|
|
764
|
+
project: str = None,
|
|
765
|
+
branch: str = None,
|
|
766
|
+
tags: List[Dict[str, str]] = None,
|
|
767
|
+
) -> List["DeployedApp"]:
|
|
768
|
+
"""
|
|
769
|
+
List deployed apps, optionally filtered by name, project, branch, or tags.
|
|
770
|
+
|
|
771
|
+
Parameters
|
|
772
|
+
----------
|
|
773
|
+
name : str, optional
|
|
774
|
+
Filter by app name.
|
|
775
|
+
project : str, optional
|
|
776
|
+
Filter by project name.
|
|
777
|
+
branch : str, optional
|
|
778
|
+
Filter by branch name.
|
|
779
|
+
tags : List[Dict[str, str]], optional
|
|
780
|
+
Filter by tags. Each tag is a dict with a single key-value pair,
|
|
781
|
+
e.g., [{"env": "prod"}] or [{"team": "ml"}, {"version": "v2"}].
|
|
782
|
+
Apps must have all specified tags to match.
|
|
783
|
+
|
|
784
|
+
Returns
|
|
785
|
+
-------
|
|
786
|
+
List[DeployedApp]
|
|
787
|
+
List of deployed apps matching the filters.
|
|
788
|
+
|
|
789
|
+
Examples
|
|
790
|
+
--------
|
|
791
|
+
List all apps:
|
|
792
|
+
|
|
793
|
+
```python
|
|
794
|
+
apps = AppDeployer.list_deployments()
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Filter by name:
|
|
798
|
+
|
|
799
|
+
```python
|
|
800
|
+
apps = AppDeployer.list_deployments(name="my-app")
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
Filter by project and branch:
|
|
804
|
+
|
|
805
|
+
```python
|
|
806
|
+
apps = AppDeployer.list_deployments(project="ml-pipeline", branch="main")
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Filter by a single tag:
|
|
810
|
+
|
|
811
|
+
```python
|
|
812
|
+
apps = AppDeployer.list_deployments(tags=[{"env": "prod"}])
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
Filter by multiple tags (AND logic - must match all):
|
|
816
|
+
|
|
817
|
+
```python
|
|
818
|
+
apps = AppDeployer.list_deployments(tags=[{"env": "prod"}, {"team": "ml"}])
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
Combine filters:
|
|
822
|
+
|
|
823
|
+
```python
|
|
824
|
+
apps = AppDeployer.list_deployments(
|
|
825
|
+
project="recommendations",
|
|
826
|
+
tags=[{"env": "staging"}]
|
|
827
|
+
)
|
|
828
|
+
```
|
|
829
|
+
"""
|
|
830
|
+
# Transform tags from {key: value} to {"key": key, "value": value}
|
|
831
|
+
transformed_tags = None
|
|
832
|
+
if tags:
|
|
833
|
+
transformed_tags = [
|
|
834
|
+
{"key": k, "value": v} for tag in tags for k, v in tag.items()
|
|
835
|
+
]
|
|
836
|
+
|
|
837
|
+
capsule_api = DeployedApp._get_capsule_api()
|
|
838
|
+
list_of_capsules = list_and_filter_capsules(
|
|
839
|
+
capsule_api,
|
|
840
|
+
project=project,
|
|
841
|
+
branch=branch,
|
|
842
|
+
name=name,
|
|
843
|
+
tags=transformed_tags,
|
|
844
|
+
auth_type=None,
|
|
845
|
+
capsule_id=None,
|
|
846
|
+
)
|
|
847
|
+
apps = []
|
|
848
|
+
for cap in list_of_capsules:
|
|
849
|
+
apps.append(DeployedApp._from_capsule(cap))
|
|
850
|
+
return apps
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
class TTLCachedObject:
|
|
854
|
+
"""
|
|
855
|
+
Caches a value with a time-to-live (TTL) per instance.
|
|
856
|
+
Returns None if accessed after TTL has expired.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
def __init__(self, ttl_seconds: float):
|
|
860
|
+
self._ttl = ttl_seconds
|
|
861
|
+
self._attr_name = None
|
|
862
|
+
|
|
863
|
+
def __set_name__(self, owner, name):
|
|
864
|
+
self._attr_name = f"_ttl_cache_{name}"
|
|
865
|
+
|
|
866
|
+
def __get__(self, instance, owner):
|
|
867
|
+
if instance is None:
|
|
868
|
+
return self
|
|
869
|
+
cache = getattr(instance, self._attr_name, None)
|
|
870
|
+
if cache is None:
|
|
871
|
+
return None
|
|
872
|
+
value, last_set = cache
|
|
873
|
+
if (time.time() - last_set) > self._ttl:
|
|
874
|
+
return None
|
|
875
|
+
return value
|
|
876
|
+
|
|
877
|
+
def __set__(self, instance, val):
|
|
878
|
+
setattr(instance, self._attr_name, (val, time.time()))
|
|
879
|
+
|
|
880
|
+
def __delete__(self, instance):
|
|
881
|
+
if hasattr(instance, self._attr_name):
|
|
882
|
+
delattr(instance, self._attr_name)
|
|
883
|
+
|
|
159
884
|
|
|
160
885
|
class DeployedApp:
|
|
886
|
+
"""
|
|
887
|
+
A deployed app on the Outerbounds Platform.
|
|
888
|
+
|
|
889
|
+
Obtain instances via `AppDeployer.deploy()` or `AppDeployer.list_deployments()`.
|
|
890
|
+
|
|
891
|
+
Examples
|
|
892
|
+
--------
|
|
893
|
+
After deployment:
|
|
894
|
+
|
|
895
|
+
```python
|
|
896
|
+
deployed = deployer.deploy()
|
|
897
|
+
print(deployed.public_url)
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
After listing:
|
|
901
|
+
|
|
902
|
+
```python
|
|
903
|
+
apps = AppDeployer.list_deployments(tags=[{"env": "staging"}])
|
|
904
|
+
for app in apps:
|
|
905
|
+
print(f"{app.name}: {app.public_url}")
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
Inspect and manage:
|
|
909
|
+
|
|
910
|
+
```python
|
|
911
|
+
# Get logs
|
|
912
|
+
for worker_id, lines in deployed.logs().items():
|
|
913
|
+
print(f"Worker {worker_id}: {len(lines)} log lines")
|
|
914
|
+
|
|
915
|
+
# Scale down
|
|
916
|
+
deployed.scale_to_zero()
|
|
917
|
+
|
|
918
|
+
# Clean up
|
|
919
|
+
deployed.delete()
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
Make authenticated requests (API auth):
|
|
923
|
+
|
|
924
|
+
```python
|
|
925
|
+
import requests
|
|
926
|
+
response = requests.get(deployed.public_url, headers=deployed.auth())
|
|
927
|
+
```
|
|
928
|
+
"""
|
|
929
|
+
|
|
930
|
+
# Keep a 3ish second TTL so that we can be gentler
|
|
931
|
+
# the backend API.
|
|
932
|
+
_capsule_info_cached = TTLCachedObject(3)
|
|
933
|
+
|
|
161
934
|
def __init__(
|
|
162
935
|
self,
|
|
163
936
|
_id: str,
|
|
@@ -167,21 +940,105 @@ class DeployedApp:
|
|
|
167
940
|
deployed_version: str,
|
|
168
941
|
deployed_at: str,
|
|
169
942
|
):
|
|
170
|
-
self._id = _id
|
|
943
|
+
self._id = _id # This ID is the capsule's ID
|
|
171
944
|
self._capsule_type = capsule_type
|
|
172
945
|
self._public_url = public_url
|
|
173
946
|
self._name = name
|
|
174
947
|
self._deployed_version = deployed_version
|
|
175
948
|
self._deployed_at = deployed_at
|
|
176
949
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
950
|
+
@classmethod
|
|
951
|
+
def _get_capsule_api(cls) -> CapsuleApi:
|
|
952
|
+
perimeter, api_server = PerimeterExtractor.during_programmatic_access()
|
|
953
|
+
# In this setting capsules maybe getting managed/deployed in a remote
|
|
954
|
+
# programmatic setting where the user might have no "hands-on" control
|
|
955
|
+
# In those situations its better to retry to 5xx errors
|
|
956
|
+
return CapsuleApi(api_server, perimeter, retry_500s=True)
|
|
957
|
+
|
|
958
|
+
@property
|
|
959
|
+
def _capsule_info(self):
|
|
960
|
+
# self._capsule_info_cached will be None every 3ish seconds
|
|
961
|
+
if self._capsule_info_cached is not None:
|
|
962
|
+
return self._capsule_info_cached
|
|
963
|
+
self._capsule_info_cached = self.info()
|
|
964
|
+
return self._capsule_info_cached
|
|
965
|
+
|
|
966
|
+
@classmethod
|
|
967
|
+
def _from_capsule(cls, capsule: dict) -> "DeployedApp":
|
|
968
|
+
capsule_id = capsule.get("id")
|
|
969
|
+
capsule_type = (
|
|
970
|
+
capsule.get("spec", {}).get("authConfig", {}).get("authType", None)
|
|
971
|
+
)
|
|
972
|
+
status = capsule.get("status", {})
|
|
973
|
+
if status is None:
|
|
974
|
+
status = {}
|
|
975
|
+
public_url = status.get("accessInfo", {}).get("outOfClusterURL", None)
|
|
976
|
+
name = capsule.get("spec", {}).get(
|
|
977
|
+
"displayName",
|
|
978
|
+
)
|
|
979
|
+
deployed_version = capsule.get(
|
|
980
|
+
"version",
|
|
981
|
+
)
|
|
982
|
+
deployed_at = capsule.get("metadata", {}).get(
|
|
983
|
+
"lastModifiedAt",
|
|
984
|
+
capsule.get("metadata", {}).get(
|
|
985
|
+
"createdAt",
|
|
986
|
+
),
|
|
987
|
+
)
|
|
988
|
+
if any(i is None for i in [capsule_type, name]):
|
|
989
|
+
raise ValueError(f"Invalid capsule id: {capsule_id}")
|
|
990
|
+
cpsule = cls(
|
|
991
|
+
capsule_id,
|
|
992
|
+
capsule_type,
|
|
993
|
+
public_url if public_url is None else _format_url_string(public_url, True),
|
|
994
|
+
name,
|
|
995
|
+
deployed_version,
|
|
996
|
+
deployed_at,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
cpsule._capsule_info_cached = capsule
|
|
1000
|
+
return cpsule
|
|
180
1001
|
|
|
181
|
-
|
|
1002
|
+
@classmethod
|
|
1003
|
+
def _from_capsule_id(cls, capsule_id: str) -> "DeployedApp":
|
|
1004
|
+
try:
|
|
1005
|
+
capsule_api = cls._get_capsule_api()
|
|
1006
|
+
capsule = capsule_api.get(capsule_id)
|
|
1007
|
+
except CapsuleApiException as e:
|
|
1008
|
+
if e.status_code == 404:
|
|
1009
|
+
raise AppNotFoundException("App with id '%s' could not be found") from e
|
|
1010
|
+
raise
|
|
1011
|
+
|
|
1012
|
+
return cls._from_capsule(capsule)
|
|
1013
|
+
|
|
1014
|
+
def logs(self, previous: bool = False) -> Dict[str, List[LogLine]]:
|
|
182
1015
|
"""
|
|
183
|
-
|
|
184
|
-
|
|
1016
|
+
Get logs from all worker replicas.
|
|
1017
|
+
|
|
1018
|
+
Parameters
|
|
1019
|
+
----------
|
|
1020
|
+
previous : bool, optional
|
|
1021
|
+
If True, returns logs from the previous execution of workers.
|
|
1022
|
+
Useful for debugging crashlooping workers. Default is False.
|
|
1023
|
+
|
|
1024
|
+
Returns
|
|
1025
|
+
-------
|
|
1026
|
+
Dict[str, List[LogLine]]
|
|
1027
|
+
Dictionary mapping worker IDs to their log lines.
|
|
1028
|
+
|
|
1029
|
+
Examples
|
|
1030
|
+
--------
|
|
1031
|
+
```python
|
|
1032
|
+
# Get current logs
|
|
1033
|
+
logs = deployed.logs()
|
|
1034
|
+
for worker_id, lines in logs.items():
|
|
1035
|
+
print(f"Worker {worker_id}:")
|
|
1036
|
+
for line in lines:
|
|
1037
|
+
print(f" {line}")
|
|
1038
|
+
|
|
1039
|
+
# Get logs from crashed workers
|
|
1040
|
+
previous_logs = deployed.logs(previous=True)
|
|
1041
|
+
```
|
|
185
1042
|
"""
|
|
186
1043
|
capsule_api = self._get_capsule_api()
|
|
187
1044
|
# extract workers from capsule
|
|
@@ -199,19 +1056,61 @@ class DeployedApp:
|
|
|
199
1056
|
|
|
200
1057
|
def info(self) -> dict:
|
|
201
1058
|
"""
|
|
202
|
-
|
|
1059
|
+
Get detailed information about the deployed app.
|
|
1060
|
+
|
|
1061
|
+
Returns
|
|
1062
|
+
-------
|
|
1063
|
+
dict
|
|
1064
|
+
Dictionary containing full app details including spec, status,
|
|
1065
|
+
metadata, and configuration.
|
|
1066
|
+
|
|
1067
|
+
Examples
|
|
1068
|
+
--------
|
|
1069
|
+
```python
|
|
1070
|
+
info = deployed.info()
|
|
1071
|
+
print(f"Status: {info.get('status')}")
|
|
1072
|
+
print(f"Spec: {info.get('spec')}")
|
|
1073
|
+
```
|
|
203
1074
|
"""
|
|
204
1075
|
capsule_api = self._get_capsule_api()
|
|
205
1076
|
capsule = capsule_api.get(self._id)
|
|
206
1077
|
return capsule
|
|
207
1078
|
|
|
208
|
-
def replicas(self):
|
|
1079
|
+
def replicas(self) -> List[dict]:
|
|
1080
|
+
"""
|
|
1081
|
+
List all active worker replicas for this app.
|
|
1082
|
+
|
|
1083
|
+
Returns
|
|
1084
|
+
-------
|
|
1085
|
+
List[dict]
|
|
1086
|
+
List of dictionaries containing worker information including
|
|
1087
|
+
workerId, status, and other metadata.
|
|
1088
|
+
|
|
1089
|
+
Examples
|
|
1090
|
+
--------
|
|
1091
|
+
```python
|
|
1092
|
+
workers = deployed.replicas()
|
|
1093
|
+
for worker in workers:
|
|
1094
|
+
print(f"Worker {worker['workerId']}: {worker.get('status')}")
|
|
1095
|
+
```
|
|
1096
|
+
"""
|
|
209
1097
|
capsule_api = self._get_capsule_api()
|
|
210
1098
|
return capsule_api.get_workers(self._id)
|
|
211
1099
|
|
|
212
1100
|
def scale_to_zero(self):
|
|
213
1101
|
"""
|
|
214
|
-
|
|
1102
|
+
Scale the app down to zero replicas.
|
|
1103
|
+
|
|
1104
|
+
This stops all running workers while preserving the app configuration.
|
|
1105
|
+
The app can be scaled back up by sending traffic to the public URL
|
|
1106
|
+
(if autoscaling is configured) or by redeploying.
|
|
1107
|
+
|
|
1108
|
+
Examples
|
|
1109
|
+
--------
|
|
1110
|
+
```python
|
|
1111
|
+
# Scale down to save resources
|
|
1112
|
+
deployed.scale_to_zero()
|
|
1113
|
+
```
|
|
215
1114
|
"""
|
|
216
1115
|
capsule_api = self._get_capsule_api()
|
|
217
1116
|
return capsule_api.patch(
|
|
@@ -226,57 +1125,172 @@ class DeployedApp:
|
|
|
226
1125
|
|
|
227
1126
|
def delete(self):
|
|
228
1127
|
"""
|
|
229
|
-
|
|
1128
|
+
Delete the deployed app.
|
|
1129
|
+
|
|
1130
|
+
This permanently removes the app from the platform, including all
|
|
1131
|
+
workers, configuration, and the public URL. This action cannot be undone.
|
|
1132
|
+
|
|
1133
|
+
Examples
|
|
1134
|
+
--------
|
|
1135
|
+
```python
|
|
1136
|
+
# Clean up the app
|
|
1137
|
+
deployed.delete()
|
|
1138
|
+
```
|
|
230
1139
|
"""
|
|
231
1140
|
capsule_api = self._get_capsule_api()
|
|
232
1141
|
return capsule_api.delete(self._id)
|
|
233
1142
|
|
|
1143
|
+
def auth(self) -> dict:
|
|
1144
|
+
"""
|
|
1145
|
+
Get authentication headers for making requests to this app.
|
|
1146
|
+
|
|
1147
|
+
Only available for apps configured with API authentication type.
|
|
1148
|
+
Use these headers when making HTTP requests to the app's public URL.
|
|
1149
|
+
|
|
1150
|
+
Returns
|
|
1151
|
+
-------
|
|
1152
|
+
dict
|
|
1153
|
+
Dictionary of HTTP headers to include in requests.
|
|
1154
|
+
|
|
1155
|
+
Raises
|
|
1156
|
+
------
|
|
1157
|
+
ValueError
|
|
1158
|
+
If the app is not configured with API authentication.
|
|
1159
|
+
|
|
1160
|
+
Examples
|
|
1161
|
+
--------
|
|
1162
|
+
```python
|
|
1163
|
+
import requests
|
|
1164
|
+
response = requests.get(deployed.public_url, headers=deployed.auth())
|
|
1165
|
+
```
|
|
1166
|
+
"""
|
|
1167
|
+
if self.auth_type == AuthType.BROWSER:
|
|
1168
|
+
raise ValueError(
|
|
1169
|
+
"Only API auth style is supported for accessing auth headers"
|
|
1170
|
+
)
|
|
1171
|
+
from metaflow.metaflow_config import SERVICE_HEADERS
|
|
1172
|
+
|
|
1173
|
+
return SERVICE_HEADERS
|
|
1174
|
+
|
|
234
1175
|
@property
|
|
235
1176
|
def id(self) -> str:
|
|
1177
|
+
"""
|
|
1178
|
+
Unique identifier for the deployed app.
|
|
1179
|
+
|
|
1180
|
+
Returns
|
|
1181
|
+
-------
|
|
1182
|
+
str
|
|
1183
|
+
The unique app identifier assigned by the platform.
|
|
1184
|
+
"""
|
|
236
1185
|
return self._id
|
|
237
1186
|
|
|
238
1187
|
@property
|
|
239
|
-
def
|
|
240
|
-
|
|
1188
|
+
def auth_type(self) -> str:
|
|
1189
|
+
"""
|
|
1190
|
+
Authentication type configured for this app. Can be either `Browser` , `API`, `BrowserAndApi`
|
|
1191
|
+
|
|
1192
|
+
Returns
|
|
1193
|
+
-------
|
|
1194
|
+
str
|
|
1195
|
+
The authentication type
|
|
1196
|
+
"""
|
|
241
1197
|
return self._capsule_type
|
|
242
1198
|
|
|
243
1199
|
@property
|
|
244
1200
|
def public_url(self) -> str:
|
|
1201
|
+
"""
|
|
1202
|
+
Public URL to access the deployed app.
|
|
1203
|
+
|
|
1204
|
+
Returns
|
|
1205
|
+
-------
|
|
1206
|
+
str
|
|
1207
|
+
The publicly accessible URL for this app.
|
|
1208
|
+
"""
|
|
1209
|
+
if self._public_url is None:
|
|
1210
|
+
info = self._capsule_info
|
|
1211
|
+
status = info.get("status", {})
|
|
1212
|
+
if status is None:
|
|
1213
|
+
status = {}
|
|
1214
|
+
access_info = status.get("accessInfo", {}) or {}
|
|
1215
|
+
self._public_url = access_info.get("outOfClusterURL", None)
|
|
1216
|
+
if self._public_url is not None:
|
|
1217
|
+
self._public_url = _format_url_string(self._public_url, True)
|
|
245
1218
|
return self._public_url
|
|
246
1219
|
|
|
1220
|
+
@property
|
|
1221
|
+
def internal_url(self) -> str:
|
|
1222
|
+
"""
|
|
1223
|
+
Internal in-cluster URL to access the deployed app.
|
|
1224
|
+
|
|
1225
|
+
This URL bypasses external network routing and can be used from within
|
|
1226
|
+
Metaflow tasks running on Kubernetes. Authentication headers are not
|
|
1227
|
+
required when accessing the app via this URL from within the cluster.
|
|
1228
|
+
|
|
1229
|
+
Returns
|
|
1230
|
+
-------
|
|
1231
|
+
str
|
|
1232
|
+
The in-cluster URL for this app.
|
|
1233
|
+
"""
|
|
1234
|
+
|
|
1235
|
+
info = self._capsule_info
|
|
1236
|
+
status = info.get("status", {})
|
|
1237
|
+
if status is None:
|
|
1238
|
+
status = {}
|
|
1239
|
+
access_info = status.get("accessInfo", {}) or {}
|
|
1240
|
+
internal_url = access_info.get("inClusterURL", None)
|
|
1241
|
+
if internal_url is not None:
|
|
1242
|
+
internal_url = _format_url_string(internal_url, False)
|
|
1243
|
+
return internal_url
|
|
1244
|
+
|
|
247
1245
|
@property
|
|
248
1246
|
def name(self) -> str:
|
|
1247
|
+
"""
|
|
1248
|
+
Logical name given to the app.
|
|
1249
|
+
|
|
1250
|
+
Returns
|
|
1251
|
+
-------
|
|
1252
|
+
str
|
|
1253
|
+
The human-readable name of the app.
|
|
1254
|
+
"""
|
|
249
1255
|
return self._name
|
|
250
1256
|
|
|
251
1257
|
@property
|
|
252
1258
|
def deployed_version(self) -> str:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def to_dict(self) -> dict:
|
|
256
|
-
return {
|
|
257
|
-
"id": self._id,
|
|
258
|
-
"auth_style": self.auth_style, # TODO : Fix naming here.
|
|
259
|
-
"public_url": self._public_url,
|
|
260
|
-
"name": self._name,
|
|
261
|
-
"deployed_version": self._deployed_version,
|
|
262
|
-
"deployed_at": self._deployed_at,
|
|
263
|
-
}
|
|
1259
|
+
"""
|
|
1260
|
+
Current deployment version of the app.
|
|
264
1261
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
name=data["name"],
|
|
272
|
-
deployed_version=data["deployed_version"],
|
|
273
|
-
deployed_at=data["deployed_at"],
|
|
274
|
-
)
|
|
1262
|
+
Returns
|
|
1263
|
+
-------
|
|
1264
|
+
str
|
|
1265
|
+
The version identifier for the current deployment.
|
|
1266
|
+
"""
|
|
1267
|
+
return self._deployed_version
|
|
275
1268
|
|
|
276
1269
|
@property
|
|
277
1270
|
def deployed_at(self) -> datetime:
|
|
1271
|
+
"""
|
|
1272
|
+
Timestamp when the app was last deployed.
|
|
1273
|
+
|
|
1274
|
+
Returns
|
|
1275
|
+
-------
|
|
1276
|
+
datetime
|
|
1277
|
+
The datetime of the last deployment.
|
|
1278
|
+
"""
|
|
278
1279
|
return datetime.fromisoformat(self._deployed_at)
|
|
279
1280
|
|
|
1281
|
+
@property
|
|
1282
|
+
def tags(self) -> List[str]:
|
|
1283
|
+
"""
|
|
1284
|
+
Tags associated with this app.
|
|
1285
|
+
|
|
1286
|
+
Returns
|
|
1287
|
+
-------
|
|
1288
|
+
List[str]
|
|
1289
|
+
List of tags assigned to this app.
|
|
1290
|
+
"""
|
|
1291
|
+
capsule_info = self._capsule_info
|
|
1292
|
+
return capsule_info.get("spec", {}).get("tags", [])
|
|
1293
|
+
|
|
280
1294
|
def __repr__(self) -> str:
|
|
281
1295
|
return (
|
|
282
1296
|
f"DeployedApp(id='{self._id}', "
|
|
@@ -284,20 +1298,3 @@ class DeployedApp:
|
|
|
284
1298
|
f"public_url='{self._public_url}', "
|
|
285
1299
|
f"deployed_version='{self._deployed_version}')"
|
|
286
1300
|
)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
class apps:
|
|
290
|
-
|
|
291
|
-
_name_prefix = None
|
|
292
|
-
|
|
293
|
-
@classmethod
|
|
294
|
-
def set_name_prefix(cls, name_prefix: str):
|
|
295
|
-
cls._name_prefix = name_prefix
|
|
296
|
-
|
|
297
|
-
@property
|
|
298
|
-
def name_prefix(self) -> str:
|
|
299
|
-
return self._name_prefix
|
|
300
|
-
|
|
301
|
-
@property
|
|
302
|
-
def Deployer(self) -> Type[AppDeployer]:
|
|
303
|
-
return AppDeployer
|