Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions runpod/api/queries/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
name
state
activeBuildId
endpoints { id name }
endpoints { id name template { name } }
}
}
}
Expand Down Expand Up @@ -60,7 +60,7 @@
state
activeBuildId
createdAt
endpoints { id name }
endpoints { id name template { name } }
networkVolumes { id name }
}
}
Expand Down
39 changes: 37 additions & 2 deletions runpod/apps/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import logging
import os
import secrets
import tarfile
import tempfile
from dataclasses import dataclass, field
Expand All @@ -29,6 +30,8 @@
log = logging.getLogger(__name__)

MANIFEST_VERSION = 1
# hex bytes in the suffix that keeps new template names unique (6 chars)
TEMPLATE_TOKEN_BYTES = 3

DEFAULT_IGNORES = [
".venv/",
Expand Down Expand Up @@ -363,6 +366,22 @@ def _container_disk_gb(spec) -> int:
return CPU_CONTAINER_DISK_GB if spec.is_cpu else GPU_CONTAINER_DISK_GB


def _legacy_template_name(app: App, spec) -> str:
"""the fixed template name used before names carried a suffix."""
return f"{app.name}-{spec.name}-template"


def _new_template_name(app: App, spec, token: str) -> str:
"""a template name for a newly created endpoint.

template names are unique per owner, and a deleted endpoint keeps
its template for a while before the platform removes it, so a fixed
name fails when an app is redeployed soon after being deleted, or
deployed to a second environment.
"""
return f"{_legacy_template_name(app, spec)}-{token}"


def _deployed_endpoint_input(
app: App,
spec,
Expand All @@ -372,9 +391,13 @@ def _deployed_endpoint_input(
*,
env_name: Optional[str] = None,
pool_members: Optional[Dict[str, Set[str]]] = None,
template_name: Optional[str] = None,
) -> Dict[str, Any]:
"""saveEndpoint payload for one deployed queue/api resource.

template_name names the bound template; it defaults to the legacy
fixed name (see reconcile_endpoints for why new endpoints vary it).

the endpoint name must equal the resource name: sentinel
resolution matches X-Flash-Endpoint against endpoint.name within
the environment. binding via flashEnvironmentId is also what makes
Expand Down Expand Up @@ -421,7 +444,7 @@ def _deployed_endpoint_input(
"scalerValue": spec.scaler_value,
"executionTimeoutMs": spec.execution_timeout_ms,
"template": {
"name": f"{app.name}-{spec.name}-template",
"name": template_name or _legacy_template_name(app, spec),
"imageName": image_for_spec(spec, python_version=python_version),
"containerDiskInGb": _container_disk_gb(spec),
"dockerArgs": "",
Expand Down Expand Up @@ -483,7 +506,14 @@ async def reconcile_endpoints(
"""
for handle in app.resources.values():
handle.spec.validate()
existing = {e["name"]: e["id"] for e in environment.get("endpoints") or []}
existing_endpoints = environment.get("endpoints") or []
existing = {e["name"]: e["id"] for e in existing_endpoints}
# updates send the bound template's current name, so redeploying an
# existing endpoint sends the same template name as before.
existing_templates = {
e["name"]: (e.get("template") or {}).get("name") for e in existing_endpoints
}
template_token = secrets.token_hex(TEMPLATE_TOKEN_BYTES)
provisionable = {
h.spec.name: h
for h in app.resources.values()
Expand All @@ -500,6 +530,10 @@ async def reconcile_endpoints(
members = None
if needs_gpu_catalog(handle.spec.gpu):
members = await client.gpu_pool_members()
if name in existing:
template_name = existing_templates.get(name)
else:
template_name = _new_template_name(app, handle.spec, template_token)
payload = _deployed_endpoint_input(
app,
handle.spec,
Expand All @@ -508,6 +542,7 @@ async def reconcile_endpoints(
python_version,
env_name=environment["name"],
pool_members=members or {},
template_name=template_name,
)
await attach_endpoint_volumes(payload, handle.spec, resolver, app)
auth_id = await resolve_registry_auth(handle.spec.registry_auth, api=client)
Expand Down
81 changes: 81 additions & 0 deletions tests/test_apps/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,84 @@ def test_import_time_invocation_message(self, tmp_path):
client.write_text("from main import que1\nque1.remote(1)\n")
with pytest.raises(DiscoveryError, match="invoked at import time"):
discover_apps(client)


class TestDeployedTemplateNames:
"""Template names are unique per owner, and a deleted endpoint holds its
template for a while, so new endpoints must not reuse a fixed name."""

@staticmethod
def _app():
app = App("demo-app")

@app.queue(name="que1", cpu="cpu5c-2-4")
def que1(x: int):
return x

return app

@staticmethod
def _client():
client = AsyncMock()
client.save_endpoint.side_effect = lambda payload: {
"id": payload.get("id", "ep-new")
}
return client

@staticmethod
def _saved_template_name(client) -> str:
(payload,), _ = client.save_endpoint.await_args
return payload["template"]["name"]

async def _reconcile(self, environment):
from runpod.apps.deploy import reconcile_endpoints

client = self._client()
await reconcile_endpoints(client, self._app(), environment, "b-1", "3.12")
return client

async def test_new_endpoint_template_name_has_unique_suffix(self):
environment = {"id": "env-1", "name": "default", "endpoints": []}

first = self._saved_template_name(await self._reconcile(environment))
second = self._saved_template_name(await self._reconcile(environment))

assert first.startswith("demo-app-que1-template-")
assert second.startswith("demo-app-que1-template-")
assert first != second

async def test_update_keeps_the_existing_template_name(self):
environment = {
"id": "env-1",
"name": "default",
"endpoints": [
{"id": "ep-1", "name": "que1", "template": {"name": "kept-name"}}
],
}

client = await self._reconcile(environment)

(payload,), _ = client.save_endpoint.await_args
assert payload["id"] == "ep-1"
assert payload["template"]["name"] == "kept-name"

async def test_update_without_template_info_uses_the_legacy_name(self):
environment = {
"id": "env-1",
"name": "default",
"endpoints": [{"id": "ep-1", "name": "que1", "template": None}],
}

client = await self._reconcile(environment)

assert self._saved_template_name(client) == "demo-app-que1-template"

@pytest.mark.parametrize(
"query_name", ["QUERY_FLASH_APP_BY_NAME", "QUERY_FLASH_ENVIRONMENT_BY_NAME"]
)
def test_environment_queries_select_the_bound_template_name(self, query_name):
from runpod.api.queries import apps as app_queries

query = " ".join(getattr(app_queries, query_name).split())

assert "endpoints { id name template { name } }" in query
Loading