|
| 1 | +import hashlib |
1 | 2 | from dataclasses import dataclass |
2 | 3 | from datetime import timedelta |
3 | | -from typing import Optional, Tuple, Union |
| 4 | +from typing import TYPE_CHECKING, Callable, Literal, Optional, Tuple, Union |
| 5 | + |
| 6 | +if TYPE_CHECKING: |
| 7 | + from flyte.app._app_environment import AppEnvironment |
| 8 | + from flyte.models import SerializationContext |
4 | 9 |
|
5 | 10 | import rich.repr |
6 | 11 |
|
@@ -150,14 +155,91 @@ def __post_init__(self): |
150 | 155 | raise ValueError("request timeout must not exceed 1 hour (3600 seconds)") |
151 | 156 |
|
152 | 157 |
|
| 158 | +_PROJECT_DOMAIN_HASH_LEN = 8 |
| 159 | + |
| 160 | + |
| 161 | +@rich.repr.auto |
| 162 | +@dataclass(frozen=True) |
| 163 | +class Subdomain: |
| 164 | + """ |
| 165 | + A subdomain that is resolved at deploy time, when the deployment project and domain are known. |
| 166 | +
|
| 167 | + Use `Subdomain.from_app_name` for the built-in naming schemes: |
| 168 | +
|
| 169 | + - `project_domain_suffix="hash"`: the subdomain is `{app_name}-{hash}`, where the hash is computed |
| 170 | + from `{project}-{domain}`. This keeps subdomains short and stable per project/domain. |
| 171 | + - `project_domain_suffix="default"`: the subdomain is `{app_name}-{project}-{domain}`. |
| 172 | +
|
| 173 | + Use `Subdomain.from_function` for full control: the function receives the `AppEnvironment` and the |
| 174 | + deployment `SerializationContext` (project, domain, org, version, ...) and returns the subdomain. |
| 175 | +
|
| 176 | + The final subdomain string is produced by `resolve()` during serialization. |
| 177 | + """ |
| 178 | + |
| 179 | + app_name: Optional[str] = None |
| 180 | + project_domain_suffix: Literal["hash", "default"] = "hash" |
| 181 | + function: Optional[Callable[["AppEnvironment", "SerializationContext"], str]] = None |
| 182 | + |
| 183 | + def __post_init__(self): |
| 184 | + if (self.app_name is None) == (self.function is None): |
| 185 | + raise ValueError("exactly one of app_name or function must be set") |
| 186 | + if self.project_domain_suffix not in ("hash", "default"): |
| 187 | + raise ValueError(f"project_domain_suffix must be 'hash' or 'default', got {self.project_domain_suffix!r}") |
| 188 | + |
| 189 | + @classmethod |
| 190 | + def from_app_name(cls, app_name: str, project_domain_suffix: Literal["hash", "default"] = "hash") -> "Subdomain": |
| 191 | + """ |
| 192 | + Create a subdomain for an app whose final value depends on the deployment project and domain. |
| 193 | +
|
| 194 | + Args: |
| 195 | + app_name: Name of the app. |
| 196 | + project_domain_suffix: `"hash"` for `{app_name}-{hash-of-project-domain}`, or `"default"` |
| 197 | + for `{app_name}-{project}-{domain}`. |
| 198 | + """ |
| 199 | + return cls(app_name=app_name, project_domain_suffix=project_domain_suffix) |
| 200 | + |
| 201 | + @classmethod |
| 202 | + def from_function(cls, function: Callable[["AppEnvironment", "SerializationContext"], str]) -> "Subdomain": |
| 203 | + """ |
| 204 | + Create a subdomain computed by a user-provided function at deploy time. |
| 205 | +
|
| 206 | + Args: |
| 207 | + function: Called with the `AppEnvironment` being deployed and the deployment |
| 208 | + `SerializationContext`; returns the subdomain string. |
| 209 | + """ |
| 210 | + return cls(function=function) |
| 211 | + |
| 212 | + def resolve(self, app_env: "AppEnvironment", serialization_context: "SerializationContext") -> str: |
| 213 | + """Resolve to the final subdomain string for the given app environment and deployment context.""" |
| 214 | + if self.function is not None: |
| 215 | + subdomain = self.function(app_env, serialization_context) |
| 216 | + if not isinstance(subdomain, str) or not subdomain: |
| 217 | + raise ValueError( |
| 218 | + f"subdomain function for app {app_env.name!r} must return a non-empty str, got {subdomain!r}" |
| 219 | + ) |
| 220 | + return subdomain |
| 221 | + |
| 222 | + project, domain = serialization_context.project, serialization_context.domain |
| 223 | + if not project or not domain: |
| 224 | + raise ValueError( |
| 225 | + f"project and domain are required to resolve subdomain for app {self.app_name!r}, " |
| 226 | + f"got project={project!r}, domain={domain!r}" |
| 227 | + ) |
| 228 | + if self.project_domain_suffix == "hash": |
| 229 | + suffix = hashlib.sha256(f"{project}-{domain}".encode()).hexdigest()[:_PROJECT_DOMAIN_HASH_LEN] |
| 230 | + return f"{self.app_name}-{suffix}" |
| 231 | + return f"{self.app_name}-{project}-{domain}" |
| 232 | + |
| 233 | + |
153 | 234 | @rich.repr.auto |
154 | 235 | @dataclass |
155 | 236 | class Domain: |
156 | 237 | # SubDomain config |
157 | 238 |
|
158 | | - """Subdomain to use for the domain. If not set, the default subdomain will be used.""" |
| 239 | + """Subdomain to use for the domain. Either a literal string, or a `Subdomain` resolved against the |
| 240 | + deployment project and domain. If not set, the default subdomain will be used.""" |
159 | 241 |
|
160 | | - subdomain: Optional[str] = None |
| 242 | + subdomain: Optional[Union[str, Subdomain]] = None |
161 | 243 |
|
162 | 244 | """Custom domain to use for the domain. If not set, the default custom domain will be used.""" |
163 | 245 | custom_domain: Optional[str] = None |
0 commit comments