diff --git a/runpod/api/queries/apps.py b/runpod/api/queries/apps.py index 1e3fdb5ac..c5b11ac3e 100644 --- a/runpod/api/queries/apps.py +++ b/runpod/api/queries/apps.py @@ -19,7 +19,7 @@ name state activeBuildId - endpoints { id name } + endpoints { id name template { name } } } } } @@ -60,7 +60,7 @@ state activeBuildId createdAt - endpoints { id name } + endpoints { id name template { name } } networkVolumes { id name } } } diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py index bd57ba639..bbf72c271 100644 --- a/runpod/apps/deploy.py +++ b/runpod/apps/deploy.py @@ -3,6 +3,7 @@ import json import logging import os +import secrets import tarfile import tempfile from dataclasses import dataclass, field @@ -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/", @@ -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, @@ -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 @@ -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": "", @@ -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() @@ -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, @@ -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) diff --git a/tests/test_apps/test_deploy.py b/tests/test_apps/test_deploy.py index fe391efea..50d9187ea 100644 --- a/tests/test_apps/test_deploy.py +++ b/tests/test_apps/test_deploy.py @@ -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