ob-metaflow-extensions 1.1.45rc3__py2.py3-none-any.whl → 1.5.1__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.
Potentially problematic release.
This version of ob-metaflow-extensions might be problematic. Click here for more details.
- metaflow_extensions/outerbounds/__init__.py +1 -7
- metaflow_extensions/outerbounds/config/__init__.py +35 -0
- metaflow_extensions/outerbounds/plugins/__init__.py +186 -57
- metaflow_extensions/outerbounds/plugins/apps/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -0
- metaflow_extensions/outerbounds/plugins/apps/app_utils.py +187 -0
- metaflow_extensions/outerbounds/plugins/apps/consts.py +3 -0
- metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +15 -0
- metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +506 -0
- metaflow_extensions/outerbounds/plugins/apps/core/_vendor/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/__init__.py +4 -0
- metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/spinners.py +478 -0
- metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +128 -0
- metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +330 -0
- metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py +0 -0
- metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +958 -0
- metaflow_extensions/outerbounds/plugins/apps/core/click_importer.py +24 -0
- metaflow_extensions/outerbounds/plugins/apps/core/code_package/__init__.py +3 -0
- metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +618 -0
- metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py +125 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +15 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +165 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +966 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +299 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +233 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +537 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +1125 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +337 -0
- metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +115 -0
- metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +959 -0
- metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +89 -0
- metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +87 -0
- metaflow_extensions/outerbounds/plugins/apps/core/secrets.py +164 -0
- metaflow_extensions/outerbounds/plugins/apps/core/utils.py +233 -0
- metaflow_extensions/outerbounds/plugins/apps/core/validations.py +17 -0
- metaflow_extensions/outerbounds/plugins/apps/deploy_decorator.py +201 -0
- metaflow_extensions/outerbounds/plugins/apps/supervisord_utils.py +243 -0
- metaflow_extensions/outerbounds/plugins/auth_server.py +28 -8
- metaflow_extensions/outerbounds/plugins/aws/__init__.py +4 -0
- metaflow_extensions/outerbounds/plugins/aws/assume_role.py +3 -0
- metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +118 -0
- metaflow_extensions/outerbounds/plugins/card_utilities/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/card_utilities/async_cards.py +142 -0
- metaflow_extensions/outerbounds/plugins/card_utilities/extra_components.py +545 -0
- metaflow_extensions/outerbounds/plugins/card_utilities/injector.py +70 -0
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/__init__.py +2 -0
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/coreweave.py +71 -0
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/external_chckpt.py +85 -0
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/nebius.py +73 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/docker_environment.py +391 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery.py +188 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery_cli.py +54 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery_decorator.py +50 -0
- metaflow_extensions/outerbounds/plugins/kubernetes/kubernetes_client.py +79 -0
- metaflow_extensions/outerbounds/plugins/kubernetes/pod_killer.py +374 -0
- metaflow_extensions/outerbounds/plugins/nim/card.py +140 -0
- metaflow_extensions/outerbounds/plugins/nim/nim_decorator.py +101 -0
- metaflow_extensions/outerbounds/plugins/nim/nim_manager.py +379 -0
- metaflow_extensions/outerbounds/plugins/nim/utils.py +36 -0
- metaflow_extensions/outerbounds/plugins/nvcf/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/nvcf/constants.py +3 -0
- metaflow_extensions/outerbounds/plugins/nvcf/exceptions.py +94 -0
- metaflow_extensions/outerbounds/plugins/nvcf/heartbeat_store.py +178 -0
- metaflow_extensions/outerbounds/plugins/nvcf/nvcf.py +417 -0
- metaflow_extensions/outerbounds/plugins/nvcf/nvcf_cli.py +280 -0
- metaflow_extensions/outerbounds/plugins/nvcf/nvcf_decorator.py +242 -0
- metaflow_extensions/outerbounds/plugins/nvcf/utils.py +6 -0
- metaflow_extensions/outerbounds/plugins/nvct/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/nvct/exceptions.py +71 -0
- metaflow_extensions/outerbounds/plugins/nvct/nvct.py +131 -0
- metaflow_extensions/outerbounds/plugins/nvct/nvct_cli.py +289 -0
- metaflow_extensions/outerbounds/plugins/nvct/nvct_decorator.py +286 -0
- metaflow_extensions/outerbounds/plugins/nvct/nvct_runner.py +218 -0
- metaflow_extensions/outerbounds/plugins/nvct/utils.py +29 -0
- metaflow_extensions/outerbounds/plugins/ollama/__init__.py +225 -0
- metaflow_extensions/outerbounds/plugins/ollama/constants.py +1 -0
- metaflow_extensions/outerbounds/plugins/ollama/exceptions.py +22 -0
- metaflow_extensions/outerbounds/plugins/ollama/ollama.py +1924 -0
- metaflow_extensions/outerbounds/plugins/ollama/status_card.py +292 -0
- metaflow_extensions/outerbounds/plugins/optuna/__init__.py +48 -0
- metaflow_extensions/outerbounds/plugins/perimeters.py +19 -5
- metaflow_extensions/outerbounds/plugins/profilers/deco_injector.py +70 -0
- metaflow_extensions/outerbounds/plugins/profilers/gpu_profile_decorator.py +88 -0
- metaflow_extensions/outerbounds/plugins/profilers/simple_card_decorator.py +96 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/__init__.py +7 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/binary_caller.py +132 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/constants.py +11 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/exceptions.py +13 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/proxy_bootstrap.py +59 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_api.py +93 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_decorator.py +250 -0
- metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_manager.py +225 -0
- metaflow_extensions/outerbounds/plugins/secrets/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/secrets/secrets.py +204 -0
- metaflow_extensions/outerbounds/plugins/snowflake/__init__.py +3 -0
- metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +378 -0
- metaflow_extensions/outerbounds/plugins/snowpark/__init__.py +0 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +309 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +277 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +150 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +273 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_exceptions.py +13 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +241 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_service_spec.py +259 -0
- metaflow_extensions/outerbounds/plugins/tensorboard/__init__.py +50 -0
- metaflow_extensions/outerbounds/plugins/torchtune/__init__.py +163 -0
- metaflow_extensions/outerbounds/plugins/vllm/__init__.py +255 -0
- metaflow_extensions/outerbounds/plugins/vllm/constants.py +1 -0
- metaflow_extensions/outerbounds/plugins/vllm/exceptions.py +1 -0
- metaflow_extensions/outerbounds/plugins/vllm/status_card.py +352 -0
- metaflow_extensions/outerbounds/plugins/vllm/vllm_manager.py +621 -0
- metaflow_extensions/outerbounds/profilers/gpu.py +131 -47
- metaflow_extensions/outerbounds/remote_config.py +53 -16
- metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +138 -2
- metaflow_extensions/outerbounds/toplevel/ob_internal.py +4 -0
- metaflow_extensions/outerbounds/toplevel/plugins/ollama/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/plugins/optuna/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/plugins/snowflake/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/plugins/torchtune/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/plugins/vllm/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/s3_proxy.py +88 -0
- {ob_metaflow_extensions-1.1.45rc3.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/METADATA +2 -2
- ob_metaflow_extensions-1.5.1.dist-info/RECORD +133 -0
- ob_metaflow_extensions-1.1.45rc3.dist-info/RECORD +0 -19
- {ob_metaflow_extensions-1.1.45rc3.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/WHEEL +0 -0
- {ob_metaflow_extensions-1.1.45rc3.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
from .config import TypedCoreConfig, TypedDict
|
|
2
|
+
from .perimeters import PerimeterExtractor
|
|
3
|
+
from .capsule import CapsuleApi
|
|
4
|
+
import time
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from ._state_machine import DEPLOYMENT_READY_CONDITIONS, LogLine
|
|
8
|
+
from .app_config import AppConfig, AppConfigError
|
|
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 CapsuleDeployer, list_and_filter_capsules, _format_url_string
|
|
13
|
+
from .dependencies import ImageBakingException
|
|
14
|
+
from functools import partial
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Dict, List, Optional, Callable, Any
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bake_image(
|
|
21
|
+
pypi: Optional[Dict[str, str]] = None,
|
|
22
|
+
conda: Optional[Dict[str, str]] = None,
|
|
23
|
+
requirements_file: Optional[str] = None,
|
|
24
|
+
pyproject_toml: Optional[str] = None,
|
|
25
|
+
base_image: Optional[str] = None,
|
|
26
|
+
python: Optional[str] = None,
|
|
27
|
+
logger: Optional[Callable[[str], Any]] = None,
|
|
28
|
+
cache_name: Optional[str] = None,
|
|
29
|
+
) -> BakedImage:
|
|
30
|
+
"""
|
|
31
|
+
Bake a Docker image with the specified dependencies.
|
|
32
|
+
|
|
33
|
+
This is a composable building block that can be used standalone or
|
|
34
|
+
combined with AppDeployer to deploy apps with custom images.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
pypi : Dict[str, str], optional
|
|
39
|
+
Dictionary of PyPI packages to install. Keys are package names,
|
|
40
|
+
values are version specifiers. Example: {"flask": ">=2.0", "requests": ""}
|
|
41
|
+
Mutually exclusive with requirements_file and pyproject_toml.
|
|
42
|
+
conda : Dict[str, str], optional
|
|
43
|
+
Dictionary of Conda packages to install.
|
|
44
|
+
requirements_file : str, optional
|
|
45
|
+
Path to a requirements.txt file.
|
|
46
|
+
Mutually exclusive with pypi and pyproject_toml.
|
|
47
|
+
pyproject_toml : str, optional
|
|
48
|
+
Path to a pyproject.toml file.
|
|
49
|
+
Mutually exclusive with pypi and requirements_file.
|
|
50
|
+
base_image : str, optional
|
|
51
|
+
Base Docker image to build from. Defaults to the platform default image.
|
|
52
|
+
python : str, optional
|
|
53
|
+
Python version to use (e.g., "3.11.0"). If None (default), uses the Python
|
|
54
|
+
already present in the base_image and installs dependencies into it. If a
|
|
55
|
+
version is specified, a new Python environment at that version is created
|
|
56
|
+
inside the base image, and all dependencies are installed into it.
|
|
57
|
+
logger : Callable, optional
|
|
58
|
+
Logger function for progress messages.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
BakedImage
|
|
63
|
+
Named tuple containing:
|
|
64
|
+
- image: The baked Docker image URL
|
|
65
|
+
- python_path: Path to Python executable in the image
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
ImageBakingException
|
|
70
|
+
If baking fails or if invalid parameters are provided.
|
|
71
|
+
|
|
72
|
+
Examples
|
|
73
|
+
--------
|
|
74
|
+
Bake with PyPI packages:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
result = bake_image(pypi={"flask": ">=2.0", "requests": ""})
|
|
78
|
+
print(result.image)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Bake from requirements.txt:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
result = bake_image(requirements_file="./requirements.txt")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Bake from pyproject.toml:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
result = bake_image(pyproject_toml="./pyproject.toml")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Combine with AppDeployer:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
baked = bake_image(pypi={"flask": ">=2.0"})
|
|
97
|
+
deployer = AppDeployer(name="my-app", port=8080, image=baked.image)
|
|
98
|
+
deployed = deployer.deploy()
|
|
99
|
+
```
|
|
100
|
+
"""
|
|
101
|
+
from metaflow.ob_internal import internal_bake_image as _internal_bake # type: ignore
|
|
102
|
+
from metaflow.plugins.pypi.parsers import (
|
|
103
|
+
requirements_txt_parser,
|
|
104
|
+
pyproject_toml_parser,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
from metaflow.metaflow_config import (
|
|
108
|
+
DEFAULT_DATASTORE,
|
|
109
|
+
get_pinned_conda_libs,
|
|
110
|
+
KUBERNETES_CONTAINER_IMAGE,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Count how many dependency sources are provided
|
|
114
|
+
dep_sources = sum(
|
|
115
|
+
[
|
|
116
|
+
pypi is not None,
|
|
117
|
+
requirements_file is not None,
|
|
118
|
+
pyproject_toml is not None,
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if dep_sources > 1:
|
|
123
|
+
raise ImageBakingException(
|
|
124
|
+
"Only one of pypi, requirements_file, or pyproject_toml can be specified."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Set defaults
|
|
128
|
+
_base_image = base_image or KUBERNETES_CONTAINER_IMAGE
|
|
129
|
+
_python_version = python # Keep it None to use image python
|
|
130
|
+
_logger = logger or (lambda x: None)
|
|
131
|
+
_cache_name = cache_name or "default"
|
|
132
|
+
|
|
133
|
+
# Set up cache directory (internal - not exposed to users)
|
|
134
|
+
cache_dir = os.path.join(tempfile.gettempdir(), f"ob-bake-{_cache_name}")
|
|
135
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
136
|
+
cache_file_path = os.path.join(cache_dir, "image_cache")
|
|
137
|
+
|
|
138
|
+
# Collect packages
|
|
139
|
+
pypi_packages: Dict[str, str] = {}
|
|
140
|
+
conda_packages: Dict[str, str] = {}
|
|
141
|
+
|
|
142
|
+
# Parse from file if provided
|
|
143
|
+
if requirements_file:
|
|
144
|
+
if not os.path.exists(requirements_file):
|
|
145
|
+
raise ImageBakingException(
|
|
146
|
+
f"Requirements file not found: {requirements_file}"
|
|
147
|
+
)
|
|
148
|
+
with open(requirements_file, "r") as f:
|
|
149
|
+
parsed = requirements_txt_parser(f.read())
|
|
150
|
+
pypi_packages = parsed.get("packages", {})
|
|
151
|
+
_python_version = parsed.get("python_version", _python_version)
|
|
152
|
+
_logger(f"📦 Parsed {len(pypi_packages)} packages from {requirements_file}")
|
|
153
|
+
|
|
154
|
+
elif pyproject_toml:
|
|
155
|
+
if not os.path.exists(pyproject_toml):
|
|
156
|
+
raise ImageBakingException(f"pyproject.toml not found: {pyproject_toml}")
|
|
157
|
+
with open(pyproject_toml, "r") as f:
|
|
158
|
+
parsed = pyproject_toml_parser(f.read())
|
|
159
|
+
pypi_packages = parsed.get("packages", {})
|
|
160
|
+
_python_version = parsed.get("python_version", _python_version)
|
|
161
|
+
_logger(f"📦 Parsed {len(pypi_packages)} packages from {pyproject_toml}")
|
|
162
|
+
|
|
163
|
+
elif pypi:
|
|
164
|
+
pypi_packages = pypi.copy()
|
|
165
|
+
|
|
166
|
+
if conda:
|
|
167
|
+
conda_packages = conda.copy()
|
|
168
|
+
|
|
169
|
+
# Check if there are any packages to bake
|
|
170
|
+
if not pypi_packages and not conda_packages:
|
|
171
|
+
_logger("⚠️ No packages to bake. Returning base image.")
|
|
172
|
+
return BakedImage(image=_base_image, python_path="python")
|
|
173
|
+
|
|
174
|
+
# Add pinned conda libs required by the platform
|
|
175
|
+
pinned_libs = get_pinned_conda_libs(_python_version, DEFAULT_DATASTORE)
|
|
176
|
+
pypi_packages.update(pinned_libs)
|
|
177
|
+
|
|
178
|
+
_logger(f"🍞 Baking image with {len(pypi_packages)} PyPI packages...")
|
|
179
|
+
|
|
180
|
+
# Call the internal bake function
|
|
181
|
+
fb_response = _internal_bake(
|
|
182
|
+
cache_file_path=cache_file_path,
|
|
183
|
+
pypi_packages=pypi_packages,
|
|
184
|
+
conda_packages=conda_packages,
|
|
185
|
+
ref=_cache_name,
|
|
186
|
+
python=_python_version,
|
|
187
|
+
base_image=_base_image,
|
|
188
|
+
logger=_logger,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if fb_response.failure:
|
|
192
|
+
raise ImageBakingException(f"Failed to bake image: {fb_response.response}")
|
|
193
|
+
|
|
194
|
+
_logger(f"🐳 Baked image: {fb_response.container_image}")
|
|
195
|
+
|
|
196
|
+
return BakedImage(
|
|
197
|
+
image=fb_response.container_image,
|
|
198
|
+
python_path=fb_response.python_path,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class CodePackagingException(Exception):
|
|
203
|
+
"""Exception raised when code packaging fails."""
|
|
204
|
+
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def package_code(
|
|
209
|
+
src_paths: List[str],
|
|
210
|
+
suffixes: Optional[List[str]] = None,
|
|
211
|
+
logger: Optional[Callable[[str], Any]] = None,
|
|
212
|
+
) -> PackagedCode:
|
|
213
|
+
"""
|
|
214
|
+
Package code for deployment to the Outerbounds Platform.
|
|
215
|
+
|
|
216
|
+
This is a composable building block that can be used standalone or
|
|
217
|
+
combined with AppDeployer to deploy apps with custom code packages.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
src_paths : List[str]
|
|
222
|
+
List of directories to include in the package. All paths must exist
|
|
223
|
+
and be directories.
|
|
224
|
+
suffixes : List[str], optional
|
|
225
|
+
File extensions to include (e.g., [".py", ".json", ".yaml"]).
|
|
226
|
+
If None, uses default suffixes: .py, .txt, .yaml, .yml, .json,
|
|
227
|
+
.html, .css, .js, .jsx, .ts, .tsx, .md, .rst
|
|
228
|
+
logger : Callable, optional
|
|
229
|
+
Logger function for progress messages. Receives a single string argument.
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
PackagedCode
|
|
234
|
+
Named tuple containing:
|
|
235
|
+
- url: The package URL in object storage
|
|
236
|
+
- key: Unique content-addressed key identifying this package
|
|
237
|
+
|
|
238
|
+
Raises
|
|
239
|
+
------
|
|
240
|
+
CodePackagingException
|
|
241
|
+
If packaging fails or if invalid paths are provided.
|
|
242
|
+
|
|
243
|
+
Examples
|
|
244
|
+
--------
|
|
245
|
+
Package a directory:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
pkg = package_code(src_paths=["./src"])
|
|
249
|
+
print(pkg.url)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Package multiple directories:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
pkg = package_code(src_paths=["./src", "./configs"])
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Package with specific file types:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
pkg = package_code(
|
|
262
|
+
src_paths=["./app"],
|
|
263
|
+
suffixes=[".py", ".yaml", ".json"]
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
"""
|
|
267
|
+
from metaflow.metaflow_config import DEFAULT_DATASTORE
|
|
268
|
+
|
|
269
|
+
_logger = logger or (lambda x: None)
|
|
270
|
+
|
|
271
|
+
# Validate paths
|
|
272
|
+
for path in src_paths:
|
|
273
|
+
if not os.path.exists(path):
|
|
274
|
+
raise CodePackagingException(f"Source path does not exist: {path}")
|
|
275
|
+
if not os.path.isdir(path):
|
|
276
|
+
raise CodePackagingException(f"Source path is not a directory: {path}")
|
|
277
|
+
|
|
278
|
+
_logger(f"📦 Packaging {len(src_paths)} directory(ies)...")
|
|
279
|
+
|
|
280
|
+
# Create packager and store
|
|
281
|
+
packager = CodePackager(
|
|
282
|
+
datastore_type=DEFAULT_DATASTORE,
|
|
283
|
+
code_package_prefix=CODE_PACKAGE_PREFIX,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
package_url, package_key = packager.store(
|
|
288
|
+
paths_to_include=src_paths,
|
|
289
|
+
file_suffixes=suffixes, # None uses defaults in CodePackager
|
|
290
|
+
)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
raise CodePackagingException(f"Failed to package code: {e}") from e
|
|
293
|
+
|
|
294
|
+
_logger(f"📦 Code package stored: {package_url}")
|
|
295
|
+
|
|
296
|
+
return PackagedCode(url=package_url, key=package_key)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class AppDeployer(TypedCoreConfig):
|
|
300
|
+
|
|
301
|
+
__examples__ = """
|
|
302
|
+
Examples
|
|
303
|
+
--------
|
|
304
|
+
Basic deployment with bake_image and package_code:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from metaflow import bake_image, package_code, AppDeployer
|
|
308
|
+
|
|
309
|
+
# Step 1: Bake dependencies into an image
|
|
310
|
+
baked = bake_image(pypi={"flask": ">=2.0", "requests": ""})
|
|
311
|
+
|
|
312
|
+
# Step 2: Package your application code
|
|
313
|
+
pkg = package_code(src_paths=["./src"])
|
|
314
|
+
|
|
315
|
+
# Step 3: Create deployer and deploy
|
|
316
|
+
deployer = AppDeployer(
|
|
317
|
+
name="my-flask-app",
|
|
318
|
+
port=8000,
|
|
319
|
+
image=baked.image,
|
|
320
|
+
code_package=pkg,
|
|
321
|
+
commands=["python server.py"],
|
|
322
|
+
replicas={"min": 1, "max": 3},
|
|
323
|
+
resources={"cpu": "1", "memory": "2048Mi"},
|
|
324
|
+
)
|
|
325
|
+
deployed = deployer.deploy()
|
|
326
|
+
print(deployed.public_url)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Deployment with API authentication:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
deployer = AppDeployer(
|
|
333
|
+
name="my-api",
|
|
334
|
+
port=8000,
|
|
335
|
+
image=baked.image,
|
|
336
|
+
code_package=pkg,
|
|
337
|
+
commands=["python api.py"],
|
|
338
|
+
auth={"type": "API"},
|
|
339
|
+
)
|
|
340
|
+
deployed = deployer.deploy()
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Deployment with environment variables and secrets:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
deployer = AppDeployer(
|
|
347
|
+
name="my-app",
|
|
348
|
+
port=8000,
|
|
349
|
+
image=baked.image,
|
|
350
|
+
code_package=pkg,
|
|
351
|
+
commands=["python app.py"],
|
|
352
|
+
environment={"DEBUG": "false", "LOG_LEVEL": "info"},
|
|
353
|
+
secrets=["my-api-keys"],
|
|
354
|
+
)
|
|
355
|
+
deployed = deployer.deploy()
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Interacting with a deployed app:
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
# Get app info
|
|
362
|
+
info = deployed.info()
|
|
363
|
+
|
|
364
|
+
# Get logs from all workers
|
|
365
|
+
logs = deployed.logs()
|
|
366
|
+
|
|
367
|
+
# Scale to zero workers
|
|
368
|
+
deployed.scale_to_zero()
|
|
369
|
+
|
|
370
|
+
# Delete the app
|
|
371
|
+
deployed.delete()
|
|
372
|
+
```
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
__doc__ = (
|
|
376
|
+
"""Programmatic API For deploying Outerbounds Apps.\n"""
|
|
377
|
+
+ TypedCoreConfig.__doc__
|
|
378
|
+
+ __examples__
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
__init__ = TypedCoreConfig.__init__
|
|
382
|
+
|
|
383
|
+
_app_config: AppConfig
|
|
384
|
+
|
|
385
|
+
# What is `_state` ?
|
|
386
|
+
# `_state` is a dictionary that will hold all information that might need
|
|
387
|
+
# to be passed down without the user explicity setting them.
|
|
388
|
+
# Setting `_state` will ensure that values are explicity passed down from
|
|
389
|
+
# top level when the class is used under different context.
|
|
390
|
+
# So for example if we need to set some things like project/branches etc
|
|
391
|
+
# during metaflow context, we can do so easily. We also like to set state like
|
|
392
|
+
# perimeters at class level since users current cannot also switch perimeters within
|
|
393
|
+
# the same interpreter.
|
|
394
|
+
_state = {}
|
|
395
|
+
|
|
396
|
+
__state_items = [
|
|
397
|
+
# perimeter and api_url come from config setups
|
|
398
|
+
# need to happen before AppDeployer and need to
|
|
399
|
+
# come from _set_state
|
|
400
|
+
"perimeter",
|
|
401
|
+
"api_url",
|
|
402
|
+
# code package URL / code package key
|
|
403
|
+
# can come from CodePackager so its fine
|
|
404
|
+
# if its in _set_state
|
|
405
|
+
"code_package_url",
|
|
406
|
+
"code_package_key",
|
|
407
|
+
# Image can be explicitly set by the user
|
|
408
|
+
# or requre some external fast-bakery API
|
|
409
|
+
"image",
|
|
410
|
+
# project/branch have to come from _set_state
|
|
411
|
+
# if users do this through current. Otherwise
|
|
412
|
+
# can come from the
|
|
413
|
+
"project",
|
|
414
|
+
"branch",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
def _init(self):
|
|
418
|
+
perimeter, api_url = PerimeterExtractor.during_programmatic_access()
|
|
419
|
+
self._set_state("perimeter", perimeter)
|
|
420
|
+
self._set_state("api_url", api_url)
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def _deploy_config(self) -> AppConfig:
|
|
424
|
+
if not hasattr(self, "_app_config"):
|
|
425
|
+
self._app_config = AppConfig(self._config)
|
|
426
|
+
return self._app_config
|
|
427
|
+
|
|
428
|
+
# Things that need to be set before deploy
|
|
429
|
+
@classmethod
|
|
430
|
+
def _set_state(cls, key, value):
|
|
431
|
+
cls._state[key] = value
|
|
432
|
+
|
|
433
|
+
def deploy(
|
|
434
|
+
self,
|
|
435
|
+
readiness_condition: str = DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
|
|
436
|
+
max_wait_time=600,
|
|
437
|
+
readiness_wait_time=10,
|
|
438
|
+
logger_fn=partial(print, file=sys.stderr),
|
|
439
|
+
**kwargs,
|
|
440
|
+
) -> "DeployedApp":
|
|
441
|
+
"""
|
|
442
|
+
Deploy the app to the Outerbounds Platform.
|
|
443
|
+
|
|
444
|
+
This method packages and deploys the configured app, waiting for it to reach
|
|
445
|
+
the specified readiness condition before returning.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
readiness_condition : str, optional
|
|
450
|
+
The condition that must be met for the deployment to be considered ready.
|
|
451
|
+
Default is ATLEAST_ONE_RUNNING.
|
|
452
|
+
|
|
453
|
+
Deployment ready conditions define what is considered a successful completion
|
|
454
|
+
of the current deployment instance. This allows users or platform designers
|
|
455
|
+
to configure the criteria for deployment readiness.
|
|
456
|
+
|
|
457
|
+
Why do we need deployment readiness conditions?
|
|
458
|
+
- Deployments might be taking place from a CI/CD-esque environment.
|
|
459
|
+
In these setups, the downstream build triggers might be depending on
|
|
460
|
+
a specific criteria for deployment completion. Having readiness conditions
|
|
461
|
+
allows the CI/CD systems to get a signal of when the deployment is ready.
|
|
462
|
+
- Users might be calling the deployment API under different conditions:
|
|
463
|
+
- Some users might want a cluster of workers ready before serving
|
|
464
|
+
traffic while others might want just one worker ready to start
|
|
465
|
+
serving traffic.
|
|
466
|
+
|
|
467
|
+
Available readiness conditions:
|
|
468
|
+
|
|
469
|
+
ATLEAST_ONE_RUNNING ("at_least_one_running")
|
|
470
|
+
At least min(min_replicas, 1) workers of the current deployment
|
|
471
|
+
instance's version have started running.
|
|
472
|
+
Usecase: Some endpoints may be deployed ephemerally and are considered
|
|
473
|
+
ready when at least one instance is running; additional instances are
|
|
474
|
+
for load management.
|
|
475
|
+
|
|
476
|
+
ALL_RUNNING ("all_running")
|
|
477
|
+
At least min_replicas number of workers are running for the deployment
|
|
478
|
+
to be considered ready.
|
|
479
|
+
Usecase: Operators may require that all replicas are available before
|
|
480
|
+
traffic is routed. Needed when inference endpoints may be under some
|
|
481
|
+
SLA or require a larger load.
|
|
482
|
+
|
|
483
|
+
FULLY_FINISHED ("fully_finished")
|
|
484
|
+
At least min_replicas number of workers are running for the deployment
|
|
485
|
+
and there are no pending or crashlooping workers from previous versions
|
|
486
|
+
lying around.
|
|
487
|
+
Usecase: Ensuring endpoint is fully available and no other versions are
|
|
488
|
+
running or endpoint has been fully scaled down.
|
|
489
|
+
|
|
490
|
+
ASYNC ("async")
|
|
491
|
+
The deployment will be assumed ready as soon as the server acknowledges
|
|
492
|
+
it has registered the app in the backend.
|
|
493
|
+
Usecase: Operators may only care that the URL is minted for the deployment
|
|
494
|
+
or the operator wants the deployment to eventually scale down to 0.
|
|
495
|
+
|
|
496
|
+
max_wait_time : int, optional
|
|
497
|
+
Maximum time in seconds to wait for the deployment to reach readiness.
|
|
498
|
+
Default is 600 (10 minutes).
|
|
499
|
+
|
|
500
|
+
readiness_wait_time : int, optional
|
|
501
|
+
Time in seconds to wait between readiness checks. Default is 10.
|
|
502
|
+
|
|
503
|
+
logger_fn : Callable, optional
|
|
504
|
+
Function to use for logging progress messages. Default prints to stderr.
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
DeployedApp
|
|
509
|
+
An object representing the deployed app with methods to interact with it
|
|
510
|
+
(logs, info, scale_to_zero, delete, etc.) and properties like public_url.
|
|
511
|
+
|
|
512
|
+
Raises
|
|
513
|
+
------
|
|
514
|
+
CodePackagingException
|
|
515
|
+
If code_package is not provided or is not a valid PackagedCode instance.
|
|
516
|
+
|
|
517
|
+
AppConfigError
|
|
518
|
+
If the app configuration is invalid or if an upgrade is already in progress
|
|
519
|
+
(unless force_upgrade is set).
|
|
520
|
+
|
|
521
|
+
Examples
|
|
522
|
+
--------
|
|
523
|
+
Basic deployment:
|
|
524
|
+
|
|
525
|
+
```python
|
|
526
|
+
from metaflow import bake_image, package_code, AppDeployer
|
|
527
|
+
baked = bake_image(pypi={"flask": ">=2.0"})
|
|
528
|
+
pkg = package_code(src_paths=["./src"])
|
|
529
|
+
deployer = AppDeployer(
|
|
530
|
+
name="my-app",
|
|
531
|
+
port=8000,
|
|
532
|
+
image=baked.image,
|
|
533
|
+
code_package=pkg,
|
|
534
|
+
commands=["python server.py"],
|
|
535
|
+
)
|
|
536
|
+
deployed = deployer.deploy()
|
|
537
|
+
print(deployed.public_url)
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Wait for all replicas to be ready:
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
deployed = deployer.deploy(
|
|
544
|
+
readiness_condition="all_running"
|
|
545
|
+
)
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Async deployment (don't wait for workers):
|
|
549
|
+
|
|
550
|
+
```python
|
|
551
|
+
deployed = deployer.deploy(
|
|
552
|
+
readiness_condition="async"
|
|
553
|
+
)
|
|
554
|
+
```
|
|
555
|
+
"""
|
|
556
|
+
if len(self._state.get("default_tags", [])) > 0:
|
|
557
|
+
self._deploy_config._core_config.tags = (
|
|
558
|
+
self._deploy_config._core_config.tags or []
|
|
559
|
+
) + self._state["default_tags"]
|
|
560
|
+
|
|
561
|
+
# Handle code_package if provided - extract url and key to state
|
|
562
|
+
code_package = getattr(self._deploy_config._core_config, "code_package", None)
|
|
563
|
+
if code_package is not None:
|
|
564
|
+
# Validate that code_package is a PackagedCode namedtuple
|
|
565
|
+
if not isinstance(code_package, PackagedCode):
|
|
566
|
+
raise CodePackagingException(
|
|
567
|
+
f"code_package must be a PackagedCode instance returned by package_code(). "
|
|
568
|
+
f"Got {type(code_package).__name__} instead.\n\n"
|
|
569
|
+
"Use package_code() to create a valid code package:\n\n"
|
|
570
|
+
" from metaflow import package_code, AppDeployer\n\n"
|
|
571
|
+
" pkg = package_code(src_paths=['./src'])\n"
|
|
572
|
+
" deployer = AppDeployer(..., code_package=pkg)\n"
|
|
573
|
+
)
|
|
574
|
+
self._set_state("code_package_url", code_package.url)
|
|
575
|
+
self._set_state("code_package_key", code_package.key)
|
|
576
|
+
# Clear the code_package field to avoid serialization issues
|
|
577
|
+
self._deploy_config._core_config.code_package = None
|
|
578
|
+
|
|
579
|
+
# Verify code_package is present (either from code_package param or from state)
|
|
580
|
+
if (
|
|
581
|
+
self._state.get("code_package_url") is None
|
|
582
|
+
and self._deploy_config.get_state("code_package_url") is None
|
|
583
|
+
):
|
|
584
|
+
raise CodePackagingException(
|
|
585
|
+
"code_package is required for deployment. "
|
|
586
|
+
"Use package_code() to create a code package:\n\n"
|
|
587
|
+
" from metaflow import package_code, AppDeployer\n\n"
|
|
588
|
+
" pkg = package_code(src_paths=['./src'])\n"
|
|
589
|
+
" deployer = AppDeployer(..., code_package=pkg)\n"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
self._deploy_config.commit()
|
|
593
|
+
# Set any state that might have been passed down from the top level
|
|
594
|
+
for k in self.__state_items:
|
|
595
|
+
if self._deploy_config.get_state(k) is None and (
|
|
596
|
+
k in self._state and self._state[k] is not None
|
|
597
|
+
):
|
|
598
|
+
self._deploy_config.set_state(k, self._state[k])
|
|
599
|
+
|
|
600
|
+
capsule = CapsuleDeployer(
|
|
601
|
+
self._deploy_config,
|
|
602
|
+
self._state["api_url"],
|
|
603
|
+
create_timeout=max_wait_time,
|
|
604
|
+
debug_dir=None,
|
|
605
|
+
success_terminal_state_condition=readiness_condition,
|
|
606
|
+
readiness_wait_time=readiness_wait_time,
|
|
607
|
+
logger_fn=logger_fn,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
currently_present_capsules = list_and_filter_capsules(
|
|
611
|
+
capsule.capsule_api,
|
|
612
|
+
None,
|
|
613
|
+
None,
|
|
614
|
+
capsule.name,
|
|
615
|
+
None,
|
|
616
|
+
None,
|
|
617
|
+
None,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
force_upgrade = self._deploy_config.get_state("force_upgrade", False)
|
|
621
|
+
|
|
622
|
+
if len(currently_present_capsules) > 0:
|
|
623
|
+
# Only update the capsule if there is no upgrade in progress
|
|
624
|
+
# Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
|
|
625
|
+
_curr_cap = currently_present_capsules[0]
|
|
626
|
+
this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
|
|
627
|
+
"updateInProgress", False
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
if this_capsule_is_being_updated and not force_upgrade:
|
|
631
|
+
_upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
|
|
632
|
+
message = f"{capsule.capsule_type} is currently being upgraded"
|
|
633
|
+
if _upgrader:
|
|
634
|
+
message = (
|
|
635
|
+
f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
|
|
636
|
+
"If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
|
|
637
|
+
)
|
|
638
|
+
raise AppConfigError(message)
|
|
639
|
+
|
|
640
|
+
logger_fn(
|
|
641
|
+
f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
|
|
642
|
+
)
|
|
643
|
+
else:
|
|
644
|
+
logger_fn(
|
|
645
|
+
f"🚀 Deploying {capsule.capsule_type.lower()} `{capsule.name}`....",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
capsule.create()
|
|
649
|
+
final_status = capsule.wait_for_terminal_state()
|
|
650
|
+
return DeployedApp(
|
|
651
|
+
final_status["id"],
|
|
652
|
+
final_status["auth_type"],
|
|
653
|
+
_format_url_string(final_status["public_url"]),
|
|
654
|
+
final_status["name"],
|
|
655
|
+
final_status["deployed_version"],
|
|
656
|
+
final_status["deployed_at"],
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
@classmethod
|
|
660
|
+
def list_deployments(
|
|
661
|
+
cls,
|
|
662
|
+
name: str = None,
|
|
663
|
+
project: str = None,
|
|
664
|
+
branch: str = None,
|
|
665
|
+
tags: List[Dict[str, str]] = None,
|
|
666
|
+
) -> List["DeployedApp"]:
|
|
667
|
+
"""
|
|
668
|
+
List deployed apps, optionally filtered by name, project, branch, or tags.
|
|
669
|
+
|
|
670
|
+
Parameters
|
|
671
|
+
----------
|
|
672
|
+
name : str, optional
|
|
673
|
+
Filter by app name.
|
|
674
|
+
project : str, optional
|
|
675
|
+
Filter by project name.
|
|
676
|
+
branch : str, optional
|
|
677
|
+
Filter by branch name.
|
|
678
|
+
tags : List[Dict[str, str]], optional
|
|
679
|
+
Filter by tags. Each tag is a dict with a single key-value pair,
|
|
680
|
+
e.g., [{"env": "prod"}] or [{"team": "ml"}, {"version": "v2"}].
|
|
681
|
+
Apps must have all specified tags to match.
|
|
682
|
+
|
|
683
|
+
Returns
|
|
684
|
+
-------
|
|
685
|
+
List[DeployedApp]
|
|
686
|
+
List of deployed apps matching the filters.
|
|
687
|
+
|
|
688
|
+
Examples
|
|
689
|
+
--------
|
|
690
|
+
List all apps:
|
|
691
|
+
|
|
692
|
+
```python
|
|
693
|
+
apps = AppDeployer.list_deployments()
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Filter by name:
|
|
697
|
+
|
|
698
|
+
```python
|
|
699
|
+
apps = AppDeployer.list_deployments(name="my-app")
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Filter by project and branch:
|
|
703
|
+
|
|
704
|
+
```python
|
|
705
|
+
apps = AppDeployer.list_deployments(project="ml-pipeline", branch="main")
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Filter by a single tag:
|
|
709
|
+
|
|
710
|
+
```python
|
|
711
|
+
apps = AppDeployer.list_deployments(tags=[{"env": "prod"}])
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Filter by multiple tags (AND logic - must match all):
|
|
715
|
+
|
|
716
|
+
```python
|
|
717
|
+
apps = AppDeployer.list_deployments(tags=[{"env": "prod"}, {"team": "ml"}])
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Combine filters:
|
|
721
|
+
|
|
722
|
+
```python
|
|
723
|
+
apps = AppDeployer.list_deployments(
|
|
724
|
+
project="recommendations",
|
|
725
|
+
tags=[{"env": "staging"}]
|
|
726
|
+
)
|
|
727
|
+
```
|
|
728
|
+
"""
|
|
729
|
+
# Transform tags from {key: value} to {"key": key, "value": value}
|
|
730
|
+
transformed_tags = None
|
|
731
|
+
if tags:
|
|
732
|
+
transformed_tags = [
|
|
733
|
+
{"key": k, "value": v} for tag in tags for k, v in tag.items()
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
capsule_api = DeployedApp._get_capsule_api()
|
|
737
|
+
list_of_capsules = list_and_filter_capsules(
|
|
738
|
+
capsule_api,
|
|
739
|
+
project=project,
|
|
740
|
+
branch=branch,
|
|
741
|
+
name=name,
|
|
742
|
+
tags=transformed_tags,
|
|
743
|
+
auth_type=None,
|
|
744
|
+
capsule_id=None,
|
|
745
|
+
)
|
|
746
|
+
apps = []
|
|
747
|
+
for cap in list_of_capsules:
|
|
748
|
+
apps.append(DeployedApp._from_capsule(cap))
|
|
749
|
+
return apps
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class TTLCachedObject:
|
|
753
|
+
"""
|
|
754
|
+
Caches a value with a time-to-live (TTL) per instance.
|
|
755
|
+
Returns None if accessed after TTL has expired.
|
|
756
|
+
"""
|
|
757
|
+
|
|
758
|
+
def __init__(self, ttl_seconds: float):
|
|
759
|
+
self._ttl = ttl_seconds
|
|
760
|
+
self._attr_name = None
|
|
761
|
+
|
|
762
|
+
def __set_name__(self, owner, name):
|
|
763
|
+
self._attr_name = f"_ttl_cache_{name}"
|
|
764
|
+
|
|
765
|
+
def __get__(self, instance, owner):
|
|
766
|
+
if instance is None:
|
|
767
|
+
return self
|
|
768
|
+
cache = getattr(instance, self._attr_name, None)
|
|
769
|
+
if cache is None:
|
|
770
|
+
return None
|
|
771
|
+
value, last_set = cache
|
|
772
|
+
if (time.time() - last_set) > self._ttl:
|
|
773
|
+
return None
|
|
774
|
+
return value
|
|
775
|
+
|
|
776
|
+
def __set__(self, instance, val):
|
|
777
|
+
setattr(instance, self._attr_name, (val, time.time()))
|
|
778
|
+
|
|
779
|
+
def __delete__(self, instance):
|
|
780
|
+
if hasattr(instance, self._attr_name):
|
|
781
|
+
delattr(instance, self._attr_name)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class DeployedApp:
|
|
785
|
+
|
|
786
|
+
# Keep a 3ish second TTL so that we can be gentler
|
|
787
|
+
# the backend API.
|
|
788
|
+
_capsule_info_cached = TTLCachedObject(3)
|
|
789
|
+
|
|
790
|
+
def __init__(
|
|
791
|
+
self,
|
|
792
|
+
_id: str,
|
|
793
|
+
capsule_type: str,
|
|
794
|
+
public_url: str,
|
|
795
|
+
name: str,
|
|
796
|
+
deployed_version: str,
|
|
797
|
+
deployed_at: str,
|
|
798
|
+
):
|
|
799
|
+
self._id = _id
|
|
800
|
+
self._capsule_type = capsule_type
|
|
801
|
+
self._public_url = public_url
|
|
802
|
+
self._name = name
|
|
803
|
+
self._deployed_version = deployed_version
|
|
804
|
+
self._deployed_at = deployed_at
|
|
805
|
+
|
|
806
|
+
@classmethod
|
|
807
|
+
def _get_capsule_api(cls) -> CapsuleApi:
|
|
808
|
+
perimeter, api_server = PerimeterExtractor.during_programmatic_access()
|
|
809
|
+
return CapsuleApi(api_server, perimeter)
|
|
810
|
+
|
|
811
|
+
@property
|
|
812
|
+
def _capsule_info(self):
|
|
813
|
+
# self._capsule_info_cached will be None every 3ish seconds
|
|
814
|
+
if self._capsule_info_cached is not None:
|
|
815
|
+
return self._capsule_info_cached
|
|
816
|
+
self._capsule_info_cached = self.info()
|
|
817
|
+
return self._capsule_info_cached
|
|
818
|
+
|
|
819
|
+
@classmethod
|
|
820
|
+
def _from_capsule(cls, capsule: dict) -> "DeployedApp":
|
|
821
|
+
capsule_id = capsule.get("id")
|
|
822
|
+
capsule_type = (
|
|
823
|
+
capsule.get("spec", {}).get("authConfig", {}).get("authType", None)
|
|
824
|
+
)
|
|
825
|
+
public_url = (
|
|
826
|
+
capsule.get("status", {}).get("accessInfo", {}).get("outOfClusterURL", None)
|
|
827
|
+
)
|
|
828
|
+
name = capsule.get("spec", {}).get(
|
|
829
|
+
"displayName",
|
|
830
|
+
)
|
|
831
|
+
deployed_version = capsule.get(
|
|
832
|
+
"version",
|
|
833
|
+
)
|
|
834
|
+
deployed_at = capsule.get("metadata", {}).get(
|
|
835
|
+
"lastModifiedAt",
|
|
836
|
+
capsule.get("metadata", {}).get(
|
|
837
|
+
"createdAt",
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
if any(i is None for i in [capsule_type, public_url, name]):
|
|
841
|
+
raise ValueError(f"Invalid capsule id: {capsule_id}")
|
|
842
|
+
cpsule = cls(
|
|
843
|
+
capsule_id,
|
|
844
|
+
capsule_type,
|
|
845
|
+
_format_url_string(public_url),
|
|
846
|
+
name,
|
|
847
|
+
deployed_version,
|
|
848
|
+
deployed_at,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
cpsule._capsule_info_cached = capsule
|
|
852
|
+
return cpsule
|
|
853
|
+
|
|
854
|
+
@classmethod
|
|
855
|
+
def _from_capsule_id(cls, capsule_id: str) -> "DeployedApp":
|
|
856
|
+
capsule_api = cls._get_capsule_api()
|
|
857
|
+
capsule = capsule_api.get(capsule_id)
|
|
858
|
+
return cls._from_capsule(capsule)
|
|
859
|
+
|
|
860
|
+
def logs(self, previous=False) -> Dict[str, List[LogLine]]:
|
|
861
|
+
"""
|
|
862
|
+
Returns a dictionary of worker_id to logs.
|
|
863
|
+
If `previous` is True, it will return the logs from the previous execution of the workers.
|
|
864
|
+
Useful when debugging a crashlooping worker.
|
|
865
|
+
"""
|
|
866
|
+
capsule_api = self._get_capsule_api()
|
|
867
|
+
# extract workers from capsule
|
|
868
|
+
workers = capsule_api.get_workers(self._id)
|
|
869
|
+
# get logs from workers
|
|
870
|
+
logs = {
|
|
871
|
+
# worker_id: logs
|
|
872
|
+
}
|
|
873
|
+
for worker in workers:
|
|
874
|
+
# TODO: Handle exceptions better over here.
|
|
875
|
+
logs[worker["workerId"]] = capsule_api.logs(
|
|
876
|
+
self._id, worker["workerId"], previous=previous
|
|
877
|
+
)
|
|
878
|
+
return logs
|
|
879
|
+
|
|
880
|
+
def info(self) -> dict:
|
|
881
|
+
"""
|
|
882
|
+
Returns a dictionary representing the deployed app.
|
|
883
|
+
"""
|
|
884
|
+
capsule_api = self._get_capsule_api()
|
|
885
|
+
capsule = capsule_api.get(self._id)
|
|
886
|
+
return capsule
|
|
887
|
+
|
|
888
|
+
def replicas(self):
|
|
889
|
+
capsule_api = self._get_capsule_api()
|
|
890
|
+
return capsule_api.get_workers(self._id)
|
|
891
|
+
|
|
892
|
+
def scale_to_zero(self):
|
|
893
|
+
"""
|
|
894
|
+
Scales the DeployedApp to 0 replicas.
|
|
895
|
+
"""
|
|
896
|
+
capsule_api = self._get_capsule_api()
|
|
897
|
+
return capsule_api.patch(
|
|
898
|
+
self._id,
|
|
899
|
+
{
|
|
900
|
+
"autoscalingConfig": {
|
|
901
|
+
"minReplicas": 0,
|
|
902
|
+
"maxReplicas": 0,
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def delete(self):
|
|
908
|
+
"""
|
|
909
|
+
Deletes the DeployedApp.
|
|
910
|
+
"""
|
|
911
|
+
capsule_api = self._get_capsule_api()
|
|
912
|
+
return capsule_api.delete(self._id)
|
|
913
|
+
|
|
914
|
+
def auth(self):
|
|
915
|
+
if self.auth_type != AuthType.API:
|
|
916
|
+
raise ValueError(
|
|
917
|
+
"Only API auth style is supported for accessing auth headers"
|
|
918
|
+
)
|
|
919
|
+
from metaflow.metaflow_config import SERVICE_HEADERS
|
|
920
|
+
|
|
921
|
+
return SERVICE_HEADERS
|
|
922
|
+
|
|
923
|
+
@property
|
|
924
|
+
def id(self) -> str:
|
|
925
|
+
return self._id
|
|
926
|
+
|
|
927
|
+
@property
|
|
928
|
+
def auth_type(self) -> str:
|
|
929
|
+
return self._capsule_type
|
|
930
|
+
|
|
931
|
+
@property
|
|
932
|
+
def public_url(self) -> str:
|
|
933
|
+
return self._public_url
|
|
934
|
+
|
|
935
|
+
@property
|
|
936
|
+
def name(self) -> str:
|
|
937
|
+
return self._name
|
|
938
|
+
|
|
939
|
+
@property
|
|
940
|
+
def deployed_version(self) -> str:
|
|
941
|
+
return self._deployed_version
|
|
942
|
+
|
|
943
|
+
@property
|
|
944
|
+
def deployed_at(self) -> datetime:
|
|
945
|
+
return datetime.fromisoformat(self._deployed_at)
|
|
946
|
+
|
|
947
|
+
@property
|
|
948
|
+
def tags(self) -> List[str]:
|
|
949
|
+
"""Tags associated with this app."""
|
|
950
|
+
capsule_info = self._capsule_info
|
|
951
|
+
return capsule_info.get("spec", {}).get("tags", [])
|
|
952
|
+
|
|
953
|
+
def __repr__(self) -> str:
|
|
954
|
+
return (
|
|
955
|
+
f"DeployedApp(id='{self._id}', "
|
|
956
|
+
f"name='{self._name}', "
|
|
957
|
+
f"public_url='{self._public_url}', "
|
|
958
|
+
f"deployed_version='{self._deployed_version}')"
|
|
959
|
+
)
|