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] 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