diff --git a/docs/sandbox.md b/docs/sandbox.md index a0738f10..ca98793c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -992,6 +992,7 @@ def create(cls, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, env: Optional[Dict[str, str]] = None, + config_files: Optional[Dict[str, str]] = None, region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, @@ -1020,6 +1021,8 @@ Create a new sandbox instance. If None, defaults to "http". If provided, must be one of "http" or "http2". - `env` - Environment variables +- `config_files` - Config files to create in the sandbox, as a dictionary mapping + file paths to file contents (e.g., {"/etc/myapp/config.yaml": "key: value"}) - `region` - Region to deploy to. Defaults to KOYEB_REGION env var, or "na" if not set. - `api_token` - Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) - `timeout` - Timeout for sandbox creation in seconds @@ -1518,6 +1521,7 @@ async def create(cls, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, env: Optional[Dict[str, str]] = None, + config_files: Optional[Dict[str, str]] = None, region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, @@ -1546,6 +1550,8 @@ Create a new sandbox instance with async support. If None, defaults to "http". If provided, must be one of "http" or "http2". - `env` - Environment variables +- `config_files` - Config files to create in the sandbox, as a dictionary mapping + file paths to file contents (e.g., {"/etc/myapp/config.yaml": "key: value"}) - `region` - Region to deploy to. Defaults to KOYEB_REGION env var, or "na" if not set. - `api_token` - Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) - `timeout` - Timeout for sandbox creation in seconds @@ -1802,16 +1808,24 @@ seconds seconds for HTTP requests - + -#### get\_api\_client +## ApiClients Objects ```python -def get_api_client( - api_token: Optional[str] = None, - host: Optional[str] = None -) -> tuple[AppsApi, ServicesApi, InstancesApi, CatalogInstancesApi, - DeploymentsApi] +@dataclass(frozen=True) +class ApiClients() +``` + +Bundle of Koyeb API clients sharing a single underlying ApiClient. + + + +#### get\_api\_clients + +```python +def get_api_clients(api_token: Optional[str] = None, + host: Optional[str] = None) -> ApiClients ``` Get configured API clients for Koyeb operations. @@ -1824,7 +1838,7 @@ Get configured API clients for Koyeb operations. **Returns**: - Tuple of (AppsApi, ServicesApi, InstancesApi, CatalogInstancesApi) instances + ApiClients with apps, services, instances, catalog_instances, deployments, and secrets attributes **Raises**: @@ -1850,6 +1864,26 @@ Build environment variables list from dictionary. List of DeploymentEnv objects + + +#### build\_config\_files + +```python +def build_config_files( + config_files: Optional[Dict[str, str]]) -> List[ConfigFile] +``` + +Build config files list from dictionary. + +**Arguments**: + +- `config_files` - Dictionary mapping file paths to file contents + + +**Returns**: + + List of ConfigFile objects + #### create\_docker\_source @@ -1952,7 +1986,9 @@ def create_deployment_definition( enable_tcp_proxy: bool = False, _experimental_enable_light_sleep: bool = False, _experimental_deep_sleep_value: int = 3900, - enable_mesh: bool = None) -> DeploymentDefinition + enable_mesh: bool = None, + config_files: Optional[List[ConfigFile]] = None +) -> DeploymentDefinition ``` Create deployment definition for a sandbox service. diff --git a/examples/02_create_sandbox_with_timing.py b/examples/02_create_sandbox_with_timing.py index a39fc376..492779c5 100644 --- a/examples/02_create_sandbox_with_timing.py +++ b/examples/02_create_sandbox_with_timing.py @@ -88,7 +88,6 @@ def main(run_long_tests=False): name=f"example-sandbox-timed-{suffix}", wait_ready=True, api_token=api_token, - # poll_interval=0.1, ) create_duration = time.time() - create_start tracker.record("Sandbox creation", create_duration, "setup") diff --git a/examples/05_environment_variables.py b/examples/05_environment_variables.py index 621abdca..2ad9bb52 100644 --- a/examples/05_environment_variables.py +++ b/examples/05_environment_variables.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 -"""Environment variables in commands""" +"""Environment variables with secrets and interpolation""" import os import sys - - import random import string + from koyeb import Sandbox +from koyeb.api.models.create_secret import CreateSecret +from koyeb.sandbox.utils import get_api_clients def main(): @@ -16,36 +17,65 @@ def main(): print("Error: KOYEB_API_TOKEN not set") return 1 - sandbox = None suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + secret_name = f"test-secret-{suffix}" + secret_value = "secret-value-123" + + secrets_api = get_api_clients(api_token).secrets + + sandbox = None + secret_id = None try: + # Create a secret + secret_response = secrets_api.create_secret( + secret=CreateSecret(name=secret_name, value=secret_value) + ) + secret_id = secret_response.secret.id + print(f"Created secret: {secret_name}") + + # Create sandbox with env vars referencing the secret and using interpolation sandbox = Sandbox.create( image="koyeb/sandbox", name=f"env-vars-{suffix}", wait_ready=True, api_token=api_token, + env={ + "SECRET_VAL": "{{ secret." + secret_name + " }}", + "X": "2", + "Y": "{{ X }}", + }, + ) + + # Check secret reference + result = sandbox.exec('echo "$SECRET_VAL"') + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" ) + print(f"SECRET_VAL={result.stdout.strip()}") - # Set environment variables - env_vars = {"MY_VAR": "Hello", "DEBUG": "true"} - result = sandbox.exec("env | grep MY_VAR", env=env_vars) - print(result.stdout.strip()) + # Check direct value + result = sandbox.exec('echo "$X"') + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" + ) + print(f"X={result.stdout.strip()}") - # Use in Python command - result = sandbox.exec( - 'python3 -c "import os; print(os.getenv(\'MY_VAR\'))"', - env={"MY_VAR": "Hello from Python!"}, + # Check interpolation + result = sandbox.exec('echo "$Y"') + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" ) - print(result.stdout.strip()) + print(f"Y={result.stdout.strip()}") return 0 except Exception as e: print(f"Error: {e}") return 1 - finally: if sandbox: sandbox.delete() + if secret_id: + secrets_api.delete_secret(id=secret_id) if __name__ == "__main__": diff --git a/examples/05_environment_variables_async.py b/examples/05_environment_variables_async.py index a1182e51..ba23fa76 100644 --- a/examples/05_environment_variables_async.py +++ b/examples/05_environment_variables_async.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 -"""Environment variables in commands (async variant)""" +"""Environment variables with secrets and interpolation (async variant)""" import asyncio -import sys import os import sys - - import random import string + from koyeb import AsyncSandbox +from koyeb.api.models.create_secret import CreateSecret +from koyeb.sandbox.utils import get_api_clients async def main(): @@ -18,36 +18,65 @@ async def main(): print("Error: KOYEB_API_TOKEN not set") return 1 - sandbox = None suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + secret_name = f"test-secret-{suffix}" + secret_value = "secret-value-123" + + secrets_api = get_api_clients(api_token).secrets + + sandbox = None + secret_id = None try: + # Create a secret + secret_response = secrets_api.create_secret( + secret=CreateSecret(name=secret_name, value=secret_value) + ) + secret_id = secret_response.secret.id + print(f"Created secret: {secret_name}") + + # Create sandbox with env vars referencing the secret and using interpolation sandbox = await AsyncSandbox.create( image="koyeb/sandbox", name=f"env-vars-{suffix}", wait_ready=True, api_token=api_token, + env={ + "SECRET_VAL": "{{ secret." + secret_name + " }}", + "X": "2", + "Y": "{{ X }}", + }, ) - # Set environment variables - env_vars = {"MY_VAR": "Hello", "DEBUG": "true"} - result = await sandbox.exec("env | grep MY_VAR", env=env_vars) - print(result.stdout.strip()) + # Check secret reference + result = await sandbox.exec('echo "$SECRET_VAL"') + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" + ) + print(f"SECRET_VAL={result.stdout.strip()}") - # Use in Python command - result = await sandbox.exec( - 'python3 -c "import os; print(os.getenv(\'MY_VAR\'))"', - env={"MY_VAR": "Hello from Python!"}, + # Check direct value + result = await sandbox.exec('echo "$X"') + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" ) - print(result.stdout.strip()) + print(f"X={result.stdout.strip()}") + + # Check interpolation + result = await sandbox.exec('echo "$Y"') + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" + ) + print(f"Y={result.stdout.strip()}") return 0 except Exception as e: print(f"Error: {e}") return 1 - finally: if sandbox: await sandbox.delete() + if secret_id: + secrets_api.delete_secret(id=secret_id) if __name__ == "__main__": diff --git a/examples/16_create_sandbox_with_auto_delete_simple.py b/examples/16_create_sandbox_with_auto_delete_simple.py index d5c49551..13da686f 100644 --- a/examples/16_create_sandbox_with_auto_delete_simple.py +++ b/examples/16_create_sandbox_with_auto_delete_simple.py @@ -9,13 +9,13 @@ import random import string from koyeb import Sandbox -from koyeb.sandbox.utils import get_api_client +from koyeb.sandbox.utils import get_api_clients def service_exists(api_token: str, service_id: str) -> bool: """Check if a service still exists""" try: - _, services_api, _, _, _ = get_api_client(api_token) + services_api = get_api_clients(api_token).services services_api.get_service(service_id) return True except Exception: diff --git a/examples/17_create_sandbox_with_auto_delete.py b/examples/17_create_sandbox_with_auto_delete.py index 1b9badd2..88a0928e 100644 --- a/examples/17_create_sandbox_with_auto_delete.py +++ b/examples/17_create_sandbox_with_auto_delete.py @@ -10,7 +10,7 @@ from datetime import datetime from koyeb import Sandbox -from koyeb.sandbox.utils import get_api_client +from koyeb.sandbox.utils import get_api_clients class TimingTracker: @@ -66,7 +66,7 @@ def print_recap(self): def get_service_lifecycle(api_token: str, service_id: str): """Fetch and return the service lifecycle settings from the API""" - _, services_api, _, _, _ = get_api_client(api_token) + services_api = get_api_clients(api_token).services service_response = services_api.get_service(service_id) return service_response.service.life_cycle @@ -74,7 +74,7 @@ def get_service_lifecycle(api_token: str, service_id: str): def service_exists(api_token: str, service_id: str) -> bool: """Check if a service still exists""" try: - _, services_api, _, _, _ = get_api_client(api_token) + services_api = get_api_clients(api_token).services services_api.get_service(service_id) return True except Exception: diff --git a/examples/18_create_sandbox_with_existing_app.py b/examples/18_create_sandbox_with_existing_app.py index 4ff6def0..a27c8d97 100644 --- a/examples/18_create_sandbox_with_existing_app.py +++ b/examples/18_create_sandbox_with_existing_app.py @@ -9,7 +9,7 @@ from koyeb import Sandbox from koyeb.api.models.create_app import CreateApp -from koyeb.sandbox.utils import get_api_client +from koyeb.sandbox.utils import get_api_clients def main(): @@ -29,7 +29,7 @@ def main(): print("=" * 60) print() - apps_api, _, _, _, _ = get_api_client(api_token) + apps_api = get_api_clients(api_token).apps app_name = f"my-sandbox-app-{int(time.time())}" print(f" Creating app: {app_name}") diff --git a/examples/19_config_files.py b/examples/19_config_files.py new file mode 100644 index 00000000..a7ed205f --- /dev/null +++ b/examples/19_config_files.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Config files with secrets and interpolation""" + +import os +import sys +import random +import string + +from koyeb import ConfigFile, Sandbox +from koyeb.api import ApiClient, Configuration +from koyeb.api.api.secrets_api import SecretsApi +from koyeb.api.models.create_secret import CreateSecret + + +def main(): + api_token = os.getenv("KOYEB_API_TOKEN") + if not api_token: + print("Error: KOYEB_API_TOKEN not set") + return 1 + + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + secret_name = f"test-secret-{suffix}" + secret_value = "secret-value-123" + + # Setup API client for secrets + api_host = os.getenv("KOYEB_API_HOST", "https://app.koyeb.com") + configuration = Configuration(host=api_host) + configuration.api_key["Bearer"] = api_token + configuration.api_key_prefix["Bearer"] = "Bearer" + api_client = ApiClient(configuration) + secrets_api = SecretsApi(api_client) + + sandbox = None + secret_id = None + try: + # Create a secret + secret_response = secrets_api.create_secret( + secret=CreateSecret(name=secret_name, value=secret_value) + ) + secret = secret_response.secret + secret_id = secret.id + print(f"Created secret: {secret_name}") + + # Create sandbox with config files referencing the secret and using interpolation + sandbox = Sandbox.create( + image="koyeb/sandbox", + name=f"config-files-{suffix}", + wait_ready=True, + api_token=api_token, + env={ + "X": "2", + # Secret reference: rendered as "{{ secret. }}" + "MY_SECRET": secret, + }, + config_files={ + # Plain string: uses default permissions (0644) + "/tmp/secret_config.txt": "{{ secret." + secret_name + " }}", + "/tmp/interpolation.txt": "{{ X }}", + # ConfigFile object: custom permissions + "/tmp/restricted.txt": ConfigFile( + content="only-owner-readable", permissions="0600" + ), + }, + ) + + # Check secret reference in config file + result = sandbox.exec("cat /tmp/secret_config.txt") + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" + ) + print(f"/tmp/secret_config.txt={result.stdout.strip()}") + + # Check env var interpolation in config file + result = sandbox.exec("cat /tmp/interpolation.txt") + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" + ) + print(f"/tmp/interpolation.txt={result.stdout.strip()}") + + # Check custom permissions + result = sandbox.exec("stat -c '%a' /tmp/restricted.txt") + assert result.stdout.strip() == "600", ( + f"Expected '600', got '{result.stdout.strip()}'" + ) + print(f"/tmp/restricted.txt permissions={result.stdout.strip()}") + + # Check Secret env var was resolved + result = sandbox.exec("printenv MY_SECRET") + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" + ) + print(f"MY_SECRET={result.stdout.strip()}") + + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + finally: + if sandbox: + sandbox.delete() + if secret_id: + secrets_api.delete_secret(id=secret_id) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/19_config_files_async.py b/examples/19_config_files_async.py new file mode 100644 index 00000000..0f752332 --- /dev/null +++ b/examples/19_config_files_async.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Config files with secrets and interpolation (async variant)""" + +import asyncio +import os +import sys +import random +import string + +from koyeb import AsyncSandbox, ConfigFile +from koyeb.api import ApiClient, Configuration +from koyeb.api.api.secrets_api import SecretsApi +from koyeb.api.models.create_secret import CreateSecret + + +async def main(): + api_token = os.getenv("KOYEB_API_TOKEN") + if not api_token: + print("Error: KOYEB_API_TOKEN not set") + return 1 + + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + secret_name = f"test-secret-{suffix}" + secret_value = "secret-value-123" + + # Setup API client for secrets + api_host = os.getenv("KOYEB_API_HOST", "https://app.koyeb.com") + configuration = Configuration(host=api_host) + configuration.api_key["Bearer"] = api_token + configuration.api_key_prefix["Bearer"] = "Bearer" + api_client = ApiClient(configuration) + secrets_api = SecretsApi(api_client) + + sandbox = None + secret_id = None + try: + # Create a secret + secret_response = secrets_api.create_secret( + secret=CreateSecret(name=secret_name, value=secret_value) + ) + secret = secret_response.secret + secret_id = secret.id + print(f"Created secret: {secret_name}") + + # Create sandbox with config files referencing the secret and using interpolation + sandbox = await AsyncSandbox.create( + image="koyeb/sandbox", + name=f"config-files-{suffix}", + wait_ready=True, + api_token=api_token, + env={ + "X": "2", + # Secret reference: rendered as "{{ secret. }}" + "MY_SECRET": secret, + }, + config_files={ + # Plain string: uses default permissions (0644) + "/tmp/secret_config.txt": "{{ secret." + secret_name + " }}", + "/tmp/interpolation.txt": "{{ X }}", + # ConfigFile object: custom permissions + "/tmp/restricted.txt": ConfigFile( + content="only-owner-readable", permissions="0600" + ), + }, + ) + + # Check secret reference in config file + result = await sandbox.exec("cat /tmp/secret_config.txt") + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" + ) + print(f"/tmp/secret_config.txt={result.stdout.strip()}") + + # Check env var interpolation in config file + result = await sandbox.exec("cat /tmp/interpolation.txt") + assert result.stdout.strip() == "2", ( + f"Expected '2', got '{result.stdout.strip()}'" + ) + print(f"/tmp/interpolation.txt={result.stdout.strip()}") + + # Check custom permissions + result = await sandbox.exec("stat -c '%a' /tmp/restricted.txt") + assert result.stdout.strip() == "600", ( + f"Expected '600', got '{result.stdout.strip()}'" + ) + print(f"/tmp/restricted.txt permissions={result.stdout.strip()}") + + # Check Secret env var was resolved + result = await sandbox.exec("printenv MY_SECRET") + assert result.stdout.strip() == secret_value, ( + f"Expected '{secret_value}', got '{result.stdout.strip()}'" + ) + print(f"MY_SECRET={result.stdout.strip()}") + + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + finally: + if sandbox: + await sandbox.delete() + if secret_id: + secrets_api.delete_secret(id=secret_id) + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/koyeb/__init__.py b/koyeb/__init__.py index 741cd1ea..53d58aeb 100644 --- a/koyeb/__init__.py +++ b/koyeb/__init__.py @@ -3,6 +3,6 @@ __version__ = "1.4.3" # Make Sandbox available at package level -from .sandbox import Sandbox, AsyncSandbox +from .sandbox import Sandbox, AsyncSandbox, ConfigFile, Secret -__all__ = ["Sandbox", "AsyncSandbox"] +__all__ = ["Sandbox", "AsyncSandbox", "ConfigFile", "Secret"] diff --git a/koyeb/sandbox/__init__.py b/koyeb/sandbox/__init__.py index 432172f8..53e13b29 100644 --- a/koyeb/sandbox/__init__.py +++ b/koyeb/sandbox/__init__.py @@ -6,7 +6,9 @@ __version__ = "1.4.3" +from koyeb.api.models.config_file import ConfigFile from koyeb.api.models.instance_status import InstanceStatus as SandboxStatus +from koyeb.api.models.secret import Secret from .exec import ( AsyncSandboxExecutor, @@ -22,6 +24,8 @@ __all__ = [ "Sandbox", "AsyncSandbox", + "ConfigFile", + "Secret", "SandboxFilesystem", "SandboxExecutor", "AsyncSandboxExecutor", diff --git a/koyeb/sandbox/sandbox.py b/koyeb/sandbox/sandbox.py index 14165f3a..9a63e738 100644 --- a/koyeb/sandbox/sandbox.py +++ b/koyeb/sandbox/sandbox.py @@ -11,7 +11,7 @@ import secrets import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from koyeb.api.api.deployments_api import DeploymentsApi from koyeb.api.exceptions import ApiException, NotFoundException @@ -26,12 +26,13 @@ SandboxError, SandboxTimeoutError, async_wrapper, + build_config_files, build_env_vars, create_deployment_definition, create_docker_source, create_koyeb_sandbox_routes, create_sandbox_client, - get_api_client, + get_api_clients, logger, run_sync_in_executor, validate_port, @@ -111,7 +112,8 @@ def create( wait_ready: bool = True, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, - env: Optional[Dict[str, str]] = None, + env: Optional[Dict[str, Any]] = None, + config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, @@ -139,6 +141,10 @@ def create( If None, defaults to "http". If provided, must be one of "http" or "http2". env: Environment variables + config_files: Config files to create in the sandbox, as a dictionary mapping + file paths to file contents. Values can be plain strings (default permissions 0644) + or ``ConfigFile`` instances for custom permissions + (e.g., {"/etc/myapp/config.yaml": "key: value", "/etc/myapp/cert.pem": ConfigFile(content="...", permissions="0600")}) region: Region to deploy to. Defaults to KOYEB_REGION env var, or "na" if not set. api_token: Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) timeout: Timeout for sandbox creation in seconds @@ -191,6 +197,7 @@ def create( instance_type=instance_type, exposed_port_protocol=exposed_port_protocol, env=env, + config_files=config_files, region=region, api_token=api_token, timeout=timeout, @@ -225,7 +232,8 @@ def _create_sync( image: str = "koyeb/sandbox", instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, - env: Optional[Dict[str, str]] = None, + env: Optional[Dict[str, Any]] = None, + config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, @@ -246,7 +254,9 @@ def _create_sync( Subclasses can override to return their own type. """ - apps_api, services_api, _, _, _ = get_api_client(api_token) + clients = get_api_clients(api_token) + apps_api = clients.apps + services_api = clients.services # Always create routes (ports are always exposed, default to "http") routes = create_koyeb_sandbox_routes() @@ -270,6 +280,7 @@ def _create_sync( app_id = app_response.app.id env_vars = build_env_vars(env) + config_file_objects = build_config_files(config_files) docker_source = create_docker_source( image, [], privileged=privileged, image_registry_secret=registry_secret ) @@ -287,6 +298,7 @@ def _create_sync( _experimental_enable_light_sleep=_experimental_enable_light_sleep, _experimental_deep_sleep_value=_experimental_deep_sleep_value, enable_mesh=enable_mesh, + config_files=config_file_objects if config_file_objects else None, ) service_life_cycle = ServiceLifeCycle( @@ -341,8 +353,9 @@ def get_from_id( if not id: raise ValueError("id is required") - _, services_api, _, _, _ = get_api_client(api_token) - deployments_api = DeploymentsApi(services_api.api_client) + clients = get_api_clients(api_token) + services_api = clients.services + deployments_api = clients.deployments # Get service by ID try: @@ -465,8 +478,8 @@ def wait_tcp_proxy_ready( def delete(self) -> None: """Delete the sandbox instance.""" - apps_api, _, _, _, _ = get_api_client(self.api_token) - apps_api.delete_app(self.app_id) + clients = get_api_clients(self.api_token) + clients.apps.delete_app(self.app_id) def _get_url_and_header_from_metadata(self) -> Optional[Tuple[str, str]]: """ @@ -475,9 +488,11 @@ def _get_url_and_header_from_metadata(self) -> Optional[Tuple[str, str]]: try: from koyeb.api.exceptions import ApiException, NotFoundException - from .utils import get_api_client + from .utils import get_api_clients - _, services_api, _, _, deployments_api = get_api_client(self.api_token) + clients = get_api_clients(self.api_token) + services_api = clients.services + deployments_api = clients.deployments service_response = services_api.get_service(self.service_id) service = service_response.service deployment = deployments_api.get_deployment(service.active_deployment_id or service.latest_deployment_id) @@ -501,9 +516,11 @@ def _get_domain(self) -> Optional[str]: try: from koyeb.api.exceptions import ApiException, NotFoundException - from .utils import get_api_client + from .utils import get_api_clients - apps_api, services_api, _, _, _ = get_api_client(self.api_token) + clients = get_api_clients(self.api_token) + apps_api = clients.apps + services_api = clients.services service_response = services_api.get_service(self.service_id) service = service_response.service @@ -570,9 +587,10 @@ def get_tcp_proxy_info(self) -> Optional[tuple[str, int]]: try: from koyeb.api.exceptions import ApiException, NotFoundException - from .utils import get_api_client + from .utils import get_api_clients - _, services_api, _, _, _ = get_api_client(self.api_token) + clients = get_api_clients(self.api_token) + services_api = clients.services service_response = services_api.get_service(self.service_id) service = service_response.service @@ -941,7 +959,9 @@ def update_lifecycle( >>> sandbox.update_life_cycle(delete_after_delay=600, delete_after_inactivity=300) """ try: - _, services_api, _, _, deployments_api = get_api_client(self.api_token) + clients = get_api_clients(self.api_token) + services_api = clients.services + deployments_api = clients.deployments service_response = services_api.get_service(self.service_id) service = service_response.service @@ -1053,7 +1073,8 @@ async def create( wait_ready: bool = True, instance_type: str = "micro", exposed_port_protocol: Optional[str] = None, - env: Optional[Dict[str, str]] = None, + env: Optional[Dict[str, Any]] = None, + config_files: Optional[Dict[str, Any]] = None, region: Optional[str] = None, api_token: Optional[str] = None, timeout: int = 300, @@ -1081,6 +1102,10 @@ async def create( If None, defaults to "http". If provided, must be one of "http" or "http2". env: Environment variables + config_files: Config files to create in the sandbox, as a dictionary mapping + file paths to file contents. Values can be plain strings (default permissions 0644) + or ``ConfigFile`` instances for custom permissions + (e.g., {"/etc/myapp/config.yaml": "key: value", "/etc/myapp/cert.pem": ConfigFile(content="...", permissions="0600")}) region: Region to deploy to. Defaults to KOYEB_REGION env var, or "na" if not set. api_token: Koyeb API token (if None, will try to get from KOYEB_API_TOKEN env var) timeout: Timeout for sandbox creation in seconds @@ -1128,6 +1153,7 @@ async def create( instance_type=instance_type, exposed_port_protocol=exposed_port_protocol, env=env, + config_files=config_files, region=region, api_token=api_token, timeout=timeout, diff --git a/koyeb/sandbox/utils.py b/koyeb/sandbox/utils.py index dd1d46b5..353f2c58 100644 --- a/koyeb/sandbox/utils.py +++ b/koyeb/sandbox/utils.py @@ -8,6 +8,7 @@ import logging import os import shlex +from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional from koyeb.api import ApiClient, Configuration @@ -16,11 +17,14 @@ CatalogInstancesApi, DeploymentsApi, InstancesApi, + SecretsApi, ServicesApi, ) +from koyeb.api.models.config_file import ConfigFile from koyeb.api.models.deployment_definition import DeploymentDefinition from koyeb.api.models.deployment_definition_type import DeploymentDefinitionType from koyeb.api.models.deployment_env import DeploymentEnv +from koyeb.api.models.secret import Secret from koyeb.api.models.deployment_instance_type import DeploymentInstanceType from koyeb.api.models.deployment_port import DeploymentPort from koyeb.api.models.deployment_proxy_port import DeploymentProxyPort @@ -88,9 +92,21 @@ def _validate_port_protocol(protocol: str) -> str: ) from e -def get_api_client( +@dataclass(frozen=True) +class ApiClients: + """Bundle of Koyeb API clients sharing a single underlying ApiClient.""" + + apps: AppsApi + services: ServicesApi + instances: InstancesApi + catalog_instances: CatalogInstancesApi + deployments: DeploymentsApi + secrets: SecretsApi + + +def get_api_clients( api_token: Optional[str] = None, host: Optional[str] = None -) -> tuple[AppsApi, ServicesApi, InstancesApi, CatalogInstancesApi, DeploymentsApi]: +) -> ApiClients: """ Get configured API clients for Koyeb operations. @@ -99,7 +115,7 @@ def get_api_client( host: Koyeb API host URL. If not provided, will try to get from KOYEB_API_HOST env var (defaults to https://app.koyeb.com) Returns: - Tuple of (AppsApi, ServicesApi, InstancesApi, CatalogInstancesApi) instances + ApiClients with apps, services, instances, catalog_instances, deployments, and secrets attributes Raises: ValueError: If API token is not provided @@ -116,21 +132,25 @@ def get_api_client( configuration.api_key_prefix["Bearer"] = "Bearer" api_client = ApiClient(configuration) - return ( - AppsApi(api_client), - ServicesApi(api_client), - InstancesApi(api_client), - CatalogInstancesApi(api_client), - DeploymentsApi(api_client), + return ApiClients( + apps=AppsApi(api_client), + services=ServicesApi(api_client), + instances=InstancesApi(api_client), + catalog_instances=CatalogInstancesApi(api_client), + deployments=DeploymentsApi(api_client), + secrets=SecretsApi(api_client), ) -def build_env_vars(env: Optional[Dict[str, str]]) -> List[DeploymentEnv]: +def build_env_vars(env: Optional[Dict[str, Any]]) -> List[DeploymentEnv]: """ Build environment variables list from dictionary. Args: - env: Dictionary of environment variables + env: Dictionary of environment variables. Values can be plain strings + or ``Secret`` instances. A ``Secret`` value is rendered as + ``"{{ secret. }}"`` so the Koyeb API substitutes the secret + value at deploy time. Returns: List of DeploymentEnv objects @@ -138,10 +158,53 @@ def build_env_vars(env: Optional[Dict[str, str]]) -> List[DeploymentEnv]: env_vars = [] if env: for key, value in env.items(): + if isinstance(value, Secret): + value = "{{ secret." + value.name + " }}" env_vars.append(DeploymentEnv(key=key, value=value)) return env_vars +DEFAULT_CONFIG_FILE_PERMISSIONS = "0644" + + +def build_config_files( + config_files: Optional[Dict[str, Any]], +) -> List[ConfigFile]: + """ + Build config files list from dictionary. + + Args: + config_files: Dictionary mapping file paths to file contents. + Values can be plain strings (default permissions 0644) or + ``ConfigFile`` instances (custom permissions). The dict key is + always used as the file path. + + Returns: + List of ConfigFile objects + """ + result = [] + if config_files: + for path, value in config_files.items(): + if isinstance(value, ConfigFile): + result.append( + ConfigFile( + path=path, + content=value.content, + permissions=value.permissions + or DEFAULT_CONFIG_FILE_PERMISSIONS, + ) + ) + else: + result.append( + ConfigFile( + path=path, + content=value, + permissions=DEFAULT_CONFIG_FILE_PERMISSIONS, + ) + ) + return result + + def create_docker_source( image: str, command_args: List[str], @@ -244,6 +307,7 @@ def create_deployment_definition( _experimental_enable_light_sleep: bool = False, _experimental_deep_sleep_value: int = 3900, enable_mesh: bool = None, + config_files: Optional[List[ConfigFile]] = None, ) -> DeploymentDefinition: """ Create deployment definition for a sandbox service. @@ -336,6 +400,7 @@ def create_deployment_definition( scalings=scalings, regions=regions_list, mesh=mesh, + config_files=config_files if config_files else None, )