Skip to content

Commit 80f82e8

Browse files
cosmicBboyclaude
andcommitted
Add flyte.app.Subdomain for deploy-time subdomain resolution
Subdomain.from_app_name(app_name, project_domain_suffix="hash"|"default") produces {app_name}-{hash-of-project-domain} or {app_name}-{project}-{domain}, and Subdomain.from_function(fn) lets users compute the subdomain from the AppEnvironment and the deployment SerializationContext. Domain.subdomain now accepts a str or a Subdomain, resolved during app serialization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KVnRzHRUANmCg7ZsLH6Fah
1 parent 1a0d78f commit 80f82e8

5 files changed

Lines changed: 216 additions & 7 deletions

File tree

src/flyte/app/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from flyte.app._context import ctx
44
from flyte.app._deploy import DeployedAppEnvironment
55
from flyte.app._parameter import AppEndpoint, ArtifactValue, Parameter, RunOutput, get_parameter
6-
from flyte.app._types import Domain, Link, Port, Scaling, Timeouts
6+
from flyte.app._types import Domain, Link, Port, Scaling, Subdomain, Timeouts
77

88
__all__ = [
99
"AppEndpoint",
@@ -17,6 +17,7 @@
1717
"Port",
1818
"RunOutput",
1919
"Scaling",
20+
"Subdomain",
2021
"Timeouts",
2122
"ctx",
2223
"get_parameter",

src/flyte/app/_runtime/app_serde.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from flyte._internal.runtime.resources_serde import get_proto_extended_resources, get_proto_resources
2323
from flyte._internal.runtime.task_serde import get_security_context, lookup_image_in_cache
2424
from flyte._logging import logger
25-
from flyte.app import AppEnvironment, Parameter, Scaling
25+
from flyte.app import AppEnvironment, Parameter, Scaling, Subdomain
2626
from flyte.app._parameter import AppEndpoint, ArtifactValue, _DelayedValue
2727
from flyte.models import SerializationContext
2828
from flyte.syncify import syncify
@@ -407,9 +407,12 @@ async def translate_app_env_to_idl(
407407
msg = "image must be a str, Image, or PodTemplate"
408408
raise ValueError(msg)
409409

410+
subdomain = app_env.domain.subdomain if app_env.domain else None
411+
if isinstance(subdomain, Subdomain):
412+
subdomain = subdomain.resolve(app_env, serialization_context)
410413
ingress = app_definition_pb2.IngressConfig(
411414
private=False,
412-
subdomain=app_env.domain.subdomain if app_env.domain else None,
415+
subdomain=subdomain,
413416
cname=app_env.domain.custom_domain if app_env.domain else None,
414417
)
415418

src/flyte/app/_types.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import hashlib
12
from dataclasses import dataclass
23
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
49

510
import rich.repr
611

@@ -150,14 +155,91 @@ def __post_init__(self):
150155
raise ValueError("request timeout must not exceed 1 hour (3600 seconds)")
151156

152157

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+
153234
@rich.repr.auto
154235
@dataclass
155236
class Domain:
156237
# SubDomain config
157238

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."""
159241

160-
subdomain: Optional[str] = None
242+
subdomain: Optional[Union[str, Subdomain]] = None
161243

162244
"""Custom domain to use for the domain. If not set, the default custom domain will be used."""
163245
custom_domain: Optional[str] = None

tests/flyte/app/runtime/test_app_serde.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
into protobuf IDL format without using mocks.
66
"""
77

8+
import hashlib
89
import pathlib
910
from datetime import timedelta
1011
from unittest.mock import AsyncMock, MagicMock, patch
@@ -28,7 +29,7 @@
2829
translate_app_env_to_idl,
2930
translate_parameters,
3031
)
31-
from flyte.app._types import Domain, Port, Scaling, Timeouts
32+
from flyte.app._types import Domain, Port, Scaling, Subdomain, Timeouts
3233
from flyte.models import CodeBundle, SerializationContext
3334

3435

@@ -1399,3 +1400,42 @@ async def test_translate_parameters_without_artifact_ids_is_unchanged():
13991400

14001401
assert inputs.items[0].WhichOneof("value") == "string_value"
14011402
assert inputs.items[0].string_value == "s3://bucket/weights.pt"
1403+
1404+
1405+
@pytest.mark.parametrize(
1406+
"subdomain,expected",
1407+
[
1408+
(
1409+
Subdomain.from_app_name("test-app"),
1410+
"test-app-" + hashlib.sha256(b"test-project-test-domain").hexdigest()[:8],
1411+
),
1412+
(
1413+
Subdomain.from_app_name("test-app", project_domain_suffix="default"),
1414+
"test-app-test-project-test-domain",
1415+
),
1416+
(
1417+
Subdomain.from_function(lambda app_env, ctx: f"{app_env.name}-{ctx.org}"),
1418+
"test-app-test-org",
1419+
),
1420+
],
1421+
)
1422+
def test_app_with_resolved_subdomain(subdomain: Subdomain, expected: str):
1423+
"""
1424+
GOAL: Verify Subdomain instances are resolved to the final subdomain string in the ingress config.
1425+
"""
1426+
app_env = AppEnvironment(
1427+
name="test-app",
1428+
image=Image.from_base("python:3.11"),
1429+
domain=Domain(subdomain=subdomain),
1430+
)
1431+
1432+
ctx = SerializationContext(
1433+
org="test-org",
1434+
project="test-project",
1435+
domain="test-domain",
1436+
version="v1",
1437+
root_dir=pathlib.Path.cwd(),
1438+
)
1439+
1440+
app_idl = translate_app_env_to_idl(app_env, ctx)
1441+
assert app_idl.spec.ingress.subdomain == expected

tests/flyte/app/test_types.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for flyte.app._types."""
2+
3+
import hashlib
4+
import pathlib
5+
6+
import pytest
7+
8+
from flyte._image import Image
9+
from flyte.app import AppEnvironment
10+
from flyte.app._types import Domain, Subdomain
11+
from flyte.models import SerializationContext
12+
13+
14+
def _ctx(project="my-project", domain="my-domain") -> SerializationContext:
15+
return SerializationContext(
16+
org="my-org",
17+
project=project,
18+
domain=domain,
19+
version="v1",
20+
root_dir=pathlib.Path.cwd(),
21+
)
22+
23+
24+
def _app_env(name="my-app") -> AppEnvironment:
25+
return AppEnvironment(name=name, image=Image.from_base("python:3.11"))
26+
27+
28+
def test_subdomain_from_app_name_hash():
29+
subdomain = Subdomain.from_app_name("my-app")
30+
expected_hash = hashlib.sha256(b"my-project-my-domain").hexdigest()[:8]
31+
assert subdomain.resolve(_app_env(), _ctx()) == f"my-app-{expected_hash}"
32+
33+
34+
def test_subdomain_hash_is_stable_per_project_domain():
35+
ctx = _ctx()
36+
resolved_a = Subdomain.from_app_name("app-a").resolve(_app_env("app-a"), ctx)
37+
resolved_b = Subdomain.from_app_name("app-b").resolve(_app_env("app-b"), ctx)
38+
# Same project/domain -> same hash suffix
39+
assert resolved_a.split("app-a-")[1] == resolved_b.split("app-b-")[1]
40+
# Different project -> different hash suffix
41+
other = Subdomain.from_app_name("app-a").resolve(_app_env("app-a"), _ctx(project="other"))
42+
assert other != resolved_a
43+
44+
45+
def test_subdomain_from_app_name_default():
46+
subdomain = Subdomain.from_app_name("my-app", project_domain_suffix="default")
47+
assert subdomain.resolve(_app_env(), _ctx()) == "my-app-my-project-my-domain"
48+
49+
50+
def test_subdomain_from_function():
51+
subdomain = Subdomain.from_function(lambda app_env, ctx: f"{app_env.name}.{ctx.org}.{ctx.domain}")
52+
assert subdomain.resolve(_app_env(), _ctx()) == "my-app.my-org.my-domain"
53+
54+
55+
def test_subdomain_from_function_invalid_return():
56+
subdomain = Subdomain.from_function(lambda app_env, ctx: None)
57+
with pytest.raises(ValueError, match="non-empty str"):
58+
subdomain.resolve(_app_env(), _ctx())
59+
60+
61+
def test_subdomain_invalid_suffix():
62+
with pytest.raises(ValueError, match="project_domain_suffix"):
63+
Subdomain.from_app_name("my-app", project_domain_suffix="bogus")
64+
65+
66+
def test_subdomain_requires_app_name_or_function():
67+
with pytest.raises(ValueError, match="exactly one"):
68+
Subdomain()
69+
with pytest.raises(ValueError, match="exactly one"):
70+
Subdomain(app_name="my-app", function=lambda app_env, ctx: "x")
71+
72+
73+
def test_subdomain_resolve_requires_project_and_domain():
74+
subdomain = Subdomain.from_app_name("my-app")
75+
with pytest.raises(ValueError, match="project and domain are required"):
76+
subdomain.resolve(_app_env(), _ctx(project=None))
77+
78+
79+
def test_domain_accepts_subdomain():
80+
domain = Domain(subdomain=Subdomain.from_app_name("my-app"))
81+
assert isinstance(domain.subdomain, Subdomain)
82+
domain = Domain(subdomain="literal-subdomain")
83+
assert domain.subdomain == "literal-subdomain"

0 commit comments

Comments
 (0)