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
50 changes: 46 additions & 4 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"}
Comment thread
deanq marked this conversation as resolved.
Resolved
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 @@ -158,9 +196,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

Expand Down Expand Up @@ -342,7 +384,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
11 changes: 8 additions & 3 deletions runpod/apps/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions tests/test_apps/test_config_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
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