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
6 changes: 4 additions & 2 deletions src/runpod_flash/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
__version__ = "1.8.0" # x-release-please-version

# Load .env vars from file before everything else
from dotenv import load_dotenv
# usecwd=True walks up from CWD (user's project) instead of from the
# package source file location, which matters for editable installs.
from dotenv import find_dotenv, load_dotenv

load_dotenv()
load_dotenv(find_dotenv(usecwd=True))

from .logger import setup_logging # noqa: E402

Expand Down
6 changes: 4 additions & 2 deletions src/runpod_flash/core/resources/environment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import Dict, Optional
from dotenv import dotenv_values
from dotenv import dotenv_values, find_dotenv


class EnvironmentVars:
Expand All @@ -16,7 +16,9 @@ def _load_env(self) -> Dict[str, str]:
Dict[str, str]: Dictionary containing environment variables from .env file
"""
# Use dotenv_values instead of load_dotenv to get only variables from .env
return dict(dotenv_values())
# usecwd=True walks up from CWD (user's project) instead of from the
# package source file location, which matters for editable installs.
return dict(dotenv_values(find_dotenv(usecwd=True)))

def get_env(self) -> Dict[str, str]:
"""
Expand Down
8 changes: 4 additions & 4 deletions src/runpod_flash/core/resources/load_balancer_sls_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,10 @@ async def _do_deploy(self) -> "LoadBalancerSlsResource":
return self

try:
# Mark this endpoint as load-balanced (triggers auto-provisioning on boot)
if self.env is None:
self.env = {}
self.env["FLASH_ENDPOINT_TYPE"] = "lb"
# NOTE: FLASH_ENDPOINT_TYPE is NOT injected here. For flash deploy,
# the runtime resource_provisioner sets it. For flash run (live
# serverless), the worker must NOT see it — otherwise it triggers
# artifact unpacking which doesn't exist for live endpoints.

# Call parent deploy (creates endpoint via RunPod API)
log.debug(f"Deploying LB endpoint: {self.name}")
Expand Down
134 changes: 91 additions & 43 deletions src/runpod_flash/core/resources/serverless.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,20 +259,19 @@ def validate_python_version(cls, v: Optional[str]) -> Optional[str]:

@property
def config_hash(self) -> str:
"""Get config hash excluding env and runtime-assigned fields.
"""Get config hash excluding runtime-assigned fields.

Prevents false drift from:
- Dynamic env vars computed at runtime
- Runtime-assigned fields (template, templateId, aiKey, userId, etc.)

Only hashes user-specified configuration, not server-assigned state.
Hashes user-specified configuration including env vars.
"""
import hashlib
import json

resource_type = self.__class__.__name__

# Exclude runtime fields, env, and id from hash
# Exclude runtime fields and id from hash
exclude_fields = (
self.__class__.RUNTIME_FIELDS | self.__class__.EXCLUDED_HASH_FIELDS
)
Expand Down Expand Up @@ -534,12 +533,24 @@ def _payload_exclude(self) -> Set[str]:

@staticmethod
def _build_template_update_payload(
template: PodTemplate, template_id: str
template: PodTemplate,
template_id: str,
*,
skip_env: bool = False,
) -> Dict[str, Any]:
"""Build saveTemplate payload from template model.

Keep this to fields supported by saveTemplate to avoid passing endpoint-only
fields to the template mutation.

Args:
template: Template model with desired configuration.
template_id: ID of the template to update.
skip_env: When True, omit ``env`` from the payload so
saveTemplate preserves the existing template env vars.
This prevents removing platform-injected vars (e.g.
PORT, PORT_HEALTH on LB endpoints) when the user's
env hasn't actually changed.
"""
template_data = template.model_dump(exclude_none=True, mode="json")
allowed_fields = {
Expand All @@ -550,6 +561,8 @@ def _build_template_update_payload(
"env",
"readme",
}
if skip_env:
allowed_fields.discard("env")
payload = {
key: value for key, value in template_data.items() if key in allowed_fields
}
Expand Down Expand Up @@ -643,6 +656,56 @@ def _get_module_path(self) -> Optional[str]:
except Exception:
return None

def _inject_template_env(self, key: str, value: str) -> None:
"""Append a KeyValuePair to self.template.env if the key isn't already present.

This injects runtime env vars directly into the template without
mutating self.env, which would cause false config drift on subsequent
deploys.
"""
if self.template is None:
return
if self.template.env is None:
self.template.env = []
existing_keys = {kv.key for kv in self.template.env}
if key not in existing_keys:
self.template.env.append(KeyValuePair(key=key, value=value))

def _inject_runtime_template_vars(self) -> None:
"""Inject runtime env vars into template.env without mutating self.env.

