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,
)