diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py index bbf72c27..43439e63 100644 --- a/runpod/apps/deploy.py +++ b/runpod/apps/deploy.py @@ -116,6 +116,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", @@ -161,9 +199,13 @@ def build_manifest( f"queue and api names must be at least 3 characters" ) - entry = spec.to_manifest() - if spec.kind in (ResourceKind.QUEUE, ResourceKind.API): - _validate_endpoint_env(entry.get("env", {})) + 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 is_endpoint: + _validate_endpoint_env(spec.env or {}) if spec.kind is ResourceKind.TASK: from .images import image_for_spec @@ -345,7 +387,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/runpod/apps/spec.py b/runpod/apps/spec.py index 391471e7..f9161a21 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 7ee1ab31..ec3a59ad 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() diff --git a/tests/test_apps/test_deploy.py b/tests/test_apps/test_deploy.py index 50d9187e..3ca87fe7 100644 --- a/tests/test_apps/test_deploy.py +++ b/tests/test_apps/test_deploy.py @@ -420,6 +420,102 @@ def test_import_time_invocation_message(self, tmp_path): 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 + + 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."""