From 14b8838899cf47114412e39e55371c9883e3a531 Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:29:58 -0800 Subject: [PATCH 1/2] feat: cleanup flash deploy/undeploy/build command output format --- src/runpod_flash/cli/commands/build.py | 399 +++++------------- .../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 | 35 +- 13 files changed, 459 insertions(+), 624 deletions(-) diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 7be24fe5..0ab672d6 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,133 @@ 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", - ) + 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)) - 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}" - ) + flash_dir = project_dir / ".flash" + deployment_manifest_path = flash_dir / "flash_manifest.json" + shutil.copy2(manifest_path, deployment_manifest_path) - progress.stop_task(manifest_task) + manifest_resources = manifest.get("resources", {}) - 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: + original_count = len(requirements) + 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 + excluded_count = original_count - len(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 +393,7 @@ def build_command( output_name=output_name, exclude=exclude, use_local_flash=use_local_flash, + verbose=True, ) except KeyboardInterrupt: @@ -948,7 +820,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 +875,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..7822f1a4 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(f"\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(f"\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..26345b6a 100644 --- a/tests/unit/cli/utils/test_deployment.py +++ b/tests/unit/cli/utils/test_deployment.py @@ -8,7 +8,7 @@ from runpod_flash.cli.utils.deployment import ( provision_resources_for_build, - deploy_to_environment, + deploy_from_uploaded_build, reconcile_and_provision_resources, ) @@ -204,12 +204,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 +221,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 +228,6 @@ async def test_deploy_to_environment_success( "resources_endpoints": {}, } - # Create temporary manifest file import json manifest_dir = tmp_path / ".flash" @@ -240,13 +237,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 +249,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 +282,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 +291,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 +304,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 c16cd727c69e9c1b40d1b186565783d73ce0ae9c Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:45:36 -0800 Subject: [PATCH 2/2] fix: cleanup --- src/runpod_flash/cli/commands/build.py | 4 ---- src/runpod_flash/cli/commands/deploy.py | 4 ++-- tests/unit/cli/utils/test_deployment.py | 1 - 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 0ab672d6..00b152ff 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -243,8 +243,6 @@ def run_build( deployment_manifest_path = flash_dir / "flash_manifest.json" shutil.copy2(manifest_path, deployment_manifest_path) - manifest_resources = manifest.get("resources", {}) - except (ImportError, SyntaxError) as e: console.print(f"[red]Error:[/red] Code analysis failed: {e}") logger.exception("Code analysis failed") @@ -280,7 +278,6 @@ def run_build( # filter out excluded packages if excluded_packages: - original_count = len(requirements) matched_exclusions = set() filtered_requirements = [] @@ -293,7 +290,6 @@ def run_build( filtered_requirements.append(req) requirements = filtered_requirements - excluded_count = original_count - len(requirements) unmatched = set(excluded_packages) - matched_exclusions if unmatched: diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index 7822f1a4..1a34a3aa 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -136,10 +136,10 @@ def _display_post_deployment_guidance( ' -H "Authorization: Bearer $RUNPOD_API_KEY" \\\n' " -d '{\"input\": {}}'" ) - console.print(f"\n[bold]Try it:[/bold]") + console.print("\n[bold]Try it:[/bold]") console.print(f" [dim]{curl_cmd}[/dim]") - console.print(f"\n[bold]Useful commands:[/bold]") + console.print("\n[bold]Useful commands:[/bold]") console.print( f" [dim]flash env get {env_name}[/dim] View environment status" ) diff --git a/tests/unit/cli/utils/test_deployment.py b/tests/unit/cli/utils/test_deployment.py index 26345b6a..1087355d 100644 --- a/tests/unit/cli/utils/test_deployment.py +++ b/tests/unit/cli/utils/test_deployment.py @@ -2,7 +2,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch -from pathlib import Path import pytest