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
45 changes: 43 additions & 2 deletions runpod/apps/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/test_apps/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading