diff --git a/.github/workflows/examples-e2e.yml b/.github/workflows/examples-e2e.yml new file mode 100644 index 00000000..84f81fcc --- /dev/null +++ b/.github/workflows/examples-e2e.yml @@ -0,0 +1,71 @@ +name: Sandbox examples E2E + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +concurrency: + group: sandbox-examples-e2e + cancel-in-progress: false + +permissions: + contents: read + +jobs: + examples: + runs-on: ubuntu-latest + timeout-minutes: 180 + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: + - name: synchronous-core + runner: 00_run_all.py + flows: 01_,02_,03_,04_,05_,06_,07_,08_,09_,10_,11_,12_,13_,14_,15_,20_,21_,22_ + timeout: 900 + - name: asynchronous + runner: 00_run_all_async.py + flows: all + timeout: 1800 + - name: lifecycle + runner: 00_run_all.py + flows: 16_,17_,18_,19_ + timeout: 900 + - name: snapshots + runner: 00_run_all.py + flows: 23_,24_,25_ + timeout: 1800 + name: ${{ matrix.name }} + env: + KOYEB_API_TOKEN: ${{ secrets.KOYEB_API_TOKEN }} + KOYEB_API_HOST: ${{ vars.KOYEB_API_HOST || 'https://app.koyeb.com' }} + KOYEB_PROJECT_ID: ${{ vars.KOYEB_PROJECT_ID }} + KOYEB_REGION: ${{ vars.KOYEB_REGION || 'na' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install --upgrade uv + - run: uv sync --frozen + - run: uv run python examples/${{ matrix.runner }} --flows "${{ matrix.flows }}" --timeout "${{ matrix.timeout }}" + + snapshot-benchmark: + runs-on: ubuntu-latest + timeout-minutes: 180 + env: + KOYEB_API_TOKEN: ${{ secrets.KOYEB_API_TOKEN }} + KOYEB_API_HOST: ${{ vars.KOYEB_API_HOST || 'https://app.koyeb.com' }} + KOYEB_PROJECT_ID: ${{ vars.KOYEB_PROJECT_ID }} + KOYEB_REGION: ${{ vars.KOYEB_REGION || 'na' }} + KOYEB_SNAPSHOT_BENCHMARK_INSTANCE_TYPE: micro + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install --upgrade uv + - run: uv sync --frozen + - run: uv run python examples/26_snapshot_boot_benchmark.py --fs-sizes 1 --full-sizes 1 --boots 1 --csv /tmp/snapshot-boot.csv diff --git a/examples/00_run_all.py b/examples/00_run_all.py index 8a144697..bed6597d 100644 --- a/examples/00_run_all.py +++ b/examples/00_run_all.py @@ -44,12 +44,19 @@ def main(): ] ) + expected_numbers = {f"{number:02d}" for number in range(1, 27)} + present_numbers = {example.name[:2] for example in all_example_files} + missing_numbers = sorted(expected_numbers - present_numbers) + if missing_numbers: + print(f"Missing synchronous example scenarios: {', '.join(missing_numbers)}") + return 1 + # Filter flows based on specification example_files = filter_flows(all_example_files, args.flows) if not example_files: print("No example files match the specified flows") - return 0 + return 1 # Build flow timeout mapping flow_timeouts = build_flow_timeouts(args.flow_timeout) @@ -120,7 +127,7 @@ def main(): "name": example_name, "status": "TIMEOUT", "time": elapsed_time, - "error": "Script exceeded 60 second timeout", + "error": f"Script exceeded {timeout} second timeout", } ) diff --git a/examples/00_run_all_async.py b/examples/00_run_all_async.py index a5c26f75..287f4916 100644 --- a/examples/00_run_all_async.py +++ b/examples/00_run_all_async.py @@ -105,7 +105,7 @@ async def run_example(example_file, timeout): "name": example_name, "status": "TIMEOUT", "time": elapsed_time, - "error": "Script exceeded 60 second timeout" + "error": f"Script exceeded {timeout} second timeout" } elapsed_time = time.time() - start_time @@ -183,7 +183,7 @@ async def main(): if not example_files: print("No async example files match the specified flows") - return 0 + return 1 # Build flow timeout mapping flow_timeouts = build_flow_timeouts(args.flow_timeout) diff --git a/examples/01_create_sandbox.py b/examples/01_create_sandbox.py index 7165ffc8..42b85aa6 100644 --- a/examples/01_create_sandbox.py +++ b/examples/01_create_sandbox.py @@ -28,10 +28,13 @@ def main(): # Check health is_healthy = sandbox.is_healthy() print(f"Healthy: {is_healthy}") + assert is_healthy, "Sandbox should be healthy" # Test command result = sandbox.exec("echo 'Sandbox is ready!'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Sandbox is ready!" return 0 finally: diff --git a/examples/01_create_sandbox_async.py b/examples/01_create_sandbox_async.py index 06138dbc..13415caf 100644 --- a/examples/01_create_sandbox_async.py +++ b/examples/01_create_sandbox_async.py @@ -30,10 +30,13 @@ async def main(): # Check health is_healthy = await sandbox.is_healthy() print(f"Healthy: {is_healthy}") + assert is_healthy, "Sandbox should be healthy" # Test command result = await sandbox.exec("echo 'Sandbox is ready!'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Sandbox is ready!" return 0 finally: diff --git a/examples/02_create_sandbox_with_timing.py b/examples/02_create_sandbox_with_timing.py index 4c597984..2ab102a0 100644 --- a/examples/02_create_sandbox_with_timing.py +++ b/examples/02_create_sandbox_with_timing.py @@ -100,6 +100,7 @@ def main(run_long_tests=False): health_duration = time.time() - health_start tracker.record("Health check", health_duration, "monitoring") print(f" ✓ took {health_duration:.1f}s") + assert is_healthy, "Sandbox should be healthy" # Test command execution with timing print(" → Executing initial test command...") @@ -108,6 +109,8 @@ def main(run_long_tests=False): exec_duration = time.time() - exec_start tracker.record("Initial exec command", exec_duration, "execution") print(f" ✓ took {exec_duration:.1f}s") + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Sandbox is ready!" if run_long_tests: # Long test 1: Install a package @@ -117,6 +120,7 @@ def main(run_long_tests=False): install_duration = time.time() - install_start tracker.record("Package installation", install_duration, "long_tests") print(f" ✓ took {install_duration:.1f}s") + assert result.exit_code == 0, result.stderr # Long test 2: Run a computation print(" → [LONG TEST] Running computation...") @@ -125,6 +129,7 @@ def main(run_long_tests=False): compute_duration = time.time() - compute_start tracker.record("Heavy computation", compute_duration, "long_tests") print(f" ✓ took {compute_duration:.1f}s") + assert result.exit_code == 0, result.stderr # Long test 3: Multiple health checks print(" → [LONG TEST] Multiple health checks...") diff --git a/examples/02_create_sandbox_with_timing_async.py b/examples/02_create_sandbox_with_timing_async.py index a2901d81..372aaabc 100644 --- a/examples/02_create_sandbox_with_timing_async.py +++ b/examples/02_create_sandbox_with_timing_async.py @@ -101,37 +101,42 @@ async def main(run_long_tests=False): # Check health with timing print(" → Checking sandbox health...") health_start = time.time() - await sandbox.is_healthy() + is_healthy = await sandbox.is_healthy() health_duration = time.time() - health_start tracker.record("Health check", health_duration, "monitoring") print(f" ✓ took {health_duration:.1f}s") + assert is_healthy, "Sandbox should be healthy" # Test command execution with timing print(" → Executing initial test command...") exec_start = time.time() - await sandbox.exec("echo 'Sandbox is ready!'") + result = await sandbox.exec("echo 'Sandbox is ready!'") exec_duration = time.time() - exec_start tracker.record("Initial exec command", exec_duration, "execution") print(f" ✓ took {exec_duration:.1f}s") + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Sandbox is ready!" if run_long_tests: # Long test 1: Install a package print(" → [LONG TEST] Installing a package...") install_start = time.time() - await sandbox.exec("pip install requests") + result = await sandbox.exec("pip install requests") install_duration = time.time() - install_start tracker.record("Package installation", install_duration, "long_tests") print(f" ✓ took {install_duration:.1f}s") + assert result.exit_code == 0, result.stderr # Long test 2: Run a computation print(" → [LONG TEST] Running computation...") compute_start = time.time() - await sandbox.exec( + result = await sandbox.exec( "python -c 'import time; sum(range(10000000)); time.sleep(2)'" ) compute_duration = time.time() - compute_start tracker.record("Heavy computation", compute_duration, "long_tests") print(f" ✓ took {compute_duration:.1f}s") + assert result.exit_code == 0, result.stderr # Long test 3: Multiple health checks print(" → [LONG TEST] Multiple health checks...") diff --git a/examples/03_basic_commands.py b/examples/03_basic_commands.py index 2e8103b5..4213d7a9 100644 --- a/examples/03_basic_commands.py +++ b/examples/03_basic_commands.py @@ -28,10 +28,14 @@ def main(): # Simple command result = sandbox.exec("echo 'Hello World'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello World" # Python command result = sandbox.exec("python3 -c 'print(2 + 2)'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "4" # Multi-line Python script result = sandbox.exec( @@ -42,6 +46,9 @@ def main(): "''' ) print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert "Python version:" in result.stdout + assert "Platform:" in result.stdout # Failing command returns non-zero exit code result = sandbox.exec("ls /nonexistent") diff --git a/examples/03_basic_commands_async.py b/examples/03_basic_commands_async.py index 34eb2ea3..4ebf2af6 100644 --- a/examples/03_basic_commands_async.py +++ b/examples/03_basic_commands_async.py @@ -30,10 +30,14 @@ async def main(): # Simple command result = await sandbox.exec("echo 'Hello World'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello World" # Python command result = await sandbox.exec("python3 -c 'print(2 + 2)'") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "4" # Multi-line Python script result = await sandbox.exec( @@ -44,6 +48,9 @@ async def main(): "''' ) print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert "Python version:" in result.stdout + assert "Platform:" in result.stdout return 0 finally: diff --git a/examples/04_streaming_output.py b/examples/04_streaming_output.py index 3f4fe9b0..7cffaec4 100644 --- a/examples/04_streaming_output.py +++ b/examples/04_streaming_output.py @@ -38,6 +38,8 @@ def main(): on_stderr=lambda data: print(f"ERR: {data.strip()}"), ) print(f"\nExit code: {result.exit_code}") + assert result.exit_code == 0, result.stderr + assert "Line 5" in result.stdout # Stream a script sandbox.filesystem.write_file( @@ -50,6 +52,8 @@ def main(): "python3 /tmp/counter.py", on_stdout=lambda data: print(data.strip()), ) + assert result.exit_code == 0, result.stderr + assert "Done!" in result.stdout # Failing command with streaming returns non-zero exit code result = sandbox.exec( diff --git a/examples/04_streaming_output_async.py b/examples/04_streaming_output_async.py index 8301a1c4..3b2ff01e 100644 --- a/examples/04_streaming_output_async.py +++ b/examples/04_streaming_output_async.py @@ -40,6 +40,8 @@ async def main(): on_stderr=lambda data: print(f"ERR: {data.strip()}"), ) print(f"\nExit code: {result.exit_code}") + assert result.exit_code == 0, result.stderr + assert "Line 5" in result.stdout # Stream a script await sandbox.filesystem.write_file( @@ -52,6 +54,8 @@ async def main(): "python3 /tmp/counter.py", on_stdout=lambda data: print(data.strip()), ) + assert result.exit_code == 0, result.stderr + assert "Done!" in result.stdout return 0 finally: diff --git a/examples/05_environment_variables.py b/examples/05_environment_variables.py index 6e88a09e..73c6cc8e 100644 --- a/examples/05_environment_variables.py +++ b/examples/05_environment_variables.py @@ -28,7 +28,11 @@ def main(): try: # Create a secret secret_response = secrets_api.create_secret( - secret=CreateSecret(name=secret_name, value=secret_value) + secret=CreateSecret( + name=secret_name, + value=secret_value, + project_id=os.getenv("KOYEB_PROJECT_ID") or None, + ) ) secret_id = secret_response.secret.id print(f"Created secret: {secret_name}") diff --git a/examples/05_environment_variables_async.py b/examples/05_environment_variables_async.py index 8d4df5dc..04ed89fe 100644 --- a/examples/05_environment_variables_async.py +++ b/examples/05_environment_variables_async.py @@ -29,7 +29,11 @@ async def main(): try: # Create a secret secret_response = secrets_api.create_secret( - secret=CreateSecret(name=secret_name, value=secret_value) + secret=CreateSecret( + name=secret_name, + value=secret_value, + project_id=os.getenv("KOYEB_PROJECT_ID") or None, + ) ) secret_id = secret_response.secret.id print(f"Created secret: {secret_name}") diff --git a/examples/06_working_directory.py b/examples/06_working_directory.py index 26c7ca1c..ad8b3f2c 100644 --- a/examples/06_working_directory.py +++ b/examples/06_working_directory.py @@ -33,14 +33,20 @@ def main(): # Run command in specific directory result = sandbox.exec("pwd", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "/tmp/my_project" # List files in working directory result = sandbox.exec("ls -la", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert "src" in result.stdout # Use relative paths result = sandbox.exec("cat src/main.py", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert 'print("hello")' in result.stdout return 0 diff --git a/examples/06_working_directory_async.py b/examples/06_working_directory_async.py index 73089fa6..5ccbac2d 100644 --- a/examples/06_working_directory_async.py +++ b/examples/06_working_directory_async.py @@ -35,14 +35,20 @@ async def main(): # Run command in specific directory result = await sandbox.exec("pwd", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "/tmp/my_project" # List files in working directory result = await sandbox.exec("ls -la", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert "src" in result.stdout # Use relative paths result = await sandbox.exec("cat src/main.py", cwd="/tmp/my_project") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert 'print("hello")' in result.stdout return 0 diff --git a/examples/07_file_operations.py b/examples/07_file_operations.py index e38ed8e9..d2c5fa74 100644 --- a/examples/07_file_operations.py +++ b/examples/07_file_operations.py @@ -35,6 +35,7 @@ def main(): # Read file file_info = fs.read_file("/tmp/hello.txt") print(file_info.content) + assert file_info.content == content # Write Python script python_code = "#!/usr/bin/env python3\nprint('Hello from Python!')\n" @@ -42,6 +43,8 @@ def main(): sandbox.exec("chmod +x /tmp/script.py") result = sandbox.exec("/tmp/script.py") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello from Python!" return 0 diff --git a/examples/07_file_operations_async.py b/examples/07_file_operations_async.py index 233a9dad..7e0ffa30 100644 --- a/examples/07_file_operations_async.py +++ b/examples/07_file_operations_async.py @@ -37,6 +37,7 @@ async def main(): # Read file file_info = await fs.read_file("/tmp/hello.txt") print(file_info.content) + assert file_info.content == content # Write Python script python_code = "#!/usr/bin/env python3\nprint('Hello from Python!')\n" @@ -44,6 +45,8 @@ async def main(): await sandbox.exec("chmod +x /tmp/script.py") result = await sandbox.exec("/tmp/script.py") print(result.stdout.strip()) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello from Python!" return 0 diff --git a/examples/08_directory_operations.py b/examples/08_directory_operations.py index 72411443..620a35f2 100644 --- a/examples/08_directory_operations.py +++ b/examples/08_directory_operations.py @@ -37,6 +37,7 @@ def main(): # List directory contents = fs.list_dir("/tmp/my_project") print(f"Contents: {contents}") + assert "src" in contents # Create project structure fs.mkdir("/tmp/my_project/src") @@ -49,6 +50,7 @@ def main(): is_dir = fs.is_dir("/tmp/my_project") is_file = fs.is_file("/tmp/my_project/src/main.py") print(f"Exists: {exists}, Is dir: {is_dir}, Is file: {is_file}") + assert exists and is_dir and is_file return 0 diff --git a/examples/08_directory_operations_async.py b/examples/08_directory_operations_async.py index aa65b4cc..8461c28e 100644 --- a/examples/08_directory_operations_async.py +++ b/examples/08_directory_operations_async.py @@ -39,6 +39,7 @@ async def main(): # List directory contents = await fs.list_dir("/tmp/my_project") print(f"Contents: {contents}") + assert "src" in contents # Create project structure await fs.mkdir("/tmp/my_project/src") @@ -51,6 +52,7 @@ async def main(): is_dir = await fs.is_dir("/tmp/my_project") is_file = await fs.is_file("/tmp/my_project/src/main.py") print(f"Exists: {exists}, Is dir: {is_dir}, Is file: {is_file}") + assert exists and is_dir and is_file return 0 diff --git a/examples/10_batch_operations.py b/examples/10_batch_operations.py index bc655b8e..72e9f0dc 100644 --- a/examples/10_batch_operations.py +++ b/examples/10_batch_operations.py @@ -42,6 +42,7 @@ def main(): created_files = fs.ls("/tmp") batch_files = [f for f in created_files if f.startswith("file")] print(f"Files: {batch_files}") + assert set(batch_files) >= {"file1.txt", "file2.txt", "file3.txt"} # Create project structure project_files = [ @@ -53,6 +54,7 @@ def main(): fs.mkdir("/tmp/project") fs.write_files(project_files) print("Created project structure") + assert fs.exists("/tmp/project/main.py") return 0 diff --git a/examples/10_batch_operations_async.py b/examples/10_batch_operations_async.py index da61c9b6..ce2a1a48 100644 --- a/examples/10_batch_operations_async.py +++ b/examples/10_batch_operations_async.py @@ -44,6 +44,7 @@ async def main(): created_files = await fs.ls("/tmp") batch_files = [f for f in created_files if f.startswith("file")] print(f"Files: {batch_files}") + assert set(batch_files) >= {"file1.txt", "file2.txt", "file3.txt"} # Create project structure project_files = [ @@ -55,6 +56,7 @@ async def main(): await fs.mkdir("/tmp/project") await fs.write_files(project_files) print("Created project structure") + assert await fs.exists("/tmp/project/main.py") return 0 diff --git a/examples/11_upload_download.py b/examples/11_upload_download.py index 3d65fd34..1c2d869f 100644 --- a/examples/11_upload_download.py +++ b/examples/11_upload_download.py @@ -39,6 +39,7 @@ def main(): fs.upload_file(local_file, "/tmp/uploaded_file.txt") uploaded_info = fs.read_file("/tmp/uploaded_file.txt") print(uploaded_info.content) + assert uploaded_info.content == "This is a local file\nUploaded to Koyeb Sandbox!" finally: os.unlink(local_file) @@ -53,7 +54,9 @@ def main(): try: fs.download_file("/tmp/download_source.txt", download_path) with open(download_path, "r") as f: - print(f.read()) + downloaded = f.read() + print(downloaded) + assert downloaded == "Download test content\nMultiple lines" finally: os.unlink(download_path) diff --git a/examples/11_upload_download_async.py b/examples/11_upload_download_async.py index cd215143..142042a6 100644 --- a/examples/11_upload_download_async.py +++ b/examples/11_upload_download_async.py @@ -41,6 +41,7 @@ async def main(): await fs.upload_file(local_file, "/tmp/uploaded_file.txt") uploaded_info = await fs.read_file("/tmp/uploaded_file.txt") print(uploaded_info.content) + assert uploaded_info.content == "This is a local file\nUploaded to Koyeb Sandbox!" finally: os.unlink(local_file) @@ -55,7 +56,9 @@ async def main(): try: await fs.download_file("/tmp/download_source.txt", download_path) with open(download_path, "r") as f: - print(f.read()) + downloaded = f.read() + print(downloaded) + assert downloaded == "Download test content\nMultiple lines" finally: os.unlink(download_path) diff --git a/examples/12_file_manipulation.py b/examples/12_file_manipulation.py index 7a98ee01..c517a10d 100644 --- a/examples/12_file_manipulation.py +++ b/examples/12_file_manipulation.py @@ -35,24 +35,34 @@ def main(): # Rename file fs.rename_file("/tmp/file1.txt", "/tmp/renamed_file.txt") - print(f"Renamed: {fs.exists('/tmp/renamed_file.txt')}") + renamed_exists = fs.exists("/tmp/renamed_file.txt") + print(f"Renamed: {renamed_exists}") + assert renamed_exists # Move file fs.move_file("/tmp/file2.txt", "/tmp/test_dir/moved_file.txt") - print(f"Moved: {fs.exists('/tmp/test_dir/moved_file.txt')}") + moved_exists = fs.exists("/tmp/test_dir/moved_file.txt") + print(f"Moved: {moved_exists}") + assert moved_exists # Copy file (read + write) original_content = fs.read_file("/tmp/renamed_file.txt") fs.write_file("/tmp/test_dir/copied_file.txt", original_content.content) - print(f"Copied: {fs.exists('/tmp/test_dir/copied_file.txt')}") + copied_exists = fs.exists("/tmp/test_dir/copied_file.txt") + print(f"Copied: {copied_exists}") + assert copied_exists # Delete file fs.rm("/tmp/renamed_file.txt") - print(f"Deleted: {not fs.exists('/tmp/renamed_file.txt')}") + deleted = not fs.exists("/tmp/renamed_file.txt") + print(f"Deleted: {deleted}") + assert deleted # Delete directory fs.rm("/tmp/test_dir", recursive=True) - print(f"Directory deleted: {not fs.exists('/tmp/test_dir')}") + directory_deleted = not fs.exists("/tmp/test_dir") + print(f"Directory deleted: {directory_deleted}") + assert directory_deleted return 0 diff --git a/examples/12_file_manipulation_async.py b/examples/12_file_manipulation_async.py index f05bb412..f410a8fb 100644 --- a/examples/12_file_manipulation_async.py +++ b/examples/12_file_manipulation_async.py @@ -39,27 +39,32 @@ async def main(): await fs.rename_file("/tmp/file1.txt", "/tmp/renamed_file.txt") renamed_exists = await fs.exists("/tmp/renamed_file.txt") print(f"Renamed: {renamed_exists}") + assert renamed_exists # Move file await fs.move_file("/tmp/file2.txt", "/tmp/test_dir/moved_file.txt") moved_exists = await fs.exists("/tmp/test_dir/moved_file.txt") print(f"Moved: {moved_exists}") + assert moved_exists # Copy file (read + write) original_content = await fs.read_file("/tmp/renamed_file.txt") await fs.write_file("/tmp/test_dir/copied_file.txt", original_content.content) copied_exists = await fs.exists("/tmp/test_dir/copied_file.txt") print(f"Copied: {copied_exists}") + assert copied_exists # Delete file await fs.rm("/tmp/renamed_file.txt") deleted_check = not await fs.exists("/tmp/renamed_file.txt") print(f"Deleted: {deleted_check}") + assert deleted_check # Delete directory await fs.rm("/tmp/test_dir", recursive=True) dir_deleted_check = not await fs.exists("/tmp/test_dir") print(f"Directory deleted: {dir_deleted_check}") + assert dir_deleted_check return 0 diff --git a/examples/13_background_processes.py b/examples/13_background_processes.py index 3375df25..a68c32f8 100755 --- a/examples/13_background_processes.py +++ b/examples/13_background_processes.py @@ -47,6 +47,8 @@ def main(): # List all processes print("\nListing all processes:") processes = sandbox.list_processes() + assert any(process.id == process_id_1 for process in processes) + assert any(process.id == process_id_2 for process in processes) for process in processes: print(f" ID: {process.id}") print(f" Command: {process.command}") @@ -84,6 +86,7 @@ def main(): print("\nKilling all running processes...") killed_count = sandbox.kill_all_processes() print(f"Killed {killed_count} processes") + assert killed_count >= 2 # Final list print("\nFinal process list:") diff --git a/examples/13_background_processes_async.py b/examples/13_background_processes_async.py index cdf10224..44bb854e 100755 --- a/examples/13_background_processes_async.py +++ b/examples/13_background_processes_async.py @@ -48,6 +48,8 @@ async def main(): # List all processes print("\nListing all processes:") processes = await sandbox.list_processes() + assert any(process.id == process_id_1 for process in processes) + assert any(process.id == process_id_2 for process in processes) for process in processes: print(f" ID: {process.id}") print(f" Command: {process.command}") @@ -85,6 +87,7 @@ async def main(): print("\nKilling all running processes...") killed_count = await sandbox.kill_all_processes() print(f"Killed {killed_count} processes") + assert killed_count >= 2 # Final list print("\nFinal process list:") diff --git a/examples/14_expose_port.py b/examples/14_expose_port.py index f3ab27f4..2132d48c 100755 --- a/examples/14_expose_port.py +++ b/examples/14_expose_port.py @@ -44,6 +44,7 @@ def main(): cwd="/tmp", ) print(f"Server started with process ID: {process_id}") + assert process_id # Wait for server to start print("Waiting for server to start...") @@ -53,6 +54,7 @@ def main(): print("\nExposing port 8080...") exposed = sandbox.expose_port(8080) print(f"Port exposed: {exposed.port}") + assert exposed.port == 8080 print(f"Exposed at: {exposed.exposed_at}") # Wait a bit for the port to be ready @@ -66,6 +68,7 @@ def main(): response.raise_for_status() print(f"✓ Request successful! Status: {response.status_code}") print(f"✓ Response content: {response.text.strip()}") + assert "Port 8080" in response.text except httpx.HTTPError as e: print(f"⚠ Request failed: {e}") print("Note: Port may still be propagating. Try again in a few seconds.") @@ -95,6 +98,7 @@ def main(): # Expose the new port (this will automatically unbind the previous port) exposed_2 = sandbox.expose_port(8081) print(f"Port exposed: {exposed_2.port}") + assert exposed_2.port == 8081 print(f"Exposed at: {exposed_2.exposed_at}") # Wait a bit for the port to be ready @@ -108,6 +112,7 @@ def main(): response.raise_for_status() print(f"✓ Request successful! Status: {response.status_code}") print(f"✓ Response content: {response.text.strip()}") + assert "Port 8081" in response.text except httpx.HTTPError as e: print(f"⚠ Request failed: {e}") print("Note: Port may still be propagating. Try again in a few seconds.") diff --git a/examples/14_expose_port_async.py b/examples/14_expose_port_async.py index ced41631..7e76059f 100755 --- a/examples/14_expose_port_async.py +++ b/examples/14_expose_port_async.py @@ -44,6 +44,7 @@ async def main(): cwd="/tmp", ) print(f"Server started with process ID: {process_id}") + assert process_id # Wait for server to start print("Waiting for server to start...") @@ -53,6 +54,7 @@ async def main(): print("\nExposing port 8080...") exposed = await sandbox.expose_port(8080) print(f"Port exposed: {exposed.port}") + assert exposed.port == 8080 print(f"Exposed at: {exposed.exposed_at}") # Wait a bit for the port to be ready @@ -67,9 +69,11 @@ async def main(): response.raise_for_status() print(f"✓ Request successful! Status: {response.status_code}") print(f"✓ Response content: {response.text.strip()}") + assert "Port 8080" in response.text except Exception as e: print(f"⚠ Request failed: {e}") print("Note: Port may still be propagating. Try again in a few seconds.") + raise # List processes to show the server is running print("\nRunning processes:") @@ -95,6 +99,7 @@ async def main(): # Expose the new port (this will automatically unbind the previous port) exposed_2 = await sandbox.expose_port(8081) print(f"Port exposed: {exposed_2.port}") + assert exposed_2.port == 8081 print(f"Exposed at: {exposed_2.exposed_at}") # Wait a bit for the port to be ready @@ -109,9 +114,11 @@ async def main(): response.raise_for_status() print(f"✓ Request successful! Status: {response.status_code}") print(f"✓ Response content: {response.text.strip()}") + assert "Port 8081" in response.text except Exception as e: print(f"⚠ Request failed: {e}") print("Note: Port may still be propagating. Try again in a few seconds.") + raise # Unexpose the port print("\nUnexposing port...") diff --git a/examples/15_get_sandbox.py b/examples/15_get_sandbox.py index 8cc9e4b0..b239adc0 100644 --- a/examples/15_get_sandbox.py +++ b/examples/15_get_sandbox.py @@ -56,11 +56,13 @@ def main(): # Check health is_healthy = retrieved_sandbox.is_healthy() print(f" Healthy: {is_healthy}") + assert is_healthy, "Retrieved sandbox should be healthy" # Execute a command with the retrieved sandbox - if is_healthy: - result = retrieved_sandbox.exec("echo 'Hello from retrieved sandbox!'") - print(f" Retrieved sandbox output: {result.stdout.strip()}") + result = retrieved_sandbox.exec("echo 'Hello from retrieved sandbox!'") + print(f" Retrieved sandbox output: {result.stdout.strip()}") + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello from retrieved sandbox!" return 0 diff --git a/examples/15_get_sandbox_async.py b/examples/15_get_sandbox_async.py index f4fbfd97..ca91b7b4 100644 --- a/examples/15_get_sandbox_async.py +++ b/examples/15_get_sandbox_async.py @@ -58,13 +58,13 @@ async def main(): # Check health is_healthy = await retrieved_sandbox.is_healthy() print(f" Healthy: {is_healthy}") + assert is_healthy, "Retrieved sandbox should be healthy" # Execute a command with the retrieved sandbox - if is_healthy: - result = await retrieved_sandbox.exec( - "echo 'Hello from retrieved sandbox!'" - ) - print(f" Retrieved sandbox output: {result.stdout.strip()}") + result = await retrieved_sandbox.exec("echo 'Hello from retrieved sandbox!'") + print(f" Retrieved sandbox output: {result.stdout.strip()}") + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello from retrieved sandbox!" return 0 diff --git a/examples/16_create_sandbox_with_auto_delete_simple.py b/examples/16_create_sandbox_with_auto_delete_simple.py index b9caa394..4ee5a333 100644 --- a/examples/16_create_sandbox_with_auto_delete_simple.py +++ b/examples/16_create_sandbox_with_auto_delete_simple.py @@ -81,7 +81,7 @@ def main(): elapsed = time.time() - start print(f" ... still waiting ({elapsed:.0f}s elapsed)") else: - print(f"✗ Timeout waiting for sandbox to be deleted") + raise AssertionError("Timeout waiting for sandbox to be deleted") return 0 diff --git a/examples/17_create_sandbox_with_auto_delete.py b/examples/17_create_sandbox_with_auto_delete.py index 82440117..597f9ea0 100644 --- a/examples/17_create_sandbox_with_auto_delete.py +++ b/examples/17_create_sandbox_with_auto_delete.py @@ -159,6 +159,7 @@ def main(): assert sandbox1.is_healthy(), "Sandbox 1 should be healthy" result = sandbox1.exec("echo 'Sandbox 1 ready'") print(f" ✓ {result.stdout.strip()}") + assert result.exit_code == 0, result.stderr print() # ===================================================================== @@ -202,6 +203,7 @@ def main(): assert sandbox2.is_healthy(), "Sandbox 2 should be healthy" result = sandbox2.exec("echo 'Sandbox 2 ready'") print(f" ✓ {result.stdout.strip()}") + assert result.exit_code == 0, result.stderr print() # ===================================================================== @@ -263,6 +265,9 @@ def main(): if not sandbox2_deleted: print(" - Sandbox 2 was not deleted") + assert sandbox1_deleted, "Sandbox 1 was not auto-deleted" + assert sandbox2_deleted, "Sandbox 2 was not auto-deleted" + # Clear sandbox references so finally block doesn't try to delete them if sandbox1_deleted: sandbox1 = None diff --git a/examples/18_create_sandbox_with_existing_app.py b/examples/18_create_sandbox_with_existing_app.py index 2bac3b32..a55bf46a 100644 --- a/examples/18_create_sandbox_with_existing_app.py +++ b/examples/18_create_sandbox_with_existing_app.py @@ -34,7 +34,12 @@ def main(): app_name = f"my-sandbox-app-{int(time.time())}" print(f" Creating app: {app_name}") - app_response = apps_api.create_app(app=CreateApp(name=app_name)) + app_response = apps_api.create_app( + app=CreateApp( + name=app_name, + project_id=os.getenv("KOYEB_PROJECT_ID") or None, + ) + ) app_id = app_response.app.id print(f" App created successfully!") @@ -75,9 +80,12 @@ def main(): is_healthy = sandbox.is_healthy() print(f" Healthy: {is_healthy}") + assert is_healthy, "Sandbox should be healthy" result = sandbox.exec("echo 'Hello from sandbox in existing app!'") print(f" Output: {result.stdout.strip()}") + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "Hello from sandbox in existing app!" print() print("Demo completed successfully!") diff --git a/examples/20_config_files.py b/examples/20_config_files.py index f4a7d710..6a57eb7c 100644 --- a/examples/20_config_files.py +++ b/examples/20_config_files.py @@ -35,7 +35,11 @@ def main(): try: # Create a secret secret_response = secrets_api.create_secret( - secret=CreateSecret(name=secret_name, value=secret_value) + secret=CreateSecret( + name=secret_name, + value=secret_value, + project_id=os.getenv("KOYEB_PROJECT_ID") or None, + ) ) secret = secret_response.secret secret_id = secret.id diff --git a/examples/20_config_files_async.py b/examples/20_config_files_async.py index f64f1470..e3663be3 100644 --- a/examples/20_config_files_async.py +++ b/examples/20_config_files_async.py @@ -36,7 +36,11 @@ async def main(): try: # Create a secret secret_response = secrets_api.create_secret( - secret=CreateSecret(name=secret_name, value=secret_value) + secret=CreateSecret( + name=secret_name, + value=secret_value, + project_id=os.getenv("KOYEB_PROJECT_ID") or None, + ) ) secret = secret_response.secret secret_id = secret.id diff --git a/examples/23_snapshot_and_spawn.py b/examples/23_snapshot_and_spawn.py index 83c6e48a..15b5b12d 100644 --- a/examples/23_snapshot_and_spawn.py +++ b/examples/23_snapshot_and_spawn.py @@ -66,6 +66,7 @@ def main(): print("✓ Waiting for spawned sandbox to be ready...") is_ready = sbx2.wait_ready(timeout=300) print(" ✓ Sandbox is ready") + assert is_ready, "Spawned sandbox should be ready" # Verify filesystem is preserved from snapshot print("✓ Verifying filesystem is preserved from snapshot...") diff --git a/examples/25_full_snapshot_and_spawn.py b/examples/25_full_snapshot_and_spawn.py index 7e1dd6ab..fca1182b 100644 --- a/examples/25_full_snapshot_and_spawn.py +++ b/examples/25_full_snapshot_and_spawn.py @@ -77,6 +77,7 @@ def main(): print("✓ Waiting for spawned sandbox to be ready...") is_ready = sbx2.wait_ready(timeout=300) print(" ✓ Sandbox is ready") + assert is_ready, "Spawned sandbox should be ready" # Verify filesystem is preserved from snapshot print("✓ Verifying full snapshot...") diff --git a/examples/25_full_snapshot_and_spawn_async.py b/examples/25_full_snapshot_and_spawn_async.py index 3e78fe78..9404f0bc 100644 --- a/examples/25_full_snapshot_and_spawn_async.py +++ b/examples/25_full_snapshot_and_spawn_async.py @@ -78,6 +78,7 @@ async def main(): print("✓ Waiting for spawned sandbox to be ready...") is_ready = sbx2.wait_ready(timeout=300) print(" ✓ Sandbox is ready") + assert is_ready, "Spawned sandbox should be ready" # Verify filesystem is preserved from snapshot print("✓ Verifying filesystem is preserved from full snapshot...") @@ -103,4 +104,4 @@ async def main(): if __name__ == "__main__": - sys.exit(asyncio.run(main())) \ No newline at end of file + sys.exit(asyncio.run(main())) diff --git a/examples/26_snapshot_boot_benchmark.py b/examples/26_snapshot_boot_benchmark.py index 75c11e4a..e2889e43 100755 --- a/examples/26_snapshot_boot_benchmark.py +++ b/examples/26_snapshot_boot_benchmark.py @@ -28,6 +28,9 @@ from koyeb.sandbox import Snapshot, SnapshotType +INSTANCE_TYPE = os.getenv("KOYEB_SNAPSHOT_BENCHMARK_INSTANCE_TYPE", "xlarge") + + @dataclass class BootTiming: """Record of a single boot timing measurement.""" @@ -279,7 +282,7 @@ def benchmark_boot_from_snapshot( sbx = Sandbox.create( name=boot_name, snapshot=snapshot, - instance_type="xlarge", + instance_type=INSTANCE_TYPE, wait_ready=True, timeout=1200, # 20 minutes timeout for boot api_token=api_token, @@ -379,7 +382,7 @@ def run_filesystem_benchmarks( print(" → Creating builder sandbox...") builder = Sandbox.create( name=f"bench-builder-fs-{size_mb}mb-{suffix}".lower(), - instance_type="xlarge", + instance_type=INSTANCE_TYPE, wait_ready=True, timeout=600, api_token=api_token, @@ -448,7 +451,7 @@ def run_full_benchmarks( builder = Sandbox.create( name=f"bench-builder-full-{size_mb}mb-{suffix}".lower(), image="koyeb/sandbox", # Standard sandbox image - instance_type="xlarge", + instance_type=INSTANCE_TYPE, wait_ready=True, timeout=600, api_token=api_token, @@ -546,7 +549,7 @@ def main(): print(f" Filesystem sizes: {fs_sizes}") print(f" Full snapshot sizes: {full_sizes}") print(f" Boots per snapshot: {args.boots}") - print(f" Instance type: xlarge") + print(f" Instance type: {INSTANCE_TYPE}") print(f" Output: {args.csv}") all_timings: List[BootTiming] = [] @@ -576,6 +579,8 @@ def main(): # Write results write_csv_results(all_timings, args.csv) print_summary(all_results) + assert all_results, "No benchmark results were produced" + assert all(len(result.boot_times) == args.boots for result in all_results) print("\n✓ Benchmark completed successfully!") return 0 diff --git a/examples/README.md b/examples/README.md index 4641a5e5..2f5314cb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,11 +7,32 @@ A collection of examples demonstrating the Koyeb Sandbox SDK capabilities. ```bash # Set your API token export KOYEB_API_TOKEN=your_api_token_here +export KOYEB_PROJECT_ID=your_project_id # Optional +export KOYEB_REGION=na # Optional # Run individual examples uv run python examples/01_create_sandbox.py ``` +Each numbered example checks its result with assertions. + +Run all synchronous examples: + +```bash +uv run python examples/00_run_all.py +``` + +Run all asynchronous examples: + +```bash +uv run python examples/00_run_all_async.py +``` + +The GitHub Actions workflow needs the `KOYEB_API_TOKEN` repository secret. +It also accepts `KOYEB_API_HOST`, `KOYEB_PROJECT_ID`, and `KOYEB_REGION` repository variables. +The API token selects the Koyeb organization. +`KOYEB_PROJECT_ID` selects the project inside that organization. + ## Examples - **01_create_sandbox.py** - Create and manage sandbox instances diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index c59614db..613c24fb 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -138,6 +138,7 @@ def create( delete_after_delay: int = 0, delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, + project_id: Optional[str] = None, enable_mesh: bool = None, poll_interval: float = DEFAULT_POLL_INTERVAL, entrypoint: Optional[List[str]] = None, @@ -184,6 +185,7 @@ def create( delete_after_inactivity_delay: If >0, automatically delete the sandbox if service sleeps due to inactivity after this many seconds. app_id: If provided, create the sandbox service in an existing app instead of creating a new one. + project_id: Project for new sandbox apps and services. Defaults to KOYEB_PROJECT_ID. enable_mesh: Enable or disable mesh for this sandbox. Disabled by default poll_interval: Time between health checks in seconds when wait_ready is True (default: 0.5) entrypoint: Override the default entrypoint of the Docker image (e.g., ["/bin/sh", "-c"]) @@ -240,6 +242,9 @@ def create( "API token is required. Set KOYEB_API_TOKEN environment variable or pass api_token parameter" ) + if project_id is None: + project_id = os.getenv("KOYEB_PROJECT_ID") or None + # Handle snapshot parameter (can be Snapshot object or snapshot name/ID string) actual_snapshot_id = None actual_snapshot_type = None @@ -297,6 +302,7 @@ def create( delete_after_delay=delete_after_delay, delete_after_inactivity_delay=delete_after_inactivity_delay, app_id=app_id, + project_id=project_id, enable_mesh=enable_mesh, poll_interval=poll_interval, entrypoint=entrypoint, @@ -342,6 +348,7 @@ def _create_sync( delete_after_delay: int = 0, delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, + project_id: Optional[str] = None, enable_mesh: bool = None, poll_interval: float = DEFAULT_POLL_INTERVAL, entrypoint: Optional[List[str]] = None, @@ -381,7 +388,9 @@ def _create_sync( app_name = f"sandbox-app-{name}-{int(time.time())}" app_response = apps_api.create_app( app=CreateApp( - name=app_name, life_cycle=AppLifeCycle(delete_when_empty=True) + name=app_name, + life_cycle=AppLifeCycle(delete_when_empty=True), + project_id=project_id, ) ) app_id = app_response.app.id @@ -457,6 +466,7 @@ def _create_sync( life_cycle=service_life_cycle, instance_snapshot_id=snapshot_id, name=name, + project_id=project_id, ) else: # For FILESYSTEM snapshots (or unknown), provide definition @@ -466,6 +476,7 @@ def _create_sync( life_cycle=service_life_cycle, instance_snapshot_id=snapshot_id, name=name, + project_id=project_id, ) else: # No snapshot, create normally with definition @@ -474,6 +485,7 @@ def _create_sync( definition=deployment_definition, life_cycle=service_life_cycle, name=name, + project_id=project_id, ) service_response = services_api.create_service(service=create_service) service_id = service_response.service.id @@ -1698,6 +1710,7 @@ async def create( delete_after_delay: int = 0, delete_after_inactivity_delay: int = 0, app_id: Optional[str] = None, + project_id: Optional[str] = None, enable_mesh: bool = False, poll_interval: float = DEFAULT_POLL_INTERVAL, entrypoint: Optional[List[str]] = None, @@ -1746,6 +1759,7 @@ async def create( delete_after_inactivity_delay: If >0, automatically delete the sandbox if service sleeps due to inactivity after this many seconds. app_id: If provided, create the sandbox service in an existing app instead of creating a new one. + project_id: Project for new sandbox apps and services. Defaults to KOYEB_PROJECT_ID. enable_mesh: Enable or disable mesh for this sandbox. Disabled by default poll_interval: Time between health checks in seconds when wait_ready is True (default: 0.5) entrypoint: Override the default entrypoint of the Docker image (e.g., ["/bin/sh", "-c"]) @@ -1776,6 +1790,9 @@ async def create( "API token is required. Set KOYEB_API_TOKEN environment variable or pass api_token parameter" ) + if project_id is None: + project_id = os.getenv("KOYEB_PROJECT_ID") or None + # Handle snapshot parameter (can be Snapshot object or snapshot name/ID string) actual_snapshot_id = None actual_snapshot_type = None @@ -1843,7 +1860,9 @@ async def create( app_name = f"sandbox-app-{name}-{int(time.time())}" app_response = await clients.apps.create_app( app=AsyncCreateApp( - name=app_name, life_cycle=AsyncAppLifeCycle(delete_when_empty=True) + name=app_name, + life_cycle=AsyncAppLifeCycle(delete_when_empty=True), + project_id=project_id, ) ) app_id = app_response.app.id @@ -1893,6 +1912,7 @@ async def create( life_cycle=service_life_cycle, instance_snapshot_id=actual_snapshot_id, name=name, + project_id=project_id, ) else: # For FILESYSTEM snapshots (or unknown), provide definition @@ -1924,6 +1944,7 @@ async def create( life_cycle=service_life_cycle, instance_snapshot_id=actual_snapshot_id, name=name, + project_id=project_id, ) else: # No snapshot, create normally with definition @@ -1954,6 +1975,7 @@ async def create( definition=deployment_definition.to_dict(), life_cycle=service_life_cycle, name=name, + project_id=project_id, ) service_response = await clients.services.create_service(service=create_service) service_id = service_response.service.id diff --git a/koyeb/sandbox/snapshot.py b/koyeb/sandbox/snapshot.py index c2395936..0315098a 100644 --- a/koyeb/sandbox/snapshot.py +++ b/koyeb/sandbox/snapshot.py @@ -341,6 +341,8 @@ def spawn( create_params["api_token"] = self.api_token if self.host: create_params["host"] = self.host + if self.project_id: + create_params["project_id"] = self.project_id if self.sandbox_secret: create_params["sandbox_secret"] = self.sandbox_secret diff --git a/koyeb/sandbox/test_egress_policy.py b/koyeb/sandbox/test_egress_policy.py index 3a927b2b..ddfbd092 100644 --- a/koyeb/sandbox/test_egress_policy.py +++ b/koyeb/sandbox/test_egress_policy.py @@ -44,6 +44,24 @@ def test_allowlist_sends_deny_all_with_destinations(self, mock_get_clients): [d.cidr for d in egress.allow_list], ["1.2.3.4/32", "10.0.0.0/8"] ) + @patch("koyeb.sandbox.sandbox.get_api_clients") + def test_project_id_scopes_created_app_and_service(self, mock_get_clients): + clients = MagicMock() + clients.apps.create_app.return_value.app.id = "mock-app-id" + mock_get_clients.return_value = clients + + Sandbox.create( + name="t", + api_token="tok", + project_id="project-id", + wait_ready=False, + ) + + app = clients.apps.create_app.call_args.kwargs["app"] + service = clients.services.create_service.call_args.kwargs["service"] + self.assertEqual(app.project_id, "project-id") + self.assertEqual(service.project_id, "project-id") + @patch("koyeb.sandbox.sandbox.get_api_clients") def test_mutually_exclusive_fails_before_any_api_call(self, mock_get_clients): with self.assertRaises(EgressPolicyError): @@ -72,6 +90,28 @@ def test_async_create_forwards_egress_kwargs(self, mock_get_clients): egress = service.definition.network_policy.egress self.assertEqual(egress.mode, EgressPolicyMode.EGRESS_POLICY_MODE_DENY_ALL) + @patch("koyeb.sandbox.utils.get_async_api_clients") + def test_async_project_id_scopes_created_app_and_service(self, mock_get_clients): + clients = MagicMock() + clients.apps.create_app = AsyncMock() + clients.apps.create_app.return_value.app.id = "mock-app-id" + clients.services.create_service = AsyncMock() + mock_get_clients.return_value = clients + + asyncio.run( + AsyncSandbox.create( + name="t", + api_token="tok", + project_id="project-id", + wait_ready=False, + ) + ) + + app = clients.apps.create_app.call_args.kwargs["app"] + service = clients.services.create_service.call_args.kwargs["service"] + self.assertEqual(app.project_id, "project-id") + self.assertEqual(service.project_id, "project-id") + @patch("koyeb.sandbox.utils.get_async_api_clients") def test_async_mutually_exclusive_fails_before_any_api_call(self, mock_get_clients): with self.assertRaises(EgressPolicyError): diff --git a/koyeb/sandbox/test_snapshot.py b/koyeb/sandbox/test_snapshot.py new file mode 100644 index 00000000..fe8677b7 --- /dev/null +++ b/koyeb/sandbox/test_snapshot.py @@ -0,0 +1,24 @@ +from datetime import datetime +from unittest.mock import patch + +from koyeb.sandbox.sandbox import Sandbox +from koyeb.sandbox.snapshot import Snapshot, SnapshotStatus, SnapshotType + + +@patch.object(Sandbox, "create") +def test_spawn_preserves_snapshot_project(mock_create): + snapshot = Snapshot( + id="snapshot-id", + name="snapshot", + service_id="service-id", + snapshot_type=SnapshotType.FILESYSTEM, + status=SnapshotStatus.AVAILABLE, + created_at=datetime.now(), + project_id="project-id", + api_token="token", + sandbox_secret="secret", + ) + + snapshot.spawn(name="restored") + + assert mock_create.call_args.kwargs["project_id"] == "project-id"