ob-metaflow-extensions 1.1.170__py2.py3-none-any.whl → 1.4.35__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/plugins/__init__.py +6 -2
- metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -0
- metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +146 -0
- metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +10 -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_cli.py +1200 -0
- metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +146 -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 +12 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +868 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +288 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +139 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +398 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +1088 -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 +303 -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/aws/assume_role_decorator.py +25 -12
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/coreweave.py +9 -77
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/external_chckpt.py +85 -0
- metaflow_extensions/outerbounds/plugins/checkpoint_datastores/nebius.py +7 -78
- metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
- metaflow_extensions/outerbounds/plugins/fast_bakery/docker_environment.py +6 -2
- metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery.py +1 -0
- metaflow_extensions/outerbounds/plugins/nvct/nvct_decorator.py +8 -8
- metaflow_extensions/outerbounds/plugins/optuna/__init__.py +48 -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/snowpark/snowpark_client.py +6 -3
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +13 -7
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +8 -2
- metaflow_extensions/outerbounds/plugins/torchtune/__init__.py +4 -0
- metaflow_extensions/outerbounds/plugins/vllm/__init__.py +173 -95
- metaflow_extensions/outerbounds/plugins/vllm/status_card.py +9 -9
- metaflow_extensions/outerbounds/plugins/vllm/vllm_manager.py +159 -9
- metaflow_extensions/outerbounds/remote_config.py +8 -3
- metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +63 -1
- metaflow_extensions/outerbounds/toplevel/ob_internal.py +3 -0
- metaflow_extensions/outerbounds/toplevel/plugins/optuna/__init__.py +1 -0
- metaflow_extensions/outerbounds/toplevel/s3_proxy.py +88 -0
- {ob_metaflow_extensions-1.1.170.dist-info → ob_metaflow_extensions-1.4.35.dist-info}/METADATA +2 -2
- {ob_metaflow_extensions-1.1.170.dist-info → ob_metaflow_extensions-1.4.35.dist-info}/RECORD +65 -21
- {ob_metaflow_extensions-1.1.170.dist-info → ob_metaflow_extensions-1.4.35.dist-info}/WHEEL +0 -0
- {ob_metaflow_extensions-1.1.170.dist-info → ob_metaflow_extensions-1.4.35.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified Configuration System for Outerbounds Apps
|
|
3
|
+
|
|
4
|
+
This module provides a type-safe, declarative configuration system that serves as the
|
|
5
|
+
single source of truth for app configuration. It automatically generates CLI options,
|
|
6
|
+
handles config file parsing, and manages field merging behavior.
|
|
7
|
+
|
|
8
|
+
No external dependencies required - uses only Python standard library.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Dict, List, Optional, Union, Type
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .config_utils import (
|
|
18
|
+
ConfigField,
|
|
19
|
+
ConfigMeta,
|
|
20
|
+
JsonFriendlyKeyValuePairType,
|
|
21
|
+
PureStringKVPairType,
|
|
22
|
+
CommaSeparatedListType,
|
|
23
|
+
FieldBehavior,
|
|
24
|
+
CLIOption,
|
|
25
|
+
config_meta_to_dict,
|
|
26
|
+
merge_field_values,
|
|
27
|
+
apply_defaults,
|
|
28
|
+
populate_config_recursive,
|
|
29
|
+
validate_config_meta,
|
|
30
|
+
validate_required_fields,
|
|
31
|
+
ConfigValidationFailedException,
|
|
32
|
+
commit_owner_names_across_tree,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthType:
|
|
37
|
+
BROWSER = "Browser"
|
|
38
|
+
API = "API"
|
|
39
|
+
BROWSER_AND_API = "BrowserAndApi"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def choices(cls):
|
|
43
|
+
return [cls.BROWSER, cls.API, cls.BROWSER_AND_API]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UnitParser:
|
|
47
|
+
UNIT_FREE_REGEX = r"^\d+$"
|
|
48
|
+
|
|
49
|
+
metrics = {
|
|
50
|
+
"memory": {
|
|
51
|
+
"default_unit": "Mi",
|
|
52
|
+
"requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
|
|
53
|
+
# Regex to match values with units (e.g., "512Mi", "4Gi", "1024Ki")
|
|
54
|
+
"correct_unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
|
|
55
|
+
},
|
|
56
|
+
"cpu": {
|
|
57
|
+
"default_unit": None,
|
|
58
|
+
"requires_unit": False, # if a Unit free value is provided then we will not add the default unit to it.
|
|
59
|
+
# Accepts values like 400m, 4, 0.4, 1000n, etc.
|
|
60
|
+
# Regex to match values with units (e.g., "400m", "1000n", "2", "0.5")
|
|
61
|
+
"correct_unit_regex": r"^(\d+(\.\d+)?(m|n)?|\d+(\.\d+)?)$",
|
|
62
|
+
},
|
|
63
|
+
"disk": {
|
|
64
|
+
"default_unit": "Mi",
|
|
65
|
+
"requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
|
|
66
|
+
# Regex to match values with units (e.g., "100Mi", "1Gi", "500Ki")
|
|
67
|
+
"correct_unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
|
|
68
|
+
},
|
|
69
|
+
"gpu": {
|
|
70
|
+
"default_unit": None,
|
|
71
|
+
"requires_unit": False,
|
|
72
|
+
# Regex to match values with units (usually just integer count, e.g., "1", "2")
|
|
73
|
+
"correct_unit_regex": r"^\d+$",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def __init__(self, metric_name: str):
|
|
78
|
+
self.metric_name = metric_name
|
|
79
|
+
|
|
80
|
+
def validate(self, value: str):
|
|
81
|
+
if re.match(self.metrics[self.metric_name]["correct_unit_regex"], value):
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def process(self, value: str):
|
|
86
|
+
value = str(value)
|
|
87
|
+
if self.metrics[self.metric_name]["requires_unit"]:
|
|
88
|
+
if re.match(self.UNIT_FREE_REGEX, value):
|
|
89
|
+
# This means the value is unit free and we need to add the default unit to it.
|
|
90
|
+
value = "%s%s" % (
|
|
91
|
+
value.strip(),
|
|
92
|
+
self.metrics[self.metric_name]["default_unit"],
|
|
93
|
+
)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
def parse(self, value: Union[str, None]):
|
|
99
|
+
if value is None:
|
|
100
|
+
return None
|
|
101
|
+
return self.process(value)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def validation_wrapper_fn(
|
|
105
|
+
metric_name: str,
|
|
106
|
+
):
|
|
107
|
+
def validation_fn(value: str):
|
|
108
|
+
if value is None:
|
|
109
|
+
return True
|
|
110
|
+
field_info = ResourceConfig._get_field(ResourceConfig, metric_name) # type: ignore
|
|
111
|
+
parser = UnitParser(metric_name)
|
|
112
|
+
validation = parser.validate(value)
|
|
113
|
+
if not validation:
|
|
114
|
+
raise ConfigValidationFailedException(
|
|
115
|
+
field_name=metric_name,
|
|
116
|
+
field_info=field_info,
|
|
117
|
+
current_value=value,
|
|
118
|
+
message=f"Invalid value for `{metric_name}`. Must be of the format {parser.metrics[metric_name]['correct_unit_regex']}.",
|
|
119
|
+
)
|
|
120
|
+
return validation
|
|
121
|
+
|
|
122
|
+
return validation_fn
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class BasicValidations:
|
|
126
|
+
def __init__(self, config_meta_class, field_name):
|
|
127
|
+
self.config_meta_class = config_meta_class
|
|
128
|
+
self.field_name = field_name
|
|
129
|
+
|
|
130
|
+
def _get_field(self):
|
|
131
|
+
return self.config_meta_class._get_field(self.config_meta_class, self.field_name) # type: ignore
|
|
132
|
+
|
|
133
|
+
def enum_validation(self, enums: List[str], current_value):
|
|
134
|
+
if current_value not in enums:
|
|
135
|
+
raise ConfigValidationFailedException(
|
|
136
|
+
field_name=self.field_name,
|
|
137
|
+
field_info=self._get_field(),
|
|
138
|
+
current_value=current_value,
|
|
139
|
+
message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be one of: {'/'.join(enums)}",
|
|
140
|
+
)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
def range_validation(self, min_value, max_value, current_value):
|
|
144
|
+
if current_value < min_value or current_value > max_value:
|
|
145
|
+
raise ConfigValidationFailedException(
|
|
146
|
+
field_name=self.field_name,
|
|
147
|
+
field_info=self._get_field(),
|
|
148
|
+
current_value=current_value,
|
|
149
|
+
message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be between {min_value} and {max_value}",
|
|
150
|
+
)
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
def length_validation(self, max_length, current_value):
|
|
154
|
+
if len(current_value) > max_length:
|
|
155
|
+
raise ConfigValidationFailedException(
|
|
156
|
+
field_name=self.field_name,
|
|
157
|
+
field_info=self._get_field(),
|
|
158
|
+
current_value=current_value,
|
|
159
|
+
message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be less than {max_length}",
|
|
160
|
+
)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def regex_validation(self, regex, current_value):
|
|
164
|
+
if not re.match(regex, current_value):
|
|
165
|
+
raise ConfigValidationFailedException(
|
|
166
|
+
field_name=self.field_name,
|
|
167
|
+
field_info=self._get_field(),
|
|
168
|
+
current_value=current_value,
|
|
169
|
+
message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must match regex {regex}",
|
|
170
|
+
)
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ResourceConfig(metaclass=ConfigMeta):
|
|
175
|
+
"""Resource configuration for the app."""
|
|
176
|
+
|
|
177
|
+
# TODO: Add Unit Validation/Parsing Support for the Fields.
|
|
178
|
+
cpu = ConfigField(
|
|
179
|
+
default="1",
|
|
180
|
+
cli_meta=CLIOption(
|
|
181
|
+
name="cpu",
|
|
182
|
+
cli_option_str="--cpu",
|
|
183
|
+
help="CPU resource request and limit.",
|
|
184
|
+
),
|
|
185
|
+
field_type=str,
|
|
186
|
+
example="500m",
|
|
187
|
+
validation_fn=UnitParser.validation_wrapper_fn("cpu"),
|
|
188
|
+
parsing_fn=UnitParser("cpu").parse,
|
|
189
|
+
)
|
|
190
|
+
memory = ConfigField(
|
|
191
|
+
default="4Gi",
|
|
192
|
+
cli_meta=CLIOption(
|
|
193
|
+
name="memory",
|
|
194
|
+
cli_option_str="--memory",
|
|
195
|
+
help="Memory resource request and limit.",
|
|
196
|
+
),
|
|
197
|
+
field_type=str,
|
|
198
|
+
example="512Mi",
|
|
199
|
+
validation_fn=UnitParser.validation_wrapper_fn("memory"),
|
|
200
|
+
parsing_fn=UnitParser("memory").parse,
|
|
201
|
+
)
|
|
202
|
+
gpu = ConfigField(
|
|
203
|
+
cli_meta=CLIOption(
|
|
204
|
+
name="gpu",
|
|
205
|
+
cli_option_str="--gpu",
|
|
206
|
+
help="GPU resource request and limit.",
|
|
207
|
+
),
|
|
208
|
+
field_type=str,
|
|
209
|
+
example="1",
|
|
210
|
+
validation_fn=UnitParser.validation_wrapper_fn("gpu"),
|
|
211
|
+
parsing_fn=UnitParser("gpu").parse,
|
|
212
|
+
)
|
|
213
|
+
disk = ConfigField(
|
|
214
|
+
default="20Gi",
|
|
215
|
+
cli_meta=CLIOption(
|
|
216
|
+
name="disk",
|
|
217
|
+
cli_option_str="--disk",
|
|
218
|
+
help="Storage resource request and limit.",
|
|
219
|
+
),
|
|
220
|
+
field_type=str,
|
|
221
|
+
example="1Gi",
|
|
222
|
+
validation_fn=UnitParser.validation_wrapper_fn("disk"),
|
|
223
|
+
parsing_fn=UnitParser("disk").parse,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
shared_memory = ConfigField(
|
|
227
|
+
cli_meta=CLIOption(
|
|
228
|
+
name="shared_memory",
|
|
229
|
+
cli_option_str="--shared-memory",
|
|
230
|
+
help="Shared memory resource request and limit.",
|
|
231
|
+
),
|
|
232
|
+
field_type=str,
|
|
233
|
+
example="1Gi",
|
|
234
|
+
validation_fn=UnitParser.validation_wrapper_fn("memory"),
|
|
235
|
+
parsing_fn=UnitParser("memory").parse,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class HealthCheckConfig(metaclass=ConfigMeta):
|
|
240
|
+
"""Health check configuration."""
|
|
241
|
+
|
|
242
|
+
enabled = ConfigField(
|
|
243
|
+
default=False,
|
|
244
|
+
cli_meta=CLIOption(
|
|
245
|
+
name="health_check_enabled",
|
|
246
|
+
cli_option_str="--health-check-enabled",
|
|
247
|
+
help="Whether to enable health checks.",
|
|
248
|
+
is_flag=True,
|
|
249
|
+
),
|
|
250
|
+
field_type=bool,
|
|
251
|
+
example=True,
|
|
252
|
+
)
|
|
253
|
+
path = ConfigField(
|
|
254
|
+
cli_meta=CLIOption(
|
|
255
|
+
name="health_check_path",
|
|
256
|
+
cli_option_str="--health-check-path",
|
|
257
|
+
help="The path for health checks.",
|
|
258
|
+
),
|
|
259
|
+
field_type=str,
|
|
260
|
+
example="/health",
|
|
261
|
+
)
|
|
262
|
+
initial_delay_seconds = ConfigField(
|
|
263
|
+
cli_meta=CLIOption(
|
|
264
|
+
name="health_check_initial_delay",
|
|
265
|
+
cli_option_str="--health-check-initial-delay",
|
|
266
|
+
help="Number of seconds to wait before performing the first health check.",
|
|
267
|
+
),
|
|
268
|
+
field_type=int,
|
|
269
|
+
example=10,
|
|
270
|
+
)
|
|
271
|
+
period_seconds = ConfigField(
|
|
272
|
+
cli_meta=CLIOption(
|
|
273
|
+
name="health_check_period",
|
|
274
|
+
cli_option_str="--health-check-period",
|
|
275
|
+
help="How often to perform the health check.",
|
|
276
|
+
),
|
|
277
|
+
field_type=int,
|
|
278
|
+
example=30,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class AuthConfig(metaclass=ConfigMeta):
|
|
283
|
+
"""Authentication configuration."""
|
|
284
|
+
|
|
285
|
+
type = ConfigField(
|
|
286
|
+
default=AuthType.BROWSER,
|
|
287
|
+
cli_meta=CLIOption(
|
|
288
|
+
name="auth_type",
|
|
289
|
+
cli_option_str="--auth-type",
|
|
290
|
+
help="The type of authentication to use for the app.",
|
|
291
|
+
choices=AuthType.choices(),
|
|
292
|
+
),
|
|
293
|
+
field_type=str,
|
|
294
|
+
example="Browser",
|
|
295
|
+
)
|
|
296
|
+
public = ConfigField(
|
|
297
|
+
default=True,
|
|
298
|
+
cli_meta=CLIOption(
|
|
299
|
+
name="auth_public",
|
|
300
|
+
cli_option_str="--public-access/--private-access",
|
|
301
|
+
help="Whether the app is public or not.",
|
|
302
|
+
is_flag=True,
|
|
303
|
+
),
|
|
304
|
+
field_type=bool,
|
|
305
|
+
example=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def validate(auth_config: "AuthConfig"):
|
|
310
|
+
if auth_config.type is None:
|
|
311
|
+
return True
|
|
312
|
+
return BasicValidations(AuthConfig, "type").enum_validation(
|
|
313
|
+
AuthType.choices(), auth_config.type
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class ScalingPolicyConfig(metaclass=ConfigMeta):
|
|
318
|
+
"""
|
|
319
|
+
Policies for autoscaling replicas. Available policies:
|
|
320
|
+
- Request based Autoscaling (rpm)
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
# TODO Change the defaulting if we have more autoscaling policies.
|
|
324
|
+
rpm = ConfigField(
|
|
325
|
+
field_type=int,
|
|
326
|
+
# TODO: Add a little more to the docstring where we explain the behavior.
|
|
327
|
+
cli_meta=CLIOption(
|
|
328
|
+
name="scaling_rpm",
|
|
329
|
+
cli_option_str="--scaling-rpm",
|
|
330
|
+
help=(
|
|
331
|
+
"Scale up replicas when the requests per minute crosses this threshold. "
|
|
332
|
+
"If nothing is provided and the replicas.max and replicas.min is set then "
|
|
333
|
+
"the default rpm would be 60."
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
default=60,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class ReplicaConfig(metaclass=ConfigMeta):
|
|
341
|
+
"""Replica configuration."""
|
|
342
|
+
|
|
343
|
+
fixed = ConfigField(
|
|
344
|
+
cli_meta=CLIOption(
|
|
345
|
+
name="fixed_replicas",
|
|
346
|
+
cli_option_str="--fixed-replicas",
|
|
347
|
+
help="The fixed number of replicas to deploy the app with. If min and max are set, this will raise an error.",
|
|
348
|
+
),
|
|
349
|
+
field_type=int,
|
|
350
|
+
example=1,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
min = ConfigField(
|
|
354
|
+
cli_meta=CLIOption(
|
|
355
|
+
name="min_replicas",
|
|
356
|
+
cli_option_str="--min-replicas",
|
|
357
|
+
help="The minimum number of replicas to deploy the app with.",
|
|
358
|
+
),
|
|
359
|
+
field_type=int,
|
|
360
|
+
example=1,
|
|
361
|
+
)
|
|
362
|
+
max = ConfigField(
|
|
363
|
+
cli_meta=CLIOption(
|
|
364
|
+
name="max_replicas",
|
|
365
|
+
cli_option_str="--max-replicas",
|
|
366
|
+
help="The maximum number of replicas to deploy the app with.",
|
|
367
|
+
),
|
|
368
|
+
field_type=int,
|
|
369
|
+
example=10,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
scaling_policy = ConfigField(
|
|
373
|
+
cli_meta=None,
|
|
374
|
+
field_type=ScalingPolicyConfig,
|
|
375
|
+
help=(
|
|
376
|
+
"Scaling policy defines the the metric based on which the replicas will horizontally scale. "
|
|
377
|
+
"If min and max replicas are set and are not the same, then a scaling policy will be applied. "
|
|
378
|
+
"Default scaling policies can be 60 rpm (ie 1 rps). "
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def defaults(replica_config: "ReplicaConfig"):
|
|
384
|
+
if all(
|
|
385
|
+
[
|
|
386
|
+
replica_config.min is None,
|
|
387
|
+
replica_config.max is None,
|
|
388
|
+
replica_config.fixed is None,
|
|
389
|
+
]
|
|
390
|
+
):
|
|
391
|
+
# if nothing is set then set
|
|
392
|
+
replica_config.fixed = 1
|
|
393
|
+
elif replica_config.min is not None and replica_config.max is None:
|
|
394
|
+
replica_config.max = replica_config.min
|
|
395
|
+
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def validate(replica_config: "ReplicaConfig"):
|
|
400
|
+
both_min_max_set = (
|
|
401
|
+
replica_config.min is not None and replica_config.max is not None
|
|
402
|
+
)
|
|
403
|
+
fixed_set = replica_config.fixed is not None
|
|
404
|
+
max_is_set = replica_config.max is not None
|
|
405
|
+
min_is_set = replica_config.min is not None
|
|
406
|
+
any_min_max_set = (
|
|
407
|
+
replica_config.min is not None or replica_config.max is not None
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _greater_than_equals_zero(x):
|
|
411
|
+
return x is not None and x >= 0
|
|
412
|
+
|
|
413
|
+
if both_min_max_set and replica_config.min > replica_config.max: # type: ignore
|
|
414
|
+
raise ConfigValidationFailedException(
|
|
415
|
+
field_name="min",
|
|
416
|
+
field_info=replica_config._get_field("min"), # type: ignore
|
|
417
|
+
current_value=replica_config.min,
|
|
418
|
+
message="Min replicas cannot be greater than max replicas",
|
|
419
|
+
)
|
|
420
|
+
if fixed_set and any_min_max_set:
|
|
421
|
+
raise ConfigValidationFailedException(
|
|
422
|
+
field_name="fixed",
|
|
423
|
+
field_info=replica_config._get_field("fixed"), # type: ignore
|
|
424
|
+
current_value=replica_config.fixed,
|
|
425
|
+
message="Fixed replicas cannot be set when min or max replicas are set",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if max_is_set and not min_is_set:
|
|
429
|
+
raise ConfigValidationFailedException(
|
|
430
|
+
field_name="min",
|
|
431
|
+
field_info=replica_config._get_field("min"), # type: ignore
|
|
432
|
+
current_value=replica_config.min,
|
|
433
|
+
message="If max replicas is set then min replicas must be set too.",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if fixed_set and replica_config.fixed < 0: # type: ignore
|
|
437
|
+
raise ConfigValidationFailedException(
|
|
438
|
+
field_name="fixed",
|
|
439
|
+
field_info=replica_config._get_field("fixed"), # type: ignore
|
|
440
|
+
current_value=replica_config.fixed,
|
|
441
|
+
message="Fixed replicas cannot be less than 0",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if min_is_set and not _greater_than_equals_zero(replica_config.min):
|
|
445
|
+
raise ConfigValidationFailedException(
|
|
446
|
+
field_name="min",
|
|
447
|
+
field_info=replica_config._get_field("min"), # type: ignore
|
|
448
|
+
current_value=replica_config.min,
|
|
449
|
+
message="Min replicas cannot be less than 0",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if max_is_set and not _greater_than_equals_zero(replica_config.max):
|
|
453
|
+
raise ConfigValidationFailedException(
|
|
454
|
+
field_name="max",
|
|
455
|
+
field_info=replica_config._get_field("max"), # type: ignore
|
|
456
|
+
current_value=replica_config.max,
|
|
457
|
+
message="Max replicas cannot be less than 0",
|
|
458
|
+
)
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def more_than_n_not_none(n, *args):
|
|
463
|
+
return sum(1 for arg in args if arg is not None) > n
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class DependencyConfig(metaclass=ConfigMeta):
|
|
467
|
+
"""Dependency configuration."""
|
|
468
|
+
|
|
469
|
+
from_requirements_file = ConfigField(
|
|
470
|
+
cli_meta=CLIOption(
|
|
471
|
+
name="dep_from_requirements",
|
|
472
|
+
cli_option_str="--dep-from-requirements",
|
|
473
|
+
help="The path to the requirements.txt file to attach to the app.",
|
|
474
|
+
),
|
|
475
|
+
field_type=str,
|
|
476
|
+
behavior=FieldBehavior.NOT_ALLOWED,
|
|
477
|
+
example="requirements.txt",
|
|
478
|
+
)
|
|
479
|
+
from_pyproject_toml = ConfigField(
|
|
480
|
+
cli_meta=CLIOption(
|
|
481
|
+
name="dep_from_pyproject",
|
|
482
|
+
cli_option_str="--dep-from-pyproject",
|
|
483
|
+
help="The path to the pyproject.toml file to attach to the app.",
|
|
484
|
+
),
|
|
485
|
+
field_type=str,
|
|
486
|
+
behavior=FieldBehavior.NOT_ALLOWED,
|
|
487
|
+
example="pyproject.toml",
|
|
488
|
+
)
|
|
489
|
+
python = ConfigField(
|
|
490
|
+
cli_meta=CLIOption(
|
|
491
|
+
name="python",
|
|
492
|
+
cli_option_str="--python",
|
|
493
|
+
help="The Python version to use for the app.",
|
|
494
|
+
),
|
|
495
|
+
field_type=str,
|
|
496
|
+
behavior=FieldBehavior.UNION,
|
|
497
|
+
example="3.10",
|
|
498
|
+
)
|
|
499
|
+
pypi = ConfigField(
|
|
500
|
+
cli_meta=CLIOption(
|
|
501
|
+
name="pypi", # TODO: Can set CLI meta to None
|
|
502
|
+
cli_option_str="--pypi",
|
|
503
|
+
help="A dictionary of pypi dependencies to attach to the app. The key is the package name and the value is the version.",
|
|
504
|
+
hidden=True, # Complex structure, better handled in config file
|
|
505
|
+
),
|
|
506
|
+
field_type=dict,
|
|
507
|
+
behavior=FieldBehavior.NOT_ALLOWED,
|
|
508
|
+
example={"numpy": "1.23.0", "pandas": ""},
|
|
509
|
+
)
|
|
510
|
+
conda = ConfigField(
|
|
511
|
+
cli_meta=CLIOption( # TODO: Can set CLI meta to None
|
|
512
|
+
name="conda",
|
|
513
|
+
cli_option_str="--conda",
|
|
514
|
+
help="A dictionary of conda dependencies to attach to the app. The key is the package name and the value is the version.",
|
|
515
|
+
hidden=True, # Complex structure, better handled in config file
|
|
516
|
+
),
|
|
517
|
+
field_type=dict,
|
|
518
|
+
behavior=FieldBehavior.NOT_ALLOWED,
|
|
519
|
+
example={"numpy": "1.23.0", "pandas": ""},
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
@staticmethod
|
|
523
|
+
def validate(dependency_config: "DependencyConfig"):
|
|
524
|
+
# You can either have from_requirements_file or from_pyproject_toml or python with pypi or conda
|
|
525
|
+
# but not more than one of them.
|
|
526
|
+
if more_than_n_not_none(
|
|
527
|
+
1,
|
|
528
|
+
dependency_config.from_requirements_file,
|
|
529
|
+
dependency_config.from_pyproject_toml,
|
|
530
|
+
):
|
|
531
|
+
raise ConfigValidationFailedException(
|
|
532
|
+
field_name="from_requirements_file",
|
|
533
|
+
field_info=dependency_config._get_field("from_requirements_file"), # type: ignore
|
|
534
|
+
current_value=dependency_config.from_requirements_file,
|
|
535
|
+
message="Cannot set from_requirements_file and from_pyproject_toml at the same time",
|
|
536
|
+
)
|
|
537
|
+
if any([dependency_config.pypi, dependency_config.conda]) and any(
|
|
538
|
+
[
|
|
539
|
+
dependency_config.from_requirements_file,
|
|
540
|
+
dependency_config.from_pyproject_toml,
|
|
541
|
+
]
|
|
542
|
+
):
|
|
543
|
+
raise ConfigValidationFailedException(
|
|
544
|
+
field_name="pypi" if dependency_config.pypi else "conda",
|
|
545
|
+
field_info=dependency_config._get_field( # type: ignore
|
|
546
|
+
"pypi" if dependency_config.pypi else "conda"
|
|
547
|
+
),
|
|
548
|
+
current_value=dependency_config.pypi or dependency_config.conda,
|
|
549
|
+
message="Cannot set pypi or conda when from_requirements_file or from_pyproject_toml is set",
|
|
550
|
+
)
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class PackageConfig(metaclass=ConfigMeta):
|
|
555
|
+
"""Package configuration."""
|
|
556
|
+
|
|
557
|
+
src_paths = ConfigField(
|
|
558
|
+
cli_meta=CLIOption(
|
|
559
|
+
name="package_src_path",
|
|
560
|
+
cli_option_str="--package-src-path",
|
|
561
|
+
multiple=True,
|
|
562
|
+
help="The path to the source code to deploy with the App.",
|
|
563
|
+
click_type=str,
|
|
564
|
+
),
|
|
565
|
+
field_type=list,
|
|
566
|
+
example=["./"],
|
|
567
|
+
)
|
|
568
|
+
suffixes = ConfigField(
|
|
569
|
+
cli_meta=CLIOption(
|
|
570
|
+
name="package_suffixes",
|
|
571
|
+
cli_option_str="--package-suffixes",
|
|
572
|
+
help="A list of suffixes to add to the source code to deploy with the App.",
|
|
573
|
+
),
|
|
574
|
+
field_type=list,
|
|
575
|
+
example=[".py", ".ipynb"],
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def validate(package_config: "PackageConfig"):
|
|
580
|
+
if package_config.src_paths is None:
|
|
581
|
+
return True
|
|
582
|
+
if package_config.src_paths:
|
|
583
|
+
for path in package_config.src_paths:
|
|
584
|
+
if not os.path.exists(path):
|
|
585
|
+
raise ConfigValidationFailedException(
|
|
586
|
+
field_name="src_paths",
|
|
587
|
+
field_info=package_config._get_field("src_paths"), # type: ignore
|
|
588
|
+
current_value=package_config.src_paths,
|
|
589
|
+
message=f"Path does not exist : `{path}`",
|
|
590
|
+
)
|
|
591
|
+
if not os.path.isdir(path):
|
|
592
|
+
raise ConfigValidationFailedException(
|
|
593
|
+
field_name="src_paths",
|
|
594
|
+
field_info=package_config._get_field("src_paths"), # type: ignore
|
|
595
|
+
current_value=package_config.src_paths,
|
|
596
|
+
message=f"Path is not a directory : `{path}`",
|
|
597
|
+
)
|
|
598
|
+
return True
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def everything_is_string(*args):
|
|
602
|
+
return all(isinstance(arg, str) for arg in args)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class BasicAppValidations:
|
|
606
|
+
@staticmethod
|
|
607
|
+
def name(name):
|
|
608
|
+
if name is None:
|
|
609
|
+
return True
|
|
610
|
+
regex = r"^[a-z0-9-]+$" # Only allow lowercase letters, numbers, and hyphens
|
|
611
|
+
validator = BasicValidations(CoreConfig, "name")
|
|
612
|
+
return validator.length_validation(15, name) and validator.regex_validation(
|
|
613
|
+
regex, name
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def port(port):
|
|
618
|
+
if port is None:
|
|
619
|
+
return True
|
|
620
|
+
return BasicValidations(CoreConfig, "port").range_validation(1, 65535, port)
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def tags(tags):
|
|
624
|
+
if tags is None:
|
|
625
|
+
return True
|
|
626
|
+
if not all(
|
|
627
|
+
isinstance(tag, dict)
|
|
628
|
+
and len(tag) == 1
|
|
629
|
+
and all(
|
|
630
|
+
[everything_is_string(*tag.keys()), everything_is_string(*tag.values())]
|
|
631
|
+
)
|
|
632
|
+
for tag in tags
|
|
633
|
+
):
|
|
634
|
+
raise ConfigValidationFailedException(
|
|
635
|
+
field_name="tags",
|
|
636
|
+
field_info=CoreConfig._get_field(CoreConfig, "tags"), # type: ignore
|
|
637
|
+
current_value=tags,
|
|
638
|
+
message="Tags must be a list of dictionaries with one key and the value must be a string. Currently they are set to %s "
|
|
639
|
+
% (str(tags)),
|
|
640
|
+
)
|
|
641
|
+
return True
|
|
642
|
+
|
|
643
|
+
@staticmethod
|
|
644
|
+
def secrets(secrets):
|
|
645
|
+
if secrets is None: # If nothing is set we dont care.
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
if not isinstance(secrets, list):
|
|
649
|
+
raise ConfigValidationFailedException(
|
|
650
|
+
field_name="secrets",
|
|
651
|
+
field_info=CoreConfig._get_field(CoreConfig, "secrets"), # type: ignore
|
|
652
|
+
current_value=secrets,
|
|
653
|
+
message="Secrets must be a list of strings. Currently they are set to %s "
|
|
654
|
+
% (str(secrets)),
|
|
655
|
+
)
|
|
656
|
+
from ..validations import secrets_validator
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
secrets_validator(secrets)
|
|
660
|
+
except Exception as e:
|
|
661
|
+
raise ConfigValidationFailedException(
|
|
662
|
+
field_name="secrets",
|
|
663
|
+
field_info=CoreConfig._get_field(CoreConfig, "secrets"), # type: ignore
|
|
664
|
+
current_value=secrets,
|
|
665
|
+
message=f"Secrets validation failed, {e}",
|
|
666
|
+
)
|
|
667
|
+
return True
|
|
668
|
+
|
|
669
|
+
@staticmethod
|
|
670
|
+
def persistence(persistence):
|
|
671
|
+
if persistence is None:
|
|
672
|
+
return True
|
|
673
|
+
return BasicValidations(CoreConfig, "persistence").enum_validation(
|
|
674
|
+
["none", "postgres"], persistence
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class CoreConfig(metaclass=ConfigMeta):
|
|
679
|
+
"""Unified App Configuration - The single source of truth for application configuration.
|
|
680
|
+
|
|
681
|
+
CoreConfig is the central configuration class that defines all application settings using the
|
|
682
|
+
ConfigMeta metaclass and ConfigField descriptors. It provides a declarative, type-safe way
|
|
683
|
+
to manage configuration from multiple sources (CLI, config files, environment) with automatic
|
|
684
|
+
validation, merging, and CLI generation.
|
|
685
|
+
|
|
686
|
+
Core Features:
|
|
687
|
+
- **Declarative Configuration**: All fields are defined using ConfigField descriptors
|
|
688
|
+
- **Multi-Source Configuration**: Supports CLI options, config files (JSON/YAML), and programmatic setting
|
|
689
|
+
- **Automatic CLI Generation**: CLI options are automatically generated from field metadata
|
|
690
|
+
- **Type Safety**: Built-in type checking and validation for all fields
|
|
691
|
+
- **Hierarchical Structure**: Supports nested configuration objects (resources, auth, dependencies)
|
|
692
|
+
- **Intelligent Merging**: Configurable merging behavior for different field types
|
|
693
|
+
- **Validation Framework**: Comprehensive validation with custom validation functions
|
|
694
|
+
|
|
695
|
+
Configuration Lifecycle:
|
|
696
|
+
1. **Definition**: Fields are defined declaratively using ConfigField descriptors
|
|
697
|
+
2. **Instantiation**: Objects are created with all fields initialized to None or nested objects
|
|
698
|
+
3. **Population**: Values are populated from CLI options, config files, or direct assignment
|
|
699
|
+
4. **Merging**: Multiple config sources are merged according to field behavior settings
|
|
700
|
+
5. **Validation**: Field validation functions and required field checks are performed
|
|
701
|
+
6. **Default Application**: Default values are applied to any remaining None fields
|
|
702
|
+
7. **Commit**: Final validation and preparation for use
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
Usage Examples:
|
|
706
|
+
Create from CLI options:
|
|
707
|
+
```python
|
|
708
|
+
config = CoreConfig.from_cli({
|
|
709
|
+
'name': 'myapp',
|
|
710
|
+
'port': 8080,
|
|
711
|
+
'commands': ['python app.py']
|
|
712
|
+
})
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Create from config file:
|
|
716
|
+
```python
|
|
717
|
+
config = CoreConfig.from_file('config.yaml')
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Create from dictionary:
|
|
721
|
+
```python
|
|
722
|
+
config = CoreConfig.from_dict({
|
|
723
|
+
'name': 'myapp',
|
|
724
|
+
'port': 8080,
|
|
725
|
+
'resources': {
|
|
726
|
+
'cpu': '500m',
|
|
727
|
+
'memory': '1Gi'
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
Merge configurations:
|
|
733
|
+
```python
|
|
734
|
+
file_config = CoreConfig.from_file('config.yaml')
|
|
735
|
+
cli_config = CoreConfig.from_cli(cli_options)
|
|
736
|
+
final_config = CoreConfig.merge_configs(file_config, cli_config)
|
|
737
|
+
final_config.commit() # Validate and apply defaults
|
|
738
|
+
```
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
# TODO: We can add Force Upgrade / No Deps flags here too if we need to.
|
|
742
|
+
# Since those can be exposed on the CLI side and the APP state will anyways
|
|
743
|
+
# be expored before being worked upon.
|
|
744
|
+
|
|
745
|
+
SCHEMA_DOC = """Schema for defining Outerbounds Apps configuration. This schema is what we will end up using on the CLI/programmatic interface.
|
|
746
|
+
How to read this schema:
|
|
747
|
+
1. If the a property has `mutation_behavior` set to `union` then it will allow overrides of values at runtime from the CLI.
|
|
748
|
+
2. If the property has `mutation_behavior`set to `not_allowed` then either the CLI or the config file value will be used (which ever is not None). If the user supplies something in both then an error will be raised.
|
|
749
|
+
3. If a property has `experimental` set to true then a lot its validations may-be skipped and parsing handled somewhere else.
|
|
750
|
+
"""
|
|
751
|
+
|
|
752
|
+
# Required fields
|
|
753
|
+
name = ConfigField(
|
|
754
|
+
cli_meta=CLIOption(
|
|
755
|
+
name="name",
|
|
756
|
+
cli_option_str="--name",
|
|
757
|
+
),
|
|
758
|
+
validation_fn=BasicAppValidations.name,
|
|
759
|
+
field_type=str,
|
|
760
|
+
required=True,
|
|
761
|
+
help="The name of the app to deploy.",
|
|
762
|
+
example="myapp",
|
|
763
|
+
)
|
|
764
|
+
port = ConfigField(
|
|
765
|
+
cli_meta=CLIOption(
|
|
766
|
+
name="port",
|
|
767
|
+
cli_option_str="--port",
|
|
768
|
+
),
|
|
769
|
+
validation_fn=BasicAppValidations.port,
|
|
770
|
+
field_type=int,
|
|
771
|
+
required=True,
|
|
772
|
+
help="Port where the app is hosted. When deployed this will be port on which we will deploy the app.",
|
|
773
|
+
example=8000,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Optional basic fields
|
|
777
|
+
description = ConfigField(
|
|
778
|
+
cli_meta=CLIOption(
|
|
779
|
+
name="description",
|
|
780
|
+
cli_option_str="--description",
|
|
781
|
+
help="The description of the app to deploy.",
|
|
782
|
+
),
|
|
783
|
+
field_type=str,
|
|
784
|
+
example="This is a description of my app.",
|
|
785
|
+
)
|
|
786
|
+
app_type = ConfigField(
|
|
787
|
+
cli_meta=CLIOption(
|
|
788
|
+
name="app_type",
|
|
789
|
+
cli_option_str="--app-type",
|
|
790
|
+
help="The User defined type of app to deploy. Its only used for bookkeeping purposes.",
|
|
791
|
+
),
|
|
792
|
+
field_type=str,
|
|
793
|
+
example="MyCustomAgent",
|
|
794
|
+
)
|
|
795
|
+
image = ConfigField(
|
|
796
|
+
cli_meta=CLIOption(
|
|
797
|
+
name="image",
|
|
798
|
+
cli_option_str="--image",
|
|
799
|
+
help="The Docker image to deploy with the App.",
|
|
800
|
+
),
|
|
801
|
+
field_type=str,
|
|
802
|
+
example="python:3.10-slim",
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# List fields
|
|
806
|
+
tags = ConfigField(
|
|
807
|
+
cli_meta=CLIOption(
|
|
808
|
+
name="tags",
|
|
809
|
+
cli_option_str="--tag",
|
|
810
|
+
multiple=True,
|
|
811
|
+
click_type=PureStringKVPairType,
|
|
812
|
+
),
|
|
813
|
+
field_type=list,
|
|
814
|
+
validation_fn=BasicAppValidations.tags,
|
|
815
|
+
help="The tags of the app to deploy.",
|
|
816
|
+
example=[{"foo": "bar"}, {"x": "y"}],
|
|
817
|
+
)
|
|
818
|
+
secrets = ConfigField(
|
|
819
|
+
cli_meta=CLIOption(
|
|
820
|
+
name="secrets", cli_option_str="--secret", multiple=True, click_type=str
|
|
821
|
+
),
|
|
822
|
+
field_type=list,
|
|
823
|
+
help="Outerbounds integrations to attach to the app. You can use the value you set in the `@secrets` decorator in your code.",
|
|
824
|
+
example=["hf-token"],
|
|
825
|
+
validation_fn=BasicAppValidations.secrets,
|
|
826
|
+
)
|
|
827
|
+
compute_pools = ConfigField(
|
|
828
|
+
cli_meta=CLIOption(
|
|
829
|
+
name="compute_pools",
|
|
830
|
+
cli_option_str="--compute-pools",
|
|
831
|
+
help="A list of compute pools to deploy the app to.",
|
|
832
|
+
multiple=True,
|
|
833
|
+
click_type=str,
|
|
834
|
+
),
|
|
835
|
+
field_type=list,
|
|
836
|
+
example=["default", "large"],
|
|
837
|
+
)
|
|
838
|
+
environment = ConfigField(
|
|
839
|
+
cli_meta=CLIOption(
|
|
840
|
+
name="environment",
|
|
841
|
+
cli_option_str="--env",
|
|
842
|
+
multiple=True,
|
|
843
|
+
click_type=JsonFriendlyKeyValuePairType, # TODO: Fix me.
|
|
844
|
+
),
|
|
845
|
+
field_type=dict,
|
|
846
|
+
help="Environment variables to deploy with the App.",
|
|
847
|
+
example={
|
|
848
|
+
"DEBUG": True,
|
|
849
|
+
"DATABASE_CONFIG": {"host": "localhost", "port": 5432},
|
|
850
|
+
"ALLOWED_ORIGINS": ["http://localhost:3000", "https://myapp.com"],
|
|
851
|
+
},
|
|
852
|
+
)
|
|
853
|
+
commands = ConfigField(
|
|
854
|
+
cli_meta=None, # We dont expose commands as an options. We rather expose it like `--` with click.
|
|
855
|
+
field_type=list,
|
|
856
|
+
required=True, # Either from CLI or from config file.
|
|
857
|
+
help="A list of commands to run the app with.", # TODO: Fix me: make me configurable via the -- stuff in click.
|
|
858
|
+
example=["python app.py", "python app.py --foo bar"],
|
|
859
|
+
behavior=FieldBehavior.NOT_ALLOWED,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# Complex nested fields
|
|
863
|
+
resources = ConfigField(
|
|
864
|
+
cli_meta=None, # No top-level CLI option, only nested fields have CLI options
|
|
865
|
+
field_type=ResourceConfig,
|
|
866
|
+
# TODO : see if we can add a validation func for resources.
|
|
867
|
+
help="Resource configuration for the app.",
|
|
868
|
+
)
|
|
869
|
+
auth = ConfigField(
|
|
870
|
+
cli_meta=None, # No top-level CLI option, only nested fields have CLI options
|
|
871
|
+
field_type=AuthConfig,
|
|
872
|
+
help="Auth related configurations.",
|
|
873
|
+
validation_fn=AuthConfig.validate,
|
|
874
|
+
)
|
|
875
|
+
replicas = ConfigField(
|
|
876
|
+
cli_meta=None, # No top-level CLI option, only nested fields have CLI options
|
|
877
|
+
validation_fn=ReplicaConfig.validate,
|
|
878
|
+
field_type=ReplicaConfig,
|
|
879
|
+
default=ReplicaConfig.defaults,
|
|
880
|
+
help="The number of replicas to deploy the app with.",
|
|
881
|
+
)
|
|
882
|
+
dependencies = ConfigField(
|
|
883
|
+
cli_meta=None, # No top-level CLI option, only nested fields have CLI options
|
|
884
|
+
validation_fn=DependencyConfig.validate,
|
|
885
|
+
field_type=DependencyConfig,
|
|
886
|
+
help="The dependencies to attach to the app. ",
|
|
887
|
+
)
|
|
888
|
+
package = ConfigField(
|
|
889
|
+
cli_meta=None, # No top-level CLI option, only nested fields have CLI options
|
|
890
|
+
field_type=PackageConfig,
|
|
891
|
+
help="Configurations associated with packaging the app.",
|
|
892
|
+
validation_fn=PackageConfig.validate,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
no_deps = ConfigField(
|
|
896
|
+
cli_meta=CLIOption(
|
|
897
|
+
name="no_deps",
|
|
898
|
+
cli_option_str="--no-deps",
|
|
899
|
+
help="Do not any dependencies. Directly used the image provided",
|
|
900
|
+
is_flag=True,
|
|
901
|
+
),
|
|
902
|
+
field_type=bool,
|
|
903
|
+
default=False,
|
|
904
|
+
help="Do not bake any dependencies. Directly used the image provided",
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
force_upgrade = ConfigField(
|
|
908
|
+
cli_meta=CLIOption(
|
|
909
|
+
name="force_upgrade",
|
|
910
|
+
cli_option_str="--force-upgrade",
|
|
911
|
+
help="Force upgrade the app even if it is currently being upgraded.",
|
|
912
|
+
is_flag=True,
|
|
913
|
+
),
|
|
914
|
+
field_type=bool,
|
|
915
|
+
default=False,
|
|
916
|
+
help="Force upgrade the app even if it is currently being upgraded.",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# ------- Experimental -------------
|
|
920
|
+
# These options get treated in the `..experimental` module.
|
|
921
|
+
# If we move any option as a first class citizen then we need to move
|
|
922
|
+
# its capsule parsing from the `..experimental` module to the `..capsule.CapsuleInput` module.
|
|
923
|
+
|
|
924
|
+
persistence = ConfigField(
|
|
925
|
+
cli_meta=CLIOption(
|
|
926
|
+
name="persistence",
|
|
927
|
+
cli_option_str="--persistence",
|
|
928
|
+
help="The persistence mode to deploy the app with.",
|
|
929
|
+
choices=["none", "postgres"],
|
|
930
|
+
),
|
|
931
|
+
validation_fn=BasicAppValidations.persistence,
|
|
932
|
+
field_type=str,
|
|
933
|
+
default="none",
|
|
934
|
+
example="postgres",
|
|
935
|
+
is_experimental=True,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
project = ConfigField(
|
|
939
|
+
cli_meta=CLIOption(
|
|
940
|
+
name="project",
|
|
941
|
+
cli_option_str="--project",
|
|
942
|
+
help="The project name to deploy the app to.",
|
|
943
|
+
),
|
|
944
|
+
field_type=str,
|
|
945
|
+
is_experimental=True,
|
|
946
|
+
example="my-project",
|
|
947
|
+
)
|
|
948
|
+
branch = ConfigField(
|
|
949
|
+
cli_meta=CLIOption(
|
|
950
|
+
name="branch",
|
|
951
|
+
cli_option_str="--branch",
|
|
952
|
+
help="The branch name to deploy the app to.",
|
|
953
|
+
),
|
|
954
|
+
field_type=str,
|
|
955
|
+
is_experimental=True,
|
|
956
|
+
example="main",
|
|
957
|
+
)
|
|
958
|
+
models = ConfigField(
|
|
959
|
+
cli_meta=None,
|
|
960
|
+
field_type=list,
|
|
961
|
+
is_experimental=True,
|
|
962
|
+
example=[{"asset_id": "model-123", "asset_instance_id": "instance-456"}],
|
|
963
|
+
)
|
|
964
|
+
data = ConfigField(
|
|
965
|
+
cli_meta=None,
|
|
966
|
+
field_type=list,
|
|
967
|
+
is_experimental=True,
|
|
968
|
+
example=[{"asset_id": "data-789", "asset_instance_id": "instance-101"}],
|
|
969
|
+
)
|
|
970
|
+
generate_static_url = ConfigField(
|
|
971
|
+
cli_meta=CLIOption(
|
|
972
|
+
name="generate_static_url",
|
|
973
|
+
cli_option_str="--generate-static-url",
|
|
974
|
+
help="Generate a static URL for the app based on its name.",
|
|
975
|
+
is_flag=True,
|
|
976
|
+
),
|
|
977
|
+
field_type=bool,
|
|
978
|
+
default=False,
|
|
979
|
+
help="Generate a static URL for the app based on its name.",
|
|
980
|
+
)
|
|
981
|
+
# ------- /Experimental -------------
|
|
982
|
+
|
|
983
|
+
def to_dict(self):
|
|
984
|
+
return config_meta_to_dict(self)
|
|
985
|
+
|
|
986
|
+
@staticmethod
|
|
987
|
+
def merge_configs(
|
|
988
|
+
base_config: "CoreConfig", override_config: "CoreConfig"
|
|
989
|
+
) -> "CoreConfig":
|
|
990
|
+
"""
|
|
991
|
+
Merge two configurations with override taking precedence.
|
|
992
|
+
|
|
993
|
+
Handles FieldBehavior for proper merging:
|
|
994
|
+
- UNION: Merge values (for lists, dicts)
|
|
995
|
+
- NOT_ALLOWED: Base config value takes precedence (override is ignored)
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
base_config: Base configuration (lower precedence)
|
|
999
|
+
override_config: Override configuration (higher precedence)
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
Merged CoreConfig instance
|
|
1003
|
+
"""
|
|
1004
|
+
merged_config = CoreConfig()
|
|
1005
|
+
|
|
1006
|
+
# Process each field according to its behavior
|
|
1007
|
+
for field_name, field_info in CoreConfig._fields.items(): # type: ignore
|
|
1008
|
+
base_value = getattr(base_config, field_name, None)
|
|
1009
|
+
override_value = getattr(override_config, field_name, None)
|
|
1010
|
+
|
|
1011
|
+
# Get the behavior for this field
|
|
1012
|
+
behavior = getattr(field_info, "behavior", FieldBehavior.UNION)
|
|
1013
|
+
|
|
1014
|
+
merged_value = merge_field_values(
|
|
1015
|
+
base_value, override_value, field_info, behavior
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
setattr(merged_config, field_name, merged_value)
|
|
1019
|
+
|
|
1020
|
+
return merged_config
|
|
1021
|
+
|
|
1022
|
+
def set_defaults(self):
|
|
1023
|
+
apply_defaults(self)
|
|
1024
|
+
|
|
1025
|
+
def validate(self):
|
|
1026
|
+
validate_config_meta(self)
|
|
1027
|
+
|
|
1028
|
+
@commit_owner_names_across_tree
|
|
1029
|
+
def commit(self):
|
|
1030
|
+
self.validate()
|
|
1031
|
+
validate_required_fields(self)
|
|
1032
|
+
self.set_defaults()
|
|
1033
|
+
|
|
1034
|
+
@classmethod
|
|
1035
|
+
def from_dict(cls, config_data: Dict[str, Any]) -> "CoreConfig":
|
|
1036
|
+
config = cls()
|
|
1037
|
+
# Define functions for dict source
|
|
1038
|
+
def get_dict_key(field_name, field_info):
|
|
1039
|
+
return field_name
|
|
1040
|
+
|
|
1041
|
+
def get_dict_value(source_data, key):
|
|
1042
|
+
return source_data.get(key)
|
|
1043
|
+
|
|
1044
|
+
populate_config_recursive(
|
|
1045
|
+
config, cls, config_data, get_dict_key, get_dict_value
|
|
1046
|
+
)
|
|
1047
|
+
return config
|
|
1048
|
+
|
|
1049
|
+
@classmethod
|
|
1050
|
+
def from_cli(cls, cli_options: Dict[str, Any]) -> "CoreConfig":
|
|
1051
|
+
config = cls()
|
|
1052
|
+
# Define functions for CLI source
|
|
1053
|
+
def get_cli_key(field_name, field_info):
|
|
1054
|
+
# Need to have a special Exception for commands since the Commands
|
|
1055
|
+
# are passed down via unprocessed args after `--` in click
|
|
1056
|
+
if field_name == cls.commands.name:
|
|
1057
|
+
return field_name
|
|
1058
|
+
# Return the CLI parameter name if CLI metadata exists
|
|
1059
|
+
if field_info.cli_meta and not field_info.cli_meta.hidden:
|
|
1060
|
+
return field_info.cli_meta.name
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
def get_cli_value(source_data, key):
|
|
1064
|
+
value = source_data.get(key)
|
|
1065
|
+
# Only return non-None values since None means not set in CLI
|
|
1066
|
+
if value is None:
|
|
1067
|
+
return None
|
|
1068
|
+
if key == cls.environment.name:
|
|
1069
|
+
_env_dict = {}
|
|
1070
|
+
for v in value:
|
|
1071
|
+
_env_dict.update(v)
|
|
1072
|
+
return _env_dict
|
|
1073
|
+
if type(value) == tuple or type(value) == list:
|
|
1074
|
+
obj = list(x for x in source_data[key])
|
|
1075
|
+
if len(obj) == 0:
|
|
1076
|
+
return None # Dont return Empty Lists so that we can set Nones
|
|
1077
|
+
return obj
|
|
1078
|
+
return value
|
|
1079
|
+
|
|
1080
|
+
# Use common recursive population function with nested value checking
|
|
1081
|
+
populate_config_recursive(
|
|
1082
|
+
config,
|
|
1083
|
+
cls,
|
|
1084
|
+
cli_options,
|
|
1085
|
+
get_cli_key,
|
|
1086
|
+
get_cli_value,
|
|
1087
|
+
)
|
|
1088
|
+
return config
|