For QB endpoints making remote calls: injects RUNPOD_API_KEY.
For LB endpoints: injects FLASH_MODULE_PATH.

Called by both _do_deploy (initial) and update (env changes) so
runtime vars survive template updates.
"""
env_dict = self.env or {}

if self.type == ServerlessType.QB:
if self._check_makes_remote_calls():
if "RUNPOD_API_KEY" not in env_dict:
from runpod_flash.core.credentials import get_api_key

api_key = get_api_key()
if api_key:
self._inject_template_env("RUNPOD_API_KEY", api_key)
log.debug(
f"{self.name}: Injected RUNPOD_API_KEY for remote calls "
f"(makes_remote_calls=True)"
)
else:
log.warning(
f"{self.name}: makes_remote_calls=True but RUNPOD_API_KEY not set. "
f"Remote calls to other endpoints will fail."
)

elif self.type == ServerlessType.LB:
module_path = self._get_module_path()
if module_path and "FLASH_MODULE_PATH" not in env_dict:
self._inject_template_env("FLASH_MODULE_PATH", module_path)
log.debug(f"{self.name}: Injected FLASH_MODULE_PATH={module_path}")

async def _do_deploy(self) -> "DeployableResource":
"""
Deploys the serverless resource using the provided configuration.
Expand All @@ -658,43 +721,7 @@ async def _do_deploy(self) -> "DeployableResource":
log.debug(f"{self} exists")
return self

# Inject API key for queue-based endpoints that make remote calls
if self.type == ServerlessType.QB:
env_dict = self.env or {}

# Check if this resource makes remote calls (from build manifest)
makes_remote_calls = self._check_makes_remote_calls()

if makes_remote_calls:
# Inject RUNPOD_API_KEY if not already set
if "RUNPOD_API_KEY" not in env_dict:
from runpod_flash.core.credentials import get_api_key

api_key = get_api_key()
if api_key:
env_dict["RUNPOD_API_KEY"] = api_key
log.debug(
f"{self.name}: Injected RUNPOD_API_KEY for remote calls "
f"(makes_remote_calls=True)"
)
else:
log.warning(
f"{self.name}: makes_remote_calls=True but RUNPOD_API_KEY not set. "
f"Remote calls to other endpoints will fail."
)

self.env = env_dict

# Inject module path for load-balanced endpoints
elif self.type == ServerlessType.LB:
env_dict = self.env or {}

module_path = self._get_module_path()
if module_path and "FLASH_MODULE_PATH" not in env_dict:
env_dict["FLASH_MODULE_PATH"] = module_path
log.debug(f"{self.name}: Injected FLASH_MODULE_PATH={module_path}")

self.env = env_dict
self._inject_runtime_template_vars()

# Ensure network volume is deployed first
await self._ensure_network_volume_deployed()
Expand Down Expand Up @@ -764,8 +791,29 @@ async def update(self, new_config: "ServerlessResource") -> "ServerlessResource"

if new_config.template:
if resolved_template_id:
# Skip env in the template payload when the user's env
# hasn't changed. This lets the platform keep vars it
# injected (e.g. PORT, PORT_HEALTH on LB endpoints)
# and avoids a spurious rolling release.
#
# Also check template.env: if env is empty but the
# caller provided explicit template env entries, those
# must not be silently dropped.
env_unchanged = self.env == new_config.env
has_explicit_template_env = (
not new_config.env and new_config.template.env is not None
Comment on lines +803 to +804
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

has_explicit_template_env is computed as not new_config.env and new_config.template.env is not None, but PodTemplate.env defaults to an empty list (not None). That makes has_explicit_template_env effectively always true whenever new_config.env is {}, forcing skip_env=False and causing update_template() to send an empty env list that can wipe platform-injected vars (e.g. PORT/PORT_HEALTH) and trigger rolling releases. Consider detecting whether env was explicitly set on the template (e.g. via Pydantic’s model_fields_set / __pydantic_fields_set__) instead of checking is not None.

Suggested change
has_explicit_template_env = (
not new_config.env and new_config.template.env is not None
template_fields_set = getattr(
new_config.template, "__pydantic_fields_set__", set()
)
has_explicit_template_env = (
not new_config.env and "env" in template_fields_set

Copilot uses AI. Check for mistakes.
)
skip_env = env_unchanged and not has_explicit_template_env

if not skip_env:
# Inject runtime vars (RUNPOD_API_KEY, FLASH_MODULE_PATH)
# so they survive the template env overwrite.
new_config._inject_runtime_template_vars()

template_payload = self._build_template_update_payload(
new_config.template, resolved_template_id
new_config.template,
resolved_template_id,
skip_env=skip_env,
)
await client.update_template(template_payload)
log.debug(
Expand Down
Loading
Loading