From 3891b3ab9340f0853e34bf88a79e9c5d263cd3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:53:18 -0800 Subject: [PATCH 01/12] feat(http): add Content-Type header to httpx and requests factories Add "Content-Type: application/json" header to centralized HTTP client factories for consistency with aiohttp usage and to prevent issues with JSON endpoints. - get_authenticated_httpx_client: Add Content-Type header - get_authenticated_requests_session: Add Content-Type header --- src/runpod_flash/core/utils/http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index 428999c9..322d1e83 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -44,6 +44,7 @@ def get_authenticated_httpx_client( headers = { "User-Agent": get_user_agent(), + "Content-Type": "application/json", } api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") if api_key: @@ -90,6 +91,7 @@ def get_authenticated_requests_session( session = requests.Session() session.headers["User-Agent"] = get_user_agent() + session.headers["Content-Type"] = "application/json" api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") if api_key: From 2c85b2b30e07fc4395952ee8a54541a8281608f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:55:48 -0800 Subject: [PATCH 02/12] feat(http): add centralized aiohttp session factory Add get_authenticated_aiohttp_session() to provide consistent HTTP client creation across all client types (httpx, requests, aiohttp). Features: - User-Agent header with flash version - Authorization header with API key support - Content-Type: application/json - Configurable timeout (default 300s for GraphQL) - TCPConnector with ThreadedResolver for DNS - API key override for worker-to-mothership propagation This enables consolidation of aiohttp session creation scattered across RunpodGraphQLClient and RunpodRestClient. --- src/runpod_flash/core/utils/http.py | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index 322d1e83..53dcbe23 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -5,6 +5,8 @@ import httpx import requests +from aiohttp import ClientSession, ClientTimeout, TCPConnector +from aiohttp.resolver import ThreadedResolver def get_authenticated_httpx_client( @@ -98,3 +100,50 @@ def get_authenticated_requests_session( session.headers["Authorization"] = f"Bearer {api_key}" return session + + +def get_authenticated_aiohttp_session( + timeout: float = 300.0, + api_key_override: Optional[str] = None, +) -> ClientSession: + """Create aiohttp ClientSession with RunPod authentication and User-Agent. + + Automatically includes: + - User-Agent header identifying flash client and version + - Authorization header if RUNPOD_API_KEY is set or api_key_override provided + - Content-Type: application/json + - 5-minute default timeout (configurable) + - TCPConnector with ThreadedResolver for DNS resolution + + Args: + timeout: Total timeout in seconds (default: 300s for GraphQL operations) + api_key_override: Optional API key to use instead of RUNPOD_API_KEY. + Used for propagating API keys from mothership to worker endpoints. + + Returns: + Configured aiohttp.ClientSession with User-Agent, Authorization, and Content-Type headers + + Example: + session = get_authenticated_aiohttp_session() + async with session.post(url, json=data) as response: + result = await response.json() + """ + from .user_agent import get_user_agent + + headers = { + "User-Agent": get_user_agent(), + "Content-Type": "application/json", + } + + api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + timeout_config = ClientTimeout(total=timeout) + connector = TCPConnector(resolver=ThreadedResolver()) + + return ClientSession( + timeout=timeout_config, + headers=headers, + connector=connector, + ) From aae56bd11740aea9c66ec4a8f95a8e955a96436b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:57:06 -0800 Subject: [PATCH 03/12] refactor(api): use centralized aiohttp factory in RunPod clients Refactor RunpodGraphQLClient and RunpodRestClient to use the new get_authenticated_aiohttp_session() factory instead of creating aiohttp.ClientSession directly. Benefits: - Removes ~18 lines of duplicated configuration - Consistent headers across all HTTP clients - Centralized timeout and connector configuration - Easier to test and maintain No behavior changes - same headers, timeout, and connector settings. --- src/runpod_flash/core/api/runpod.py | 33 +++++++++-------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/runpod_flash/core/api/runpod.py b/src/runpod_flash/core/api/runpod.py index f62588ae..232063f7 100644 --- a/src/runpod_flash/core/api/runpod.py +++ b/src/runpod_flash/core/api/runpod.py @@ -9,7 +9,6 @@ from typing import Any, Dict, Optional, List import aiohttp -from aiohttp.resolver import ThreadedResolver from runpod_flash.core.exceptions import RunpodAPIKeyError from runpod_flash.runtime.exceptions import GraphQLMutationError, GraphQLQueryError @@ -69,18 +68,11 @@ def __init__(self, api_key: Optional[str] = None): async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self.session is None or self.session.closed: - from runpod_flash.core.utils.user_agent import get_user_agent - - timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout - connector = aiohttp.TCPConnector(resolver=ThreadedResolver()) - self.session = aiohttp.ClientSession( - timeout=timeout, - headers={ - "User-Agent": get_user_agent(), - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, - connector=connector, + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session + + self.session = get_authenticated_aiohttp_session( + timeout=300.0, # 5 minute timeout for GraphQL operations + api_key_override=self.api_key, ) return self.session @@ -815,16 +807,11 @@ def __init__(self, api_key: Optional[str] = None): async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self.session is None or self.session.closed: - from runpod_flash.core.utils.user_agent import get_user_agent - - timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout - self.session = aiohttp.ClientSession( - timeout=timeout, - headers={ - "User-Agent": get_user_agent(), - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session + + self.session = get_authenticated_aiohttp_session( + timeout=300.0, # 5 minute timeout for REST operations + api_key_override=self.api_key, ) return self.session From ad5b9cb43f538e68baeec0ea8425e6ec72b253a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:58:07 -0800 Subject: [PATCH 04/12] refactor(app): use centralized requests factory for tarball operations Refactor download_tarball() and upload_build() to use get_authenticated_requests_session() instead of direct requests.get/put. Benefits: - Automatic User-Agent header inclusion - Consistent with other HTTP operations - Proper session management with context manager - Centralized configuration Changes: - download_tarball: Use session factory for presigned URL download - upload_build: Use session factory with Content-Type override for tarball upload --- src/runpod_flash/core/resources/app.py | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/runpod_flash/core/resources/app.py b/src/runpod_flash/core/resources/app.py index a109f474..0951f27e 100644 --- a/src/runpod_flash/core/resources/app.py +++ b/src/runpod_flash/core/resources/app.py @@ -1,5 +1,4 @@ from pathlib import Path -import requests import asyncio import json from typing import Dict, Optional, Union, Tuple, TYPE_CHECKING, Any, List @@ -337,20 +336,19 @@ async def download_tarball(self, environment_id: str, dest_file: str) -> None: ValueError: If environment has no active artifact requests.HTTPError: If download fails """ - from runpod_flash.core.utils.user_agent import get_user_agent + from runpod_flash.core.utils.http import get_authenticated_requests_session await self._hydrate() result = await self._get_active_artifact(environment_id) url = result["downloadUrl"] - headers = {"User-Agent": get_user_agent()} - with open(dest_file, "wb") as stream: - with requests.get(url, stream=True, headers=headers) as resp: - resp.raise_for_status() - for chunk in resp.iter_content(): - if chunk: - stream.write(chunk) + with get_authenticated_requests_session() as session: + with session.get(url, stream=True) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(): + if chunk: + stream.write(chunk) async def _finalize_upload_build( self, object_key: str, manifest: Dict[str, Any] @@ -467,7 +465,7 @@ async def upload_build(self, tar_path: Union[str, Path]) -> Dict[str, Any]: except json.JSONDecodeError as e: raise ValueError(f"Invalid manifest JSON at {manifest_path}: {e}") from e - from runpod_flash.core.utils.user_agent import get_user_agent + from runpod_flash.core.utils.http import get_authenticated_requests_session await self._hydrate() tarball_size = tar_path.stat().st_size @@ -476,13 +474,12 @@ async def upload_build(self, tar_path: Union[str, Path]) -> Dict[str, Any]: url = result["uploadUrl"] object_key = result["objectKey"] - headers = { - "User-Agent": get_user_agent(), - "Content-Type": TARBALL_CONTENT_TYPE, - } + with get_authenticated_requests_session() as session: + # Override Content-Type for tarball upload + session.headers["Content-Type"] = TARBALL_CONTENT_TYPE - with tar_path.open("rb") as fh: - resp = requests.put(url, data=fh, headers=headers) + with tar_path.open("rb") as fh: + resp = session.put(url, data=fh) resp.raise_for_status() resp = await self._finalize_upload_build(object_key, manifest) From 65401be25c378dc4d34a3580af9b64ccdd69e0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:59:09 -0800 Subject: [PATCH 05/12] test(http): add comprehensive tests for HTTP client factories Add tests for Content-Type headers and API key override functionality in existing factories. Add complete test suite for new aiohttp factory. New tests: - Content-Type header presence (httpx, requests, aiohttp) - API key override parameter (httpx, requests, aiohttp) - aiohttp session creation with User-Agent - aiohttp API key inclusion/exclusion - aiohttp custom/default timeouts (300s) All 26 tests passing. --- tests/unit/core/utils/test_http.py | 110 +++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/unit/core/utils/test_http.py b/tests/unit/core/utils/test_http.py index 023bb812..9768cb73 100644 --- a/tests/unit/core/utils/test_http.py +++ b/tests/unit/core/utils/test_http.py @@ -2,6 +2,7 @@ import requests from runpod_flash.core.utils.http import ( + get_authenticated_aiohttp_session, get_authenticated_httpx_client, get_authenticated_requests_session, ) @@ -99,6 +100,22 @@ def test_get_authenticated_httpx_client_user_agent_with_auth(self, monkeypatch): assert client.headers["User-Agent"].startswith("Runpod Flash/") assert client.headers["Authorization"] == "Bearer test-key" + def test_includes_content_type_header(self, monkeypatch): + """Client includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + client = get_authenticated_httpx_client() + + assert client.headers["Content-Type"] == "application/json" + + def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + client = get_authenticated_httpx_client(api_key_override="override-key") + + assert client.headers["Authorization"] == "Bearer override-key" + class TestGetAuthenticatedRequestsSession: """Test the get_authenticated_requests_session utility function.""" @@ -169,3 +186,96 @@ def test_get_authenticated_requests_session_user_agent_with_auth(self, monkeypat assert session.headers["User-Agent"].startswith("Runpod Flash/") assert session.headers["Authorization"] == "Bearer test-key" session.close() + + def test_includes_content_type_header(self, monkeypatch): + """Session includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_requests_session() + + assert session.headers["Content-Type"] == "application/json" + session.close() + + def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + session = get_authenticated_requests_session(api_key_override="override-key") + + assert session.headers["Authorization"] == "Bearer override-key" + session.close() + + +class TestGetAuthenticatedAiohttpSession: + """Test aiohttp session factory.""" + + async def test_creates_session_with_user_agent(self, monkeypatch): + """Session includes User-Agent header.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert "User-Agent" in session.headers + assert session.headers["User-Agent"].startswith("Runpod Flash/") + finally: + await session.close() + + async def test_includes_api_key_when_set(self, monkeypatch): + """Session includes Authorization header when RUNPOD_API_KEY set.""" + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + + session = get_authenticated_aiohttp_session() + try: + assert session.headers["Authorization"] == "Bearer test-key" + finally: + await session.close() + + async def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + session = get_authenticated_aiohttp_session(api_key_override="override-key") + try: + assert session.headers["Authorization"] == "Bearer override-key" + finally: + await session.close() + + async def test_no_auth_header_when_no_api_key(self, monkeypatch): + """No Authorization header when API key not provided.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() + + async def test_includes_content_type_header(self, monkeypatch): + """Session includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert session.headers["Content-Type"] == "application/json" + finally: + await session.close() + + async def test_custom_timeout(self, monkeypatch): + """Custom timeout can be specified.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session(timeout=60.0) + try: + assert session.timeout.total == 60.0 + finally: + await session.close() + + async def test_default_timeout_is_300_seconds(self, monkeypatch): + """Default timeout is 300s for GraphQL operations.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert session.timeout.total == 300.0 + finally: + await session.close() From c99b486d301043e7982b7f995f1754fb89379ff8 Mon Sep 17 00:00:00 2001 From: Ezekiel Wotring <40004347+KAJdev@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:08:32 -0800 Subject: [PATCH 06/12] feat: cleanup flash deploy/undeploy/build command output format (#191) * feat: cleanup flash deploy/undeploy/build command output format * fix: cleanup --- src/runpod_flash/cli/commands/build.py | 397 +++++------------- .../cli/commands/build_utils/manifest.py | 2 +- src/runpod_flash/cli/commands/deploy.py | 154 +++---- src/runpod_flash/cli/commands/undeploy.py | 180 ++++---- src/runpod_flash/cli/utils/deployment.py | 69 ++- src/runpod_flash/core/api/runpod.py | 12 +- .../resources/load_balancer_sls_resource.py | 12 +- .../core/resources/network_volume.py | 2 +- .../core/resources/resource_manager.py | 14 +- src/runpod_flash/core/resources/serverless.py | 34 +- tests/unit/cli/test_deploy.py | 167 +++++--- tests/unit/cli/test_undeploy.py | 3 +- tests/unit/cli/utils/test_deployment.py | 36 +- 13 files changed, 456 insertions(+), 626 deletions(-) diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 7be24fe5..00b152ff 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -14,9 +14,6 @@ import typer from rich.console import Console -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.table import Table try: import tomllib # Python 3.11+ @@ -191,6 +188,7 @@ def run_build( output_name: str | None = None, exclude: str | None = None, use_local_flash: bool = False, + verbose: bool = False, ) -> Path: """Run the build process and return the artifact path. @@ -224,260 +222,129 @@ def run_build( if exclude: excluded_packages = [pkg.strip().lower() for pkg in exclude.split(",")] - # Display configuration - _display_build_config( - project_dir, app_name, no_deps, output_name, excluded_packages - ) - - # Execute build - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - # Load ignore patterns - ignore_task = progress.add_task("Loading ignore patterns...") - spec = load_ignore_patterns(project_dir) - progress.update(ignore_task, description="[green]✓ Loaded ignore patterns") - progress.stop_task(ignore_task) - - # Collect files - collect_task = progress.add_task("Collecting project files...") - files = get_file_tree(project_dir, spec) - progress.update( - collect_task, - description=f"[green]✓ Found {len(files)} files to package", - ) - progress.stop_task(collect_task) + spec = load_ignore_patterns(project_dir) + files = get_file_tree(project_dir, spec) - # Note: build directory already created before progress tracking - build_task = progress.add_task("Creating build directory...") - progress.update( - build_task, - description="[green]✓ Created .flash/.build/", - ) - progress.stop_task(build_task) + try: + copy_project_files(files, project_dir, build_dir) try: - # Copy files - copy_task = progress.add_task("Copying project files...") - copy_project_files(files, project_dir, build_dir) - progress.update( - copy_task, description=f"[green]✓ Copied {len(files)} files" - ) - progress.stop_task(copy_task) - - # Generate manifest - manifest_task = progress.add_task("Generating service manifest...") - try: - scanner = RemoteDecoratorScanner(build_dir) - remote_functions = scanner.discover_remote_functions() + scanner = RemoteDecoratorScanner(build_dir) + remote_functions = scanner.discover_remote_functions() - # Always build manifest (includes mothership even without @remote functions) - manifest_builder = ManifestBuilder( - app_name, remote_functions, scanner, build_dir=build_dir - ) - manifest = manifest_builder.build() - manifest_path = build_dir / "flash_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2)) - - # Copy manifest to .flash/ directory for deployment reference - # This avoids needing to extract from tarball during deploy - flash_dir = project_dir / ".flash" - deployment_manifest_path = flash_dir / "flash_manifest.json" - shutil.copy2(manifest_path, deployment_manifest_path) - - manifest_resources = manifest.get("resources", {}) - - if manifest_resources: - progress.update( - manifest_task, - description=f"[green]✓ Generated manifest with {len(manifest_resources)} resources", - ) - else: - progress.update( - manifest_task, - description="[yellow]⚠ No resources detected", - ) - - except (ImportError, SyntaxError) as e: - progress.stop_task(manifest_task) - console.print(f"[red]Error:[/red] Code analysis failed: {e}") - logger.exception("Code analysis failed") - raise typer.Exit(1) - except ValueError as e: - progress.stop_task(manifest_task) - console.print(f"[red]Error:[/red] {e}") - logger.exception("Handler generation validation failed") - raise typer.Exit(1) - except Exception as e: - progress.stop_task(manifest_task) - logger.exception("Handler generation failed") - console.print( - f"[yellow]Warning:[/yellow] Handler generation failed: {e}" - ) + manifest_builder = ManifestBuilder( + app_name, remote_functions, scanner, build_dir=build_dir + ) + manifest = manifest_builder.build() + manifest_path = build_dir / "flash_manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2)) - progress.stop_task(manifest_task) + flash_dir = project_dir / ".flash" + deployment_manifest_path = flash_dir / "flash_manifest.json" + shutil.copy2(manifest_path, deployment_manifest_path) - except typer.Exit: - # Clean up on fatal errors (ImportError, SyntaxError, ValueError) - if build_dir.exists(): - shutil.rmtree(build_dir) - raise - except Exception as e: - # Clean up on unexpected errors - if build_dir.exists(): - shutil.rmtree(build_dir) - console.print(f"[red]Error:[/red] Build failed: {e}") - logger.exception("Build failed") + except (ImportError, SyntaxError) as e: + console.print(f"[red]Error:[/red] Code analysis failed: {e}") + logger.exception("Code analysis failed") raise typer.Exit(1) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + logger.exception("Handler generation validation failed") + raise typer.Exit(1) + except Exception as e: + logger.exception("Handler generation failed") + console.print(f"[yellow]Warning:[/yellow] Handler generation failed: {e}") - # Extract runpod_flash dependencies if bundling local version - flash_deps = [] - if use_local_flash: - flash_pkg = _find_local_runpod_flash() - if flash_pkg: - flash_deps = _extract_runpod_flash_dependencies(flash_pkg) - - # Install dependencies - deps_task = progress.add_task("Installing dependencies...") - requirements = collect_requirements(project_dir, build_dir) - - # Add runpod_flash dependencies if bundling local version - # This ensures all runpod_flash runtime dependencies are available in the build - requirements.extend(flash_deps) - - # Filter out excluded packages - if excluded_packages: - original_count = len(requirements) - matched_exclusions = set() - filtered_requirements = [] - - for req in requirements: - if should_exclude_package(req, excluded_packages): - # Extract which exclusion matched - pkg_name = extract_package_name(req) - if pkg_name in excluded_packages: - matched_exclusions.add(pkg_name) - else: - filtered_requirements.append(req) - - requirements = filtered_requirements - excluded_count = original_count - len(requirements) - - if excluded_count > 0: - console.print( - f"[yellow]Excluded {excluded_count} package(s) " - f"(assumed in base image)[/yellow]" - ) + except typer.Exit: + if build_dir.exists(): + shutil.rmtree(build_dir) + raise + except Exception as e: + if build_dir.exists(): + shutil.rmtree(build_dir) + console.print(f"[red]Error:[/red] Build failed: {e}") + logger.exception("Build failed") + raise typer.Exit(1) - # Warn about exclusions that didn't match any packages - unmatched = set(excluded_packages) - matched_exclusions - if unmatched: - console.print( - f"[yellow]Warning: No packages matched exclusions: " - f"{', '.join(sorted(unmatched))}[/yellow]" - ) + flash_deps = [] + if use_local_flash: + flash_pkg = _find_local_runpod_flash() + if flash_pkg: + flash_deps = _extract_runpod_flash_dependencies(flash_pkg) - if not requirements: - progress.update( - deps_task, - description="[yellow]⚠ No dependencies found", - ) - else: - progress.update( - deps_task, - description=f"Installing {len(requirements)} packages...", - ) + # install dependencies + requirements = collect_requirements(project_dir, build_dir) + requirements.extend(flash_deps) - success = install_dependencies(build_dir, requirements, no_deps) + # filter out excluded packages + if excluded_packages: + matched_exclusions = set() + filtered_requirements = [] + + for req in requirements: + if should_exclude_package(req, excluded_packages): + pkg_name = extract_package_name(req) + if pkg_name in excluded_packages: + matched_exclusions.add(pkg_name) + else: + filtered_requirements.append(req) - if not success: - progress.stop_task(deps_task) - console.print("[red]Error:[/red] Failed to install dependencies") - raise typer.Exit(1) + requirements = filtered_requirements - progress.update( - deps_task, - description=f"[green]✓ Installed {len(requirements)} packages", + unmatched = set(excluded_packages) - matched_exclusions + if unmatched: + console.print( + f"[yellow]Warning:[/yellow] No packages matched exclusions: " + f"{', '.join(sorted(unmatched))}" ) - progress.stop_task(deps_task) - - # Bundle local runpod_flash if requested - if use_local_flash: - flash_task = progress.add_task("Bundling local runpod_flash...") - if _bundle_local_runpod_flash(build_dir): - _remove_runpod_flash_from_requirements(build_dir) - progress.update( - flash_task, - description="[green]✓ Bundled local runpod_flash", - ) - else: - progress.update( - flash_task, - description="[yellow]⚠ Using PyPI runpod_flash", - ) - progress.stop_task(flash_task) + if requirements: + with console.status(f"Installing {len(requirements)} packages..."): + success = install_dependencies(build_dir, requirements, no_deps) - # Generate resource configuration files - # IMPORTANT: Must happen AFTER bundle_local_runpod_flash to avoid being overwritten - # These files tell each resource which functions are local vs remote - from .build_utils.resource_config_generator import ( - generate_all_resource_configs, - ) + if not success: + console.print("[red]Error:[/red] Failed to install dependencies") + raise typer.Exit(1) - generate_all_resource_configs(manifest, build_dir) + # bundle local runpod_flash if requested + if use_local_flash: + if _bundle_local_runpod_flash(build_dir): + _remove_runpod_flash_from_requirements(build_dir) - # Clean up Python bytecode before archiving - cleanup_python_bytecode(build_dir) + # clean up and create archive + cleanup_python_bytecode(build_dir) - # Create archive - archive_task = progress.add_task("Creating archive...") - archive_name = output_name or "artifact.tar.gz" - archive_path = project_dir / ".flash" / archive_name + archive_name = output_name or "artifact.tar.gz" + archive_path = project_dir / ".flash" / archive_name + with console.status("Creating archive..."): create_tarball(build_dir, archive_path, app_name) - # Get archive size - size_mb = archive_path.stat().st_size / (1024 * 1024) + size_mb = archive_path.stat().st_size / (1024 * 1024) - progress.update( - archive_task, - description=f"[green]✓ Created {archive_name} ({size_mb:.1f} MB)", + # fail build if archive exceeds size limit + if size_mb > MAX_TARBALL_SIZE_MB: + console.print() + console.print( + f"[red]Error:[/red] Archive exceeds RunPod limit " + f"({size_mb:.1f} MB / {MAX_TARBALL_SIZE_MB} MB)" + ) + console.print( + " Use --exclude to skip packages in base image: " + "[dim]flash deploy --exclude torch,torchvision,torchaudio[/dim]" ) - progress.stop_task(archive_task) - - # Fail build if archive exceeds size limit - if size_mb > MAX_TARBALL_SIZE_MB: - console.print() - console.print( - Panel( - f"[red bold]✗ BUILD FAILED: Archive exceeds RunPod limit[/red bold]\n\n" - f"[red]Archive size:[/red] {size_mb:.1f} MB\n" - f"[red]RunPod limit:[/red] {MAX_TARBALL_SIZE_MB} MB\n" - f"[red]Over by:[/red] {size_mb - MAX_TARBALL_SIZE_MB:.1f} MB\n\n" - f"[bold]Solutions:[/bold]\n" - f" 1. Use --exclude to skip packages in base image:\n" - f" [dim]flash deploy --exclude torch,torchvision,torchaudio[/dim]\n\n" - f" 2. Reduce dependencies in requirements.txt", - title="Build Artifact Too Large", - border_style="red", - ) - ) - console.print() - # Cleanup: Remove invalid artifacts - console.print("[dim]Cleaning up invalid artifacts...[/dim]") - if archive_path.exists(): - archive_path.unlink() - if build_dir.exists(): - shutil.rmtree(build_dir) + if archive_path.exists(): + archive_path.unlink() + if build_dir.exists(): + shutil.rmtree(build_dir) - raise typer.Exit(1) + raise typer.Exit(1) # Success summary - _display_build_summary(archive_path, app_name, len(files), len(requirements)) + _display_build_summary( + archive_path, app_name, len(files), len(requirements), size_mb, verbose=verbose + ) return archive_path @@ -522,6 +389,7 @@ def build_command( output_name=output_name, exclude=exclude, use_local_flash=use_local_flash, + verbose=True, ) except KeyboardInterrupt: @@ -948,7 +816,7 @@ def install_dependencies( platform_str = "x86_64-unknown-linux-gnu" else: platform_str = f"{len(RUNPOD_PLATFORMS)} manylinux variants" - console.print(f"[dim]Installing for: {platform_str}, Python {python_version}[/dim]") + logger.debug(f"Installing for: {platform_str}, Python {python_version}") try: result = subprocess.run( @@ -1003,64 +871,21 @@ def cleanup_build_directory(build_base: Path) -> None: shutil.rmtree(build_base) -def _display_build_config( - project_dir: Path, - app_name: str, - no_deps: bool, - output_name: str | None, - excluded_packages: list[str], -): - """Display build configuration.""" - archive_name = output_name or "artifact.tar.gz" - - config_text = ( - f"[bold]Project:[/bold] {app_name}\n" - f"[bold]Directory:[/bold] {project_dir}\n" - f"[bold]Archive:[/bold] .flash/{archive_name}\n" - f"[bold]Skip transitive deps:[/bold] {no_deps}" - ) - - if excluded_packages: - config_text += ( - f"\n[bold]Excluded packages:[/bold] {', '.join(excluded_packages)}" - ) - - console.print( - Panel( - config_text, - title="Flash Build Configuration", - expand=False, - ) - ) - - def _display_build_summary( - archive_path: Path, app_name: str, file_count: int, dep_count: int + archive_path: Path, + app_name: str, + file_count: int, + dep_count: int, + size_mb: float, + verbose: bool = False, ): """Display build summary.""" - size_mb = archive_path.stat().st_size / (1024 * 1024) - - summary = Table(show_header=False, box=None) - summary.add_column("Item", style="bold") - summary.add_column("Value", style="cyan") - - summary.add_row("Application", app_name) - summary.add_row("Files packaged", str(file_count)) - summary.add_row("Dependencies", str(dep_count)) - summary.add_row("Archive", str(archive_path.relative_to(Path.cwd()))) - summary.add_row("Size", f"{size_mb:.1f} MB") - - console.print("\n") - console.print(summary) - - archive_rel = archive_path.relative_to(Path.cwd()) - console.print( - Panel( - f"[bold]{app_name}[/bold] built successfully!\n\n" - f"[bold]Archive:[/bold] {archive_rel}", - title="Build Complete", - expand=False, - border_style="green", - ) + f"[green]Built[/green] [bold]{app_name}[/bold] " + f"[dim]{file_count} files, {dep_count} deps, {size_mb:.1f} MB[/dim]" ) + if verbose: + console.print(f" [dim]Archive:[/dim] {archive_path}") + build_dir = archive_path.parent / ".build" + if build_dir.exists(): + console.print(f" [dim]Build:[/dim] {build_dir}") diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index c4dbc407..b67ce9bd 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -403,7 +403,7 @@ def build(self) -> Dict[str, Any]: if explicit_mothership: # Use explicit configuration - logger.info("Found explicit mothership configuration in mothership.py") + logger.debug("Found explicit mothership configuration in mothership.py") # Check for name conflict mothership_name = explicit_mothership.get("name", "mothership") diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index d128d768..1a34a3aa 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -4,14 +4,13 @@ import json import logging import shutil -import textwrap import typer from pathlib import Path from rich.console import Console from ..utils.app import discover_flash_project -from ..utils.deployment import deploy_to_environment +from ..utils.deployment import deploy_from_uploaded_build, validate_local_manifest from .build import run_build from runpod_flash.core.resources.app import FlashApp @@ -95,11 +94,11 @@ def deploy_command( raise typer.Exit(1) -def _display_post_deployment_guidance(env_name: str) -> None: +def _display_post_deployment_guidance( + env_name: str, mothership_url: str | None = None +) -> None: """Display helpful next steps after successful deployment.""" - # Try to read manifest for endpoint information manifest_path = Path.cwd() / ".flash" / "flash_manifest.json" - mothership_url = None mothership_routes = {} try: @@ -109,90 +108,43 @@ def _display_post_deployment_guidance(env_name: str) -> None: resources = manifest.get("resources", {}) routes = manifest.get("routes", {}) - # Find mothership URL and routes - for resource_name, url in resources_endpoints.items(): + for resource_name in resources_endpoints: if resources.get(resource_name, {}).get("is_mothership", False): - mothership_url = url mothership_routes = routes.get(resource_name, {}) break except (FileNotFoundError, json.JSONDecodeError) as e: logger.debug(f"Could not read manifest: {e}") - console.print("\n[bold]Next Steps:[/bold]\n") - - # 1. Authentication - console.print("[bold cyan]1. Authentication Required[/bold cyan]") - console.print( - " All endpoints require authentication. Set your API key as an environment " - "variable. Avoid typing secrets directly into shell commands, as they may be " - "stored in your shell history." - ) - console.print( - " [dim]# Recommended: store RUNPOD_API_KEY in a .env file or your shell profile[/dim]" - ) - console.print( - " [dim]# Or securely prompt for it without echo (Bash example):[/dim]" - ) - console.print(" [dim]read -s RUNPOD_API_KEY && export RUNPOD_API_KEY[/dim]\n") - - # 2. Calling functions - console.print("[bold cyan]2. Call Your Functions[/bold cyan]") - - if mothership_url: - console.print( - f" Your mothership is deployed at:\n [link]{mothership_url}[/link]\n" - ) - - console.print(" [bold]Using HTTP/curl:[/bold]") - if mothership_url: - curl_example = textwrap.dedent(f""" - curl -X POST {mothership_url}/YOUR_PATH \\ - -H "Authorization: Bearer $RUNPOD_API_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{{"param1": "value1"}}' - """).strip() - else: - curl_example = textwrap.dedent(""" - curl -X POST https://YOUR_ENDPOINT_URL/YOUR_PATH \\ - -H "Authorization: Bearer $RUNPOD_API_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{"param1": "value1"}' - """).strip() - console.print(f" [dim]{curl_example}[/dim]\n") - - # 3. Available routes - console.print("[bold cyan]3. Available Routes[/bold cyan]") if mothership_routes: + console.print("\n[bold]Routes:[/bold]") for route_key in sorted(mothership_routes.keys()): - # route_key format: "POST /api/hello" method, path = route_key.split(" ", 1) - console.print(f" [cyan]{method:6s}[/cyan] {path}") - console.print() - else: - # Routes not found - could mean manifest missing, no LB endpoints, or no routes defined - if mothership_url: - console.print( - " [dim]No routes found in manifest. Check @remote decorators in your code.[/dim]\n" - ) - else: - console.print( - " Check your code for @remote decorators to find available endpoints:" - ) - console.print( - ' [dim]@remote(mothership, method="POST", path="/api/process")[/dim]\n' + console.print(f" {method:6s} {path}") + + # curl example using the first POST route + if mothership_url and mothership_routes: + post_routes = [ + k.split(" ", 1)[1] + for k in sorted(mothership_routes.keys()) + if k.startswith("POST ") + ] + if post_routes: + example_route = post_routes[0] + curl_cmd = ( + f"curl -X POST {mothership_url}{example_route} \\\n" + f' -H "Content-Type: application/json" \\\n' + ' -H "Authorization: Bearer $RUNPOD_API_KEY" \\\n' + " -d '{\"input\": {}}'" ) + console.print("\n[bold]Try it:[/bold]") + console.print(f" [dim]{curl_cmd}[/dim]") - # 4. Monitor & Debug - console.print("[bold cyan]4. Monitor & Debug[/bold cyan]") - console.print(f" [dim]flash env get {env_name}[/dim] - View environment status") + console.print("\n[bold]Useful commands:[/bold]") console.print( - " [dim]Runpod Console[/dim] - View logs and metrics at https://console.runpod.io/serverless\n" + f" [dim]flash env get {env_name}[/dim] View environment status" ) - - # 5. Update & Teardown - console.print("[bold cyan]5. Update or Remove Deployment[/bold cyan]") - console.print(f" [dim]flash deploy --env {env_name}[/dim] - Update deployment") - console.print(f" [dim]flash env delete {env_name}[/dim] - Remove deployment\n") + console.print(f" [dim]flash deploy --env {env_name}[/dim] Update deployment") + console.print(f" [dim]flash env delete {env_name}[/dim] Remove deployment") def _launch_preview(project_dir): @@ -216,17 +168,43 @@ def _launch_preview(project_dir): async def _resolve_and_deploy( app_name: str, env_name: str | None, archive_path ) -> None: - resolved_env_name = await _resolve_environment(app_name, env_name) + app, resolved_env_name = await _resolve_environment(app_name, env_name) - console.print(f"\nDeploying to '[bold]{resolved_env_name}[/bold]'...") + local_manifest = validate_local_manifest() - await deploy_to_environment(app_name, resolved_env_name, archive_path) + with console.status("Uploading build..."): + build = await app.upload_build(archive_path) - # Display next steps guidance - _display_post_deployment_guidance(resolved_env_name) + with console.status("Deploying resources..."): + result = await deploy_from_uploaded_build( + app, build["id"], resolved_env_name, local_manifest + ) + console.print(f"[green]Deployed[/green] to [bold]{resolved_env_name}[/bold]") + resources_endpoints = result.get("resources_endpoints", {}) + local_manifest = result.get("local_manifest", {}) + resources = local_manifest.get("resources", {}) -async def _resolve_environment(app_name: str, env_name: str | None) -> str: + # mothership first, then workers + mothership_url = None + if resources_endpoints: + console.print() + other_items = [] + for resource_name, url in resources_endpoints.items(): + if resources.get(resource_name, {}).get("is_mothership", False): + mothership_url = url + console.print(f" [bold]{url}[/bold] [dim]({resource_name})[/dim]") + else: + other_items.append((resource_name, url)) + for resource_name, url in other_items: + console.print(f" [dim]{url} ({resource_name})[/dim]") + + _display_post_deployment_guidance(resolved_env_name, mothership_url=mothership_url) + + +async def _resolve_environment( + app_name: str, env_name: str | None +) -> tuple[FlashApp, str]: try: app = await FlashApp.from_name(app_name) except Exception as exc: @@ -236,8 +214,8 @@ async def _resolve_environment(app_name: str, env_name: str | None) -> str: console.print( f"[dim]No app '{app_name}' found. Creating app and '{target}' environment...[/dim]" ) - await FlashApp.create_environment_and_app(app_name, target) - return target + app, _ = await FlashApp.create_environment_and_app(app_name, target) + return app, target if env_name: envs = await app.list_environments() @@ -247,21 +225,19 @@ async def _resolve_environment(app_name: str, env_name: str | None) -> str: f"[dim]Environment '{env_name}' not found. Creating it...[/dim]" ) await app.create_environment(env_name) - return env_name + return app, env_name envs = await app.list_environments() if len(envs) == 1: - resolved = envs[0].get("name") - console.print(f"[dim]Auto-selected environment: {resolved}[/dim]") - return resolved + return app, envs[0].get("name") if len(envs) == 0: console.print( "[dim]No environments found. Creating 'production' environment...[/dim]" ) await app.create_environment("production") - return "production" + return app, "production" env_names = [e.get("name", "?") for e in envs] console.print( diff --git a/src/runpod_flash/cli/commands/undeploy.py b/src/runpod_flash/cli/commands/undeploy.py index 73974f94..42f43000 100644 --- a/src/runpod_flash/cli/commands/undeploy.py +++ b/src/runpod_flash/cli/commands/undeploy.py @@ -307,13 +307,6 @@ def undeploy_command( elif name: _undeploy_by_name(name, resources, skip_confirm=force) else: - console.print( - Panel( - "Usage: flash undeploy [name | list | --all | --interactive | --cleanup-stale]", - title="Undeploy Help", - expand=False, - ) - ) console.print( "[red]Error:[/red] Please specify a name, use --all/--interactive, or run `flash undeploy list`" ) @@ -338,31 +331,18 @@ def _undeploy_by_name(name: str, resources: dict, skip_confirm: bool = False): if not matches: console.print(f"[red]Error:[/red] No endpoint found with name '{name}'") console.print( - "\n💡 Use [bold]flash undeploy list[/bold] to see available endpoints" + "\nUse [bold]flash undeploy list[/bold] to see available endpoints" ) raise typer.Exit(1) # Show what will be deleted - console.print( - Panel( - "[yellow]⚠️ The following endpoint(s) will be deleted:[/yellow]\n", - title="Undeploy Confirmation", - expand=False, - ) - ) - + console.print() for resource_id, resource in matches: endpoint_id = getattr(resource, "id", "N/A") - resource_type = _get_resource_type(resource) - status_icon, status_text = _get_resource_status(resource) - - console.print(f" • [bold]{resource.name}[/bold]") - console.print(f" Endpoint ID: {endpoint_id}") - console.print(f" Type: {resource_type}") - console.print(f" Status: {status_icon} {status_text}") - console.print() - - console.print("[red]🚨 This action cannot be undone![/red]\n") + console.print(f" [bold]{resource.name}[/bold] [dim]({endpoint_id})[/dim]") + console.print() + console.print(" [yellow]This action cannot be undone.[/yellow]") + console.print() if not skip_confirm: try: @@ -371,34 +351,41 @@ def _undeploy_by_name(name: str, resources: dict, skip_confirm: bool = False): ).ask() if not confirmed: - console.print("Undeploy cancelled") + console.print("Cancelled.") raise typer.Exit(0) except KeyboardInterrupt: - console.print("\nUndeploy cancelled") + console.print("\nCancelled.") raise typer.Exit(0) - # Delete endpoints + console.print() manager = _get_resource_manager() - with console.status("Deleting endpoint(s)..."): - results = [] - for resource_id, resource in matches: + results = [] + for resource_id, resource in matches: + with console.status(f" Deleting [bold]{resource.name}[/bold]..."): result = asyncio.run(manager.undeploy_resource(resource_id, resource.name)) - results.append(result) + if result["success"]: + console.print(f" [green]✓[/green] Deleted [bold]{resource.name}[/bold]") + else: + console.print( + f" [red]✗[/red] Failed to delete [bold]{resource.name}[/bold]" + ) + results.append(result) - # Show results success_count = sum(1 for r in results if r["success"]) fail_count = len(results) - success_count - - if success_count > 0: + console.print() + if fail_count == 0: console.print( - f"\n[green]✓[/green] Successfully deleted {success_count} endpoint(s)" + f"[green]✓[/green] Successfully deleted {success_count} " + f"endpoint{'s' if success_count != 1 else ''}" + ) + else: + console.print( + f"[red]✗[/red] {fail_count} of {len(results)} endpoint(s) failed to delete" ) - if fail_count > 0: - console.print(f"[red]✗[/red] Failed to delete {fail_count} endpoint(s)") - console.print("\nErrors:") for result in results: if not result["success"]: - console.print(f" • {result['message']}") + console.print(f" {result['message']}") def _undeploy_all(resources: dict, skip_confirm: bool = False): @@ -409,20 +396,17 @@ def _undeploy_all(resources: dict, skip_confirm: bool = False): skip_confirm: Skip confirmation prompts """ # Show what will be deleted - console.print( - Panel( - f"[yellow]⚠️ ALL {len(resources)} endpoint(s) will be deleted![/yellow]\n", - title="Undeploy All Confirmation", - expand=False, - ) - ) - + console.print() for resource_id, resource in resources.items(): name = getattr(resource, "name", "N/A") endpoint_id = getattr(resource, "id", "N/A") - console.print(f" • {name} ({endpoint_id})") - - console.print("\n[red]🚨 This action cannot be undone![/red]\n") + console.print(f" [bold]{name}[/bold] [dim]({endpoint_id})[/dim]") + console.print() + console.print( + f" [yellow]All {len(resources)} endpoint(s) will be deleted. " + f"This action cannot be undone.[/yellow]" + ) + console.print() if not skip_confirm: try: @@ -431,7 +415,7 @@ def _undeploy_all(resources: dict, skip_confirm: bool = False): ).ask() if not confirmed: - console.print("Undeploy cancelled") + console.print("Cancelled.") raise typer.Exit(0) # Double confirmation for --all @@ -441,33 +425,37 @@ def _undeploy_all(resources: dict, skip_confirm: bool = False): console.print("Confirmation failed - text does not match") raise typer.Exit(1) except KeyboardInterrupt: - console.print("\nUndeploy cancelled") + console.print("\nCancelled.") raise typer.Exit(0) - # Delete all endpoints + console.print() manager = _get_resource_manager() - with console.status(f"Deleting {len(resources)} endpoint(s)..."): - results = [] - for resource_id, resource in resources.items(): - name = getattr(resource, "name", "N/A") + results = [] + for resource_id, resource in resources.items(): + name = getattr(resource, "name", "N/A") + with console.status(f" Deleting [bold]{name}[/bold]..."): result = asyncio.run(manager.undeploy_resource(resource_id, name)) - results.append(result) + if result["success"]: + console.print(f" [green]✓[/green] Deleted [bold]{name}[/bold]") + else: + console.print(f" [red]✗[/red] Failed to delete [bold]{name}[/bold]") + results.append(result) - # Show results success_count = sum(1 for r in results if r["success"]) fail_count = len(results) - success_count - - console.print("\n" + "=" * 50) - if success_count > 0: + console.print() + if fail_count == 0: + console.print( + f"[green]✓[/green] Successfully deleted {success_count} " + f"endpoint{'s' if success_count != 1 else ''}" + ) + else: console.print( - f"[green]✓[/green] Successfully deleted {success_count} endpoint(s)" + f"[red]✗[/red] {fail_count} of {len(results)} endpoint(s) failed to delete" ) - if fail_count > 0: - console.print(f"[red]✗[/red] Failed to delete {fail_count} endpoint(s)") - console.print("\nErrors:") for result in results: if not result["success"]: - console.print(f" • {result['message']}") + console.print(f" {result['message']}") def _interactive_undeploy(resources: dict, skip_confirm: bool = False): @@ -501,23 +489,17 @@ def _interactive_undeploy(resources: dict, skip_confirm: bool = False): raise typer.Exit(0) # Show confirmation - console.print( - Panel( - f"[yellow]⚠️ {len(selected)} endpoint(s) will be deleted:[/yellow]\n", - title="Undeploy Confirmation", - expand=False, - ) - ) - selected_resources = [] + console.print() for choice in selected: resource_id, resource = resource_map[choice] selected_resources.append((resource_id, resource)) name = getattr(resource, "name", "N/A") endpoint_id = getattr(resource, "id", "N/A") - console.print(f" • {name} ({endpoint_id})") - - console.print("\n[red]🚨 This action cannot be undone![/red]\n") + console.print(f" [bold]{name}[/bold] [dim]({endpoint_id})[/dim]") + console.print() + console.print(" [yellow]This action cannot be undone.[/yellow]") + console.print() if not skip_confirm: confirmed = questionary.confirm( @@ -525,33 +507,37 @@ def _interactive_undeploy(resources: dict, skip_confirm: bool = False): ).ask() if not confirmed: - console.print("Undeploy cancelled") + console.print("Cancelled.") raise typer.Exit(0) except KeyboardInterrupt: - console.print("\nUndeploy cancelled") + console.print("\nCancelled.") raise typer.Exit(0) - # Delete selected endpoints + console.print() manager = _get_resource_manager() - with console.status(f"Deleting {len(selected_resources)} endpoint(s)..."): - results = [] - for resource_id, resource in selected_resources: - name = getattr(resource, "name", "N/A") + results = [] + for resource_id, resource in selected_resources: + name = getattr(resource, "name", "N/A") + with console.status(f" Deleting [bold]{name}[/bold]..."): result = asyncio.run(manager.undeploy_resource(resource_id, name)) - results.append(result) + if result["success"]: + console.print(f" [green]✓[/green] Deleted [bold]{name}[/bold]") + else: + console.print(f" [red]✗[/red] Failed to delete [bold]{name}[/bold]") + results.append(result) - # Show results success_count = sum(1 for r in results if r["success"]) fail_count = len(results) - success_count - - console.print("\n" + "=" * 50) - if success_count > 0: + console.print() + if fail_count == 0: + console.print( + f"[green]✓[/green] Successfully deleted {success_count} " + f"endpoint{'s' if success_count != 1 else ''}" + ) + else: console.print( - f"[green]✓[/green] Successfully deleted {success_count} endpoint(s)" + f"[red]✗[/red] {fail_count} of {len(results)} endpoint(s) failed to delete" ) - if fail_count > 0: - console.print(f"[red]✗[/red] Failed to delete {fail_count} endpoint(s)") - console.print("\nErrors:") for result in results: if not result["success"]: - console.print(f" • {result['message']}") + console.print(f" {result['message']}") diff --git a/src/runpod_flash/cli/utils/deployment.py b/src/runpod_flash/cli/utils/deployment.py index 89c61f5f..daa7605b 100644 --- a/src/runpod_flash/cli/utils/deployment.py +++ b/src/runpod_flash/cli/utils/deployment.py @@ -216,7 +216,7 @@ async def reconcile_and_provision_resources( to_delete = state_resources - local_resources # Removed resources if show_progress: - print( + log.debug( f"Reconciliation: {len(to_provision)} new, " f"{len(to_update)} existing, {len(to_delete)} to remove" ) @@ -274,7 +274,7 @@ async def reconcile_and_provision_resources( # Delete removed resources for resource_name in sorted(to_delete): - log.info(f"Resource {resource_name} marked for deletion (not implemented yet)") + log.debug(f"Resource {resource_name} marked for deletion (not implemented yet)") # Execute all actions in parallel with timeout if actions: @@ -308,11 +308,10 @@ async def reconcile_and_provision_resources( if endpoint_url: local_manifest["resources_endpoints"][resource_name] = endpoint_url - if show_progress: - action_label = ( - "✓ Provisioned" if action_type == "provision" else "✓ Updated" - ) - print(f"{action_label}: {resource_name} → {endpoint_url}") + log.debug( + f"{'Provisioned' if action_type == 'provision' else 'Updated'}: " + f"{resource_name} -> {endpoint_url}" + ) # Validate mothership was provisioned mothership_resources = [ @@ -338,31 +337,11 @@ async def reconcile_and_provision_resources( manifest_path = Path.cwd() / ".flash" / "flash_manifest.json" manifest_path.write_text(json.dumps(local_manifest, indent=2)) - if show_progress: - print(f"✓ Local manifest updated at {manifest_path.relative_to(Path.cwd())}") + log.debug(f"Local manifest updated at {manifest_path.relative_to(Path.cwd())}") # Overwrite State Manager manifest with local manifest await app.update_build_manifest(build_id, local_manifest) - if show_progress: - print("✓ State Manager manifest updated") - print() - - # Display mothership in simplified format - resources_endpoints = local_manifest.get("resources_endpoints", {}) - resources = local_manifest.get("resources", {}) - - for resource_name in sorted(resources_endpoints.keys()): - resource_config = resources.get(resource_name, {}) - is_mothership = resource_config.get("is_mothership", False) - - if is_mothership: - print(f"🚀 Deployed: {app.name}") - print(f" Environment: {environment_name}") - print(f" URL: {resources_endpoints[resource_name]}") - print() - break - return local_manifest.get("resources_endpoints", {}) @@ -398,26 +377,24 @@ def validate_local_manifest() -> Dict[str, Any]: return manifest -async def deploy_to_environment( - app_name: str, env_name: str, build_path: Path +async def deploy_from_uploaded_build( + app: FlashApp, + build_id: str, + env_name: str, + local_manifest: Dict[str, Any], ) -> Dict[str, Any]: - """Deploy current project to environment. + """Deploy an already-uploaded build to an environment. - Raises: - runpod_flash.core.resources.app.FlashEnvironmentNotFoundError: If the environment does not exist - FileNotFoundError: If manifest not found - ValueError: If manifest is invalid - """ - # Validate manifest exists before proceeding - local_manifest = validate_local_manifest() + Args: + app: FlashApp instance (already resolved) + build_id: ID of the uploaded build + env_name: Target environment name + local_manifest: Validated local manifest dict - app = await FlashApp.from_name(app_name) - # Verify environment exists (will raise FlashEnvironmentNotFoundError if not) + Returns: + Deployment result with resources_endpoints and local_manifest keys + """ environment = await app.get_environment_by_name(env_name) - - build = await app.upload_build(build_path) - build_id = build["id"] - result = await app.deploy_build_to_environment(build_id, environment_name=env_name) try: @@ -427,13 +404,15 @@ async def deploy_to_environment( env_name, local_manifest, environment_id=environment.get("id"), - show_progress=True, + show_progress=False, ) log.debug(f"Provisioned {len(resources_endpoints)} resources for {env_name}") except Exception as e: log.error(f"Resource provisioning failed: {e}") raise + result["resources_endpoints"] = resources_endpoints + result["local_manifest"] = local_manifest return result diff --git a/src/runpod_flash/core/api/runpod.py b/src/runpod_flash/core/api/runpod.py index f62588ae..b451b31f 100644 --- a/src/runpod_flash/core/api/runpod.py +++ b/src/runpod_flash/core/api/runpod.py @@ -210,7 +210,7 @@ async def save_endpoint(self, input_data: Dict[str, Any]) -> Dict[str, Any]: raise Exception("Unexpected GraphQL response structure") endpoint_data = result["saveEndpoint"] - log.info( + log.debug( f"Saved endpoint: {endpoint_data.get('id', 'unknown')} - {endpoint_data.get('name', 'unnamed')}" ) @@ -281,7 +281,7 @@ async def delete_endpoint(self, endpoint_id: str) -> Dict[str, Any]: """ variables = {"id": endpoint_id} - log.info(f"Deleting endpoint: {endpoint_id}") + log.debug(f"Deleting endpoint: {endpoint_id}") result = await self._execute_graphql(mutation, variables) @@ -744,7 +744,7 @@ async def delete_flash_app(self, app_id: str) -> Dict[str, Any]: """ variables = {"flashAppId": app_id} - log.info(f"Deleting flash app: {app_id}") + log.debug(f"Deleting flash app: {app_id}") result = await self._execute_graphql(mutation, variables) return {"success": "deleteFlashApp" in result} @@ -758,7 +758,7 @@ async def delete_flash_environment(self, environment_id: str) -> Dict[str, Any]: """ variables = {"flashEnvironmentId": environment_id} - log.info(f"Deleting flash environment: {environment_id}") + log.debug(f"Deleting flash environment: {environment_id}") result = await self._execute_graphql(mutation, variables) return {"success": "deleteFlashEnvironment" in result} @@ -784,7 +784,7 @@ async def endpoint_exists(self, endpoint_id: str) -> bool: log.debug(f"Endpoint {endpoint_id} exists: {exists}") return exists except Exception as e: - log.error(f"Error checking endpoint existence: {e}") + log.debug(f"Error checking endpoint existence: {e}") return False async def close(self): @@ -863,7 +863,7 @@ async def create_network_volume(self, payload: Dict[str, Any]) -> Dict[str, Any] "POST", f"{RUNPOD_REST_API_URL}/networkvolumes", payload ) - log.info( + log.debug( f"Created network volume: {result.get('id', 'unknown')} - {result.get('name', 'unnamed')}" ) diff --git a/src/runpod_flash/core/resources/load_balancer_sls_resource.py b/src/runpod_flash/core/resources/load_balancer_sls_resource.py index 56804e75..eb664ed0 100644 --- a/src/runpod_flash/core/resources/load_balancer_sls_resource.py +++ b/src/runpod_flash/core/resources/load_balancer_sls_resource.py @@ -198,7 +198,7 @@ async def _wait_for_health( if not self.id: raise ValueError("Cannot wait for health: endpoint not deployed") - log.info( + log.debug( f"Waiting for LB endpoint {self.name} ({self.id}) to become healthy... " f"(max {max_retries} retries, {retry_interval}s interval)" ) @@ -206,7 +206,7 @@ async def _wait_for_health( for attempt in range(max_retries): try: if await self._check_ping_endpoint(): - log.info( + log.debug( f"LB endpoint {self.name} is healthy (attempt {attempt + 1})" ) return True @@ -223,7 +223,7 @@ async def _wait_for_health( if attempt < max_retries - 1: await asyncio.sleep(retry_interval) - log.error( + log.debug( f"LB endpoint {self.name} failed to become healthy after " f"{max_retries} attempts" ) @@ -259,14 +259,14 @@ async def _do_deploy(self) -> "LoadBalancerSlsResource": self.env["FLASH_IS_MOTHERSHIP"] = "true" # Call parent deploy (creates endpoint via RunPod API) - log.info(f"Deploying LB endpoint {self.name}...") + log.debug(f"Deploying LB endpoint {self.name}...") deployed = await super()._do_deploy() - log.info(f"LB endpoint {self.name} ({deployed.id}) deployed successfully") + log.debug(f"LB endpoint {self.name} ({deployed.id}) deployed successfully") return deployed except Exception as e: - log.error(f"Failed to deploy LB endpoint {self.name}: {e}") + log.debug(f"Failed to deploy LB endpoint {self.name}: {e}") raise def is_deployed(self) -> bool: diff --git a/src/runpod_flash/core/resources/network_volume.py b/src/runpod_flash/core/resources/network_volume.py index 48851cb8..7c7af7ff 100644 --- a/src/runpod_flash/core/resources/network_volume.py +++ b/src/runpod_flash/core/resources/network_volume.py @@ -111,7 +111,7 @@ async def _find_existing_volume(self, client) -> Optional["NetworkVolume"]: existing_volumes = self._normalize_volumes_response(volumes_response) if matching_volume := self._find_matching_volume(existing_volumes): - log.info( + log.debug( f"Found existing network volume: {matching_volume.get('id')} with name '{self.name}'" ) # Update our instance with the existing volume's ID diff --git a/src/runpod_flash/core/resources/resource_manager.py b/src/runpod_flash/core/resources/resource_manager.py index 447d48b1..a43f2d5e 100644 --- a/src/runpod_flash/core/resources/resource_manager.py +++ b/src/runpod_flash/core/resources/resource_manager.py @@ -101,7 +101,7 @@ def _migrate_to_name_based_keys(self) -> None: migrated_configs[key] = self._resource_configs.get(key, "") if len(migrated) != len(self._resources): - log.info(f"Migrated {len(self._resources)} resources to name-based keys") + log.debug(f"Migrated {len(self._resources)} resources to name-based keys") self._resources = migrated self._resource_configs = migrated_configs self._save_resources() # Persist migration @@ -136,7 +136,7 @@ def _refresh_config_hashes(self) -> None: # Save if any hashes were updated if updated: - log.info("Refreshed config hashes after code changes") + log.debug("Refreshed config hashes after code changes") self._save_resources() def _save_resources(self) -> None: @@ -256,7 +256,7 @@ async def get_or_deploy_resource( deployed_resource = await self._deploy_with_error_context( config ) - log.info(f"URL: {deployed_resource.url}") + log.debug(f"URL: {deployed_resource.url}") self._add_resource(resource_key, deployed_resource) return deployed_resource except Exception: @@ -283,7 +283,7 @@ async def get_or_deploy_resource( f" Existing config fields: {existing.model_dump(exclude_none=True, exclude={'id'}) if hasattr(existing, 'model_dump') else 'N/A'}\n" f" New config fields: {config.model_dump(exclude_none=True, exclude={'id'}) if hasattr(config, 'model_dump') else 'N/A'}" ) - log.info( + log.debug( f"Config drift detected for '{config.name}': " f"Automatically updating endpoint" ) @@ -304,7 +304,7 @@ async def get_or_deploy_resource( deployed_resource = await self._deploy_with_error_context( config ) - log.info(f"URL: {deployed_resource.url}") + log.debug(f"URL: {deployed_resource.url}") self._add_resource(resource_key, deployed_resource) return deployed_resource except Exception: @@ -319,7 +319,7 @@ async def get_or_deploy_resource( # Config unchanged, reuse existing log.debug(f"{existing} exists, reusing (config unchanged)") - log.info(f"URL: {existing.url}") + log.debug(f"URL: {existing.url}") return existing # No existing resource, deploy new one @@ -329,7 +329,7 @@ async def get_or_deploy_resource( ) try: deployed_resource = await self._deploy_with_error_context(config) - log.info(f"URL: {deployed_resource.url}") + log.debug(f"URL: {deployed_resource.url}") self._add_resource(resource_key, deployed_resource) return deployed_resource except Exception: diff --git a/src/runpod_flash/core/resources/serverless.py b/src/runpod_flash/core/resources/serverless.py index e94cf4a0..bf9238ce 100644 --- a/src/runpod_flash/core/resources/serverless.py +++ b/src/runpod_flash/core/resources/serverless.py @@ -366,7 +366,7 @@ def _apply_smart_disk_sizing(self, template: PodTemplate) -> None: # Auto-size if using default value default_disk_size = PodTemplate.model_fields["containerDiskInGb"].default if template.containerDiskInGb == default_disk_size: - log.info( + log.debug( f"Auto-sizing containerDiskInGb from {default_disk_size}GB " f"to {cpu_limit}GB (CPU instance limit)" ) @@ -477,7 +477,7 @@ def is_deployed(self) -> bool: response = self.endpoint.health() return response is not None except Exception as e: - log.error(f"Error checking {self}: {e}") + log.debug(f"Error checking {self}: {e}") return False def _payload_exclude(self) -> Set[str]: @@ -648,12 +648,12 @@ async def update(self, new_config: "ServerlessResource") -> "ServerlessResource" resolved_template_id = self.templateId or new_config.templateId # Log if version-triggering changes detected (informational only) if self._has_structural_changes(new_config): - log.info( + log.debug( f"{self.name}: Version-triggering changes detected. " "Server will increment version and recreate workers." ) else: - log.info(f"Updating endpoint '{self.name}' (ID: {self.id})") + log.debug(f"Updating endpoint '{self.name}' (ID: {self.id})") # Ensure network volume is deployed if specified await new_config._ensure_network_volume_deployed() @@ -790,21 +790,21 @@ async def _do_undeploy(self) -> bool: success = result.get("success", False) if success: - log.info(f"{self} successfully undeployed") + log.debug(f"{self} successfully undeployed") return True else: - log.error(f"{self} failed to undeploy") + log.debug(f"{self} failed to undeploy") return False except Exception as e: - log.error(f"{self} failed to undeploy: {e}") + log.debug(f"{self} failed to undeploy: {e}") # Deletion failed. Check if endpoint still exists. # If it doesn't exist, treat as successful cleanup (orphaned endpoint). try: async with RunpodGraphQLClient() as client: if not await client.endpoint_exists(self.id): - log.info( + log.debug( f"{self} no longer exists on RunPod, removing from cache" ) return True @@ -835,14 +835,14 @@ def _fetch_job(): try: # log.debug(f"[{self}] Payload: {payload}") - log.info(f"{self} | API /run_sync") + log.debug(f"{self} | API /run_sync") response = await asyncio.to_thread(_fetch_job) return JobOutput(**response) except Exception as e: health = await asyncio.to_thread(self.endpoint.health) health = ServerlessHealth(**health) - log.info(f"{self} | Health {health.workers.status}") + log.debug(f"{self} | Health {health.workers.status}") log.error(f"{self} | Exception: {e}") raise @@ -860,12 +860,12 @@ async def run(self, payload: Dict[str, Any]) -> "JobOutput": # log.debug(f"[{self}] Payload: {payload}") # Create a job using the endpoint - log.info(f"{self} | API /run") + log.debug(f"{self} | API /run") job = await asyncio.to_thread(self.endpoint.run, request_input=payload) log_subgroup = f"Job:{job.job_id}" - log.info(f"{self} | Started {log_subgroup}") + log.debug(f"{self} | Started {log_subgroup}") current_pace = 0 attempt = 0 @@ -884,10 +884,10 @@ async def run(self, payload: Dict[str, Any]) -> "JobOutput": attempt += 1 indicator = "." * (attempt // 2) if attempt % 2 == 0 else "" if indicator: - log.info(f"{log_subgroup} | {indicator}") + log.debug(f"{log_subgroup} | {indicator}") else: # status changed, reset the gap - log.info(f"{log_subgroup} | Status: {job_status}") + log.debug(f"{log_subgroup} | Status: {job_status}") attempt = 0 last_status = job_status @@ -901,7 +901,7 @@ async def run(self, payload: Dict[str, Any]) -> "JobOutput": except Exception as e: if job and job.job_id: - log.info(f"{self} | Cancelling job {job.job_id}") + log.debug(f"{self} | Cancelling job {job.job_id}") await asyncio.to_thread(job.cancel) log.error(f"{self} | Exception: {e}") @@ -974,8 +974,8 @@ class JobOutput(BaseModel): def model_post_init(self, _: Any) -> None: log_group = f"Worker:{self.workerId}" - log.info(f"{log_group} | Delay Time: {self.delayTime} ms") - log.info(f"{log_group} | Execution Time: {self.executionTime} ms") + log.debug(f"{log_group} | Delay Time: {self.delayTime} ms") + log.debug(f"{log_group} | Execution Time: {self.executionTime} ms") class Status(str, Enum): diff --git a/tests/unit/cli/test_deploy.py b/tests/unit/cli/test_deploy.py index 90872ec1..80df379d 100644 --- a/tests/unit/cli/test_deploy.py +++ b/tests/unit/cli/test_deploy.py @@ -24,9 +24,24 @@ def patched_console(): yield mock_console +def _make_flash_app(**kwargs): + """Create a MagicMock flash app with common async methods.""" + flash_app = MagicMock() + flash_app.upload_build = AsyncMock(return_value={"id": "build-123"}) + flash_app.get_environment_by_name = AsyncMock() + for key, value in kwargs.items(): + setattr(flash_app, key, value) + return flash_app + + class TestDeployCommand: @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -38,17 +53,20 @@ def test_deploy_single_env_auto_selects( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[{"name": "production", "id": "env-1"}] + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[{"name": "production", "id": "env-1"}] + ), ) mock_from_name.return_value = flash_app @@ -63,10 +81,15 @@ def test_deploy_single_env_auto_selects( assert result.exit_code == 0 mock_build.assert_called_once() - mock_deploy_to_env.assert_awaited_once() + mock_deploy.assert_awaited_once() @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -78,20 +101,23 @@ def test_deploy_with_explicit_env( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") - - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[ - {"name": "staging", "id": "env-1"}, - {"name": "production", "id": "env-2"}, - ] + mock_deploy.return_value = {"success": True} + + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[ + {"name": "staging", "id": "env-1"}, + {"name": "production", "id": "env-2"}, + ] + ), ) mock_from_name.return_value = flash_app @@ -105,9 +131,9 @@ def test_deploy_with_explicit_env( result = runner.invoke(app, ["deploy", "--env", "staging"]) assert result.exit_code == 0 - mock_deploy_to_env.assert_awaited_once() - call_args = mock_deploy_to_env.call_args - assert call_args[0][1] == "staging" + mock_deploy.assert_awaited_once() + call_args = mock_deploy.call_args + assert call_args[0][2] == "staging" # env_name @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -126,12 +152,13 @@ def test_deploy_multiple_envs_no_flag_errors( mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[ - {"name": "staging", "id": "env-1"}, - {"name": "production", "id": "env-2"}, - ] + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[ + {"name": "staging", "id": "env-1"}, + {"name": "production", "id": "env-2"}, + ] + ), ) mock_from_name.return_value = flash_app @@ -151,7 +178,12 @@ def test_deploy_multiple_envs_no_flag_errors( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock ) @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch("runpod_flash.cli.commands.deploy.run_build") @patch("runpod_flash.cli.commands.deploy.discover_flash_project") @@ -159,7 +191,8 @@ def test_deploy_no_app_creates_app_and_env( self, mock_discover, mock_build, - mock_deploy_to_env, + mock_validate, + mock_deploy, mock_from_name, mock_create, runner, @@ -168,8 +201,11 @@ def test_deploy_no_app_creates_app_and_env( ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} mock_from_name.side_effect = Exception("GraphQL errors: app not found") - mock_create.return_value = (MagicMock(), {"id": "env-1", "name": "production"}) + + created_app = _make_flash_app() + mock_create.return_value = (created_app, {"id": "env-1", "name": "production"}) with ( patch( @@ -211,7 +247,12 @@ def test_deploy_non_app_error_propagates( assert result.exit_code == 1 @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -223,19 +264,22 @@ def test_deploy_auto_creates_nonexistent_env( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[{"name": "production", "id": "env-1"}] + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[{"name": "production", "id": "env-1"}] + ), + create_environment=AsyncMock(), ) - flash_app.create_environment = AsyncMock() mock_from_name.return_value = flash_app with ( @@ -251,7 +295,12 @@ def test_deploy_auto_creates_nonexistent_env( flash_app.create_environment.assert_awaited_once_with("staging") @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -263,17 +312,20 @@ def test_deploy_zero_envs_creates_production( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} - flash_app = MagicMock() - flash_app.list_environments = AsyncMock(return_value=[]) - flash_app.create_environment = AsyncMock() + flash_app = _make_flash_app( + list_environments=AsyncMock(return_value=[]), + create_environment=AsyncMock(), + ) mock_from_name.return_value = flash_app with ( @@ -289,7 +341,12 @@ def test_deploy_zero_envs_creates_production( flash_app.create_environment.assert_awaited_once_with("production") @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -301,17 +358,20 @@ def test_deploy_shows_completion_panel( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "my-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[{"name": "production", "id": "env-1"}] + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[{"name": "production", "id": "env-1"}] + ), ) mock_from_name.return_value = flash_app @@ -331,11 +391,15 @@ def test_deploy_shows_completion_panel( for call in patched_console.print.call_args_list ] guidance_text = " ".join(printed_output) - assert "Next Steps:" in guidance_text - assert "Authentication Required" in guidance_text + assert "Useful commands:" in guidance_text @patch( - "runpod_flash.cli.commands.deploy.deploy_to_environment", new_callable=AsyncMock + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -347,17 +411,20 @@ def test_deploy_uses_app_flag( mock_discover, mock_build, mock_from_name, - mock_deploy_to_env, + mock_validate, + mock_deploy, runner, mock_asyncio_run_coro, patched_console, ): mock_discover.return_value = (Path("/tmp/project"), "default-app") mock_build.return_value = Path("/tmp/project/.flash/artifact.tar.gz") + mock_deploy.return_value = {"success": True} - flash_app = MagicMock() - flash_app.list_environments = AsyncMock( - return_value=[{"name": "production", "id": "env-1"}] + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[{"name": "production", "id": "env-1"}] + ), ) mock_from_name.return_value = flash_app diff --git a/tests/unit/cli/test_undeploy.py b/tests/unit/cli/test_undeploy.py index 16113163..47a3103b 100644 --- a/tests/unit/cli/test_undeploy.py +++ b/tests/unit/cli/test_undeploy.py @@ -173,7 +173,7 @@ def test_undeploy_no_args_shows_help(self, runner): assert "Usage" in result.stdout or "undeploy" in result.stdout.lower() def test_undeploy_no_args_shows_usage_text(self, runner): - """Ensure usage panel is rendered when no args are provided.""" + """Ensure usage help is rendered when no args are provided.""" with patch( "runpod_flash.cli.commands.undeploy._get_resource_manager" ) as mock_get_rm: @@ -185,7 +185,6 @@ def test_undeploy_no_args_shows_usage_text(self, runner): result = runner.invoke(app, ["undeploy"]) - assert "usage: flash undeploy" in result.stdout.lower() assert "please specify a name" in result.stdout.lower() def test_undeploy_nonexistent_name(self, runner, sample_resources): diff --git a/tests/unit/cli/utils/test_deployment.py b/tests/unit/cli/utils/test_deployment.py index 5880643e..1087355d 100644 --- a/tests/unit/cli/utils/test_deployment.py +++ b/tests/unit/cli/utils/test_deployment.py @@ -2,13 +2,12 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch -from pathlib import Path import pytest from runpod_flash.cli.utils.deployment import ( provision_resources_for_build, - deploy_to_environment, + deploy_from_uploaded_build, reconcile_and_provision_resources, ) @@ -204,12 +203,11 @@ async def mock_get_or_deploy_resource(resource): @pytest.mark.asyncio -async def test_deploy_to_environment_success( +async def test_deploy_from_uploaded_build_success( mock_flash_app, mock_deployed_resource, tmp_path ): """Test successful deployment flow with provisioning.""" mock_flash_app.get_environment_by_name = AsyncMock() - mock_flash_app.upload_build = AsyncMock(return_value={"id": "build-123"}) mock_flash_app.deploy_build_to_environment = AsyncMock( return_value={"success": True} ) @@ -222,7 +220,6 @@ async def test_deploy_to_environment_success( ) mock_flash_app.update_build_manifest = AsyncMock() - build_path = Path("/tmp/build.tar.gz") local_manifest = { "resources": { "cpu": {"resource_type": "ServerlessResource"}, @@ -230,7 +227,6 @@ async def test_deploy_to_environment_success( "resources_endpoints": {}, } - # Create temporary manifest file import json manifest_dir = tmp_path / ".flash" @@ -240,13 +236,11 @@ async def test_deploy_to_environment_success( with ( patch("pathlib.Path.cwd", return_value=tmp_path), - patch("runpod_flash.cli.utils.deployment.FlashApp.from_name") as mock_from_name, patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls, patch( "runpod_flash.cli.utils.deployment.create_resource_from_manifest" ) as mock_create_resource, ): - mock_from_name.return_value = mock_flash_app mock_manager = MagicMock() mock_manager.get_or_deploy_resource = AsyncMock( return_value=mock_deployed_resource @@ -254,27 +248,32 @@ async def test_deploy_to_environment_success( mock_manager_cls.return_value = mock_manager mock_create_resource.return_value = MagicMock() - result = await deploy_to_environment("app-name", "dev", build_path) + result = await deploy_from_uploaded_build( + mock_flash_app, "build-123", "dev", local_manifest + ) - assert result == {"success": True} + assert result["success"] is True + assert "resources_endpoints" in result + assert "local_manifest" in result mock_flash_app.get_environment_by_name.assert_awaited_once_with("dev") - mock_flash_app.upload_build.assert_awaited_once_with(build_path) mock_flash_app.deploy_build_to_environment.assert_awaited_once() @pytest.mark.asyncio -async def test_deploy_to_environment_provisioning_failure(mock_flash_app, tmp_path): +async def test_deploy_from_uploaded_build_provisioning_failure( + mock_flash_app, tmp_path +): """Test deployment when provisioning fails.""" mock_flash_app.get_environment_by_name = AsyncMock() - mock_flash_app.upload_build = AsyncMock(return_value={"id": "build-123"}) - # State Manager has no resources, so local_manifest resources will be NEW + mock_flash_app.deploy_build_to_environment = AsyncMock( + return_value={"success": True} + ) mock_flash_app.get_build_manifest = AsyncMock( return_value={ "resources": {}, } ) - build_path = Path("/tmp/build.tar.gz") local_manifest = { "resources": { "cpu": {"resource_type": "ServerlessResource"}, @@ -282,7 +281,6 @@ async def test_deploy_to_environment_provisioning_failure(mock_flash_app, tmp_pa "resources_endpoints": {}, } - # Create temporary manifest file import json manifest_dir = tmp_path / ".flash" @@ -292,13 +290,11 @@ async def test_deploy_to_environment_provisioning_failure(mock_flash_app, tmp_pa with ( patch("pathlib.Path.cwd", return_value=tmp_path), - patch("runpod_flash.cli.utils.deployment.FlashApp.from_name") as mock_from_name, patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls, patch( "runpod_flash.cli.utils.deployment.create_resource_from_manifest" ) as mock_create_resource, ): - mock_from_name.return_value = mock_flash_app mock_manager = MagicMock() mock_manager.get_or_deploy_resource = AsyncMock( side_effect=Exception("Resource deployment failed") @@ -307,7 +303,9 @@ async def test_deploy_to_environment_provisioning_failure(mock_flash_app, tmp_pa mock_create_resource.return_value = MagicMock() with pytest.raises(RuntimeError) as exc_info: - await deploy_to_environment("app-name", "dev", build_path) + await deploy_from_uploaded_build( + mock_flash_app, "build-123", "dev", local_manifest + ) assert "Failed to provision resources" in str(exc_info.value) From 826f1695ab2bbe620da290783194b8456fbb77cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Sat, 14 Feb 2026 08:36:06 -0800 Subject: [PATCH 07/12] refactor: remove noisy debug logs from flash (AE-1966) (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove noisy @remote decorator debug logs Remove debug logs from client.py that fire on every @remote function/class: - RUNPOD_ENDPOINT_ID/FLASH_RESOURCE_NAME environment check logs - Local dev mode stub creation logs - is_local_function result logs - Original function return logs - Remote execution wrapper creation logs Also remove unused flash_resource_name variable that was only used in the removed debug log. These logs provide no actionable information during normal development and create substantial noise (5-10 lines per decorated item). * refactor: remove class serialization debug logs from execute_class.py Remove 5 debug log lines that fire for every @remote class: - Cached class data log (line 60) - Retrieved cached class data log (lines 84-86) - Successfully extracted class code log (line 125) - Generated cache key log (line 185) - Created remote class wrapper log (line 232) These logs fire 5-10 times per run and only matter when debugging class serialization issues, not during normal development. * refactor: remove per-resource discovery debug logs Remove 2 debug log lines that fire for every decorated resource: - Entry point resource discovery log (lines 55-57) - Project directory resource discovery log (lines 408-410) These logs fire 10-20 times per run. The INFO-level summary logs already show total resource counts, making per-resource debug logs redundant. * refactor: remove duplicate parse failure debug logs from scanner.py Remove 6 duplicate debug log lines across three scanning passes: - First pass (resource configs): lines 77, 81 - Second pass (@remote functions): lines 90, 94 - Third pass (function calls): lines 118, 122 These logs fire 3× per Python file during scanning (150+ logs for 50 files). Parse failures in dependencies are expected and not actionable. Keep SyntaxError warnings as they indicate actual issues. * refactor: remove per-request load balancer debug logs Remove 3 debug log lines that fire on every request: - ROUND_ROBIN selection log (lines 88-91) - LEAST_CONNECTIONS selection log (lines 112-115) - RANDOM selection log (line 128) These logs fire on EVERY request (100+ times per second in production) and would flood production systems with no actionable value. * refactor: comment out verbose API debug logs in runpod.py Comment out (not remove) 8 debug log lines in API methods: GraphQL (_execute_graphql): - GraphQL Query log - GraphQL Variables log - GraphQL Response Status log - GraphQL Response log REST (_execute_rest): - REST Request log - REST Data log - REST Response Status log - REST Response log These logs dump multi-KB JSON responses on every API call (10-50× per deploy operation). Commenting out preserves them for future debugging while silencing them during normal development. Add noqa comment to json import since it's only used in commented code. * refactor: remove verbose debug/info logs from resource_manager.py Removed 6 noisy logs that fire per-resource operation: - get_or_deploy_resource called with config dump - DRIFT DEBUG with existing/new config fields - Resource found in cache (per-lookup) - exists, reusing (per-reuse) - Resource NOT found in cache (per-deployment) - Config drift detected (redundant with warning log) * refactor: simplify DEBUG log format to remove logger name and file location Removed %(name)s | %(filename)s:%(lineno)d from DEBUG format. DEBUG and INFO now use the same clean format: timestamp | level | message Updated test to match new behavior. * refactor: remove structural change debug logs from serverless.py Removed 3 noisy logs: - Version-triggering changes detected (INFO) - Structural change in field (DEBUG, 2 occurrences) These logs fire during endpoint updates and provide no actionable value. * refactor: silence httpcore/httpx trace logs Set httpcore and httpx loggers to WARNING level to suppress verbose connection/request trace logs that appear in DEBUG mode: - connect_tcp.started/complete - start_tls.started/complete - send_request_headers/body - receive_response_headers These low-level HTTP transport logs provide no actionable value during normal development. * fix: prevent false redaction of Job IDs and Template IDs Replaced overly broad TOKEN_PATTERN with PREFIXED_KEY_PATTERN that only redacts tokens with known sensitive prefixes (sk-, key_, api_). This fixes false positives where Job IDs, Worker IDs, and Template IDs were being redacted even though they're not sensitive. Updated test to use prefixed token instead of generic long token. * refactor: remove verbose debug logs from build and API operations Removed repetitive and overly-verbose debug logs: - ignore.py: Remove per-file "Ignoring:" logs (pattern summary sufficient) - app.py: Remove "already hydrated" debug log - runpod.py: Remove logs that print full input_data/variables (finalizing upload, fetching environment, deploying environment) - runpod.py: Change template update logs from info to debug - serverless.py: Change template update log from info to debug These logs added noise without value. Pattern summaries and operation names provide sufficient context. * refactor: silence file lock and asyncio debug logs Removed verbose file locking and resource persistence logs: - file_lock.py: Remove "File lock acquired" and "File lock released" - resource_manager.py: Remove "Saved resources in .runpod/resources.pkl" - logger.py: Silence asyncio logger (prevents "Using selector: KqueueSelector") These operational details added noise without debugging value. * refactor: remove app hydration debug logs Removed: - runpod.py: "Fetching flash app by name for input:" - app.py: "Hydrating app" These operation-level logs add noise without debugging value. --- .../cli/commands/build_utils/scanner.py | 18 +++++----- src/runpod_flash/cli/utils/ignore.py | 1 - src/runpod_flash/client.py | 15 -------- src/runpod_flash/core/api/runpod.py | 34 ++++++++----------- src/runpod_flash/core/discovery.py | 7 ---- src/runpod_flash/core/resources/app.py | 2 -- .../core/resources/resource_manager.py | 33 +----------------- src/runpod_flash/core/resources/serverless.py | 15 +++----- src/runpod_flash/core/utils/file_lock.py | 3 -- src/runpod_flash/execute_class.py | 9 ----- src/runpod_flash/logger.py | 19 +++++++++-- src/runpod_flash/runtime/load_balancer.py | 9 ----- tests/unit/test_logger.py | 7 ++-- tests/unit/test_logger_sensitive_data.py | 7 ++-- 14 files changed, 51 insertions(+), 128 deletions(-) diff --git a/src/runpod_flash/cli/commands/build_utils/scanner.py b/src/runpod_flash/cli/commands/build_utils/scanner.py index 5ff5c4d2..2215ab9e 100644 --- a/src/runpod_flash/cli/commands/build_utils/scanner.py +++ b/src/runpod_flash/cli/commands/build_utils/scanner.py @@ -74,11 +74,11 @@ def discover_remote_functions(self) -> List[RemoteFunctionMetadata]: tree = ast.parse(content) self._extract_resource_configs(tree, py_file) except UnicodeDecodeError: - logger.debug(f"Skipping non-UTF-8 file: {py_file}") + pass except SyntaxError as e: logger.warning(f"Syntax error in {py_file}: {e}") - except Exception as e: - logger.debug(f"Failed to parse {py_file}: {e}") + except Exception: + pass # Second pass: extract @remote decorated functions for py_file in self.py_files: @@ -87,11 +87,11 @@ def discover_remote_functions(self) -> List[RemoteFunctionMetadata]: tree = ast.parse(content) functions.extend(self._extract_remote_functions(tree, py_file)) except UnicodeDecodeError: - logger.debug(f"Skipping non-UTF-8 file: {py_file}") + pass except SyntaxError as e: logger.warning(f"Syntax error in {py_file}: {e}") - except Exception as e: - logger.debug(f"Failed to parse {py_file}: {e}") + except Exception: + pass # Third pass: analyze function call graphs remote_function_names = {f.function_name for f in functions} @@ -115,11 +115,11 @@ def discover_remote_functions(self) -> List[RemoteFunctionMetadata]: node, func_meta, remote_function_names ) except UnicodeDecodeError: - logger.debug(f"Skipping non-UTF-8 file: {py_file}") + pass except SyntaxError as e: logger.warning(f"Syntax error in {py_file}: {e}") - except Exception as e: - logger.debug(f"Failed to parse {py_file}: {e}") + except Exception: + pass return functions diff --git a/src/runpod_flash/cli/utils/ignore.py b/src/runpod_flash/cli/utils/ignore.py index bd3b8c7b..b9634dc2 100644 --- a/src/runpod_flash/cli/utils/ignore.py +++ b/src/runpod_flash/cli/utils/ignore.py @@ -128,7 +128,6 @@ def get_file_tree( for item in directory.iterdir(): # Check if should ignore if should_ignore(item, spec, base_dir): - log.debug(f"Ignoring: {item.relative_to(base_dir)}") continue if item.is_file(): diff --git a/src/runpod_flash/client.py b/src/runpod_flash/client.py index 1288e24f..ed68bc30 100644 --- a/src/runpod_flash/client.py +++ b/src/runpod_flash/client.py @@ -25,17 +25,9 @@ def _should_execute_locally(func_name: str) -> bool: # Check if we're in a deployed environment runpod_endpoint_id = os.getenv("RUNPOD_ENDPOINT_ID") runpod_pod_id = os.getenv("RUNPOD_POD_ID") - flash_resource_name = os.getenv("FLASH_RESOURCE_NAME") - - log.debug( - f"@remote decorator for {func_name}: " - f"RUNPOD_ENDPOINT_ID={runpod_endpoint_id}, " - f"FLASH_RESOURCE_NAME={flash_resource_name}" - ) if not runpod_endpoint_id and not runpod_pod_id: # Local development - create stub for remote execution via ResourceManager - log.debug(f"@remote {func_name}: local dev mode, creating stub") return False # In deployed environment - check build-time generated configuration @@ -43,9 +35,6 @@ def _should_execute_locally(func_name: str) -> bool: from .runtime._flash_resource_config import is_local_function result = is_local_function(func_name) - log.debug( - f"@remote {func_name}: deployed mode, is_local_function returned {result}" - ) return result except ImportError as e: # Configuration not generated (shouldn't happen in deployed env) @@ -186,14 +175,10 @@ def decorator(func_or_class): if should_execute_local: # This function belongs to our resource - execute locally - log.debug( - f"@remote {func_name}: returning original function (local execution)" - ) func_or_class.__remote_config__ = routing_config return func_or_class # Remote execution mode - create stub for calling other endpoints - log.debug(f"@remote {func_name}: creating wrapper for remote execution") if inspect.isclass(func_or_class): # Handle class decoration diff --git a/src/runpod_flash/core/api/runpod.py b/src/runpod_flash/core/api/runpod.py index b451b31f..bc30219a 100644 --- a/src/runpod_flash/core/api/runpod.py +++ b/src/runpod_flash/core/api/runpod.py @@ -3,7 +3,7 @@ Bypasses the outdated runpod-python SDK limitations. """ -import json +import json # noqa: F401 - used in commented debug logs import logging import os from typing import Any, Dict, Optional, List @@ -92,19 +92,19 @@ async def _execute_graphql( payload = {"query": query, "variables": variables or {}} - log.debug(f"GraphQL Query: {query}") - sanitized_vars = _sanitize_for_logging(variables) - log.debug(f"GraphQL Variables: {json.dumps(sanitized_vars, indent=2)}") + # log.debug(f"GraphQL Query: {query}") + # sanitized_vars = _sanitize_for_logging(variables) + # log.debug(f"GraphQL Variables: {json.dumps(sanitized_vars, indent=2)}") try: async with session.post(self.GRAPHQL_URL, json=payload) as response: response_data = await response.json() - log.debug(f"GraphQL Response Status: {response.status}") - sanitized_response = _sanitize_for_logging(response_data) - log.debug( - f"GraphQL Response: {json.dumps(sanitized_response, indent=2)}" - ) + # log.debug(f"GraphQL Response Status: {response.status}") + # sanitized_response = _sanitize_for_logging(response_data) + # log.debug( + # f"GraphQL Response: {json.dumps(sanitized_response, indent=2)}" + # ) if response.status >= 400: sanitized_err = _sanitize_for_logging(response_data) @@ -156,7 +156,7 @@ async def update_template(self, input_data: Dict[str, Any]) -> Dict[str, Any]: raise Exception("Unexpected GraphQL response structure") template_data = result["saveTemplate"] - log.info( + log.debug( f"Updated template: {template_data.get('id', 'unknown')} - {template_data.get('name', 'unnamed')}" ) @@ -354,8 +354,6 @@ async def finalize_artifact_upload( """ variables = {"input": input_data} - log.debug(f"finalizing upload for flash app: {input_data}") - result = await self._execute_graphql(mutation, variables) return result["finalizeFlashArtifactUpload"] @@ -407,7 +405,6 @@ async def get_flash_app_by_name(self, app_name: str) -> Dict[str, Any]: """ variables = {"flashAppName": app_name} - log.debug(f"Fetching flash app by name for input: {app_name}") result = await self._execute_graphql(query, variables) return result["flashAppByName"] @@ -460,7 +457,6 @@ async def get_flash_environment_by_name( """ variables = {"input": input_data} - log.debug(f"Fetching flash environment by name for input: {variables}") result = await self._execute_graphql(query, variables) return result["flashEnvironmentByName"] @@ -513,8 +509,6 @@ async def deploy_build_to_environment( variables = {"input": input_data} - log.debug(f"Deploying flash environment with vars: {input_data}") - result = await self._execute_graphql(mutation, variables) return result["deployBuildToEnvironment"] @@ -834,15 +828,15 @@ async def _execute_rest( """Execute a REST API request.""" session = await self._get_session() - log.debug(f"REST Request: {method} {url}") - log.debug(f"REST Data: {json.dumps(data, indent=2) if data else 'None'}") + # log.debug(f"REST Request: {method} {url}") + # log.debug(f"REST Data: {json.dumps(data, indent=2) if data else 'None'}") try: async with session.request(method, url, json=data) as response: response_data = await response.json() - log.debug(f"REST Response Status: {response.status}") - log.debug(f"REST Response: {json.dumps(response_data, indent=2)}") + # log.debug(f"REST Response Status: {response.status}") + # log.debug(f"REST Response: {json.dumps(response_data, indent=2)}") if response.status >= 400: raise Exception( diff --git a/src/runpod_flash/core/discovery.py b/src/runpod_flash/core/discovery.py index 8ce4f3e5..06c5d57e 100644 --- a/src/runpod_flash/core/discovery.py +++ b/src/runpod_flash/core/discovery.py @@ -52,9 +52,6 @@ def discover(self) -> List[DeployableResource]: resource = self._resolve_resource_variable(module, var_name) if resource: resources.append(resource) - log.debug( - f"Discovered resource: {var_name} -> {resource.__class__.__name__}" - ) else: log.warning(f"Failed to import {self.entry_point}") @@ -405,10 +402,6 @@ def _scan_project_directory(self) -> List[DeployableResource]: resource = self._resolve_resource_variable(module, var_name) if resource: resources.append(resource) - log.debug( - f"Discovered resource in {file_path.relative_to(project_root)}: " - f"{var_name} -> {resource.__class__.__name__}" - ) except Exception as e: log.debug(f"Failed to scan {file_path}: {e}") diff --git a/src/runpod_flash/core/resources/app.py b/src/runpod_flash/core/resources/app.py index a109f474..2fdce8f3 100644 --- a/src/runpod_flash/core/resources/app.py +++ b/src/runpod_flash/core/resources/app.py @@ -185,10 +185,8 @@ async def _hydrate(self) -> None: """ async with self._hydrate_lock: if self._hydrated: - log.debug("App is already hydrated while calling hydrate. Returning") return - log.debug("Hydrating app") async with RunpodGraphQLClient() as client: try: result = await client.get_flash_app_by_name(self.name) diff --git a/src/runpod_flash/core/resources/resource_manager.py b/src/runpod_flash/core/resources/resource_manager.py index a43f2d5e..0cd18f51 100644 --- a/src/runpod_flash/core/resources/resource_manager.py +++ b/src/runpod_flash/core/resources/resource_manager.py @@ -152,7 +152,6 @@ def _save_resources(self) -> None: data = (self._resources, self._resource_configs) cloudpickle.dump(data, f) f.flush() # Ensure data is written to disk - log.debug(f"Saved resources in {RESOURCE_STATE_FILE}") except (FileLockError, Exception) as e: log.error(f"Failed to save resources to {RESOURCE_STATE_FILE}: {e}") raise @@ -224,15 +223,6 @@ async def get_or_deploy_resource( resource_key = config.get_resource_key() new_config_hash = config.config_hash - log.debug( - f"get_or_deploy_resource called:\n" - f" Config type: {type(config).__name__}\n" - f" Config name: {getattr(config, 'name', 'N/A')}\n" - f" Resource key: {resource_key}\n" - f" New config hash: {new_config_hash[:16]}...\n" - f" Available keys in cache: {list(self._resources.keys())}" - ) - # Ensure global lock is initialized assert ResourceManager._global_lock is not None, "Global lock not initialized" @@ -247,7 +237,6 @@ async def get_or_deploy_resource( existing = self._resources.get(resource_key) if existing: - log.debug(f"Resource found in cache: {resource_key}") # Resource exists - check if still valid if not existing.is_deployed(): log.warning(f"{existing} is no longer valid, redeploying.") @@ -273,21 +262,6 @@ async def get_or_deploy_resource( stored_config_hash = self._resource_configs.get(resource_key, "") if stored_config_hash != new_config_hash: - # Detailed drift debugging - log.debug( - f"DRIFT DEBUG for '{config.name}':\n" - f" Stored hash: {stored_config_hash}\n" - f" New hash: {new_config_hash}\n" - f" Stored resource type: {type(existing).__name__}\n" - f" New resource type: {type(config).__name__}\n" - f" Existing config fields: {existing.model_dump(exclude_none=True, exclude={'id'}) if hasattr(existing, 'model_dump') else 'N/A'}\n" - f" New config fields: {config.model_dump(exclude_none=True, exclude={'id'}) if hasattr(config, 'model_dump') else 'N/A'}" - ) - log.debug( - f"Config drift detected for '{config.name}': " - f"Automatically updating endpoint" - ) - # Attempt update (will redeploy if structural changes detected) if hasattr(existing, "update"): updated_resource = await existing.update(config) @@ -318,15 +292,10 @@ async def get_or_deploy_resource( raise # Config unchanged, reuse existing - log.debug(f"{existing} exists, reusing (config unchanged)") - log.debug(f"URL: {existing.url}") + log.info(f"URL: {existing.url}") return existing # No existing resource, deploy new one - log.debug( - f"Resource NOT found in cache, deploying new: {resource_key}\n" - f" Searched in keys: {list(self._resources.keys())}" - ) try: deployed_resource = await self._deploy_with_error_context(config) log.debug(f"URL: {deployed_resource.url}") diff --git a/src/runpod_flash/core/resources/serverless.py b/src/runpod_flash/core/resources/serverless.py index bf9238ce..fc87efa6 100644 --- a/src/runpod_flash/core/resources/serverless.py +++ b/src/runpod_flash/core/resources/serverless.py @@ -646,14 +646,9 @@ async def update(self, new_config: "ServerlessResource") -> "ServerlessResource" try: resolved_template_id = self.templateId or new_config.templateId - # Log if version-triggering changes detected (informational only) - if self._has_structural_changes(new_config): - log.debug( - f"{self.name}: Version-triggering changes detected. " - "Server will increment version and recreate workers." - ) - else: - log.debug(f"Updating endpoint '{self.name}' (ID: {self.id})") + # Check for version-triggering changes + if not self._has_structural_changes(new_config): + log.info(f"Updating endpoint '{self.name}' (ID: {self.id})") # Ensure network volume is deployed if specified await new_config._ensure_network_volume_deployed() @@ -678,7 +673,7 @@ async def update(self, new_config: "ServerlessResource") -> "ServerlessResource" new_config.template, resolved_template_id ) await client.update_template(template_payload) - log.info( + log.debug( f"Updated template '{resolved_template_id}' for endpoint '{self.name}'" ) else: @@ -752,11 +747,9 @@ def _has_structural_changes(self, new_config: "ServerlessResource") -> bool: # Handle list comparison if isinstance(old_val, list) and isinstance(new_val, list): if sorted(str(v) for v in old_val) != sorted(str(v) for v in new_val): - log.debug(f"Structural change in '{field}': {old_val} → {new_val}") return True # Handle other types elif old_val != new_val: - log.debug(f"Structural change in '{field}': {old_val} → {new_val}") return True return False diff --git a/src/runpod_flash/core/utils/file_lock.py b/src/runpod_flash/core/utils/file_lock.py index c104cfd8..b1866c34 100644 --- a/src/runpod_flash/core/utils/file_lock.py +++ b/src/runpod_flash/core/utils/file_lock.py @@ -102,7 +102,6 @@ def file_lock( _acquire_fallback_lock(file_handle, exclusive, timeout) lock_acquired = True - log.debug(f"File lock acquired (exclusive={exclusive})") except (OSError, IOError, FileLockError) as e: # Check timeout @@ -128,8 +127,6 @@ def file_lock( else: _release_fallback_lock(file_handle) - log.debug("File lock released") - except Exception as e: log.error(f"Error releasing file lock: {e}") # Don't raise - we're in cleanup diff --git a/src/runpod_flash/execute_class.py b/src/runpod_flash/execute_class.py index 0e301d5d..643bc378 100644 --- a/src/runpod_flash/execute_class.py +++ b/src/runpod_flash/execute_class.py @@ -57,8 +57,6 @@ def get_or_cache_class_data( }, ) - log.debug(f"Cached class data for {cls.__name__} with key: {cache_key}") - except (TypeError, AttributeError, OSError, SerializationError) as e: log.warning( f"Could not serialize constructor arguments for {cls.__name__}: {e}" @@ -81,9 +79,6 @@ def get_or_cache_class_data( else: # Cache hit - retrieve cached data cached_data = _SERIALIZED_CLASS_CACHE.get(cache_key) - log.debug( - f"Retrieved cached class data for {cls.__name__} with key: {cache_key}" - ) return cached_data["class_code"] @@ -121,7 +116,6 @@ def extract_class_code_simple(cls: Type) -> str: # Validate the code by trying to compile it compile(class_code, "", "exec") - log.debug(f"Successfully extracted class code for {cls.__name__}") return class_code except Exception as e: @@ -182,7 +176,6 @@ def get_class_cache_key( # Combine hashes for final cache key cache_key = f"{cls.__name__}_{class_hash[:HASH_TRUNCATE_LENGTH]}_{args_hash[:HASH_TRUNCATE_LENGTH]}" - log.debug(f"Generated cache key for {cls.__name__}: {cache_key}") return cache_key except (TypeError, AttributeError, OSError) as e: @@ -229,8 +222,6 @@ def __init__(self, *args, **kwargs): cls, args, kwargs, self._cache_key ) - log.debug(f"Created remote class wrapper for {cls.__name__}") - async def _ensure_initialized(self): """Ensure the remote instance is created.""" if self._initialized: diff --git a/src/runpod_flash/logger.py b/src/runpod_flash/logger.py index d024b079..88283edc 100644 --- a/src/runpod_flash/logger.py +++ b/src/runpod_flash/logger.py @@ -64,6 +64,10 @@ class SensitiveDataFilter(logging.Filter): # Pattern for Bearer tokens in Authorization headers BEARER_PATTERN = re.compile(r"(bearer\s+)([A-Za-z0-9_.-]+)", re.IGNORECASE) + # Pattern for common API key prefixes (OpenAI, Anthropic, etc) + # Matches: sk-..., key_..., etc. (32+ chars total) + PREFIXED_KEY_PATTERN = re.compile(r"\b(sk-|key_|api_)[A-Za-z0-9_-]{28,}\b") + def filter(self, record: logging.LogRecord) -> bool: """Sanitize log record by redacting sensitive data. @@ -129,8 +133,12 @@ def _redact_string(self, text: str) -> str: lambda m: f"{m.group(1)}***REDACTED***{m.group(3)}", text ) - # Redact generic long tokens - text = self.TOKEN_PATTERN.sub(self._redact_token, text) + # Redact common prefixed API keys (sk-, key_, api_) + text = self.PREFIXED_KEY_PATTERN.sub(self._redact_token, text) + + # Generic token pattern disabled - causes false positives with Job IDs, Template IDs, etc. + # Specific patterns above catch actual sensitive tokens. + # text = self.TOKEN_PATTERN.sub(self._redact_token, text) # Redact common password/secret patterns # Match field names with : or = separators and redact the value, preserving separator @@ -293,7 +301,7 @@ def setup_logging( # Determine format based on final effective level if fmt is None: if level == logging.DEBUG: - fmt = "%(asctime)s | %(levelname)-5s | %(name)s | %(filename)s:%(lineno)d | %(message)s" + fmt = "%(asctime)s | %(levelname)-5s | %(message)s" else: # Default format for INFO level and above fmt = "%(asctime)s | %(levelname)-5s | %(message)s" @@ -322,3 +330,8 @@ def setup_logging( existing_handler.addFilter(sensitive_filter) root_logger.setLevel(level) + + # Silence httpcore trace logs (connection/request details) + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("asyncio").setLevel(logging.WARNING) diff --git a/src/runpod_flash/runtime/load_balancer.py b/src/runpod_flash/runtime/load_balancer.py index 6c32b465..0c4b6f44 100644 --- a/src/runpod_flash/runtime/load_balancer.py +++ b/src/runpod_flash/runtime/load_balancer.py @@ -85,10 +85,6 @@ async def _round_robin_select(self, endpoints: List[str]) -> str: async with self._lock: selected = endpoints[self._round_robin_index % len(endpoints)] self._round_robin_index += 1 - logger.debug( - f"Load balancer: ROUND_ROBIN selected {selected} " - f"(index {self._round_robin_index - 1})" - ) return selected async def _least_connections_select(self, endpoints: List[str]) -> str: @@ -109,10 +105,6 @@ async def _least_connections_select(self, endpoints: List[str]) -> str: # Find endpoint with minimum connections selected = min(endpoints, key=lambda e: self._in_flight_requests.get(e, 0)) - logger.debug( - f"Load balancer: LEAST_CONNECTIONS selected {selected} " - f"({self._in_flight_requests.get(selected, 0)} in-flight)" - ) return selected async def _random_select(self, endpoints: List[str]) -> str: @@ -125,7 +117,6 @@ async def _random_select(self, endpoints: List[str]) -> str: Selected endpoint URL """ selected = random.choice(endpoints) - logger.debug(f"Load balancer: RANDOM selected {selected}") return selected async def record_request(self, endpoint: str) -> None: diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index de33226f..7b527ede 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -253,7 +253,7 @@ def test_log_level_override_via_env(self, tmp_path, monkeypatch): monkeypatch.delenv("LOG_LEVEL") def test_debug_format_includes_details(self, tmp_path, monkeypatch): - """Verify DEBUG level uses detailed format.""" + """Verify DEBUG level logging works with clean format.""" # Change to temp directory monkeypatch.chdir(tmp_path) @@ -275,10 +275,9 @@ def test_debug_format_includes_details(self, tmp_path, monkeypatch): output = stream.getvalue() - # Verify detailed format includes filename and line number + # Verify message is logged assert "Debug message" in output - assert "test_logger.py" in output # filename - assert "test" in output # logger name + assert "DEBUG" in output # Cleanup cleanup_handlers(root_logger) diff --git a/tests/unit/test_logger_sensitive_data.py b/tests/unit/test_logger_sensitive_data.py index 573c3193..e5ad2640 100644 --- a/tests/unit/test_logger_sensitive_data.py +++ b/tests/unit/test_logger_sensitive_data.py @@ -127,10 +127,11 @@ def test_recursive_dict_sanitization(self): assert sanitized_config["api"]["endpoint"] == "https://api.example.com" def test_long_token_partial_redaction(self): - """Verify long tokens show first/last 4 chars for debugging.""" + """Verify prefixed API keys show first/last 4 chars for debugging.""" filter_instance = SensitiveDataFilter() - long_token = "abcdefghijklmnopqrstuvwxyz0123456789" + # Use a prefixed token that will be caught by PREFIXED_KEY_PATTERN + long_token = "sk-abcdefghijklmnopqrstuvwxyz0123456789" record = logging.LogRecord( name="test", level=logging.INFO, @@ -143,7 +144,7 @@ def test_long_token_partial_redaction(self): filter_instance.filter(record) # Should show first 4 and last 4 chars - assert "abcd" in record.msg + assert "sk-a" in record.msg assert "6789" in record.msg assert "***REDACTED***" in record.msg assert long_token not in record.msg From b60df1b91530f1cf2efd9f2bd11e2e1514f9a9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:53:18 -0800 Subject: [PATCH 08/12] feat(http): add Content-Type header to httpx and requests factories Add "Content-Type: application/json" header to centralized HTTP client factories for consistency with aiohttp usage and to prevent issues with JSON endpoints. - get_authenticated_httpx_client: Add Content-Type header - get_authenticated_requests_session: Add Content-Type header --- src/runpod_flash/core/utils/http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index 428999c9..322d1e83 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -44,6 +44,7 @@ def get_authenticated_httpx_client( headers = { "User-Agent": get_user_agent(), + "Content-Type": "application/json", } api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") if api_key: @@ -90,6 +91,7 @@ def get_authenticated_requests_session( session = requests.Session() session.headers["User-Agent"] = get_user_agent() + session.headers["Content-Type"] = "application/json" api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") if api_key: From afea83506cd7ba929de5c9be7a8befc6a9b29bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:55:48 -0800 Subject: [PATCH 09/12] feat(http): add centralized aiohttp session factory Add get_authenticated_aiohttp_session() to provide consistent HTTP client creation across all client types (httpx, requests, aiohttp). Features: - User-Agent header with flash version - Authorization header with API key support - Content-Type: application/json - Configurable timeout (default 300s for GraphQL) - TCPConnector with ThreadedResolver for DNS - API key override for worker-to-mothership propagation This enables consolidation of aiohttp session creation scattered across RunpodGraphQLClient and RunpodRestClient. --- src/runpod_flash/core/utils/http.py | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/runpod_flash/core/utils/http.py b/src/runpod_flash/core/utils/http.py index 322d1e83..53dcbe23 100644 --- a/src/runpod_flash/core/utils/http.py +++ b/src/runpod_flash/core/utils/http.py @@ -5,6 +5,8 @@ import httpx import requests +from aiohttp import ClientSession, ClientTimeout, TCPConnector +from aiohttp.resolver import ThreadedResolver def get_authenticated_httpx_client( @@ -98,3 +100,50 @@ def get_authenticated_requests_session( session.headers["Authorization"] = f"Bearer {api_key}" return session + + +def get_authenticated_aiohttp_session( + timeout: float = 300.0, + api_key_override: Optional[str] = None, +) -> ClientSession: + """Create aiohttp ClientSession with RunPod authentication and User-Agent. + + Automatically includes: + - User-Agent header identifying flash client and version + - Authorization header if RUNPOD_API_KEY is set or api_key_override provided + - Content-Type: application/json + - 5-minute default timeout (configurable) + - TCPConnector with ThreadedResolver for DNS resolution + + Args: + timeout: Total timeout in seconds (default: 300s for GraphQL operations) + api_key_override: Optional API key to use instead of RUNPOD_API_KEY. + Used for propagating API keys from mothership to worker endpoints. + + Returns: + Configured aiohttp.ClientSession with User-Agent, Authorization, and Content-Type headers + + Example: + session = get_authenticated_aiohttp_session() + async with session.post(url, json=data) as response: + result = await response.json() + """ + from .user_agent import get_user_agent + + headers = { + "User-Agent": get_user_agent(), + "Content-Type": "application/json", + } + + api_key = api_key_override or os.environ.get("RUNPOD_API_KEY") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + timeout_config = ClientTimeout(total=timeout) + connector = TCPConnector(resolver=ThreadedResolver()) + + return ClientSession( + timeout=timeout_config, + headers=headers, + connector=connector, + ) From 51f8d2e98f096e6f17417a1ee209092d3f53803c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:57:06 -0800 Subject: [PATCH 10/12] refactor(api): use centralized aiohttp factory in RunPod clients Refactor RunpodGraphQLClient and RunpodRestClient to use the new get_authenticated_aiohttp_session() factory instead of creating aiohttp.ClientSession directly. Benefits: - Removes ~18 lines of duplicated configuration - Consistent headers across all HTTP clients - Centralized timeout and connector configuration - Easier to test and maintain No behavior changes - same headers, timeout, and connector settings. --- src/runpod_flash/core/api/runpod.py | 33 +++++++++-------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/runpod_flash/core/api/runpod.py b/src/runpod_flash/core/api/runpod.py index bc30219a..46d54419 100644 --- a/src/runpod_flash/core/api/runpod.py +++ b/src/runpod_flash/core/api/runpod.py @@ -9,7 +9,6 @@ from typing import Any, Dict, Optional, List import aiohttp -from aiohttp.resolver import ThreadedResolver from runpod_flash.core.exceptions import RunpodAPIKeyError from runpod_flash.runtime.exceptions import GraphQLMutationError, GraphQLQueryError @@ -69,18 +68,11 @@ def __init__(self, api_key: Optional[str] = None): async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self.session is None or self.session.closed: - from runpod_flash.core.utils.user_agent import get_user_agent - - timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout - connector = aiohttp.TCPConnector(resolver=ThreadedResolver()) - self.session = aiohttp.ClientSession( - timeout=timeout, - headers={ - "User-Agent": get_user_agent(), - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, - connector=connector, + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session + + self.session = get_authenticated_aiohttp_session( + timeout=300.0, # 5 minute timeout for GraphQL operations + api_key_override=self.api_key, ) return self.session @@ -809,16 +801,11 @@ def __init__(self, api_key: Optional[str] = None): async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session.""" if self.session is None or self.session.closed: - from runpod_flash.core.utils.user_agent import get_user_agent - - timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout - self.session = aiohttp.ClientSession( - timeout=timeout, - headers={ - "User-Agent": get_user_agent(), - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, + from runpod_flash.core.utils.http import get_authenticated_aiohttp_session + + self.session = get_authenticated_aiohttp_session( + timeout=300.0, # 5 minute timeout for REST operations + api_key_override=self.api_key, ) return self.session From 4543d2dff33aa65ff98514b8fc76c6ac625e5bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:58:07 -0800 Subject: [PATCH 11/12] refactor(app): use centralized requests factory for tarball operations Refactor download_tarball() and upload_build() to use get_authenticated_requests_session() instead of direct requests.get/put. Benefits: - Automatic User-Agent header inclusion - Consistent with other HTTP operations - Proper session management with context manager - Centralized configuration Changes: - download_tarball: Use session factory for presigned URL download - upload_build: Use session factory with Content-Type override for tarball upload --- src/runpod_flash/core/resources/app.py | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/runpod_flash/core/resources/app.py b/src/runpod_flash/core/resources/app.py index 2fdce8f3..b545a6e3 100644 --- a/src/runpod_flash/core/resources/app.py +++ b/src/runpod_flash/core/resources/app.py @@ -1,5 +1,4 @@ from pathlib import Path -import requests import asyncio import json from typing import Dict, Optional, Union, Tuple, TYPE_CHECKING, Any, List @@ -335,20 +334,19 @@ async def download_tarball(self, environment_id: str, dest_file: str) -> None: ValueError: If environment has no active artifact requests.HTTPError: If download fails """ - from runpod_flash.core.utils.user_agent import get_user_agent + from runpod_flash.core.utils.http import get_authenticated_requests_session await self._hydrate() result = await self._get_active_artifact(environment_id) url = result["downloadUrl"] - headers = {"User-Agent": get_user_agent()} - with open(dest_file, "wb") as stream: - with requests.get(url, stream=True, headers=headers) as resp: - resp.raise_for_status() - for chunk in resp.iter_content(): - if chunk: - stream.write(chunk) + with get_authenticated_requests_session() as session: + with session.get(url, stream=True) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(): + if chunk: + stream.write(chunk) async def _finalize_upload_build( self, object_key: str, manifest: Dict[str, Any] @@ -465,7 +463,7 @@ async def upload_build(self, tar_path: Union[str, Path]) -> Dict[str, Any]: except json.JSONDecodeError as e: raise ValueError(f"Invalid manifest JSON at {manifest_path}: {e}") from e - from runpod_flash.core.utils.user_agent import get_user_agent + from runpod_flash.core.utils.http import get_authenticated_requests_session await self._hydrate() tarball_size = tar_path.stat().st_size @@ -474,13 +472,12 @@ async def upload_build(self, tar_path: Union[str, Path]) -> Dict[str, Any]: url = result["uploadUrl"] object_key = result["objectKey"] - headers = { - "User-Agent": get_user_agent(), - "Content-Type": TARBALL_CONTENT_TYPE, - } + with get_authenticated_requests_session() as session: + # Override Content-Type for tarball upload + session.headers["Content-Type"] = TARBALL_CONTENT_TYPE - with tar_path.open("rb") as fh: - resp = requests.put(url, data=fh, headers=headers) + with tar_path.open("rb") as fh: + resp = session.put(url, data=fh) resp.raise_for_status() resp = await self._finalize_upload_build(object_key, manifest) From 04c055d6f5619167cedba97ec91b0edacac49cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Fri, 13 Feb 2026 21:59:09 -0800 Subject: [PATCH 12/12] test(http): add comprehensive tests for HTTP client factories Add tests for Content-Type headers and API key override functionality in existing factories. Add complete test suite for new aiohttp factory. New tests: - Content-Type header presence (httpx, requests, aiohttp) - API key override parameter (httpx, requests, aiohttp) - aiohttp session creation with User-Agent - aiohttp API key inclusion/exclusion - aiohttp custom/default timeouts (300s) All 26 tests passing. --- tests/unit/core/utils/test_http.py | 110 +++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/unit/core/utils/test_http.py b/tests/unit/core/utils/test_http.py index 023bb812..9768cb73 100644 --- a/tests/unit/core/utils/test_http.py +++ b/tests/unit/core/utils/test_http.py @@ -2,6 +2,7 @@ import requests from runpod_flash.core.utils.http import ( + get_authenticated_aiohttp_session, get_authenticated_httpx_client, get_authenticated_requests_session, ) @@ -99,6 +100,22 @@ def test_get_authenticated_httpx_client_user_agent_with_auth(self, monkeypatch): assert client.headers["User-Agent"].startswith("Runpod Flash/") assert client.headers["Authorization"] == "Bearer test-key" + def test_includes_content_type_header(self, monkeypatch): + """Client includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + client = get_authenticated_httpx_client() + + assert client.headers["Content-Type"] == "application/json" + + def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + client = get_authenticated_httpx_client(api_key_override="override-key") + + assert client.headers["Authorization"] == "Bearer override-key" + class TestGetAuthenticatedRequestsSession: """Test the get_authenticated_requests_session utility function.""" @@ -169,3 +186,96 @@ def test_get_authenticated_requests_session_user_agent_with_auth(self, monkeypat assert session.headers["User-Agent"].startswith("Runpod Flash/") assert session.headers["Authorization"] == "Bearer test-key" session.close() + + def test_includes_content_type_header(self, monkeypatch): + """Session includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_requests_session() + + assert session.headers["Content-Type"] == "application/json" + session.close() + + def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + session = get_authenticated_requests_session(api_key_override="override-key") + + assert session.headers["Authorization"] == "Bearer override-key" + session.close() + + +class TestGetAuthenticatedAiohttpSession: + """Test aiohttp session factory.""" + + async def test_creates_session_with_user_agent(self, monkeypatch): + """Session includes User-Agent header.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert "User-Agent" in session.headers + assert session.headers["User-Agent"].startswith("Runpod Flash/") + finally: + await session.close() + + async def test_includes_api_key_when_set(self, monkeypatch): + """Session includes Authorization header when RUNPOD_API_KEY set.""" + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + + session = get_authenticated_aiohttp_session() + try: + assert session.headers["Authorization"] == "Bearer test-key" + finally: + await session.close() + + async def test_api_key_override_takes_precedence(self, monkeypatch): + """api_key_override parameter overrides environment variable.""" + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + + session = get_authenticated_aiohttp_session(api_key_override="override-key") + try: + assert session.headers["Authorization"] == "Bearer override-key" + finally: + await session.close() + + async def test_no_auth_header_when_no_api_key(self, monkeypatch): + """No Authorization header when API key not provided.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert "Authorization" not in session.headers + finally: + await session.close() + + async def test_includes_content_type_header(self, monkeypatch): + """Session includes Content-Type: application/json.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert session.headers["Content-Type"] == "application/json" + finally: + await session.close() + + async def test_custom_timeout(self, monkeypatch): + """Custom timeout can be specified.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session(timeout=60.0) + try: + assert session.timeout.total == 60.0 + finally: + await session.close() + + async def test_default_timeout_is_300_seconds(self, monkeypatch): + """Default timeout is 300s for GraphQL operations.""" + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + + session = get_authenticated_aiohttp_session() + try: + assert session.timeout.total == 300.0 + finally: + await session.close()