From 51e347fcfaa38f09fe38f9990678451d45230e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sat, 7 Nov 2026 12:57:35 -0800 Subject: [PATCH 1/2] fix(apps): keep env values out of the build manifest Deploy wrote every resource's env, literal values included, into the manifest it uploads and packs into the build artifact. - Queue and API env is checked against reserved names, then left out of the manifest. Endpoints get their env from the endpoint template. - Task env stays in the manifest sent to the backend, which launches deployed task pods from it. - The packaged runpod_manifest.json carries no env for any resource. - Warn when a secret-looking env name (*_TOKEN, *_KEY, *_SECRET, *_PASSWORD) has a literal value, pointing to runpod.Secret. SLS-994 --- runpod/apps/deploy.py | 45 +++++++++++++++- tests/test_apps/test_deploy.py | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py index bd57ba639..f47a89030 100644 --- a/runpod/apps/deploy.py +++ b/runpod/apps/deploy.py @@ -113,6 +113,44 @@ class DeployResult: endpoints: Dict[str, str] = field(default_factory=dict) +SECRET_LIKE_ENV_SUFFIXES = ("_TOKEN", "_KEY", "_SECRET", "_PASSWORD") + + +def _warn_secret_like_plain_env(resource_name: str, env: Dict[str, Any]) -> None: + """warn when a secret-looking env name carries a literal value. + + literal values are stored in the endpoint or task definition as typed; + runpod.Secret keeps the value encrypted until the worker boots. + """ + from .secret import Secret + + for key, value in env.items(): + if isinstance(value, Secret): + continue + if key.upper().endswith(SECRET_LIKE_ENV_SUFFIXES): + log.warning( + "resource '%s' sets %s to a literal value; store it with " + "`rp secret add` and reference it with runpod.Secret(...)", + resource_name, + key, + ) + + +def _without_env(manifest: Dict[str, Any]) -> Dict[str, Any]: + """copy of a manifest with every resource's env removed. + + the packaged artifact never needs env: endpoints get it from their + template and deployed tasks from the backend's task definition. + """ + if "resources" not in manifest: + return dict(manifest) + resources = [ + {k: v for k, v in resource.items() if k != "env"} + for resource in manifest["resources"] + ] + return {**manifest, "resources": resources} + + def _validate_endpoint_env(env: Dict[str, str]) -> None: reserved = { "RUNPOD_API_KEY", @@ -159,8 +197,11 @@ def build_manifest( ) entry = spec.to_manifest() + _warn_secret_like_plain_env(spec.name, spec.env or {}) if spec.kind in (ResourceKind.QUEUE, ResourceKind.API): - _validate_endpoint_env(entry.get("env", {})) + # endpoint env reaches workers through the endpoint template; + # keep the values out of the uploaded manifest + _validate_endpoint_env(entry.pop("env", {})) if spec.kind is ResourceKind.TASK: from .images import image_for_spec @@ -342,7 +383,7 @@ def package_project( rel = path.relative_to(env_resolved).as_posix() tar.add(path, arcname=f"{ENV_DIR_NAME}/{rel}", recursive=False) - manifest_bytes = json.dumps(manifest, indent=2).encode() + manifest_bytes = json.dumps(_without_env(manifest), indent=2).encode() info = tarfile.TarInfo(name="runpod_manifest.json") info.size = len(manifest_bytes) import io diff --git a/tests/test_apps/test_deploy.py b/tests/test_apps/test_deploy.py index fe391efea..a20965c8a 100644 --- a/tests/test_apps/test_deploy.py +++ b/tests/test_apps/test_deploy.py @@ -418,3 +418,99 @@ 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 TestManifestKeepsEnvValuesOut: + PLAIN_VALUE = "hf_plain_value_from_os_environ" + + def _app_with_env(self): + app = App("env-app") + + @app.queue(name="qee", cpu="cpu5c-2-4", env={"HF_TOKEN": self.PLAIN_VALUE}) + def qee(x): + return x + + @app.api(name="web", cpu="cpu5c-2-4", env={"API_SECRET": self.PLAIN_VALUE}) + def web(): + return {} + + @app.task(name="job", cpu="cpu3c-1-2", env={"MODE": "fast"}) + def job(): + pass + + return app + + def test_endpoint_env_is_left_out_of_the_manifest(self, tmp_path): + manifest = build_manifest(self._app_with_env(), tmp_path) + + endpoints = [r for r in manifest["resources"] if r["kind"] != "task"] + assert len(endpoints) == 2 + assert all("env" not in r for r in endpoints) + assert self.PLAIN_VALUE not in json.dumps(manifest) + + def test_task_env_stays_in_the_manifest_for_the_backend(self, tmp_path): + manifest = build_manifest(self._app_with_env(), tmp_path) + + (task,) = [r for r in manifest["resources"] if r["kind"] == "task"] + assert task["env"] == {"MODE": "fast"} + + def test_reserved_endpoint_env_is_still_rejected(self, tmp_path): + app = App("env-app") + + @app.queue(name="qee", cpu="cpu5c-2-4", env={"RUNPOD_ENDPOINT_ID": "x"}) + def qee(x): + return x + + with pytest.raises(InvalidResourceError, match="RUNPOD_ENDPOINT_ID"): + build_manifest(app, tmp_path) + + def test_packaged_manifest_has_no_env_values(self, tmp_path): + manifest = { + "version": 1, + "app": "env-app", + "resources": [ + {"kind": "task", "name": "job", "env": {"MODE": self.PLAIN_VALUE}} + ], + } + + with tarfile.open(package_project(tmp_path, manifest)) as tar: + packaged = json.load(tar.extractfile("runpod_manifest.json")) + + assert packaged["resources"] == [{"kind": "task", "name": "job"}] + assert manifest["resources"][0]["env"] == {"MODE": self.PLAIN_VALUE} + + @pytest.mark.parametrize( + "name", ["HF_TOKEN", "OPENAI_API_KEY", "DB_PASSWORD", "app_secret"] + ) + def test_secret_like_plain_value_warns(self, tmp_path, caplog, name): + app = App("env-app") + + @app.queue(name="qee", cpu="cpu5c-2-4", env={name: self.PLAIN_VALUE}) + def qee(x): + return x + + build_manifest(app, tmp_path) + + assert name in caplog.text + assert "runpod.Secret" in caplog.text + assert self.PLAIN_VALUE not in caplog.text + + @pytest.mark.parametrize( + "env", + [ + {"HF_TOKEN": runpod.Secret("hf-token")}, + {"MODE": "fast", "KEYBOARD": "us"}, + ], + ) + def test_secret_references_and_ordinary_names_do_not_warn( + self, tmp_path, caplog, env + ): + app = App("env-app") + + @app.queue(name="qee", cpu="cpu5c-2-4", env=env) + def qee(x): + return x + + build_manifest(app, tmp_path) + + assert "runpod.Secret" not in caplog.text From 375ee91ceb50115a05294f15282333be7f563d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 9 Nov 2026 11:19:17 -0800 Subject: [PATCH 2/2] refactor(apps): never add endpoint env to the manifest Endpoint env was serialized into the manifest entry and then popped back out. Build the entry without it instead, via a new include_env flag on ResourceSpec.to_manifest, and validate the reserved keys from the spec. Behavior is unchanged: endpoints carry no env in the manifest, task env still goes to the backend. --- runpod/apps/deploy.py | 11 ++++++----- runpod/apps/spec.py | 11 ++++++++--- tests/test_apps/test_config_params.py | 8 ++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py index f47a89030..f85f18055 100644 --- a/runpod/apps/deploy.py +++ b/runpod/apps/deploy.py @@ -196,12 +196,13 @@ def build_manifest( f"queue and api names must be at least 3 characters" ) - entry = spec.to_manifest() + is_endpoint = spec.kind in (ResourceKind.QUEUE, ResourceKind.API) + # endpoint env reaches workers through the endpoint template, so it + # is never put into the manifest rather than stripped out afterward + entry = spec.to_manifest(include_env=not is_endpoint) _warn_secret_like_plain_env(spec.name, spec.env or {}) - if spec.kind in (ResourceKind.QUEUE, ResourceKind.API): - # endpoint env reaches workers through the endpoint template; - # keep the values out of the uploaded manifest - _validate_endpoint_env(entry.pop("env", {})) + if is_endpoint: + _validate_endpoint_env(spec.env or {}) if spec.kind is ResourceKind.TASK: from .images import image_for_spec diff --git a/runpod/apps/spec.py b/runpod/apps/spec.py index 391471e7a..f9161a21e 100644 --- a/runpod/apps/spec.py +++ b/runpod/apps/spec.py @@ -306,8 +306,13 @@ def effective_scaler_type(self) -> str: return self.scaler_type return "REQUEST_COUNT" if self.kind is ResourceKind.API else "QUEUE_DELAY" - def to_manifest(self) -> Dict[str, Any]: - """serialize for the deploy manifest.""" + def to_manifest(self, *, include_env: bool = True) -> Dict[str, Any]: + """serialize for the deploy manifest. + + include_env is False for endpoints, whose env reaches workers + through the endpoint template rather than the manifest, so it is + never written into the packaged artifact. + """ self.validate() data: Dict[str, Any] = { "kind": self.kind.value, @@ -330,7 +335,7 @@ def to_manifest(self) -> Dict[str, Any]: {"kind": volume.kind, "reference": volume.reference, "path": path} for path, volume in self.mounts.items() ] - if self.env: + if self.env and include_env: from .secret import render_env data["env"] = render_env(self.env) diff --git a/tests/test_apps/test_config_params.py b/tests/test_apps/test_config_params.py index 7ee1ab31c..ec3a59add 100644 --- a/tests/test_apps/test_config_params.py +++ b/tests/test_apps/test_config_params.py @@ -216,6 +216,14 @@ def test_non_defaults_serialized(self): assert data["accelerateDownloads"] is False assert data["containerDiskGb"] == 42 + def test_env_included_by_default(self): + spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", env={"MODE": "fast"}) + assert spec.to_manifest()["env"] == {"MODE": "fast"} + + def test_env_excluded_when_not_requested(self): + spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", env={"MODE": "fast"}) + assert "env" not in spec.to_manifest(include_env=False) + def test_defaults_omitted(self): spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q") data = spec.to_manifest()