From 29d959a2f9b769cfdf553c10f85950834d1cd390 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sat, 19 Sep 2026 23:44:49 -0700 Subject: [PATCH 01/13] feat: add first-class Python resource bindings --- .github/workflows/python-bindings.yml | 78 +++ Cargo.lock | 95 +++ Cargo.toml | 3 + crates/alien-bindings-python/.gitignore | 5 + crates/alien-bindings-python/Cargo.toml | 38 ++ crates/alien-bindings-python/README.md | 97 +++ crates/alien-bindings-python/pyproject.toml | 45 ++ .../python/alienplatform/__init__.py | 73 +++ .../python/alienplatform/bindings.py | 578 +++++++++++++++++ .../python/alienplatform/errors.py | 63 ++ .../python/alienplatform/py.typed | 1 + crates/alien-bindings-python/src/error.rs | 60 ++ crates/alien-bindings-python/src/lib.rs | 595 ++++++++++++++++++ crates/alien-bindings-python/src/sandbox.rs | 398 ++++++++++++ crates/alien-bindings-python/src/storage.rs | 134 ++++ crates/alien-bindings-python/src/worker.rs | 62 ++ .../tests/test_bindings.py | 175 ++++++ crates/alien-bindings/src/bindings.rs | 10 +- examples/python-bindings/README.md | 10 + examples/python-bindings/app.py | 63 ++ 20 files changed, 2581 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python-bindings.yml create mode 100644 crates/alien-bindings-python/.gitignore create mode 100644 crates/alien-bindings-python/Cargo.toml create mode 100644 crates/alien-bindings-python/README.md create mode 100644 crates/alien-bindings-python/pyproject.toml create mode 100644 crates/alien-bindings-python/python/alienplatform/__init__.py create mode 100644 crates/alien-bindings-python/python/alienplatform/bindings.py create mode 100644 crates/alien-bindings-python/python/alienplatform/errors.py create mode 100644 crates/alien-bindings-python/python/alienplatform/py.typed create mode 100644 crates/alien-bindings-python/src/error.rs create mode 100644 crates/alien-bindings-python/src/lib.rs create mode 100644 crates/alien-bindings-python/src/sandbox.rs create mode 100644 crates/alien-bindings-python/src/storage.rs create mode 100644 crates/alien-bindings-python/src/worker.rs create mode 100644 crates/alien-bindings-python/tests/test_bindings.py create mode 100644 examples/python-bindings/README.md create mode 100644 examples/python-bindings/app.py diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 0000000000..b3912e4daa --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,78 @@ +name: Python bindings + +on: + pull_request: + paths: + - "Cargo.lock" + - "Cargo.toml" + - "crates/alien-bindings/**" + - "crates/alien-ai-gateway/**" + - "crates/alien-core/src/bindings/**" + - "crates/alien-bindings-python/**" + - "examples/python-bindings/**" + - ".github/workflows/python-bindings.yml" + push: + branches: [main] + paths: + - "Cargo.lock" + - "Cargo.toml" + - "crates/alien-bindings/**" + - "crates/alien-ai-gateway/**" + - "crates/alien-core/src/bindings/**" + - "crates/alien-bindings-python/**" + - "examples/python-bindings/**" + - ".github/workflows/python-bindings.yml" + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14] + python: ["3.10", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install test tools + run: python -m pip install 'maturin>=1.9,<2' 'mypy>=1.18,<2' 'pytest>=8,<10' 'pytest-asyncio>=1,<2' 'ruff>=0.13,<1' + - name: Build and install local-provider wheel + run: maturin develop --manifest-path crates/alien-bindings-python/Cargo.toml --no-default-features --features local + - name: Check Python sources + run: | + ruff check crates/alien-bindings-python/python crates/alien-bindings-python/tests examples/python-bindings + mypy crates/alien-bindings-python/python examples/python-bindings + cargo fmt -p alien-bindings-python --check + cargo clippy -p alien-bindings-python --no-default-features --features local --no-deps -- -D warnings + - name: Test Python bindings + run: pytest -q crates/alien-bindings-python/tests + + wheels: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64 + - os: macos-14 + target: aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: PyO3/maturin-action@v1 + with: + command: build + target: ${{ matrix.target }} + args: --release --out dist --no-default-features --features all-platforms + - uses: actions/upload-artifact@v4 + with: + name: alienplatform-${{ matrix.os }} + path: dist/*.whl + if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index de1ff115df..ab2070b6c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,24 @@ dependencies = [ "tempfile", ] +[[package]] +name = "alien-bindings-python" +version = "3.3.22" +dependencies = [ + "alien-ai-gateway", + "alien-bindings", + "alien-core", + "alien-error", + "futures", + "object_store", + "pyo3", + "pyo3-async-runtimes", + "serde", + "serde_json", + "temp-env", + "tokio", +] + [[package]] name = "alien-build" version = "3.3.22" @@ -8037,6 +8055,77 @@ dependencies = [ "snafu 0.6.10", ] +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" +dependencies = [ + "futures-channel", + "futures-util", + "once_cell", + "pin-project-lite", + "pyo3", + "tokio", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "query_map" version = "0.7.0" @@ -10225,6 +10314,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "target-triple" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2f81243ca8..87cff46660 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/alien-bindings", "crates/alien-worker-protocol", "crates/alien-bindings-node", + "crates/alien-bindings-python", "crates/alien-cli", "crates/alien-cli-common", "crates/alien-worker-runtime", @@ -140,6 +141,8 @@ dotenvy = "0.15.7" flate2 = "1.0" fs_extra = "1.3" futures = "0.3.31" +pyo3 = "0.29.2" +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } futures-util = "0.3.31" heck = "0.5" hmac = "0.12" diff --git a/crates/alien-bindings-python/.gitignore b/crates/alien-bindings-python/.gitignore new file mode 100644 index 0000000000..b8ec0c4098 --- /dev/null +++ b/crates/alien-bindings-python/.gitignore @@ -0,0 +1,5 @@ +.pytest_cache/ +.ruff_cache/ +__pycache__/ +*.so +*.pyd diff --git a/crates/alien-bindings-python/Cargo.toml b/crates/alien-bindings-python/Cargo.toml new file mode 100644 index 0000000000..05d5205608 --- /dev/null +++ b/crates/alien-bindings-python/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "alien-bindings-python" +version.workspace = true +edition.workspace = true +description = "Python bindings for Alien application resources" +license-file.workspace = true +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib", "rlib"] + +[features] +default = ["all-platforms", "platform-sdk"] +all-platforms = ["aws", "gcp", "azure", "kubernetes", "local", "test"] +aws = ["alien-bindings/aws"] +gcp = ["alien-bindings/gcp"] +azure = ["alien-bindings/azure"] +kubernetes = ["alien-bindings/kubernetes"] +local = ["alien-bindings/local"] +test = ["alien-bindings/test"] +platform-sdk = ["alien-bindings/platform-sdk"] + +[dependencies] +alien-ai-gateway = { workspace = true } +alien-bindings = { workspace = true } +alien-core = { workspace = true } +alien-error = { workspace = true } +futures = { workspace = true } +object_store = { workspace = true } +pyo3 = { workspace = true, features = ["extension-module", "abi3-py310"] } +pyo3-async-runtimes = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +temp-env = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/alien-bindings-python/README.md b/crates/alien-bindings-python/README.md new file mode 100644 index 0000000000..d2717f0adc --- /dev/null +++ b/crates/alien-bindings-python/README.md @@ -0,0 +1,97 @@ +# Alien Python bindings + +`alienplatform` gives async Python applications typed access to resources linked by Alien. Provider selection, short-lived credential refresh, error metadata, and cloud behavior stay in the shared Rust binding core. + +The package is under development and is not published to a package registry yet. + +## Local development + +Build an editable local-provider package from the repository root: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install 'maturin>=1.9,<2' 'pytest>=8,<10' 'pytest-asyncio>=1,<2' +maturin develop --manifest-path crates/alien-bindings-python/Cargo.toml \ + --no-default-features --features local +pytest -q crates/alien-bindings-python/tests +``` + +The native extension uses Python's `asyncio` interface and runs Alien futures on its Tokio runtime. Resource factories are lazy: missing or malformed bindings fail when first used with a structured `AlienError`. + +## Resources + +```python +from alienplatform import ai, container, key, kv, postgres, queue, sandbox, storage, vault, worker + +files = storage("files") +await files.put("reports/today.json", b"{}") +assert await files.get("reports/today.json") == b"{}" + +cache = kv("cache") +await cache.put("status", b"ready", condition="absent") + +jobs = queue("jobs") +await jobs.send({"kind": "index"}) + +secrets = vault("secrets") +api_token = await secrets.get("api-token") + +database = await postgres("database").connection() +print(database.sqlalchemy_async_url()) + +service_url = await container("api").internal_url() + +response = await worker("processor").invoke( + method="POST", path="/jobs", body=b"{}", timeout_ms=30_000 +) + +ciphertext = await key("records").encrypt(b"private", {"tenant": "tenant-42"}) + +model = await ai("assistant").connection() +# OpenAI-compatible clients use model.openai_base_url. model.api_key is None +# for ambient Bedrock, Vertex, and Foundry resources because the embedded Rust +# gateway authenticates with the workload identity. +``` + +Do not log or render Postgres connection strings, passwords, vault values, or model credentials. +Connection object representations redact credentials by default. + +## AI + +`ai(name).connection()` returns a provider-neutral base URL. Managed Bedrock, +Vertex, and Foundry resources start the same in-process Rust gateway used by the +TypeScript SDK, so signing and short-lived credential refresh stay in the shared +implementation. External OpenAI and Anthropic bindings return the projected key +and provider endpoint. Pass `openai_base_url` to an OpenAI-compatible client. + +Sandbox commands stream raw stdout and stderr frames with production-order sequence numbers: + +```python +runtime = sandbox("runtime") +instance, created = await runtime.get_or_create(tenant_key="tenant-42", timeout_ms=300_000) +stream = await runtime.run_command( + instance.sandbox_id, + "python", + ["-c", "print('hello')"], + timeout_ms=30_000, +) +try: + async for frame in stream: + if frame.data is not None: + print(frame.data.decode(errors="replace"), end="") + if frame.exit_code is not None and frame.exit_code != 0: + raise RuntimeError(f"command exited with {frame.exit_code}") +finally: + await stream.close() +``` + +Long-running commands can use `start_job`, `poll_job`, and `cancel_job`. Call `capabilities()` before optional file, job, pause/resume, or isolation behavior because support differs by platform. + +## PostgreSQL TLS + +`Postgres.connection()` returns the provider-neutral connection parameters, a wire-protocol URL, CA certificates, and SSL mode. `sqlalchemy_async_url()` translates the URL for SQLAlchemy with `asyncpg`; `ssl_context()` returns a configured Python TLS context where required. + +## Errors + +`AlienError` includes stable `code`, `context`, `retryable`, `internal`, `http_status_code`, and `hint` fields. Branch on `code` rather than parsing the message. Errors from object storage are intentionally mapped without object paths or provider response bodies so application logs do not leak customer data. diff --git a/crates/alien-bindings-python/pyproject.toml b/crates/alien-bindings-python/pyproject.toml new file mode 100644 index 0000000000..dd10c38602 --- /dev/null +++ b/crates/alien-bindings-python/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[project] +name = "alienplatform" +dynamic = ["version"] +description = "Typed Python resource bindings for Alien workloads" +readme = "README.md" +requires-python = ">=3.10" +license = { file = "../../LICENSE.md" } +authors = [{ name = "Alien Software, Inc.", email = "hi@alien.dev" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] + +[tool.maturin] +module-name = "alienplatform._native" +python-source = "python" +features = ["pyo3/extension-module"] + +[dependency-groups] +test = [ + "maturin>=1.9,<2", + "mypy>=1.18,<2", + "pytest>=8,<10", + "pytest-asyncio>=1,<2", + "ruff>=0.13,<1", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC"] diff --git a/crates/alien-bindings-python/python/alienplatform/__init__.py b/crates/alien-bindings-python/python/alienplatform/__init__.py new file mode 100644 index 0000000000..4b86a990af --- /dev/null +++ b/crates/alien-bindings-python/python/alienplatform/__init__.py @@ -0,0 +1,73 @@ +"""Alien's application-facing Python SDK.""" + +from .bindings import ( + Ai, + AiConnection, + CommandFrame, + CommandStream, + Container, + JobResult, + Key, + Kv, + KvEntry, + KvPage, + ObjectInfo, + Postgres, + PostgresConnection, + Queue, + QueueMessage, + Sandbox, + SandboxInfo, + SignedRequest, + Storage, + Vault, + Worker, + WorkerResponse, + ai, + container, + key, + kv, + postgres, + queue, + sandbox, + storage, + vault, + worker, +) +from .errors import AlienError + +__all__ = [ + "Ai", + "AiConnection", + "AlienError", + "Container", + "Key", + "JobResult", + "Kv", + "KvEntry", + "KvPage", + "ObjectInfo", + "Postgres", + "PostgresConnection", + "Queue", + "QueueMessage", + "Sandbox", + "SandboxInfo", + "CommandFrame", + "CommandStream", + "Storage", + "SignedRequest", + "Vault", + "Worker", + "WorkerResponse", + "ai", + "container", + "key", + "kv", + "postgres", + "queue", + "sandbox", + "storage", + "vault", + "worker", +] diff --git a/crates/alien-bindings-python/python/alienplatform/bindings.py b/crates/alien-bindings-python/python/alienplatform/bindings.py new file mode 100644 index 0000000000..8956d661ae --- /dev/null +++ b/crates/alien-bindings-python/python/alienplatform/bindings.py @@ -0,0 +1,578 @@ +"""Lazy, typed factories for resources linked to the current workload.""" + +from __future__ import annotations + +import asyncio +import json +import ssl +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from . import _native # type: ignore[attr-defined] +from .errors import translate_errors + +H = TypeVar("H") + + +class _LazyHandle(Generic[H]): + def __init__(self, kind: str, name: str) -> None: + self._kind = kind + self._name = name + self._task: asyncio.Task[H] | None = None + + async def get(self) -> H: + if self._task is None: + + async def resolve() -> H: + bindings = _native.BindingsHandle() + return await getattr(bindings, self._kind)(self._name) + + self._task = asyncio.create_task(resolve()) + try: + return await self._task + except Exception: + self._task = None + raise + + +@dataclass(frozen=True, repr=False) +class PostgresConnection: + connection_string: str + host: str + port: int + database: str + username: str + password: str + sslmode: Literal["disable", "verify-ca", "verify-full"] + ca_certificates: tuple[str, ...] + + def __repr__(self) -> str: + return ( + "PostgresConnection(" + f"host={self.host!r}, port={self.port!r}, database={self.database!r}, " + f"username={self.username!r}, password='', " + f"sslmode={self.sslmode!r}, ca_certificates={len(self.ca_certificates)})" + ) + + def sqlalchemy_async_url(self) -> str: + """Return a SQLAlchemy asyncpg URL without leaking driver translation to apps.""" + parts = urlsplit(self.connection_string) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + sslmode = query.pop("sslmode", self.sslmode) + query["ssl"] = "disable" if sslmode == "disable" else "require" + return urlunsplit( + ("postgresql+asyncpg", parts.netloc, parts.path, urlencode(query), parts.fragment) + ) + + def ssl_context(self) -> ssl.SSLContext | None: + """Build a TLS context for drivers that accept structured SSL configuration.""" + if self.sslmode == "disable": + return None + context = ssl.create_default_context() + for certificate in self.ca_certificates: + context.load_verify_locations(cadata=certificate) + context.check_hostname = self.sslmode == "verify-full" + return context + + +class Storage: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("storage", name) + + @translate_errors + async def get(self, path: str) -> bytes: + return bytes(await (await self._handle.get()).get(path)) + + @translate_errors + async def put(self, path: str, data: bytes) -> None: + await (await self._handle.get()).put(path, list(data)) + + @translate_errors + async def delete(self, path: str) -> None: + await (await self._handle.get()).delete(path) + + @translate_errors + async def list(self, prefix: str | None = None) -> list[ObjectInfo]: + return [_object_info(value) for value in await (await self._handle.get()).list(prefix)] + + @translate_errors + async def signed_url( + self, method: Literal["GET", "PUT", "DELETE"], path: str, expires_in: int + ) -> SignedRequest: + value = await (await self._handle.get()).signed_url(method, path, expires_in) + return SignedRequest(value.url, value.method, dict(value.headers)) + + +@dataclass(frozen=True) +class ObjectInfo: + location: str + size: int + last_modified: str + e_tag: str | None + version: str | None + + +@dataclass(frozen=True) +class SignedRequest: + url: str + method: str + headers: Mapping[str, str] + + +def _object_info(value: Any) -> ObjectInfo: + return ObjectInfo( + value.location, + value.size, + value.last_modified, + value.e_tag, + value.version, + ) + + +class Key: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("key", name) + + @translate_errors + async def encrypt(self, plaintext: bytes, context: Mapping[str, str] | None = None) -> bytes: + return bytes( + await (await self._handle.get()).encrypt( + list(plaintext), dict(context) if context else None + ) + ) + + @translate_errors + async def decrypt(self, ciphertext: bytes, context: Mapping[str, str] | None = None) -> bytes: + return bytes( + await (await self._handle.get()).decrypt( + list(ciphertext), dict(context) if context else None + ) + ) + + +class Kv: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("kv", name) + + @translate_errors + async def get(self, key: str) -> KvEntry | None: + value = await (await self._handle.get()).get(key) + return _kv_entry(value) if value is not None else None + + @translate_errors + async def put( + self, + key: str, + value: bytes, + *, + ttl_seconds: int | None = None, + condition: Literal["absent", "version"] | None = None, + version: str | None = None, + ) -> bool: + return await (await self._handle.get()).put( + key, list(value), ttl_seconds, condition, version + ) + + @translate_errors + async def delete(self, key: str, *, if_version: str | None = None) -> bool: + return await (await self._handle.get()).delete(key, if_version) + + @translate_errors + async def exists(self, key: str) -> bool: + return await (await self._handle.get()).exists(key) + + @translate_errors + async def scan( + self, prefix: str, *, limit: int | None = None, cursor: str | None = None + ) -> KvPage: + value = await (await self._handle.get()).scan(prefix, limit, cursor) + return KvPage(tuple(_kv_entry(item) for item in value.items), value.next_cursor) + + +@dataclass(frozen=True) +class KvEntry: + key: str + value: bytes + version: str + + +@dataclass(frozen=True) +class KvPage: + items: tuple[KvEntry, ...] + next_cursor: str | None + + +def _kv_entry(value: Any) -> KvEntry: + return KvEntry(value.key, bytes(value.value), value.version) + + +class Queue: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("queue", name) + + @translate_errors + async def send(self, message: Any) -> None: + await (await self._handle.get()).send_json(json.dumps(message, separators=(",", ":"))) + + @translate_errors + async def send_text(self, message: str) -> None: + await (await self._handle.get()).send_text(message) + + @translate_errors + async def receive(self, max_messages: int = 1) -> list[QueueMessage]: + return [ + QueueMessage(value.payload_type, value.payload, value.receipt_handle, value.attempt) + for value in await (await self._handle.get()).receive(max_messages) + ] + + @translate_errors + async def ack(self, receipt_handle: str) -> None: + await (await self._handle.get()).ack(receipt_handle) + + @translate_errors + async def nack(self, receipt_handle: str) -> None: + await (await self._handle.get()).nack(receipt_handle) + + @translate_errors + async def purge(self) -> None: + await (await self._handle.get()).purge() + + +@dataclass(frozen=True) +class QueueMessage: + payload_type: Literal["json", "text"] + payload: str + receipt_handle: str + attempt: int + + +class Vault: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("vault", name) + + @translate_errors + async def get(self, name: str) -> str: + return await (await self._handle.get()).get(name) + + @translate_errors + async def put(self, name: str, value: str) -> None: + await (await self._handle.get()).put(name, value) + + @translate_errors + async def delete(self, name: str) -> None: + await (await self._handle.get()).delete(name) + + @translate_errors + async def list(self) -> list[str]: + return await (await self._handle.get()).list() + + +class Postgres: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("postgres", name) + + @translate_errors + async def connection(self) -> PostgresConnection: + value = (await self._handle.get()).connection() + return PostgresConnection( + value.connection_string, + value.host, + value.port, + value.database, + value.username, + value.password, + value.sslmode, + tuple(value.ca_certificates), + ) + + +class Container: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("container", name) + + @translate_errors + async def internal_url(self) -> str: + return (await self._handle.get()).internal_url() + + @translate_errors + async def public_url(self) -> str | None: + return (await self._handle.get()).public_url() + + +@dataclass(frozen=True) +class WorkerResponse: + status: int + headers: Mapping[str, str] + body: bytes + + +class Worker: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("worker", name) + + @translate_errors + async def invoke( + self, + *, + method: str, + path: str, + headers: Mapping[str, str] | None = None, + body: bytes = b"", + timeout_ms: int | None = None, + ) -> WorkerResponse: + value = await (await self._handle.get()).invoke( + method, + path, + dict(headers or {}), + list(body), + timeout_ms, + ) + return WorkerResponse(value.status, dict(value.headers), bytes(value.body)) + + @translate_errors + async def public_url(self) -> str | None: + return await (await self._handle.get()).public_url() + + +@dataclass(frozen=True, repr=False) +class AiConnection: + base_url: str + api_key: str | None + provider: str | None + + def __repr__(self) -> str: + return ( + "AiConnection(" + f"base_url={self.base_url!r}, api_key='', provider={self.provider!r})" + ) + + @property + def openai_base_url(self) -> str: + """Return the base URL expected by OpenAI-compatible clients.""" + return f"{self.base_url.rstrip('/')}/v1" + + +class Ai: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("ai", name) + + @translate_errors + async def connection(self) -> AiConnection: + value = (await self._handle.get()).connection() + return AiConnection(value.base_url, value.api_key, value.provider) + + +@dataclass(frozen=True) +class SandboxInfo: + sandbox_id: str + state: Literal["starting", "running", "paused", "terminated"] + generation: int + + +@dataclass(frozen=True) +class CommandFrame: + kind: Literal["stdout", "stderr", "exit"] + seq: int | None + data: bytes | None + exit_code: int | None + truncated: bool | None + + +@dataclass(frozen=True) +class JobResult: + running: bool + frames: tuple[CommandFrame, ...] + exit_code: int | None + truncated: bool | None + error_code: str | None + error_message: str | None + + +def _sandbox_info(value: Any) -> SandboxInfo: + return SandboxInfo(value.sandbox_id, value.state, value.generation) + + +def _command_frame(value: Any) -> CommandFrame: + return CommandFrame( + value.kind, + value.seq, + bytes(value.data) if value.data is not None else None, + value.exit_code, + value.truncated, + ) + + +class CommandStream(AsyncIterator[CommandFrame]): + def __init__(self, handle: Any) -> None: + self._handle = handle + + def __aiter__(self) -> CommandStream: + return self + + @translate_errors + async def __anext__(self) -> CommandFrame: + value = await self._handle.next() + if value is None: + raise StopAsyncIteration + return _command_frame(value) + + @translate_errors + async def close(self) -> None: + await self._handle.close() + + +class Sandbox: + def __init__(self, name: str) -> None: + self._handle = _LazyHandle[Any]("sandbox", name) + + @translate_errors + async def capabilities(self) -> frozenset[str]: + return frozenset((await self._handle.get()).capabilities()) + + @translate_errors + async def create( + self, + *, + sandbox_id: str | None = None, + tenant_key: str | None = None, + env: Mapping[str, str] | None = None, + timeout_ms: int | None = None, + ) -> SandboxInfo: + value = await (await self._handle.get()).create( + sandbox_id, tenant_key, dict(env) if env else None, timeout_ms + ) + return _sandbox_info(value) + + @translate_errors + async def get(self, sandbox_id: str) -> SandboxInfo | None: + value = await (await self._handle.get()).get(sandbox_id) + return _sandbox_info(value) if value is not None else None + + @translate_errors + async def get_or_create( + self, + *, + sandbox_id: str | None = None, + tenant_key: str | None = None, + env: Mapping[str, str] | None = None, + timeout_ms: int | None = None, + ) -> tuple[SandboxInfo, bool]: + value = await (await self._handle.get()).get_or_create( + sandbox_id, tenant_key, dict(env) if env else None, timeout_ms + ) + return _sandbox_info(value.sandbox), value.created + + @translate_errors + async def list(self) -> list[SandboxInfo]: + return [_sandbox_info(value) for value in await (await self._handle.get()).list()] + + @translate_errors + async def run_command( + self, + sandbox_id: str, + command: str, + args: Sequence[str], + *, + timeout_ms: int, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + ) -> CommandStream: + handle = await (await self._handle.get()).run_command( + sandbox_id, command, list(args), timeout_ms, cwd, dict(env) if env else None + ) + return CommandStream(handle) + + @translate_errors + async def start_job( + self, + sandbox_id: str, + command: str, + args: Sequence[str], + *, + timeout_ms: int, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + ) -> str: + return await (await self._handle.get()).start_job( + sandbox_id, command, list(args), timeout_ms, cwd, dict(env) if env else None + ) + + @translate_errors + async def poll_job( + self, sandbox_id: str, job_id: str, *, since_seq: int | None = None + ) -> JobResult: + value = await (await self._handle.get()).poll_job(sandbox_id, job_id, since_seq) + return JobResult( + value.running, + tuple(_command_frame(frame) for frame in value.frames), + value.exit_code, + value.truncated, + value.error_code, + value.error_message, + ) + + @translate_errors + async def cancel_job(self, sandbox_id: str, job_id: str) -> None: + await (await self._handle.get()).cancel_job(sandbox_id, job_id) + + @translate_errors + async def read_file(self, sandbox_id: str, path: str) -> bytes: + return bytes(await (await self._handle.get()).read_file(sandbox_id, path)) + + @translate_errors + async def write_file(self, sandbox_id: str, path: str, contents: bytes) -> None: + await (await self._handle.get()).write_file(sandbox_id, path, list(contents)) + + @translate_errors + async def pause(self, sandbox_id: str) -> None: + await (await self._handle.get()).pause(sandbox_id) + + @translate_errors + async def resume(self, sandbox_id: str) -> None: + await (await self._handle.get()).resume(sandbox_id) + + @translate_errors + async def terminate(self, sandbox_id: str) -> None: + await (await self._handle.get()).terminate(sandbox_id) + + +def storage(name: str) -> Storage: + return Storage(name) + + +def ai(name: str) -> Ai: + return Ai(name) + + +def key(name: str) -> Key: + return Key(name) + + +def kv(name: str) -> Kv: + return Kv(name) + + +def queue(name: str) -> Queue: + return Queue(name) + + +def vault(name: str) -> Vault: + return Vault(name) + + +def postgres(name: str) -> Postgres: + return Postgres(name) + + +def container(name: str) -> Container: + return Container(name) + + +def worker(name: str) -> Worker: + return Worker(name) + + +def sandbox(name: str) -> Sandbox: + return Sandbox(name) diff --git a/crates/alien-bindings-python/python/alienplatform/errors.py b/crates/alien-bindings-python/python/alienplatform/errors.py new file mode 100644 index 0000000000..793a759077 --- /dev/null +++ b/crates/alien-bindings-python/python/alienplatform/errors.py @@ -0,0 +1,63 @@ +"""Structured exceptions raised by Alien resource bindings.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any, ParamSpec, TypeVar + +P = ParamSpec("P") +T = TypeVar("T") + + +class AlienError(RuntimeError): + """A provider-neutral Alien error with stable machine-readable metadata.""" + + def __init__( + self, + message: str, + *, + code: str = "BINDINGS_ERROR", + context: dict[str, Any] | None = None, + retryable: bool = False, + internal: bool = True, + http_status_code: int | None = None, + hint: str | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.context = context + self.retryable = retryable + self.internal = internal + self.http_status_code = http_status_code + self.hint = hint + + @classmethod + def from_native(cls, error: RuntimeError) -> AlienError: + try: + value = json.loads(str(error)) + except (json.JSONDecodeError, TypeError): + return cls(str(error)) + return cls( + value.get("message", str(error)), + code=value.get("code", "BINDINGS_ERROR"), + context=value.get("context"), + retryable=value.get("retryable", False), + internal=value.get("internal", True), + http_status_code=value.get("httpStatusCode"), + hint=value.get("hint"), + ) + + +def translate_errors(function: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + """Translate the native JSON error envelope into :class:`AlienError`.""" + + @wraps(function) + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: + try: + return await function(*args, **kwargs) + except RuntimeError as error: + raise AlienError.from_native(error) from None + + return wrapped diff --git a/crates/alien-bindings-python/python/alienplatform/py.typed b/crates/alien-bindings-python/python/alienplatform/py.typed new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/crates/alien-bindings-python/python/alienplatform/py.typed @@ -0,0 +1 @@ + diff --git a/crates/alien-bindings-python/src/error.rs b/crates/alien-bindings-python/src/error.rs new file mode 100644 index 0000000000..edd11ccc84 --- /dev/null +++ b/crates/alien-bindings-python/src/error.rs @@ -0,0 +1,60 @@ +use alien_bindings::ErrorData; +use alien_error::{AlienError, AlienErrorData}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::PyErr; +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorEnvelope<'a> { + code: &'a str, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option<&'a serde_json::Value>, + retryable: bool, + internal: bool, + #[serde(skip_serializing_if = "Option::is_none")] + http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option<&'a str>, +} + +pub(crate) fn map_alien_error(error: AlienError) -> PyErr +where + T: AlienErrorData + Clone + std::fmt::Debug + Serialize, +{ + let envelope = ErrorEnvelope { + code: &error.code, + message: &error.message, + context: error.context.as_ref(), + retryable: error.retryable, + internal: error.internal, + http_status_code: error.http_status_code, + hint: error.hint.as_deref(), + }; + let message = serde_json::to_string(&envelope).unwrap_or_else(|_| error.message.clone()); + PyRuntimeError::new_err(message) +} + +pub(crate) fn map_object_store_error( + error: object_store::Error, + binding_name: &str, + operation: &str, +) -> PyErr { + let data = match error { + object_store::Error::PermissionDenied { .. } + | object_store::Error::Unauthenticated { .. } => ErrorData::StorageAccessDenied { + binding_name: binding_name.to_string(), + operation: operation.to_string(), + }, + object_store::Error::NotFound { .. } => ErrorData::StorageObjectNotFound { + binding_name: binding_name.to_string(), + operation: operation.to_string(), + }, + _ => ErrorData::StorageOperationFailed { + binding_name: binding_name.to_string(), + operation: operation.to_string(), + }, + }; + map_alien_error(AlienError::new(data)) +} diff --git a/crates/alien-bindings-python/src/lib.rs b/crates/alien-bindings-python/src/lib.rs new file mode 100644 index 0000000000..c064d9af7e --- /dev/null +++ b/crates/alien-bindings-python/src/lib.rs @@ -0,0 +1,595 @@ +//! Python extension for Alien's application-facing resource bindings. +//! +//! This crate only translates Python values and exceptions. Provider selection, +//! workload identity, credential refresh, and resource behavior remain in +//! `alien-bindings`. + +#![deny(clippy::all)] + +mod error; +mod sandbox; +mod storage; +mod worker; + +use crate::error::map_alien_error; +use alien_bindings::traits::{MessagePayload, PutCondition, PutOptions}; +use alien_bindings::{Bindings, BoundQueue, Container, Key, Kv, Postgres, Vault}; +use alien_core::bindings::{parse_binding_from_env, AiBinding}; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use sandbox::{ + CommandFrame, CommandStreamHandle, JobResult, ResolvedSandbox, SandboxHandle, SandboxInfo, +}; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use storage::{ObjectInfo, SignedRequest, StorageHandle}; +use worker::{WorkerHandle, WorkerResponse}; + +pub(crate) fn future_into_py(py: Python<'_>, future: F) -> PyResult> +where + F: Future> + Send + 'static, + T: for<'py> IntoPyObject<'py> + Send + 'static, +{ + pyo3_async_runtimes::tokio::future_into_py(py, future) +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +struct PostgresConnection { + connection_string: String, + host: String, + port: u16, + database: String, + username: String, + password: String, + sslmode: String, + ca_certificates: Vec, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +struct AiConnection { + base_url: String, + api_key: Option, + provider: Option, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +struct KvEntry { + key: String, + value: Vec, + version: String, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +struct KvPage { + items: Vec, + next_cursor: Option, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +struct QueueMessage { + payload_type: String, + payload: String, + receipt_handle: String, + attempt: u32, +} + +#[pyclass] +struct BindingsHandle { + inner: Arc, +} + +#[pymethods] +impl BindingsHandle { + #[new] + fn new() -> PyResult { + Ok(Self { + inner: Arc::new(Bindings::from_env().map_err(map_alien_error)?), + }) + } + + fn storage<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + let storage = bindings.storage(&name).await.map_err(map_alien_error)?; + Ok(StorageHandle { + inner: storage, + binding: name, + }) + }) + } + + fn key<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(KeyHandle { + inner: bindings.key(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn kv<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(KvHandle { + inner: bindings.kv(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn queue<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(QueueHandle { + inner: bindings.queue(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn vault<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(VaultHandle { + inner: bindings.vault(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn sandbox<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(SandboxHandle { + inner: bindings.sandbox(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn postgres<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(PostgresHandle { + inner: bindings.postgres(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn container<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(ContainerHandle { + inner: bindings.container(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn worker<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let bindings = self.inner.clone(); + future_into_py(py, async move { + Ok(WorkerHandle { + inner: bindings.worker(&name).await.map_err(map_alien_error)?, + }) + }) + } + + fn ai<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let env = std::env::vars().collect::>(); + future_into_py(py, async move { + let binding: AiBinding = + parse_binding_from_env(&env, &name).map_err(map_alien_error)?; + match binding { + AiBinding::External(binding) => { + let provider = binding.provider.to_lowercase(); + let base_url = match provider.as_str() { + "openai" => "https://api.openai.com".to_string(), + "anthropic" => "https://api.anthropic.com".to_string(), + _ => { + return Err(PyValueError::new_err(format!( + "unsupported external AI provider '{provider}'" + ))) + } + }; + let base_url = std::env::var("ALIEN_AI_LOCAL_BASE_URL") + .unwrap_or(base_url) + .trim_end_matches('/') + .to_string(); + let api_key = binding + .api_key + .into_value(&name, "apiKey") + .map_err(map_alien_error)?; + Ok(AiHandle { + connection: AiConnection { + base_url, + api_key: Some(api_key), + provider: Some(provider), + }, + _gateway: None, + }) + } + _ => { + let gateway = Arc::new( + alien_ai_gateway::start_gateway( + alien_ai_gateway::bindings_from_env().map_err(map_alien_error)?, + ) + .await + .map_err(map_alien_error)?, + ); + let segment = name.to_lowercase().replace('_', "-"); + Ok(AiHandle { + connection: AiConnection { + base_url: format!("{}/{segment}", gateway.url), + api_key: None, + provider: None, + }, + _gateway: Some(gateway), + }) + } + } + }) + } +} + +#[pyclass] +struct AiHandle { + connection: AiConnection, + // Keep the in-process gateway alive for the lifetime of the Python resource. + _gateway: Option>, +} + +#[pymethods] +impl AiHandle { + fn connection(&self) -> AiConnection { + self.connection.clone() + } +} + +#[pyclass] +struct KeyHandle { + inner: Arc, +} + +#[pymethods] +impl KeyHandle { + fn encrypt<'py>( + &self, + py: Python<'py>, + plaintext: Vec, + context: Option>, + ) -> PyResult> { + let inner = self.inner.clone(); + let context: Option> = context.map(|value| value.into_iter().collect()); + future_into_py(py, async move { + inner + .encrypt(&plaintext, context.as_ref()) + .await + .map_err(map_alien_error) + }) + } + + fn decrypt<'py>( + &self, + py: Python<'py>, + ciphertext: Vec, + context: Option>, + ) -> PyResult> { + let inner = self.inner.clone(); + let context: Option> = context.map(|value| value.into_iter().collect()); + future_into_py(py, async move { + inner + .decrypt(&ciphertext, context.as_ref()) + .await + .map_err(map_alien_error) + }) + } +} + +#[pyclass] +struct KvHandle { + inner: Arc, +} + +fn kv_options( + ttl_seconds: Option, + condition: Option, + version: Option, +) -> PyResult> { + let condition = match condition.as_deref() { + None => PutCondition::None, + Some("absent") => PutCondition::Absent, + Some("version") => PutCondition::Version( + version + .ok_or_else(|| PyValueError::new_err("a version condition requires a version"))?, + ), + Some(other) => { + return Err(PyValueError::new_err(format!( + "unsupported KV put condition '{other}'" + ))) + } + }; + if ttl_seconds.is_none() && matches!(condition, PutCondition::None) { + return Ok(None); + } + Ok(Some(PutOptions { + ttl: ttl_seconds.map(Duration::from_secs), + condition, + })) +} + +fn kv_entry(entry: alien_bindings::KvEntry) -> KvEntry { + KvEntry { + key: entry.key, + value: entry.value, + version: entry.version, + } +} + +#[pymethods] +impl KvHandle { + fn get<'py>(&self, py: Python<'py>, key: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + Ok(inner + .get(&key) + .await + .map_err(map_alien_error)? + .map(kv_entry)) + }) + } + + #[pyo3(signature = (key, value, ttl_seconds=None, condition=None, version=None))] + fn put<'py>( + &self, + py: Python<'py>, + key: String, + value: Vec, + ttl_seconds: Option, + condition: Option, + version: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + let options = kv_options(ttl_seconds, condition, version)?; + future_into_py(py, async move { + inner + .put(&key, value, options) + .await + .map_err(map_alien_error) + }) + } + + #[pyo3(signature = (key, if_version=None))] + fn delete<'py>( + &self, + py: Python<'py>, + key: String, + if_version: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .delete(&key, if_version.as_deref()) + .await + .map_err(map_alien_error) + }) + } + + fn exists<'py>(&self, py: Python<'py>, key: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.exists(&key).await.map_err(map_alien_error) + }) + } + + #[pyo3(signature = (prefix, limit=None, cursor=None))] + fn scan<'py>( + &self, + py: Python<'py>, + prefix: String, + limit: Option, + cursor: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let page = inner + .scan_prefix(&prefix, limit, cursor) + .await + .map_err(map_alien_error)?; + Ok(KvPage { + items: page.items.into_iter().map(kv_entry).collect(), + next_cursor: page.next_cursor, + }) + }) + } +} + +#[pyclass] +struct QueueHandle { + inner: BoundQueue, +} + +fn queue_message(message: alien_bindings::traits::QueueMessage) -> PyResult { + let (payload_type, payload) = match message.payload { + MessagePayload::Json(value) => ( + "json".to_string(), + serde_json::to_string(&value) + .map_err(|error| PyValueError::new_err(error.to_string()))?, + ), + MessagePayload::Text(value) => ("text".to_string(), value), + }; + Ok(QueueMessage { + payload_type, + payload, + receipt_handle: message.receipt_handle, + attempt: message.attempt, + }) +} + +#[pymethods] +impl QueueHandle { + fn send_json<'py>(&self, py: Python<'py>, value: String) -> PyResult> { + let value = serde_json::from_str(&value) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .send(MessagePayload::Json(value)) + .await + .map_err(map_alien_error) + }) + } + fn send_text<'py>(&self, py: Python<'py>, value: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .send(MessagePayload::Text(value)) + .await + .map_err(map_alien_error) + }) + } + #[pyo3(signature = (max_messages=1))] + fn receive<'py>(&self, py: Python<'py>, max_messages: usize) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .receive(max_messages) + .await + .map_err(map_alien_error)? + .into_iter() + .map(queue_message) + .collect::>>() + }) + } + fn ack<'py>(&self, py: Python<'py>, receipt_handle: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.ack(&receipt_handle).await.map_err(map_alien_error) + }) + } + fn nack<'py>(&self, py: Python<'py>, receipt_handle: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.nack(&receipt_handle).await.map_err(map_alien_error) + }) + } + fn purge<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py( + py, + async move { inner.purge().await.map_err(map_alien_error) }, + ) + } +} + +#[pyclass] +struct VaultHandle { + inner: Arc, +} + +#[pymethods] +impl VaultHandle { + fn get<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.get_secret(&name).await.map_err(map_alien_error) + }) + } + fn put<'py>( + &self, + py: Python<'py>, + name: String, + value: String, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .set_secret(&name, &value) + .await + .map_err(map_alien_error) + }) + } + fn delete<'py>(&self, py: Python<'py>, name: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.delete_secret(&name).await.map_err(map_alien_error) + }) + } + fn list<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.list_secrets().await.map_err(map_alien_error) + }) + } +} + +#[pyclass] +struct PostgresHandle { + inner: Arc, +} + +#[pymethods] +impl PostgresHandle { + fn connection(&self) -> PostgresConnection { + let value = self.inner.connection_params(); + PostgresConnection { + connection_string: value.connection_string(), + host: value.host.clone(), + port: value.port, + database: value.database.clone(), + username: value.username.clone(), + password: value.password.clone(), + sslmode: value.sslmode().as_str().to_string(), + ca_certificates: value.ca_certificates().to_vec(), + } + } +} + +#[pyclass] +struct ContainerHandle { + inner: Arc, +} + +#[pymethods] +impl ContainerHandle { + fn internal_url(&self) -> String { + self.inner.get_internal_url().to_string() + } + fn public_url(&self) -> Option { + self.inner.get_public_url().map(str::to_string) + } +} + +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("__version__", env!("CARGO_PKG_VERSION"))?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/crates/alien-bindings-python/src/sandbox.rs b/crates/alien-bindings-python/src/sandbox.rs new file mode 100644 index 0000000000..924218a562 --- /dev/null +++ b/crates/alien-bindings-python/src/sandbox.rs @@ -0,0 +1,398 @@ +use crate::error::map_alien_error; +use crate::future_into_py; +use alien_bindings::traits::{ + CommandOutput, CreateSandboxRequest, JobPoll, RunCommandRequest, Sandbox, SandboxInstance, + SandboxState, +}; +use futures::lock::Mutex; +use futures::stream::BoxStream; +use futures::StreamExt; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct SandboxInfo { + sandbox_id: String, + state: String, + generation: u64, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct ResolvedSandbox { + sandbox: SandboxInfo, + created: bool, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct CommandFrame { + kind: String, + seq: Option, + data: Option>, + exit_code: Option, + truncated: Option, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct JobResult { + running: bool, + frames: Vec, + exit_code: Option, + truncated: Option, + error_code: Option, + error_message: Option, +} + +fn sandbox_info(value: SandboxInstance) -> SandboxInfo { + SandboxInfo { + sandbox_id: value.sandbox_id, + state: match value.state { + SandboxState::Starting => "starting", + SandboxState::Running => "running", + SandboxState::Paused => "paused", + SandboxState::Terminated => "terminated", + } + .to_string(), + generation: value.generation, + } +} + +fn command_frame(value: CommandOutput) -> CommandFrame { + match value { + CommandOutput::Stdout { seq, data } => CommandFrame { + kind: "stdout".to_string(), + seq: Some(seq), + data: Some(data), + exit_code: None, + truncated: None, + }, + CommandOutput::Stderr { seq, data } => CommandFrame { + kind: "stderr".to_string(), + seq: Some(seq), + data: Some(data), + exit_code: None, + truncated: None, + }, + CommandOutput::Exit { code, truncated } => CommandFrame { + kind: "exit".to_string(), + seq: None, + data: None, + exit_code: Some(code), + truncated: Some(truncated), + }, + } +} + +fn job_result(value: JobPoll) -> JobResult { + JobResult { + running: value.running, + frames: value.frames.into_iter().map(command_frame).collect(), + exit_code: value.exit.as_ref().map(|exit| exit.code), + truncated: value.exit.map(|exit| exit.truncated), + error_code: value.error.as_ref().map(|error| error.code.clone()), + error_message: value.error.map(|error| error.message), + } +} + +#[pyclass] +pub(crate) struct CommandStreamHandle { + frames: Arc>>>>, +} + +#[pymethods] +impl CommandStreamHandle { + fn next<'py>(&self, py: Python<'py>) -> PyResult> { + let frames = self.frames.clone(); + future_into_py(py, async move { + let mut frames = frames.lock().await; + let Some(stream) = frames.as_mut() else { + return Ok(None); + }; + match stream.next().await { + Some(Ok(frame)) => Ok(Some(command_frame(frame))), + Some(Err(error)) => Err(map_alien_error(error)), + None => { + *frames = None; + Ok(None) + } + } + }) + } + + fn close<'py>(&self, py: Python<'py>) -> PyResult> { + let frames = self.frames.clone(); + future_into_py(py, async move { + *frames.lock().await = None; + Ok(()) + }) + } +} + +#[pyclass] +pub(crate) struct SandboxHandle { + pub(crate) inner: Arc, +} + +fn sandbox_request( + sandbox_id: Option, + tenant_key: Option, + env: Option>, + timeout_ms: Option, +) -> CreateSandboxRequest { + CreateSandboxRequest { + sandbox_id, + tenant_key, + env: env + .map(|value| value.into_iter().collect()) + .unwrap_or_default(), + timeout_ms, + } +} + +fn command_request( + command: String, + args: Vec, + timeout_ms: u64, + cwd: Option, + env: Option>, +) -> RunCommandRequest { + RunCommandRequest { + command, + args, + cwd, + env: env + .map(|value| value.into_iter().collect()) + .unwrap_or_default(), + timeout: Duration::from_millis(timeout_ms), + } +} + +#[pymethods] +impl SandboxHandle { + fn capabilities(&self) -> Vec { + let value = self.inner.capabilities(); + [ + (value.files, "files"), + (value.reconnect, "reconnect"), + (value.jobs, "jobs"), + (value.pause_resume, "pauseResume"), + (value.domain_egress_rules, "domainEgressRules"), + (value.egress_deny, "egressDeny"), + (value.enforced_limits, "enforcedLimits"), + (value.process_limit, "processLimit"), + (value.sandbox_lifetime, "sandboxLifetime"), + (value.supervisor_pid_namespace, "supervisorPidNamespace"), + (value.supervisor_isolation, "supervisorIsolation"), + ] + .into_iter() + .filter(|(supported, _)| *supported) + .map(|(_, name)| name.to_string()) + .collect() + } + + #[pyo3(signature = (sandbox_id=None, tenant_key=None, env=None, timeout_ms=None))] + fn create<'py>( + &self, + py: Python<'py>, + sandbox_id: Option, + tenant_key: Option, + env: Option>, + timeout_ms: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .create(sandbox_request(sandbox_id, tenant_key, env, timeout_ms)) + .await + .map(sandbox_info) + .map_err(map_alien_error) + }) + } + + fn get<'py>(&self, py: Python<'py>, sandbox_id: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .get(&sandbox_id) + .await + .map(|value| value.map(sandbox_info)) + .map_err(map_alien_error) + }) + } + + #[pyo3(signature = (sandbox_id=None, tenant_key=None, env=None, timeout_ms=None))] + fn get_or_create<'py>( + &self, + py: Python<'py>, + sandbox_id: Option, + tenant_key: Option, + env: Option>, + timeout_ms: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let value = inner + .get_or_create(sandbox_request(sandbox_id, tenant_key, env, timeout_ms)) + .await + .map_err(map_alien_error)?; + Ok(ResolvedSandbox { + sandbox: sandbox_info(value.sandbox), + created: value.created, + }) + }) + } + + fn list<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .list() + .await + .map(|values| values.into_iter().map(sandbox_info).collect::>()) + .map_err(map_alien_error) + }) + } + + #[pyo3(signature = (sandbox_id, command, args, timeout_ms, cwd=None, env=None))] + #[allow(clippy::too_many_arguments)] // PyO3 exposes these as named Python arguments. + fn run_command<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + command: String, + args: Vec, + timeout_ms: u64, + cwd: Option, + env: Option>, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let frames = inner + .run_command( + &sandbox_id, + command_request(command, args, timeout_ms, cwd, env), + ) + .await + .map_err(map_alien_error)?; + Ok(CommandStreamHandle { + frames: Arc::new(Mutex::new(Some(frames))), + }) + }) + } + + #[pyo3(signature = (sandbox_id, command, args, timeout_ms, cwd=None, env=None))] + #[allow(clippy::too_many_arguments)] // Keep parity with run_command's Python API. + fn start_job<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + command: String, + args: Vec, + timeout_ms: u64, + cwd: Option, + env: Option>, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .start_job( + &sandbox_id, + command_request(command, args, timeout_ms, cwd, env), + ) + .await + .map(|value| value.job_id) + .map_err(map_alien_error) + }) + } + + #[pyo3(signature = (sandbox_id, job_id, since_seq=None))] + fn poll_job<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + job_id: String, + since_seq: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .poll_job(&sandbox_id, &job_id, since_seq) + .await + .map(job_result) + .map_err(map_alien_error) + }) + } + + fn cancel_job<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + job_id: String, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .cancel_job(&sandbox_id, &job_id) + .await + .map_err(map_alien_error) + }) + } + + fn read_file<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + path: String, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .read_file(&sandbox_id, &path) + .await + .map_err(map_alien_error) + }) + } + + fn write_file<'py>( + &self, + py: Python<'py>, + sandbox_id: String, + path: String, + contents: Vec, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .write_files(&sandbox_id, BTreeMap::from([(path, contents)])) + .await + .map_err(map_alien_error) + }) + } + + fn pause<'py>(&self, py: Python<'py>, sandbox_id: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.pause(&sandbox_id).await.map_err(map_alien_error) + }) + } + + fn resume<'py>(&self, py: Python<'py>, sandbox_id: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.resume(&sandbox_id).await.map_err(map_alien_error) + }) + } + + fn terminate<'py>(&self, py: Python<'py>, sandbox_id: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.terminate(&sandbox_id).await.map_err(map_alien_error) + }) + } +} diff --git a/crates/alien-bindings-python/src/storage.rs b/crates/alien-bindings-python/src/storage.rs new file mode 100644 index 0000000000..9d6b689198 --- /dev/null +++ b/crates/alien-bindings-python/src/storage.rs @@ -0,0 +1,134 @@ +use crate::error::{map_alien_error, map_object_store_error}; +use crate::future_into_py; +use alien_bindings::Storage; +use futures::TryStreamExt; +use object_store::path::Path; +use object_store::PutPayload; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct ObjectInfo { + location: String, + size: u64, + last_modified: String, + e_tag: Option, + version: Option, +} + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct SignedRequest { + url: String, + method: String, + headers: HashMap, +} + +#[pyclass] +pub(crate) struct StorageHandle { + pub(crate) inner: Arc, + pub(crate) binding: String, +} + +fn parse_path(path: String) -> PyResult { + Path::parse(path).map_err(|error| PyValueError::new_err(error.to_string())) +} + +#[pymethods] +impl StorageHandle { + fn get<'py>(&self, py: Python<'py>, path: String) -> PyResult> { + let inner = self.inner.clone(); + let binding = self.binding.clone(); + future_into_py(py, async move { + let location = parse_path(path)?; + let result = inner + .get(&location) + .await + .map_err(|error| map_object_store_error(error, &binding, "get"))?; + Ok(result + .bytes() + .await + .map_err(|error| map_object_store_error(error, &binding, "get"))? + .to_vec()) + }) + } + + fn put<'py>( + &self, + py: Python<'py>, + path: String, + data: Vec, + ) -> PyResult> { + let inner = self.inner.clone(); + let binding = self.binding.clone(); + future_into_py(py, async move { + inner + .put(&parse_path(path)?, PutPayload::from(data)) + .await + .map_err(|error| map_object_store_error(error, &binding, "put"))?; + Ok(()) + }) + } + + fn delete<'py>(&self, py: Python<'py>, path: String) -> PyResult> { + let inner = self.inner.clone(); + let binding = self.binding.clone(); + future_into_py(py, async move { + inner + .delete(&parse_path(path)?) + .await + .map_err(|error| map_object_store_error(error, &binding, "delete")) + }) + } + + fn list<'py>(&self, py: Python<'py>, prefix: Option) -> PyResult> { + let inner = self.inner.clone(); + let binding = self.binding.clone(); + future_into_py(py, async move { + let prefix = prefix.map(parse_path).transpose()?; + inner + .list(prefix.as_ref()) + .map_ok(|meta| ObjectInfo { + location: meta.location.to_string(), + size: meta.size, + last_modified: meta.last_modified.to_rfc3339(), + e_tag: meta.e_tag, + version: meta.version, + }) + .try_collect::>() + .await + .map_err(|error| map_object_store_error(error, &binding, "list")) + }) + } + + fn signed_url<'py>( + &self, + py: Python<'py>, + method: String, + path: String, + expires_in: u64, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let path = parse_path(path)?; + let expires = Duration::from_secs(expires_in); + let request = match method.as_str() { + "GET" => inner.presigned_get(&path, expires).await, + "PUT" => inner.presigned_put(&path, expires).await, + "DELETE" => inner.presigned_delete(&path, expires).await, + _ => return Err(PyValueError::new_err("method must be GET, PUT, or DELETE")), + } + .map_err(map_alien_error)?; + Ok(SignedRequest { + url: request.url(), + method: request.method().to_string(), + headers: request.headers(), + }) + }) + } +} diff --git a/crates/alien-bindings-python/src/worker.rs b/crates/alien-bindings-python/src/worker.rs new file mode 100644 index 0000000000..c203cedf64 --- /dev/null +++ b/crates/alien-bindings-python/src/worker.rs @@ -0,0 +1,62 @@ +use crate::error::map_alien_error; +use crate::future_into_py; +use alien_bindings::traits::{Worker, WorkerInvokeRequest}; +use pyo3::prelude::*; +use pyo3::types::PyAny; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +#[pyclass(frozen, get_all, skip_from_py_object)] +#[derive(Clone)] +pub(crate) struct WorkerResponse { + status: u16, + headers: BTreeMap, + body: Vec, +} + +#[pyclass] +pub(crate) struct WorkerHandle { + pub(crate) inner: Arc, +} + +#[pymethods] +impl WorkerHandle { + #[pyo3(signature = (method, path, headers, body, timeout_ms=None))] + fn invoke<'py>( + &self, + py: Python<'py>, + method: String, + path: String, + headers: BTreeMap, + body: Vec, + timeout_ms: Option, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let response = inner + .invoke(WorkerInvokeRequest { + target_worker: String::new(), + method, + path, + headers, + body, + timeout: timeout_ms.map(Duration::from_millis), + }) + .await + .map_err(map_alien_error)?; + Ok(WorkerResponse { + status: response.status, + headers: response.headers, + body: response.body, + }) + }) + } + + fn public_url<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + inner.get_worker_url().await.map_err(map_alien_error) + }) + } +} diff --git a/crates/alien-bindings-python/tests/test_bindings.py b/crates/alien-bindings-python/tests/test_bindings.py new file mode 100644 index 0000000000..6722daa24e --- /dev/null +++ b/crates/alien-bindings-python/tests/test_bindings.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +from alienplatform import ( + AlienError, + ai, + container, + kv, + postgres, + queue, + sandbox, + storage, + vault, + worker, +) + + +def bind(name: str, value: dict[str, object]) -> None: + os.environ["ALIEN_DEPLOYMENT_TYPE"] = "local" + os.environ[f"ALIEN_{name.replace('-', '_').upper()}_BINDING"] = json.dumps(value) + + +@pytest.mark.asyncio +async def test_storage_round_trip_and_structured_missing_error(tmp_path: Path) -> None: + bind("files", {"service": "local-storage", "storagePath": str(tmp_path / "storage")}) + files = storage("files") + payload = bytes([0, 1, 255, 0, 128]) + await files.put("nested/data.bin", payload) + assert await files.get("nested/data.bin") == payload + assert [item.location for item in await files.list("nested")] == ["nested/data.bin"] + await files.delete("nested/data.bin") + with pytest.raises(AlienError) as raised: + await files.get("nested/data.bin") + assert raised.value.code == "STORAGE_OBJECT_NOT_FOUND" + assert "data.bin" not in str(raised.value) + + +@pytest.mark.asyncio +async def test_kv_queue_and_vault_real_local_providers(tmp_path: Path) -> None: + bind("cache", {"service": "local-kv", "dataDir": str(tmp_path / "kv")}) + bind("jobs", {"service": "local-queue", "queuePath": str(tmp_path / "queue")}) + bind( + "secrets", + {"service": "local-vault", "vaultName": "test", "dataDir": str(tmp_path / "vault")}, + ) + + cache = kv("cache") + assert await cache.put("key", b"value", condition="absent") is True + entry = await cache.get("key") + assert entry is not None and bytes(entry.value) == b"value" + assert await cache.put("key", b"other", condition="absent") is False + + jobs = queue("jobs") + await jobs.send({"job": 7}) + [message] = await jobs.receive() + assert message.payload_type == "json" + assert json.loads(message.payload) == {"job": 7} + await jobs.ack(message.receipt_handle) + assert await jobs.receive() == [] + + secrets = vault("secrets") + await secrets.put("credential", "value") + assert await secrets.get("credential") == "value" + assert await secrets.list() == ["credential"] + await secrets.delete("credential") + + +@pytest.mark.asyncio +async def test_postgres_helpers_and_container_discovery() -> None: + bind( + "database", + { + "service": "local-postgres", + "host": "db.internal", + "port": 5432, + "database": "app", + "username": "alien", + "password": "p@ss/word", + }, + ) + bind( + "api", + { + "service": "local", + "containerName": "api", + "internalUrl": "http://api.svc:8000", + "publicUrl": "http://localhost:18000", + }, + ) + + connection = await postgres("database").connection() + assert ( + connection.connection_string + == "postgres://alien:p%40ss%2Fword@db.internal:5432/app?sslmode=disable" + ) + assert ( + connection.sqlalchemy_async_url() + == "postgresql+asyncpg://alien:p%40ss%2Fword@db.internal:5432/app?ssl=disable" + ) + assert connection.ssl_context() is None + assert "p@ss/word" not in repr(connection) + assert await container("api").internal_url() == "http://api.svc:8000" + assert await container("api").public_url() == "http://localhost:18000" + + +@pytest.mark.asyncio +async def test_missing_binding_is_stable_alien_error() -> None: + with pytest.raises(AlienError) as raised: + await storage("missing-resource").list() + assert raised.value.code == "BINDING_NOT_CONFIGURED" + assert raised.value.retryable is False + assert raised.value.context == { + "binding_name": "missing-resource", + "env_var": "ALIEN_MISSING_RESOURCE_BINDING", + } + + with pytest.raises(AlienError) as sandbox_error: + await sandbox("missing-sandbox").capabilities() + assert sandbox_error.value.code == "BINDING_NOT_CONFIGURED" + + +@pytest.mark.asyncio +async def test_external_ai_connection_is_typed_and_redacted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bind( + "model", + {"service": "external-ai", "provider": "openai", "apiKey": "test-secret"}, + ) + monkeypatch.setenv("ALIEN_AI_LOCAL_BASE_URL", "http://localhost:9876/") + + connection = await ai("model").connection() + assert connection.base_url == "http://localhost:9876" + assert connection.openai_base_url == "http://localhost:9876/v1" + assert connection.api_key == "test-secret" + assert connection.provider == "openai" + assert "test-secret" not in repr(connection) + + +@pytest.mark.asyncio +async def test_worker_invocation_and_public_url() -> None: + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + assert self.path == "/jobs" + body = self.rfile.read(int(self.headers["Content-Length"])) + assert body == b"payload" + self.send_response(202) + self.send_header("X-Worker", "local") + self.end_headers() + self.wfile.write(b"accepted") + + def log_message(self, format: str, *args: object) -> None: + del format, args + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_port}" + bind("processor", {"service": "local", "workerUrl": url}) + processor = worker("processor") + response = await processor.invoke(method="POST", path="/jobs", body=b"payload") + assert response.status == 202 + assert response.headers["x-worker"] == "local" + assert response.body == b"accepted" + assert await processor.public_url() == url + finally: + server.shutdown() + thread.join() diff --git a/crates/alien-bindings/src/bindings.rs b/crates/alien-bindings/src/bindings.rs index f2f9d8e479..9c2be9db6d 100644 --- a/crates/alien-bindings/src/bindings.rs +++ b/crates/alien-bindings/src/bindings.rs @@ -2,7 +2,8 @@ //! //! [`Bindings`] wraps a [`crate::provider::LazyEnvBindingsProvider`], giving application //! code a small, stable surface — `storage`, `kv`, `queue`, `vault`, `container`, -//! `postgres`, `sandbox` — instead of the full [`crate::traits::BindingsProviderApi`] used internally +//! `worker`, `postgres`, `sandbox` — instead of the full +//! [`crate::traits::BindingsProviderApi`] used internally //! by the manager and controllers. use crate::error::Result; @@ -12,7 +13,7 @@ use crate::refreshing::{ }; use crate::traits::{ BindingsProviderApi, Container, Key, Kv, MessagePayload, Postgres, Queue, QueueMessage, - Sandbox, Storage, Vault, + Sandbox, Storage, Vault, Worker, }; use std::collections::HashMap; use std::sync::Arc; @@ -179,6 +180,11 @@ impl Bindings { self.provider.load_container(binding_name).await } + /// Loads a linked worker for direct invocation and public URL discovery. + pub async fn worker(&self, binding_name: &str) -> Result> { + self.provider.load_worker(binding_name).await + } + /// Loads the connection details for a linked Postgres database. /// /// Unlike the other kinds this returns no operations — Postgres has no gRPC service diff --git a/examples/python-bindings/README.md b/examples/python-bindings/README.md new file mode 100644 index 0000000000..d260805c76 --- /dev/null +++ b/examples/python-bindings/README.md @@ -0,0 +1,10 @@ +# Python bindings smoke workload + +`app.py` exercises every application-facing Python resource binding through the +same public API used by an ordinary container. Link resources named `files`, +`cache`, `jobs`, `secrets`, `records`, `database`, `api`, `models`, `processor`, +and `runtime`, then run `python app.py` inside the workload. + +The example intentionally contains no provider-specific configuration. Alien +injects binding descriptions and projected workload identity for the selected +platform. diff --git a/examples/python-bindings/app.py b/examples/python-bindings/app.py new file mode 100644 index 0000000000..84beb6d8c9 --- /dev/null +++ b/examples/python-bindings/app.py @@ -0,0 +1,63 @@ +"""Generic smoke workload for every application-facing Python binding.""" + +from __future__ import annotations + +import asyncio + +from alienplatform import ( + ai, + container, + key, + kv, + postgres, + queue, + sandbox, + storage, + vault, + worker, +) + + +async def main() -> None: + files = storage("files") + await files.put("smoke/payload", b"hello") + assert await files.get("smoke/payload") == b"hello" + + cache = kv("cache") + await cache.put("smoke", b"ready") + assert (await cache.get("smoke")) is not None + + jobs = queue("jobs") + await jobs.send({"kind": "smoke"}) + [message] = await jobs.receive() + await jobs.ack(message.receipt_handle) + + secrets = vault("secrets") + await secrets.put("smoke", "ready") + assert await secrets.get("smoke") == "ready" + + ciphertext = await key("records").encrypt(b"private") + assert await key("records").decrypt(ciphertext) == b"private" + + assert (await postgres("database").connection()).database + assert await container("api").internal_url() + assert (await ai("models").connection()).base_url + + response = await worker("processor").invoke(method="POST", path="/smoke") + assert response.status < 500 + + runtime = sandbox("runtime") + instance, _ = await runtime.get_or_create(tenant_key="python-bindings-smoke") + command = await runtime.run_command( + instance.sandbox_id, + "python", + ["-c", "print('ready')"], + timeout_ms=30_000, + ) + async for frame in command: + if frame.exit_code is not None: + assert frame.exit_code == 0 + + +if __name__ == "__main__": + asyncio.run(main()) From e645c2e3c5ffe583fceb4e4707d34d676a69e89e Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 09:34:31 -0700 Subject: [PATCH 02/13] ci: install Python bindings from built wheel --- .github/workflows/python-bindings.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index b3912e4daa..bd95e89630 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -44,7 +44,9 @@ jobs: - name: Install test tools run: python -m pip install 'maturin>=1.9,<2' 'mypy>=1.18,<2' 'pytest>=8,<10' 'pytest-asyncio>=1,<2' 'ruff>=0.13,<1' - name: Build and install local-provider wheel - run: maturin develop --manifest-path crates/alien-bindings-python/Cargo.toml --no-default-features --features local + run: | + maturin build --manifest-path crates/alien-bindings-python/Cargo.toml --no-default-features --features local --out dist + python -m pip install --force-reinstall dist/*.whl - name: Check Python sources run: | ruff check crates/alien-bindings-python/python crates/alien-bindings-python/tests examples/python-bindings @@ -70,7 +72,7 @@ jobs: with: command: build target: ${{ matrix.target }} - args: --release --out dist --no-default-features --features all-platforms + args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms - uses: actions/upload-artifact@v4 with: name: alienplatform-${{ matrix.os }} From dd2b563c5356c41cad49069cba0e4c7de97393a0 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 16:10:53 -0700 Subject: [PATCH 03/13] ci: build portable Linux Python wheels --- .github/workflows/python-bindings.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index bd95e89630..88a3f969de 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -62,8 +62,10 @@ jobs: include: - os: ubuntu-latest target: x86_64 + wheel_args: --manylinux 2014 - os: macos-14 target: aarch64 + wheel_args: "" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -72,7 +74,7 @@ jobs: with: command: build target: ${{ matrix.target }} - args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms + args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms ${{ matrix.wheel_args }} - uses: actions/upload-artifact@v4 with: name: alienplatform-${{ matrix.os }} From 9a94f770aadc58092a4a0e8af241668bcbc345ef Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 16:15:30 -0700 Subject: [PATCH 04/13] ci: use manylinux build container --- .github/workflows/python-bindings.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 88a3f969de..1772ad95e7 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -62,10 +62,10 @@ jobs: include: - os: ubuntu-latest target: x86_64 - wheel_args: --manylinux 2014 + manylinux: "2014" - os: macos-14 target: aarch64 - wheel_args: "" + manylinux: "off" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -74,7 +74,8 @@ jobs: with: command: build target: ${{ matrix.target }} - args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms ${{ matrix.wheel_args }} + manylinux: ${{ matrix.manylinux }} + args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms - uses: actions/upload-artifact@v4 with: name: alienplatform-${{ matrix.os }} From 58cd007e8bb0e5c807f8a413dd954fc5418a8bf9 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 16:46:32 -0700 Subject: [PATCH 05/13] ci: build Linux arm64 Python wheels --- .github/workflows/python-bindings.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 1772ad95e7..4b37849d31 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -63,6 +63,9 @@ jobs: - os: ubuntu-latest target: x86_64 manylinux: "2014" + - os: ubuntu-latest + target: aarch64 + manylinux: "2014" - os: macos-14 target: aarch64 manylinux: "off" @@ -78,6 +81,6 @@ jobs: args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms - uses: actions/upload-artifact@v4 with: - name: alienplatform-${{ matrix.os }} + name: alienplatform-${{ matrix.os }}-${{ matrix.target }} path: dist/*.whl if-no-files-found: error From 5200d48952a8c2a9209a30eae29529bda0c6f4ae Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 16:51:15 -0700 Subject: [PATCH 06/13] ci: use modern arm64 manylinux toolchain --- .github/workflows/python-bindings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 4b37849d31..788b326180 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -65,7 +65,7 @@ jobs: manylinux: "2014" - os: ubuntu-latest target: aarch64 - manylinux: "2014" + manylinux: "2_28" - os: macos-14 target: aarch64 manylinux: "off" From 7d4a5a5d0aa17d62b23a2cb0ff94a0b7cb5b58a1 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 17:03:13 -0700 Subject: [PATCH 07/13] ci: build arm64 wheel on native runner --- .github/workflows/python-bindings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 788b326180..ec93a75fde 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -63,7 +63,7 @@ jobs: - os: ubuntu-latest target: x86_64 manylinux: "2014" - - os: ubuntu-latest + - os: ubuntu-24.04-arm target: aarch64 manylinux: "2_28" - os: macos-14 From 01a11462d1a24cc817937c7e4c31162c098f8b11 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Sun, 20 Sep 2026 19:01:06 -0700 Subject: [PATCH 08/13] feat: add Python container source builds --- README.md | 2 +- crates/alien-build/src/lib.rs | 38 ++- crates/alien-build/src/toolchain/docker.rs | 21 +- crates/alien-build/src/toolchain/mod.rs | 10 + crates/alien-build/src/toolchain/python.rs | 251 ++++++++++++++++++ crates/alien-core/src/resources/worker.rs | 12 + examples/python-bindings/README.md | 17 ++ examples/python-container/README.md | 11 + examples/python-container/alien.ts | 24 ++ examples/python-container/app.py | 16 ++ examples/python-container/package.json | 8 + examples/python-container/pnpm-lock.yaml | 13 + examples/python-container/pyproject.toml | 5 + examples/python-container/uv.lock | 8 + .../core/src/generated/schemas/container.json | 2 +- .../src/generated/schemas/containerCode.json | 2 +- .../core/src/generated/schemas/daemon.json | 2 +- .../src/generated/schemas/daemonCode.json | 2 +- .../core/src/generated/schemas/sandbox.json | 2 +- .../src/generated/schemas/sandboxCode.json | 2 +- .../generated/schemas/toolchainConfig.json | 2 +- .../core/src/generated/schemas/worker.json | 2 +- .../src/generated/schemas/workerCode.json | 2 +- .../generated/zod/toolchain-config-schema.ts | 5 + 24 files changed, 445 insertions(+), 14 deletions(-) create mode 100644 crates/alien-build/src/toolchain/python.rs create mode 100644 examples/python-container/README.md create mode 100644 examples/python-container/alien.ts create mode 100644 examples/python-container/app.py create mode 100644 examples/python-container/package.json create mode 100644 examples/python-container/pnpm-lock.yaml create mode 100644 examples/python-container/pyproject.toml create mode 100644 examples/python-container/uv.lock diff --git a/README.md b/README.md index 52355e9e65..42cf49ac82 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ outputs. ## Features - **[AWS, GCP, and Azure support](https://www.alien.dev/docs/how-alien-works)** - Deploy to all major clouds. -- **[TypeScript & Rust](https://alien.dev/docs/infrastructure/worker/toolchains)** — First-class support for both. Python and arbitrary containers coming soon. +- **[TypeScript & Rust](https://alien.dev/docs/infrastructure/worker/toolchains)** — First-class source builds, plus Python source containers and arbitrary Docker containers. - **[Real-time Heartbeat](https://alien.dev/docs/how-alien-works)** — Know the instant a deployment goes down. - **[Auto Updates & Rollbacks](https://alien.dev/docs/releases)** — Push a release and every remote environment picks it up automatically. - **[Local-first Development](https://alien.dev/docs/local-development)** — Build and test on your machine. Local equivalents for every cloud resource. diff --git a/crates/alien-build/src/lib.rs b/crates/alien-build/src/lib.rs index d2b8f92f32..c9fcefbc0f 100644 --- a/crates/alien-build/src/lib.rs +++ b/crates/alien-build/src/lib.rs @@ -85,6 +85,7 @@ struct DedupeKey { enum ToolchainType { Rust, TypeScript, + Python, Docker, } @@ -102,6 +103,20 @@ impl DedupeKey { toolchain_type: ToolchainType::TypeScript, binary_name: binary_name.clone().unwrap_or_else(|| "default".to_string()), }, + ToolchainConfig::Python { + python_version, + package, + command, + } => Self { + src: src.to_string(), + toolchain_type: ToolchainType::Python, + binary_name: format!( + "{}\0{}\0{}", + python_version.as_deref().unwrap_or_default(), + package.as_deref().unwrap_or_default(), + command.join("\0") + ), + }, ToolchainConfig::Docker { dockerfile, .. } => Self { src: src.to_string(), toolchain_type: ToolchainType::Docker, @@ -2047,6 +2062,22 @@ async fn hash_build_input_source( hash_source_directory(Path::new(src), hasher).await?; hash_typescript_dependency_inputs(Path::new(src), targets, hasher).await } + ToolchainConfig::Python { .. } => { + hash_source_directory(Path::new(src), hasher).await?; + hasher.update(toolchain::python::build_recipe_cache_key()); + if let Some(wheel) = toolchain::python::sdk_wheel_path()? { + let bytes = fs::read(&wheel).await.into_alien_error().context( + ErrorData::FileOperationFailed { + operation: "read file".to_string(), + file_path: wheel.display().to_string(), + reason: "Failed to read Python SDK wheel for build cache key".to_string(), + }, + )?; + hasher.update(b"python-sdk-wheel"); + hasher.update(bytes); + } + Ok(()) + } _ => hash_source_directory(Path::new(src), hasher).await, } } @@ -2962,7 +2993,12 @@ fn effective_source_base_images( workload: toolchain::WorkloadKind, host_process: bool, ) -> Vec { - if host_process || matches!(toolchain_config, alien_core::ToolchainConfig::Docker { .. }) { + if host_process + || matches!( + toolchain_config, + alien_core::ToolchainConfig::Docker { .. } | alien_core::ToolchainConfig::Python { .. } + ) + { return vec![]; } diff --git a/crates/alien-build/src/toolchain/docker.rs b/crates/alien-build/src/toolchain/docker.rs index df5f695808..d61d518292 100644 --- a/crates/alien-build/src/toolchain/docker.rs +++ b/crates/alien-build/src/toolchain/docker.rs @@ -325,9 +325,12 @@ impl DockerToolchain { } } -#[async_trait] -impl Toolchain for DockerToolchain { - async fn build(&self, context: &ToolchainContext) -> Result { +impl DockerToolchain { + pub(crate) async fn build_with_contexts( + &self, + context: &ToolchainContext, + additional_contexts: &[(&str, &Path)], + ) -> Result { let dockerfile_name = self.dockerfile.as_deref().unwrap_or("Dockerfile"); info!( @@ -406,6 +409,11 @@ impl Toolchain for DockerToolchain { args.push(target.clone()); } + for (name, path) in additional_contexts { + args.push("--build-context".to_string()); + args.push(format!("{name}={}", path.display())); + } + // Add build context args.push(".".to_string()); // Build context is the src_dir @@ -503,6 +511,13 @@ impl Toolchain for DockerToolchain { runtime_command, }) } +} + +#[async_trait] +impl Toolchain for DockerToolchain { + async fn build(&self, context: &ToolchainContext) -> Result { + self.build_with_contexts(context, &[]).await + } fn dev_command(&self, _src_dir: &Path) -> Vec { vec!["docker".to_string(), "run".to_string()] diff --git a/crates/alien-build/src/toolchain/mod.rs b/crates/alien-build/src/toolchain/mod.rs index 3833a6ec08..2e7a772b2e 100644 --- a/crates/alien-build/src/toolchain/mod.rs +++ b/crates/alien-build/src/toolchain/mod.rs @@ -10,6 +10,7 @@ use std::sync::Arc; pub mod cache_utils; pub mod docker; pub(crate) mod native_addon; +pub mod python; pub mod rust; pub mod typescript; @@ -304,6 +305,15 @@ pub fn create_toolchain(config: &ToolchainConfig) -> Box { ToolchainConfig::TypeScript { binary_name } => Box::new(typescript::TypeScriptToolchain { binary_name: binary_name.clone(), }), + ToolchainConfig::Python { + python_version, + package, + command, + } => Box::new(python::PythonToolchain { + python_version: python_version.clone(), + package: package.clone(), + command: command.clone(), + }), ToolchainConfig::Docker { dockerfile, build_args, diff --git a/crates/alien-build/src/toolchain/python.rs b/crates/alien-build/src/toolchain/python.rs new file mode 100644 index 0000000000..1b7c441d3e --- /dev/null +++ b/crates/alien-build/src/toolchain/python.rs @@ -0,0 +1,251 @@ +use super::docker::DockerToolchain; +use super::{Toolchain, ToolchainContext, ToolchainOutput, WorkloadKind}; +use crate::error::{ErrorData, Result}; +use alien_error::{AlienError, Context, IntoAlienError}; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const SDK_WHEEL_ENV: &str = "ALIEN_PYTHON_SDK_WHEEL"; +const DEFAULT_PYTHON_VERSION: &str = "3.12"; +const UV_VERSION: &str = "0.8.11"; + +pub(crate) fn build_recipe_cache_key() -> &'static [u8] { + b"python-toolchain-v1:python-slim-bookworm:uv-0.8.11" +} + +#[derive(Debug, Clone)] +pub struct PythonToolchain { + pub python_version: Option, + pub package: Option, + pub command: Vec, +} + +pub(crate) fn sdk_wheel_path() -> Result> { + let Some(value) = std::env::var_os(SDK_WHEEL_ENV) else { + return Ok(None); + }; + let path = PathBuf::from(value); + if !path.is_file() || path.extension().and_then(|value| value.to_str()) != Some("whl") { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: "python-toolchain".to_string(), + reason: format!("{SDK_WHEEL_ENV} must point to a readable .whl file"), + })); + } + Ok(Some(path)) +} + +impl PythonToolchain { + fn version(&self) -> &str { + self.python_version + .as_deref() + .unwrap_or(DEFAULT_PYTHON_VERSION) + } + + fn dockerfile(&self, inject_sdk: bool) -> Result { + let command = serde_json::to_string(&self.command) + .into_alien_error() + .context(ErrorData::InvalidResourceConfig { + resource_id: "python-toolchain".to_string(), + reason: "Python command could not be encoded".to_string(), + })?; + let package = self + .package + .as_ref() + .map(|name| format!(" --package {}", shell_escape(name))) + .unwrap_or_default(); + let sdk_copy = if inject_sdk { + "COPY --from=alien_python_sdk / /tmp/alien-python-sdk/\n" + } else { + "" + }; + let sdk_install = if inject_sdk { + "RUN uv pip install --python /app/.venv/bin/python --reinstall /tmp/alien-python-sdk/*.whl && rm -rf /tmp/alien-python-sdk\n" + } else { + "" + }; + + Ok(format!( + "# syntax=docker/dockerfile:1\nFROM python:{version}-slim-bookworm\nRUN python -m pip install --no-cache-dir uv=={UV_VERSION}\nWORKDIR /app\nENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1 VIRTUAL_ENV=/app/.venv PATH=/app/.venv/bin:$PATH\nCOPY . /app\nRUN uv sync --frozen --no-dev{package}\n{sdk_copy}{sdk_install}CMD {command}\n", + version = self.version(), + )) + } +} + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[async_trait] +impl Toolchain for PythonToolchain { + fn validate_source(&self, src_dir: &Path, resource_name: &str) -> Result<()> { + if !src_dir.join("pyproject.toml").is_file() || !src_dir.join("uv.lock").is_file() { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: "Python source requires pyproject.toml and uv.lock".to_string(), + })); + } + if self.command.is_empty() || self.command.iter().any(String::is_empty) { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: "Python toolchain command must contain non-empty argv values".to_string(), + })); + } + if !matches!(self.version(), "3.10" | "3.11" | "3.12" | "3.13") { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: resource_name.to_string(), + reason: "Python version must be one of 3.10, 3.11, 3.12, or 3.13".to_string(), + })); + } + Ok(()) + } + + async fn build(&self, context: &ToolchainContext) -> Result { + if context.workload != WorkloadKind::Container { + return Err(AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: "python-toolchain".to_string(), + reason: "Python source builds currently support Container workloads".to_string(), + })); + } + + fs::create_dir_all(&context.build_dir) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to create Python build directory".to_string(), + build_output: None, + })?; + let wheel = sdk_wheel_path()?; + let dockerfile_path = context.build_dir.join("Python.Dockerfile"); + fs::write(&dockerfile_path, self.dockerfile(wheel.is_some())?) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to write generated Python Dockerfile".to_string(), + build_output: None, + })?; + + let sdk_context_dir = context.build_dir.join("python-sdk"); + let mut contexts = Vec::new(); + if let Some(wheel) = wheel { + if sdk_context_dir.exists() { + fs::remove_dir_all(&sdk_context_dir) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to clear stale Python SDK build context".to_string(), + build_output: None, + })?; + } + fs::create_dir_all(&sdk_context_dir) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to create Python SDK build context".to_string(), + build_output: None, + })?; + let filename = wheel.file_name().ok_or_else(|| { + AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: "python-toolchain".to_string(), + reason: format!("{SDK_WHEEL_ENV} must include a wheel filename"), + }) + })?; + fs::copy(&wheel, sdk_context_dir.join(filename)) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to stage Python SDK wheel".to_string(), + build_output: None, + })?; + contexts.push(("alien_python_sdk", sdk_context_dir.as_path())); + } + + let result = DockerToolchain { + dockerfile: Some(dockerfile_path.to_string_lossy().into_owned()), + build_args: None, + target: None, + } + .build_with_contexts(context, &contexts) + .await; + + if sdk_context_dir.exists() { + fs::remove_dir_all(&sdk_context_dir) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to remove staged Python SDK wheel".to_string(), + build_output: None, + })?; + } + result + } + + fn dev_command(&self, _src_dir: &Path) -> Vec { + let mut command = vec!["uv".to_string(), "run".to_string()]; + if let Some(package) = &self.package { + command.extend(["--package".to_string(), package.clone()]); + } + command.extend(self.command.clone()); + command + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn validates_locked_project_and_explicit_command() { + let dir = tempdir().expect("temp directory"); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname='example'\n", + ) + .expect("write pyproject"); + std::fs::write(dir.path().join("uv.lock"), "version = 1\n").expect("write lock"); + let toolchain = PythonToolchain { + python_version: None, + package: None, + command: vec![ + "python".to_string(), + "-m".to_string(), + "example".to_string(), + ], + }; + + toolchain + .validate_source(dir.path(), "example") + .expect("locked project should be valid"); + let dockerfile = toolchain.dockerfile(true).expect("render Dockerfile"); + assert!(dockerfile.contains("uv sync --frozen --no-dev")); + assert!(dockerfile.contains("COPY --from=alien_python_sdk / /tmp/alien-python-sdk/")); + assert!(dockerfile.contains("CMD [\"python\",\"-m\",\"example\"]")); + } + + #[test] + fn rejects_unlocked_or_unsupported_projects() { + let dir = tempdir().expect("temp directory"); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname='example'\n", + ) + .expect("write pyproject"); + let mut toolchain = PythonToolchain { + python_version: None, + package: None, + command: vec!["python".to_string(), "app.py".to_string()], + }; + assert!(toolchain.validate_source(dir.path(), "example").is_err()); + + std::fs::write(dir.path().join("uv.lock"), "version = 1\n").expect("write lock"); + toolchain.python_version = Some("3.9".to_string()); + assert!(toolchain.validate_source(dir.path(), "example").is_err()); + } +} diff --git a/crates/alien-core/src/resources/worker.rs b/crates/alien-core/src/resources/worker.rs index 24cef47d31..cdbe00c21e 100644 --- a/crates/alien-core/src/resources/worker.rs +++ b/crates/alien-core/src/resources/worker.rs @@ -49,6 +49,18 @@ pub enum ToolchainConfig { #[serde(default, skip_serializing_if = "Option::is_none")] binary_name: Option, }, + /// Python application installed from a locked uv project + #[serde(rename_all = "camelCase")] + Python { + /// Python version used by the runtime image (defaults to 3.12) + #[serde(default, skip_serializing_if = "Option::is_none")] + python_version: Option, + /// Workspace package to install (omit for a single-package project) + #[serde(default, skip_serializing_if = "Option::is_none")] + package: Option, + /// Process argv used to start the application + command: Vec, + }, /// Docker build from Dockerfile #[serde(rename_all = "camelCase")] Docker { diff --git a/examples/python-bindings/README.md b/examples/python-bindings/README.md index d260805c76..e4aaf9f5ee 100644 --- a/examples/python-bindings/README.md +++ b/examples/python-bindings/README.md @@ -8,3 +8,20 @@ and `runtime`, then run `python app.py` inside the workload. The example intentionally contains no provider-specific configuration. Alien injects binding descriptions and projected workload identity for the selected platform. + +Python containers can use the source toolchain with a checked-in `uv.lock`: + +```ts +toolchain: { + type: "python", + pythonVersion: "3.12", + package: "my-package", // omit for a single-package project + command: ["python", "-m", "my_package"], +} +``` + +For an unpublished SDK build, set `ALIEN_PYTHON_SDK_WHEEL` to the target +platform's wheel. Alien stages it through a separate build context; the wheel +does not need to be copied into the application source. Once the SDK is +published, declare `alienplatform` in the project's locked dependencies and +omit that environment variable. diff --git a/examples/python-container/README.md b/examples/python-container/README.md new file mode 100644 index 0000000000..88c2eccea6 --- /dev/null +++ b/examples/python-container/README.md @@ -0,0 +1,11 @@ +# Python container + +This example builds a locked Python project without an application Dockerfile. +It imports the Alien Python SDK and serves a small HTTP readiness response. + +Until `alienplatform` is published, point the build at a wheel for the target +architecture: + +```sh +ALIEN_PYTHON_SDK_WHEEL=/path/to/alienplatform.whl alien build --platforms local +``` diff --git a/examples/python-container/alien.ts b/examples/python-container/alien.ts new file mode 100644 index 0000000000..981ff1413a --- /dev/null +++ b/examples/python-container/alien.ts @@ -0,0 +1,24 @@ +import * as alien from "@alienplatform/core" + +const app = new alien.Container("app") + .code({ + type: "source", + src: ".", + toolchain: { + type: "python", + pythonVersion: "3.12", + command: ["python", "app.py"], + }, + }) + .cpu(0.25) + .memory("256Mi") + .port(8080) + .publicEndpoint("web", 8080, "http") + .permissions("app") + .build() + +export default new alien.Stack("python-container") + .platforms(["aws", "gcp", "azure"]) + .add(app, "live") + .permissions({ profiles: { app: {} } }) + .build() diff --git a/examples/python-container/app.py b/examples/python-container/app.py new file mode 100644 index 0000000000..6ff7afd0f2 --- /dev/null +++ b/examples/python-container/app.py @@ -0,0 +1,16 @@ +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import alienplatform + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = f"{alienplatform.__name__}: ready\n".encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() diff --git a/examples/python-container/package.json b/examples/python-container/package.json new file mode 100644 index 0000000000..cf990e7da4 --- /dev/null +++ b/examples/python-container/package.json @@ -0,0 +1,8 @@ +{ + "name": "@alienplatform/example-python-container", + "private": true, + "type": "module", + "dependencies": { + "@alienplatform/core": "link:../../packages/core" + } +} diff --git a/examples/python-container/pnpm-lock.yaml b/examples/python-container/pnpm-lock.yaml new file mode 100644 index 0000000000..eee989d407 --- /dev/null +++ b/examples/python-container/pnpm-lock.yaml @@ -0,0 +1,13 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@alienplatform/core': + specifier: link:../../packages/core + version: link:../../packages/core diff --git a/examples/python-container/pyproject.toml b/examples/python-container/pyproject.toml new file mode 100644 index 0000000000..3f60e46083 --- /dev/null +++ b/examples/python-container/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "alien-python-container-example" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] diff --git a/examples/python-container/uv.lock b/examples/python-container/uv.lock new file mode 100644 index 0000000000..d301d5be14 --- /dev/null +++ b/examples/python-container/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alien-python-container-example" +version = "0.1.0" +source = { virtual = "." } diff --git a/packages/core/src/generated/schemas/container.json b/packages/core/src/generated/schemas/container.json index 371024404c..6364446a98 100644 --- a/packages/core/src/generated/schemas/container.json +++ b/packages/core/src/generated/schemas/container.json @@ -1 +1 @@ -{"type":"object","description":"Container resource for running long-running container workloads.\n\nA Container defines a deployable unit that runs on a ComputeCluster.\nThe managed container backend handles scheduling replicas across machines,\nautoscaling based on various metrics, and service discovery.\n\n## Example\n\n```rust\nuse alien_core::{Container, ContainerCode, ResourceSpec, ContainerAutoscaling, PublicEndpoint, ExposeProtocol};\n\nlet container = Container::new(\"api\".to_string())\n .cluster(\"compute\".to_string())\n .code(ContainerCode::Image {\n image: \"myapp:latest\".to_string(),\n })\n .cpu(ResourceSpec { min: \"0.5\".to_string(), desired: \"1\".to_string() })\n .memory(ResourceSpec { min: \"512Mi\".to_string(), desired: \"1Gi\".to_string() })\n .port(8080)\n .public_endpoint(PublicEndpoint {\n name: \"api\".to_string(),\n port: 8080,\n protocol: ExposeProtocol::Http,\n host_label: None,\n wildcard_subdomains: false,\n })\n .autoscaling(ContainerAutoscaling {\n min: 2,\n desired: 3,\n max: 10,\n target_cpu_percent: Some(70.0),\n target_memory_percent: None,\n target_http_in_flight_per_replica: Some(100),\n max_http_p95_latency_ms: None,\n })\n .permissions(\"container-execution\".to_string())\n .build();\n```","required":["id","links","ports","code","cpu","memory","permissions"],"properties":{"autoscaling":{"oneOf":[{"type":"null"},{"description":"Autoscaling configuration (only for stateless containers)","type":"object","required":["min","desired","max"],"properties":{"desired":{"type":"integer","format":"int32","description":"Initial desired replicas at container creation","minimum":0},"max":{"type":"integer","format":"int32","description":"Maximum replicas under load","minimum":0},"maxHttpP95LatencyMs":{"type":["number","null"],"format":"double","description":"Maximum acceptable p95 HTTP latency in milliseconds"},"min":{"type":"integer","format":"int32","description":"Minimum replicas (always running)","minimum":0},"targetCpuPercent":{"type":["number","null"],"format":"double","description":"Target CPU utilization percentage for scaling (default: 70%)"},"targetHttpInFlightPerReplica":{"type":["integer","null"],"format":"int32","description":"Target in-flight HTTP requests per replica","minimum":0},"targetMemoryPercent":{"type":["number","null"],"format":"double","description":"Target memory utilization percentage for scaling (default: 80%)"}},"x-readme-ref-name":"ContainerAutoscaling"}]},"cluster":{"type":["string","null"],"description":"ComputeCluster resource ID that this container runs on.\nIf None, will be auto-assigned by ComputeClusterMutation at deployment time."},"code":{"description":"Container code (image or source)","oneOf":[{"type":"object","description":"Container image reference","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `postgres:16`, `ghcr.io/myorg/myimage:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"ContainerCode"},"command":{"type":["array","null"],"items":{"type":"string"},"description":"Command to override image default"},"commandsEnabled":{"type":"boolean","description":"Whether the container can receive remote commands via the Commands protocol.\nWhen enabled, an app-owned command receiver can lease pending commands\nfor this Container and execute registered handlers.","default":false},"cpu":{"description":"CPU resource requirements","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"environment":{"type":"object","description":"Environment variables","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"ephemeralStorage":{"type":["string","null"],"description":"Ephemeral storage requirement (e.g., \"10Gi\")"},"gpu":{"oneOf":[{"type":"null"},{"description":"GPU requirements (optional)","type":"object","required":["type","count"],"properties":{"count":{"type":"integer","format":"int32","description":"Number of GPUs required (1-8)","minimum":0},"type":{"type":"string","description":"GPU type identifier (e.g., \"nvidia-a100\", \"nvidia-t4\")"}},"x-readme-ref-name":"ContainerGpuSpec"}]},"healthCheck":{"oneOf":[{"type":"null"},{"description":"Health check configuration","type":"object","properties":{"failureThreshold":{"type":"integer","format":"int32","description":"Number of consecutive failures before marking replica unhealthy","minimum":0},"method":{"type":"string","description":"HTTP method to use for health check"},"path":{"type":"string","description":"HTTP endpoint path to check (e.g., \"/health\", \"/ready\")"},"port":{"type":["integer","null"],"format":"int32","description":"Port to check (defaults to container port if not specified)","minimum":0},"timeoutSeconds":{"type":"integer","format":"int32","description":"Request timeout in seconds (1-5)","minimum":0}},"x-readme-ref-name":"HealthCheck"}]},"id":{"type":"string","description":"Unique identifier for the container.\nMust be DNS-compatible: lowercase alphanumeric with hyphens."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"Resource links (dependencies)"},"memory":{"description":"Memory resource requirements (must use Ki/Mi/Gi/Ti suffix)","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"permissions":{"type":"string","description":"Permission profile name"},"persistentStorage":{"oneOf":[{"type":"null"},{"description":"Persistent storage configuration (only for stateful containers)","type":"object","required":["size","mountPath"],"properties":{"mountPath":{"type":"string","description":"Mount path inside the container"},"size":{"type":"string","description":"Storage size (e.g., \"100Gi\", \"500Gi\")"}},"x-readme-ref-name":"PersistentStorage"}]},"pool":{"type":["string","null"],"description":"Capacity group to run on (must exist in the cluster)\nIf not specified, containers are scheduled to any available group."},"ports":{"type":"array","items":{"type":"object","description":"Container port configuration.","required":["port"],"properties":{"port":{"type":"integer","format":"int32","description":"Port number","minimum":0}},"x-readme-ref-name":"ContainerPort"},"description":"Internal container ports (at least one required)."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for port-backed workload resources.","required":["name","port","protocol"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"port":{"type":"integer","format":"int32","description":"Workload port served by the public endpoint.","minimum":0},"protocol":{"description":"Public protocol.","type":"string","enum":["http","tcp"],"x-readme-ref-name":"ExposeProtocol"},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"PublicEndpoint"},"description":"Public endpoints exposed by the container."},"replicas":{"type":["integer","null"],"format":"int32","description":"Fixed replica count (for stateful containers or stateless without autoscaling)","minimum":0},"stateful":{"type":"boolean","description":"Whether container is stateful (gets stable ordinals, optional persistent volumes)"},"stopGracePeriodSeconds":{"type":["integer","null"],"format":"int32","description":"Grace period in seconds for stopping replicas during updates, drains, and deletes.\n\nWhen omitted, the runtime backend applies its default. Valid values are\n1 second through 24 hours.","maximum":86400,"minimum":1}},"additionalProperties":false,"x-readme-ref-name":"Container"} \ No newline at end of file +{"type":"object","description":"Container resource for running long-running container workloads.\n\nA Container defines a deployable unit that runs on a ComputeCluster.\nThe managed container backend handles scheduling replicas across machines,\nautoscaling based on various metrics, and service discovery.\n\n## Example\n\n```rust\nuse alien_core::{Container, ContainerCode, ResourceSpec, ContainerAutoscaling, PublicEndpoint, ExposeProtocol};\n\nlet container = Container::new(\"api\".to_string())\n .cluster(\"compute\".to_string())\n .code(ContainerCode::Image {\n image: \"myapp:latest\".to_string(),\n })\n .cpu(ResourceSpec { min: \"0.5\".to_string(), desired: \"1\".to_string() })\n .memory(ResourceSpec { min: \"512Mi\".to_string(), desired: \"1Gi\".to_string() })\n .port(8080)\n .public_endpoint(PublicEndpoint {\n name: \"api\".to_string(),\n port: 8080,\n protocol: ExposeProtocol::Http,\n host_label: None,\n wildcard_subdomains: false,\n })\n .autoscaling(ContainerAutoscaling {\n min: 2,\n desired: 3,\n max: 10,\n target_cpu_percent: Some(70.0),\n target_memory_percent: None,\n target_http_in_flight_per_replica: Some(100),\n max_http_p95_latency_ms: None,\n })\n .permissions(\"container-execution\".to_string())\n .build();\n```","required":["id","links","ports","code","cpu","memory","permissions"],"properties":{"autoscaling":{"oneOf":[{"type":"null"},{"description":"Autoscaling configuration (only for stateless containers)","type":"object","required":["min","desired","max"],"properties":{"desired":{"type":"integer","format":"int32","description":"Initial desired replicas at container creation","minimum":0},"max":{"type":"integer","format":"int32","description":"Maximum replicas under load","minimum":0},"maxHttpP95LatencyMs":{"type":["number","null"],"format":"double","description":"Maximum acceptable p95 HTTP latency in milliseconds"},"min":{"type":"integer","format":"int32","description":"Minimum replicas (always running)","minimum":0},"targetCpuPercent":{"type":["number","null"],"format":"double","description":"Target CPU utilization percentage for scaling (default: 70%)"},"targetHttpInFlightPerReplica":{"type":["integer","null"],"format":"int32","description":"Target in-flight HTTP requests per replica","minimum":0},"targetMemoryPercent":{"type":["number","null"],"format":"double","description":"Target memory utilization percentage for scaling (default: 80%)"}},"x-readme-ref-name":"ContainerAutoscaling"}]},"cluster":{"type":["string","null"],"description":"ComputeCluster resource ID that this container runs on.\nIf None, will be auto-assigned by ComputeClusterMutation at deployment time."},"code":{"description":"Container code (image or source)","oneOf":[{"type":"object","description":"Container image reference","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `postgres:16`, `ghcr.io/myorg/myimage:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"ContainerCode"},"command":{"type":["array","null"],"items":{"type":"string"},"description":"Command to override image default"},"commandsEnabled":{"type":"boolean","description":"Whether the container can receive remote commands via the Commands protocol.\nWhen enabled, an app-owned command receiver can lease pending commands\nfor this Container and execute registered handlers.","default":false},"cpu":{"description":"CPU resource requirements","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"environment":{"type":"object","description":"Environment variables","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"ephemeralStorage":{"type":["string","null"],"description":"Ephemeral storage requirement (e.g., \"10Gi\")"},"gpu":{"oneOf":[{"type":"null"},{"description":"GPU requirements (optional)","type":"object","required":["type","count"],"properties":{"count":{"type":"integer","format":"int32","description":"Number of GPUs required (1-8)","minimum":0},"type":{"type":"string","description":"GPU type identifier (e.g., \"nvidia-a100\", \"nvidia-t4\")"}},"x-readme-ref-name":"ContainerGpuSpec"}]},"healthCheck":{"oneOf":[{"type":"null"},{"description":"Health check configuration","type":"object","properties":{"failureThreshold":{"type":"integer","format":"int32","description":"Number of consecutive failures before marking replica unhealthy","minimum":0},"method":{"type":"string","description":"HTTP method to use for health check"},"path":{"type":"string","description":"HTTP endpoint path to check (e.g., \"/health\", \"/ready\")"},"port":{"type":["integer","null"],"format":"int32","description":"Port to check (defaults to container port if not specified)","minimum":0},"timeoutSeconds":{"type":"integer","format":"int32","description":"Request timeout in seconds (1-5)","minimum":0}},"x-readme-ref-name":"HealthCheck"}]},"id":{"type":"string","description":"Unique identifier for the container.\nMust be DNS-compatible: lowercase alphanumeric with hyphens."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"Resource links (dependencies)"},"memory":{"description":"Memory resource requirements (must use Ki/Mi/Gi/Ti suffix)","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"permissions":{"type":"string","description":"Permission profile name"},"persistentStorage":{"oneOf":[{"type":"null"},{"description":"Persistent storage configuration (only for stateful containers)","type":"object","required":["size","mountPath"],"properties":{"mountPath":{"type":"string","description":"Mount path inside the container"},"size":{"type":"string","description":"Storage size (e.g., \"100Gi\", \"500Gi\")"}},"x-readme-ref-name":"PersistentStorage"}]},"pool":{"type":["string","null"],"description":"Capacity group to run on (must exist in the cluster)\nIf not specified, containers are scheduled to any available group."},"ports":{"type":"array","items":{"type":"object","description":"Container port configuration.","required":["port"],"properties":{"port":{"type":"integer","format":"int32","description":"Port number","minimum":0}},"x-readme-ref-name":"ContainerPort"},"description":"Internal container ports (at least one required)."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for port-backed workload resources.","required":["name","port","protocol"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"port":{"type":"integer","format":"int32","description":"Workload port served by the public endpoint.","minimum":0},"protocol":{"description":"Public protocol.","type":"string","enum":["http","tcp"],"x-readme-ref-name":"ExposeProtocol"},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"PublicEndpoint"},"description":"Public endpoints exposed by the container."},"replicas":{"type":["integer","null"],"format":"int32","description":"Fixed replica count (for stateful containers or stateless without autoscaling)","minimum":0},"stateful":{"type":"boolean","description":"Whether container is stateful (gets stable ordinals, optional persistent volumes)"},"stopGracePeriodSeconds":{"type":["integer","null"],"format":"int32","description":"Grace period in seconds for stopping replicas during updates, drains, and deletes.\n\nWhen omitted, the runtime backend applies its default. Valid values are\n1 second through 24 hours.","maximum":86400,"minimum":1}},"additionalProperties":false,"x-readme-ref-name":"Container"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/containerCode.json b/packages/core/src/generated/schemas/containerCode.json index 5d9ddaa422..8470b23cfc 100644 --- a/packages/core/src/generated/schemas/containerCode.json +++ b/packages/core/src/generated/schemas/containerCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"Container image reference","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `postgres:16`, `ghcr.io/myorg/myimage:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies the source of the container's executable code.","x-readme-ref-name":"ContainerCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"Container image reference","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `postgres:16`, `ghcr.io/myorg/myimage:latest`)"},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies the source of the container's executable code.","x-readme-ref-name":"ContainerCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/daemon.json b/packages/core/src/generated/schemas/daemon.json index e8cb14fd11..bf8713ceb7 100644 --- a/packages/core/src/generated/schemas/daemon.json +++ b/packages/core/src/generated/schemas/daemon.json @@ -1 +1 @@ -{"type":"object","required":["id","links","permissions","code"],"properties":{"cluster":{"type":["string","null"],"description":"ComputeCluster resource ID that this daemon runs on for managed cloud\ncompute backends. Kubernetes and Local runtimes ignore this field."},"code":{"oneOf":[{"type":"object","required":["image","type"],"properties":{"image":{"type":"string"},"type":{"type":"string","enum":["image"]}}},{"type":"object","required":["src","toolchain","type"],"properties":{"src":{"type":"string"},"toolchain":{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"DaemonCode"},"command":{"type":["array","null"],"items":{"type":"string"},"description":"Command to override the image default."},"commandsEnabled":{"type":"boolean","description":"Whether an app-owned command receiver can lease pending commands for\nthis Daemon and execute registered handlers.","default":false},"cpu":{"description":"CPU resource requirements for each daemon instance.","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"environment":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"healthCheck":{"oneOf":[{"type":"null"},{"description":"HTTP health check for public daemon endpoint load balancers.","type":"object","properties":{"failureThreshold":{"type":"integer","format":"int32","description":"Number of consecutive failures before marking replica unhealthy","minimum":0},"method":{"type":"string","description":"HTTP method to use for health check"},"path":{"type":"string","description":"HTTP endpoint path to check (e.g., \"/health\", \"/ready\")"},"port":{"type":["integer","null"],"format":"int32","description":"Port to check (defaults to container port if not specified)","minimum":0},"timeoutSeconds":{"type":"integer","format":"int32","description":"Request timeout in seconds (1-5)","minimum":0}},"x-readme-ref-name":"HealthCheck"}]},"id":{"type":"string"},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"}},"memory":{"description":"Memory resource requirements for each daemon instance.","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"permissions":{"type":"string"},"pool":{"type":["string","null"],"description":"Capacity group/pool to run on for backends that expose machine pools."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for port-backed workload resources.","required":["name","port","protocol"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"port":{"type":"integer","format":"int32","description":"Workload port served by the public endpoint.","minimum":0},"protocol":{"description":"Public protocol.","type":"string","enum":["http","tcp"],"x-readme-ref-name":"ExposeProtocol"},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"PublicEndpoint"},"description":"Public endpoints exposed by the daemon."},"runtime":{"oneOf":[{"type":"null"},{"description":"Optional backend runtime settings for trusted daemons.\n\nThese settings are intended for daemon-style infrastructure that must\noperate on the host. Backends that do not support a setting may reject\nit during provisioning.","type":"object","properties":{"mounts":{"type":"array","items":{"type":"object","required":["source","target"],"properties":{"options":{"type":["string","null"],"description":"Optional mount options understood by the backend runtime."},"source":{"type":"string","description":"Absolute host path to mount into the daemon container."},"target":{"type":"string","description":"Absolute container path where the source is mounted."}},"additionalProperties":false,"x-readme-ref-name":"DaemonRuntimeMount"},"description":"Host mounts exposed to the daemon container."},"networkMode":{"type":["string","null"],"description":"Network mode. Supported values are `host` and `appnet`."},"pidNamespace":{"type":["string","null"],"description":"Process namespace mode. Supported values are `host` and `private`."},"privileged":{"type":["boolean","null"],"description":"Run the daemon container with elevated host capabilities."},"user":{"type":["string","null"],"description":"Runtime user, as a numeric uid or uid:gid string."}},"additionalProperties":false,"x-readme-ref-name":"DaemonRuntime"}]},"stopGracePeriodSeconds":{"type":["integer","null"],"format":"int32","description":"Grace period in seconds for stopping daemon instances during updates, drains, and deletes.\n\nWhen omitted, the runtime backend applies its default. Valid values are\n1 second through 24 hours.","maximum":86400,"minimum":1}},"additionalProperties":false,"x-readme-ref-name":"Daemon"} \ No newline at end of file +{"type":"object","required":["id","links","permissions","code"],"properties":{"cluster":{"type":["string","null"],"description":"ComputeCluster resource ID that this daemon runs on for managed cloud\ncompute backends. Kubernetes and Local runtimes ignore this field."},"code":{"oneOf":[{"type":"object","required":["image","type"],"properties":{"image":{"type":"string"},"type":{"type":"string","enum":["image"]}}},{"type":"object","required":["src","toolchain","type"],"properties":{"src":{"type":"string"},"toolchain":{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"DaemonCode"},"command":{"type":["array","null"],"items":{"type":"string"},"description":"Command to override the image default."},"commandsEnabled":{"type":"boolean","description":"Whether an app-owned command receiver can lease pending commands for\nthis Daemon and execute registered handlers.","default":false},"cpu":{"description":"CPU resource requirements for each daemon instance.","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"environment":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"healthCheck":{"oneOf":[{"type":"null"},{"description":"HTTP health check for public daemon endpoint load balancers.","type":"object","properties":{"failureThreshold":{"type":"integer","format":"int32","description":"Number of consecutive failures before marking replica unhealthy","minimum":0},"method":{"type":"string","description":"HTTP method to use for health check"},"path":{"type":"string","description":"HTTP endpoint path to check (e.g., \"/health\", \"/ready\")"},"port":{"type":["integer","null"],"format":"int32","description":"Port to check (defaults to container port if not specified)","minimum":0},"timeoutSeconds":{"type":"integer","format":"int32","description":"Request timeout in seconds (1-5)","minimum":0}},"x-readme-ref-name":"HealthCheck"}]},"id":{"type":"string"},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"}},"memory":{"description":"Memory resource requirements for each daemon instance.","type":"object","required":["min","desired"],"properties":{"desired":{"type":"string","description":"Desired resource allocation (used by scheduler)"},"min":{"type":"string","description":"Minimum resource allocation"}},"x-readme-ref-name":"ResourceSpec"},"permissions":{"type":"string"},"pool":{"type":["string","null"],"description":"Capacity group/pool to run on for backends that expose machine pools."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for port-backed workload resources.","required":["name","port","protocol"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"port":{"type":"integer","format":"int32","description":"Workload port served by the public endpoint.","minimum":0},"protocol":{"description":"Public protocol.","type":"string","enum":["http","tcp"],"x-readme-ref-name":"ExposeProtocol"},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"PublicEndpoint"},"description":"Public endpoints exposed by the daemon."},"runtime":{"oneOf":[{"type":"null"},{"description":"Optional backend runtime settings for trusted daemons.\n\nThese settings are intended for daemon-style infrastructure that must\noperate on the host. Backends that do not support a setting may reject\nit during provisioning.","type":"object","properties":{"mounts":{"type":"array","items":{"type":"object","required":["source","target"],"properties":{"options":{"type":["string","null"],"description":"Optional mount options understood by the backend runtime."},"source":{"type":"string","description":"Absolute host path to mount into the daemon container."},"target":{"type":"string","description":"Absolute container path where the source is mounted."}},"additionalProperties":false,"x-readme-ref-name":"DaemonRuntimeMount"},"description":"Host mounts exposed to the daemon container."},"networkMode":{"type":["string","null"],"description":"Network mode. Supported values are `host` and `appnet`."},"pidNamespace":{"type":["string","null"],"description":"Process namespace mode. Supported values are `host` and `private`."},"privileged":{"type":["boolean","null"],"description":"Run the daemon container with elevated host capabilities."},"user":{"type":["string","null"],"description":"Runtime user, as a numeric uid or uid:gid string."}},"additionalProperties":false,"x-readme-ref-name":"DaemonRuntime"}]},"stopGracePeriodSeconds":{"type":["integer","null"],"format":"int32","description":"Grace period in seconds for stopping daemon instances during updates, drains, and deletes.\n\nWhen omitted, the runtime backend applies its default. Valid values are\n1 second through 24 hours.","maximum":86400,"minimum":1}},"additionalProperties":false,"x-readme-ref-name":"Daemon"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/daemonCode.json b/packages/core/src/generated/schemas/daemonCode.json index 0dcb40ce90..feac17fc46 100644 --- a/packages/core/src/generated/schemas/daemonCode.json +++ b/packages/core/src/generated/schemas/daemonCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","required":["image","type"],"properties":{"image":{"type":"string"},"type":{"type":"string","enum":["image"]}}},{"type":"object","required":["src","toolchain","type"],"properties":{"src":{"type":"string"},"toolchain":{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"DaemonCode"} \ No newline at end of file +{"oneOf":[{"type":"object","required":["image","type"],"properties":{"image":{"type":"string"},"type":{"type":"string","enum":["image"]}}},{"type":"object","required":["src","toolchain","type"],"properties":{"src":{"type":"string"},"toolchain":{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"DaemonCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandbox.json b/packages/core/src/generated/schemas/sandbox.json index 8c09ca3e08..e78ef11774 100644 --- a/packages/core/src/generated/schemas/sandbox.json +++ b/packages/core/src/generated/schemas/sandbox.json @@ -1 +1 @@ -{"type":"object","description":"An isolated environment for running untrusted code, created at runtime.","required":["id","code","egress","lifecycle"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"lifecycle":{"description":"Sandbox lifetime ceiling and idle behaviour","type":"object","properties":{"idlePauseSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the sandbox is paused, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single sandbox, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxLifecyclePolicy"},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. An application reaches its sandbox through the\nprovider, so it cannot widen its own ingress at runtime; a holder of a remote binding's\ncredentials is bounded by no port condition, which is why a remote sandbox declares none."}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file +{"type":"object","description":"An isolated environment for running untrusted code, created at runtime.","required":["id","code","egress","lifecycle"],"properties":{"code":{"description":"Where the sandbox's root filesystem comes from","oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"SandboxCode"},"egress":{"description":"Outbound network policy","oneOf":[{"type":"object","description":"No outbound network access.\n\nRouted traffic only. Link-local is not outbound and no backend's egress control reaches\nit, so this is not a boundary against instance metadata.","required":["mode"],"properties":{"mode":{"type":"string","enum":["deny"]}}},{"type":"object","description":"Unrestricted outbound access to the public internet, and none to private ranges or the\ndeployment's own network.\n\nLink-local carries the same exception as `Deny`. AWS and Kubernetes deliver both halves.\nAzure and GCP deliver the first only: one matches host patterns and the other is a single\nswitch, so neither can name an address range to exclude.","required":["mode"],"properties":{"mode":{"type":"string","enum":["allow"]}}},{"type":"object","description":"Outbound access only to the listed hostnames.\n\nAzure alone expresses it: its egress proxy matches on host pattern. The others filter by\nCIDR or carry a single switch, and both would approximate the list rather than keep it.","required":["domains","mode"],"properties":{"domains":{"type":"array","items":{"type":"string"},"description":"Hostnames the sandbox may reach"},"mode":{"type":"string","enum":["allowDomains"]}}}],"x-readme-ref-name":"SandboxEgress"},"id":{"type":"string","description":"Identifier for the sandbox. Must contain only alphanumeric characters, hyphens, and\nunderscores ([A-Za-z0-9-_]). Maximum 64 characters."},"lifecycle":{"description":"Sandbox lifetime ceiling and idle behaviour","type":"object","properties":{"idlePauseSeconds":{"type":["integer","null"],"format":"int32","description":"Idle period after which the sandbox is paused, where the platform supports it","minimum":0},"maxLifetimeSeconds":{"type":["integer","null"],"format":"int32","description":"Wall-clock ceiling on a single sandbox, after which the platform terminates it.\n\nOptional because not every backend has the primitive: Kubernetes has\n`activeDeadlineSeconds` and AWS `maximumDurationInSeconds`, while neither Azure nor Local\nexpose one, so declaring a ceiling there is refused at plan time rather than accepted and\nnever applied. AWS caps it at 8 hours.","minimum":0}},"additionalProperties":false,"x-readme-ref-name":"SandboxLifecyclePolicy"},"limits":{"oneOf":[{"type":"null"},{"description":"Enforced resource ceilings.\n\nOptional because not every platform can enforce them, and a declaration that names none\ntakes the platform's own defaults. Naming them on a platform that cannot enforce them is\nrejected at plan time rather than silently ignored.","type":"object","required":["cpu","memory","disk"],"properties":{"cpu":{"type":"string","description":"CPU ceiling in cores or millicores (e.g. `\"1\"`, `\"500m\"`)"},"disk":{"type":"string","description":"Disk ceiling (e.g. `\"20Gi\"`)"},"maxProcesses":{"type":["integer","null"],"format":"int32","description":"Maximum number of processes, which bounds fork bombs.\n\nOptional because only a container runtime has the primitive: Kubernetes sets a pid ceiling\nper node, not per pod, and neither AWS MicroVMs nor Azure sandboxes expose one. Declaring\nit on a platform that cannot apply it is refused at plan time.","minimum":0},"memory":{"type":"string","description":"Memory ceiling (e.g. `\"2Gi\"`, `\"512Mi\"`)"}},"additionalProperties":false,"x-readme-ref-name":"SandboxLimits"}]},"previewPorts":{"type":"array","items":{"type":"integer","format":"int32","minimum":0},"description":"Ports eligible for a preview capability. An application reaches its sandbox through the\nprovider, so it cannot widen its own ingress at runtime; a holder of a remote binding's\ncredentials is bounded by no port condition, which is why a remote sandbox declares none."}},"additionalProperties":false,"x-readme-ref-name":"Sandbox"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/sandboxCode.json b/packages/core/src/generated/schemas/sandboxCode.json index 28f575af4f..6c8802b704 100644 --- a/packages/core/src/generated/schemas/sandboxCode.json +++ b/packages/core/src/generated/schemas/sandboxCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"A prebuilt container image used as the sandbox root filesystem.","required":["image","type"],"properties":{"image":{"type":"string","description":"Image reference (e.g. `ubuntu:24.04`, `ghcr.io/myorg/sandbox:latest`).\n\nTwo backends narrow it in opposite directions: AWS wants an `s3://` bundle, Azure a\nbare catalog name such as `ubuntu`. Each refuses the other's shape while planning."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source built into a sandbox image at deploy time.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies where the sandbox's root filesystem comes from.","x-readme-ref-name":"SandboxCode"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/toolchainConfig.json b/packages/core/src/generated/schemas/toolchainConfig.json index 00b3591d4f..24498f1aed 100644 --- a/packages/core/src/generated/schemas/toolchainConfig.json +++ b/packages/core/src/generated/schemas/toolchainConfig.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"description":"Configuration for different programming language toolchains.\nEach toolchain provides type-safe build configuration and auto-detection capabilities.","x-readme-ref-name":"ToolchainConfig"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/worker.json b/packages/core/src/generated/schemas/worker.json index 1009c8e714..6427cf9358 100644 --- a/packages/core/src/generated/schemas/worker.json +++ b/packages/core/src/generated/schemas/worker.json @@ -1 +1 @@ -{"type":"object","description":"Represents a serverless worker that executes code in response to triggers or direct invocations.\nWorkers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.","required":["id","links","triggers","permissions","code"],"properties":{"code":{"description":"Code for the worker, either a pre-built image or source code to be built.","oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"WorkerCode"},"commandsEnabled":{"type":"boolean","description":"Whether the worker can receive remote commands via the Commands protocol.\nWhen enabled, the platform pushes commands into the Worker runtime,\nwhich executes registered handlers.","default":false},"concurrencyLimit":{"type":["integer","null"],"format":"int32","description":"Maximum number of concurrent executions allowed for the worker.\nNone means platform default applies.","minimum":0},"environment":{"type":"object","description":"Key-value pairs to set as environment variables for the worker.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"id":{"type":"string","description":"Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).\nMaximum 64 characters."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"List of resource references this worker depends on."},"memoryMb":{"type":"integer","format":"int32","description":"Memory allocated to the worker in megabytes (MB).\nDefault: 512\n\nPlatform-specific constraints:\n- **AWS Lambda**: 128–10240 MB in 1 MB increments\n- **GCP Cloud Run**: 128–32768 MB\n- **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,\n 3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.","default":512,"minimum":0},"permissions":{"type":"string","description":"Permission profile name that defines the permissions granted to this worker.\nThis references a profile defined in the stack's permission definitions."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for Worker resources.","required":["name"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"WorkerPublicEndpoint"},"description":"Public endpoints exposed by this worker."},"readinessProbe":{"oneOf":[{"type":"null"},{"description":"Optional readiness probe configuration.\nOnly applicable for workers with Public ingress.\nWhen configured, the probe will be executed after provisioning/update to verify the worker is ready.","type":"object","properties":{"method":{"description":"HTTP method to use for the probe request.\nDefault: GET","type":"string","enum":["GET","POST","PUT","DELETE","HEAD","OPTIONS","PATCH"],"x-readme-ref-name":"HttpMethod"},"path":{"type":"string","description":"Path to request for the probe (e.g., \"/health\", \"/ready\").\nDefault: \"/\""}},"x-readme-ref-name":"ReadinessProbe"}]},"timeoutSeconds":{"type":"integer","format":"int32","description":"Maximum execution time for the worker in seconds.\nConstraints: 1‑3600 seconds (platform-specific limits may apply)\nDefault: 180","default":180,"maximum":3600,"minimum":1},"triggers":{"type":"array","items":{"oneOf":[{"type":"object","description":"Worker triggered by queue messages (always 1 message per invocation)","required":["queue","type"],"properties":{"queue":{"description":"Reference to the queue resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["queue"]}}},{"type":"object","description":"Worker triggered by storage events (object created, deleted, etc.)","required":["storage","events","type"],"properties":{"events":{"type":"array","items":{"type":"string"},"description":"Events to trigger on (e.g., [\"created\", \"deleted\"])"},"storage":{"description":"Reference to the storage resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["storage"]}}},{"type":"object","description":"Worker triggered on a schedule (cron expression)","required":["cron","type"],"properties":{"cron":{"type":"string","description":"Cron expression for scheduling (standard 5-field unix cron)"},"type":{"type":"string","enum":["schedule"]}}}],"description":"Defines what triggers a worker execution.","x-readme-ref-name":"WorkerTrigger"},"description":"List of triggers that define what events automatically invoke this worker.\nIf empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.\nWhen configured, the worker will be automatically invoked when any of the specified trigger conditions are met."}},"additionalProperties":false,"x-readme-ref-name":"Worker"} \ No newline at end of file +{"type":"object","description":"Represents a serverless worker that executes code in response to triggers or direct invocations.\nWorkers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.","required":["id","links","triggers","permissions","code"],"properties":{"code":{"description":"Code for the worker, either a pre-built image or source code to be built.","oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"x-readme-ref-name":"WorkerCode"},"commandsEnabled":{"type":"boolean","description":"Whether the worker can receive remote commands via the Commands protocol.\nWhen enabled, the platform pushes commands into the Worker runtime,\nwhich executes registered handlers.","default":false},"concurrencyLimit":{"type":["integer","null"],"format":"int32","description":"Maximum number of concurrent executions allowed for the worker.\nNone means platform default applies.","minimum":0},"environment":{"type":"object","description":"Key-value pairs to set as environment variables for the worker.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"id":{"type":"string","description":"Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).\nMaximum 64 characters."},"links":{"type":"array","items":{"type":"object","description":"Reference to a resource by its stable id and resource type.","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"description":"List of resource references this worker depends on."},"memoryMb":{"type":"integer","format":"int32","description":"Memory allocated to the worker in megabytes (MB).\nDefault: 512\n\nPlatform-specific constraints:\n- **AWS Lambda**: 128–10240 MB in 1 MB increments\n- **GCP Cloud Run**: 128–32768 MB\n- **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,\n 3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.","default":512,"minimum":0},"permissions":{"type":"string","description":"Permission profile name that defines the permissions granted to this worker.\nThis references a profile defined in the stack's permission definitions."},"publicEndpoints":{"type":"array","items":{"type":"object","description":"Public endpoint configuration for Worker resources.","required":["name"],"properties":{"hostLabel":{"type":["string","null"],"description":"Optional DNS label override for generated endpoint hostnames."},"name":{"type":"string","description":"Endpoint name within the resource."},"wildcardSubdomains":{"type":"boolean","description":"Whether to route wildcard subdomains to this endpoint."}},"x-readme-ref-name":"WorkerPublicEndpoint"},"description":"Public endpoints exposed by this worker."},"readinessProbe":{"oneOf":[{"type":"null"},{"description":"Optional readiness probe configuration.\nOnly applicable for workers with Public ingress.\nWhen configured, the probe will be executed after provisioning/update to verify the worker is ready.","type":"object","properties":{"method":{"description":"HTTP method to use for the probe request.\nDefault: GET","type":"string","enum":["GET","POST","PUT","DELETE","HEAD","OPTIONS","PATCH"],"x-readme-ref-name":"HttpMethod"},"path":{"type":"string","description":"Path to request for the probe (e.g., \"/health\", \"/ready\").\nDefault: \"/\""}},"x-readme-ref-name":"ReadinessProbe"}]},"timeoutSeconds":{"type":"integer","format":"int32","description":"Maximum execution time for the worker in seconds.\nConstraints: 1‑3600 seconds (platform-specific limits may apply)\nDefault: 180","default":180,"maximum":3600,"minimum":1},"triggers":{"type":"array","items":{"oneOf":[{"type":"object","description":"Worker triggered by queue messages (always 1 message per invocation)","required":["queue","type"],"properties":{"queue":{"description":"Reference to the queue resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["queue"]}}},{"type":"object","description":"Worker triggered by storage events (object created, deleted, etc.)","required":["storage","events","type"],"properties":{"events":{"type":"array","items":{"type":"string"},"description":"Events to trigger on (e.g., [\"created\", \"deleted\"])"},"storage":{"description":"Reference to the storage resource","type":"object","required":["type","id"],"properties":{"id":{"type":"string"},"type":{"type":"string","description":"Resource type identifier that determines the specific kind of resource. This field is used for polymorphic deserialization and resource-specific behavior.","examples":["worker","storage","queue","redis","postgres"],"x-readme-ref-name":"ResourceType"}},"x-readme-ref-name":"ResourceRef"},"type":{"type":"string","enum":["storage"]}}},{"type":"object","description":"Worker triggered on a schedule (cron expression)","required":["cron","type"],"properties":{"cron":{"type":"string","description":"Cron expression for scheduling (standard 5-field unix cron)"},"type":{"type":"string","enum":["schedule"]}}}],"description":"Defines what triggers a worker execution.","x-readme-ref-name":"WorkerTrigger"},"description":"List of triggers that define what events automatically invoke this worker.\nIf empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.\nWhen configured, the worker will be automatically invoked when any of the specified trigger conditions are met."}},"additionalProperties":false,"x-readme-ref-name":"Worker"} \ No newline at end of file diff --git a/packages/core/src/generated/schemas/workerCode.json b/packages/core/src/generated/schemas/workerCode.json index bbdca35821..3f6e88fd92 100644 --- a/packages/core/src/generated/schemas/workerCode.json +++ b/packages/core/src/generated/schemas/workerCode.json @@ -1 +1 @@ -{"oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies the source of the worker's executable code.\nThis can be a pre-built container image or source code that the system will build.","x-readme-ref-name":"WorkerCode"} \ No newline at end of file +{"oneOf":[{"type":"object","description":"Container image.","required":["image","type"],"properties":{"image":{"type":"string","description":"Container image (e.g., `ghcr.io/myorg/myimage:latest`)."},"type":{"type":"string","enum":["image"]}}},{"type":"object","description":"Source code to be built.","required":["src","toolchain","type"],"properties":{"src":{"type":"string","description":"The source directory to build from"},"toolchain":{"description":"Toolchain configuration with type-safe options","oneOf":[{"type":"object","description":"Rust with Cargo build system","required":["binaryName","type"],"properties":{"binaryName":{"type":"string","description":"Name of the binary to build and run"},"type":{"type":"string","enum":["rust"]}}},{"type":"object","description":"TypeScript/JavaScript compiled to single executable with Bun","required":["type"],"properties":{"binaryName":{"type":["string","null"],"description":"Name of the compiled binary (defaults to package.json name if not specified)"},"type":{"type":"string","enum":["typescript"]}}},{"type":"object","description":"Python application installed from a locked uv project","required":["command","type"],"properties":{"command":{"type":"array","items":{"type":"string"},"description":"Process argv used to start the application"},"package":{"type":["string","null"],"description":"Workspace package to install (omit for a single-package project)"},"pythonVersion":{"type":["string","null"],"description":"Python version used by the runtime image (defaults to 3.12)"},"type":{"type":"string","enum":["python"]}}},{"type":"object","description":"Docker build from Dockerfile","required":["type"],"properties":{"buildArgs":{"type":["object","null"],"description":"Build arguments for docker build","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"dockerfile":{"type":["string","null"],"description":"Dockerfile path relative to src (default: \"Dockerfile\")"},"target":{"type":["string","null"],"description":"Multi-stage build target"},"type":{"type":"string","enum":["docker"]}}}],"x-readme-ref-name":"ToolchainConfig"},"type":{"type":"string","enum":["source"]}}}],"description":"Specifies the source of the worker's executable code.\nThis can be a pre-built container image or source code that the system will build.","x-readme-ref-name":"WorkerCode"} \ No newline at end of file diff --git a/packages/core/src/generated/zod/toolchain-config-schema.ts b/packages/core/src/generated/zod/toolchain-config-schema.ts index ea8099d4a5..90451f59fe 100644 --- a/packages/core/src/generated/zod/toolchain-config-schema.ts +++ b/packages/core/src/generated/zod/toolchain-config-schema.ts @@ -14,6 +14,11 @@ export const ToolchainConfigSchema = z.union([z.object({ }), z.object({ "binaryName": z.string().describe("Name of the compiled binary (defaults to package.json name if not specified)").nullish(), "type": z.enum(["typescript"]) + }), z.object({ + "command": z.array(z.string()).describe("Process argv used to start the application"), +"package": z.string().describe("Workspace package to install (omit for a single-package project)").nullish(), +"pythonVersion": z.string().describe("Python version used by the runtime image (defaults to 3.12)").nullish(), +"type": z.enum(["python"]) }), z.object({ "buildArgs": z.object({ From 72b9f634365d1d79aa05826566580284386f5065 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 21 Sep 2026 08:38:40 -0700 Subject: [PATCH 09/13] fix: harden Python binding lifecycles --- crates/alien-bindings-python/Cargo.toml | 3 +- crates/alien-bindings-python/README.md | 5 +- .../python/alienplatform/bindings.py | 28 +- crates/alien-bindings-python/src/sandbox.rs | 99 +++++-- .../tests/test_bindings.py | 119 +++++++++ crates/alien-bindings/src/bindings.rs | 69 ++++- crates/alien-bindings/src/refreshing.rs | 38 ++- crates/alien-build/src/toolchain/docker.rs | 2 +- crates/alien-build/src/toolchain/python.rs | 247 +++++++++++++----- 9 files changed, 515 insertions(+), 95 deletions(-) diff --git a/crates/alien-bindings-python/Cargo.toml b/crates/alien-bindings-python/Cargo.toml index 05d5205608..b556e1c475 100644 --- a/crates/alien-bindings-python/Cargo.toml +++ b/crates/alien-bindings-python/Cargo.toml @@ -32,7 +32,8 @@ pyo3 = { workspace = true, features = ["extension-module", "abi3-py310"] } pyo3-async-runtimes = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true, features = ["sync"] } [dev-dependencies] temp-env = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync"] } diff --git a/crates/alien-bindings-python/README.md b/crates/alien-bindings-python/README.md index d2717f0adc..cdc4d89a22 100644 --- a/crates/alien-bindings-python/README.md +++ b/crates/alien-bindings-python/README.md @@ -23,6 +23,7 @@ The native extension uses Python's `asyncio` interface and runs Alien futures on ```python from alienplatform import ai, container, key, kv, postgres, queue, sandbox, storage, vault, worker +from sqlalchemy.ext.asyncio import create_async_engine files = storage("files") await files.put("reports/today.json", b"{}") @@ -38,7 +39,7 @@ secrets = vault("secrets") api_token = await secrets.get("api-token") database = await postgres("database").connection() -print(database.sqlalchemy_async_url()) +engine = create_async_engine(**database.sqlalchemy_async_engine_kwargs()) service_url = await container("api").internal_url() @@ -90,7 +91,7 @@ Long-running commands can use `start_job`, `poll_job`, and `cancel_job`. Call `c ## PostgreSQL TLS -`Postgres.connection()` returns the provider-neutral connection parameters, a wire-protocol URL, CA certificates, and SSL mode. `sqlalchemy_async_url()` translates the URL for SQLAlchemy with `asyncpg`; `ssl_context()` returns a configured Python TLS context where required. +`Postgres.connection()` returns the provider-neutral connection parameters, a wire-protocol URL, CA certificates, and SSL mode. `sqlalchemy_async_engine_kwargs()` translates the URL and supplies the verified TLS context together so `create_async_engine()` cannot omit provider CA certificates. Lower-level integrations can use `sqlalchemy_async_url()` and `ssl_context()` directly. ## Errors diff --git a/crates/alien-bindings-python/python/alienplatform/bindings.py b/crates/alien-bindings-python/python/alienplatform/bindings.py index 8956d661ae..7e03028a8d 100644 --- a/crates/alien-bindings-python/python/alienplatform/bindings.py +++ b/crates/alien-bindings-python/python/alienplatform/bindings.py @@ -6,7 +6,7 @@ import json import ssl from collections.abc import AsyncIterator, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Generic, Literal, TypeVar from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -30,10 +30,16 @@ async def resolve() -> H: return await getattr(bindings, self._kind)(self._name) self._task = asyncio.create_task(resolve()) + task = self._task try: - return await self._task + return await asyncio.shield(task) + except asyncio.CancelledError: + if task.cancelled() and self._task is task: + self._task = None + raise except Exception: - self._task = None + if self._task is task: + self._task = None raise @@ -61,11 +67,19 @@ def sqlalchemy_async_url(self) -> str: parts = urlsplit(self.connection_string) query = dict(parse_qsl(parts.query, keep_blank_values=True)) sslmode = query.pop("sslmode", self.sslmode) - query["ssl"] = "disable" if sslmode == "disable" else "require" + query["ssl"] = sslmode return urlunsplit( ("postgresql+asyncpg", parts.netloc, parts.path, urlencode(query), parts.fragment) ) + def sqlalchemy_async_engine_kwargs(self) -> dict[str, Any]: + """Return safe keyword arguments for SQLAlchemy's ``create_async_engine``.""" + tls = self.ssl_context() + return { + "url": self.sqlalchemy_async_url(), + "connect_args": {"ssl": False if tls is None else tls}, + } + def ssl_context(self) -> ssl.SSLContext | None: """Build a TLS context for drivers that accept structured SSL configuration.""" if self.sslmode == "disable": @@ -341,6 +355,7 @@ class AiConnection: base_url: str api_key: str | None provider: str | None + _owner: Any = field(default=None, repr=False, compare=False) def __repr__(self) -> str: return ( @@ -360,8 +375,9 @@ def __init__(self, name: str) -> None: @translate_errors async def connection(self) -> AiConnection: - value = (await self._handle.get()).connection() - return AiConnection(value.base_url, value.api_key, value.provider) + handle = await self._handle.get() + value = handle.connection() + return AiConnection(value.base_url, value.api_key, value.provider, handle) @dataclass(frozen=True) diff --git a/crates/alien-bindings-python/src/sandbox.rs b/crates/alien-bindings-python/src/sandbox.rs index 924218a562..31078c61d4 100644 --- a/crates/alien-bindings-python/src/sandbox.rs +++ b/crates/alien-bindings-python/src/sandbox.rs @@ -12,6 +12,7 @@ use pyo3::types::PyAny; use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::Duration; +use tokio::sync::watch; #[pyclass(frozen, get_all, skip_from_py_object)] #[derive(Clone)] @@ -100,21 +101,44 @@ fn job_result(value: JobPoll) -> JobResult { } } -#[pyclass] +#[pyclass(skip_from_py_object)] +#[derive(Clone)] pub(crate) struct CommandStreamHandle { frames: Arc>>>>, + closed: watch::Sender, } -#[pymethods] impl CommandStreamHandle { - fn next<'py>(&self, py: Python<'py>) -> PyResult> { - let frames = self.frames.clone(); - future_into_py(py, async move { - let mut frames = frames.lock().await; - let Some(stream) = frames.as_mut() else { - return Ok(None); - }; - match stream.next().await { + fn new(frames: BoxStream<'static, alien_bindings::error::Result>) -> Self { + let (closed, _) = watch::channel(false); + Self { + frames: Arc::new(Mutex::new(Some(frames))), + closed, + } + } + + async fn next_frame(&self) -> PyResult> { + let mut closed = self.closed.subscribe(); + if *closed.borrow() { + return Ok(None); + } + + let mut frames = self.frames.lock().await; + if *closed.borrow() { + *frames = None; + return Ok(None); + } + let Some(stream) = frames.as_mut() else { + return Ok(None); + }; + + tokio::select! { + biased; + _ = closed.changed() => { + *frames = None; + Ok(None) + } + frame = stream.next() => match frame { Some(Ok(frame)) => Ok(Some(command_frame(frame))), Some(Err(error)) => Err(map_alien_error(error)), None => { @@ -122,13 +146,26 @@ impl CommandStreamHandle { Ok(None) } } - }) + } + } + + async fn close_stream(&self) { + self.closed.send_replace(true); + *self.frames.lock().await = None; + } +} + +#[pymethods] +impl CommandStreamHandle { + fn next<'py>(&self, py: Python<'py>) -> PyResult> { + let handle = self.clone(); + future_into_py(py, async move { handle.next_frame().await }) } fn close<'py>(&self, py: Python<'py>) -> PyResult> { - let frames = self.frames.clone(); + let handle = self.clone(); future_into_py(py, async move { - *frames.lock().await = None; + handle.close_stream().await; Ok(()) }) } @@ -280,9 +317,7 @@ impl SandboxHandle { ) .await .map_err(map_alien_error)?; - Ok(CommandStreamHandle { - frames: Arc::new(Mutex::new(Some(frames))), - }) + Ok(CommandStreamHandle::new(frames)) }) } @@ -396,3 +431,35 @@ impl SandboxHandle { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn close_wakes_a_pending_command_read() { + let handle = Arc::new(CommandStreamHandle::new(futures::stream::pending().boxed())); + let reader = { + let handle = Arc::clone(&handle); + tokio::spawn(async move { handle.next_frame().await }) + }; + tokio::task::yield_now().await; + + tokio::time::timeout(Duration::from_millis(100), handle.close_stream()) + .await + .expect("close should wake the pending read"); + assert!(reader + .await + .expect("reader task should complete") + .expect("close should end the stream without an error") + .is_none()); + assert!(handle + .next_frame() + .await + .expect("closed stream should remain readable") + .is_none()); + + handle.close_stream().await; + assert!(handle.frames.lock().await.is_none()); + } +} diff --git a/crates/alien-bindings-python/tests/test_bindings.py b/crates/alien-bindings-python/tests/test_bindings.py index 6722daa24e..ffe0858727 100644 --- a/crates/alien-bindings-python/tests/test_bindings.py +++ b/crates/alien-bindings-python/tests/test_bindings.py @@ -1,11 +1,17 @@ from __future__ import annotations +import asyncio +import gc import json import os +import ssl import threading +import weakref from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from types import SimpleNamespace +import alienplatform.bindings as bindings_module import pytest from alienplatform import ( AlienError, @@ -19,6 +25,7 @@ vault, worker, ) +from alienplatform.bindings import AiConnection, PostgresConnection def bind(name: str, value: dict[str, object]) -> None: @@ -109,6 +116,83 @@ async def test_postgres_helpers_and_container_discovery() -> None: assert await container("api").public_url() == "http://localhost:18000" +def test_postgres_sqlalchemy_helper_preserves_tls_verification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeContext: + def __init__(self) -> None: + self.loaded: list[str] = [] + self.check_hostname = True + + def load_verify_locations(self, *, cadata: str) -> None: + self.loaded.append(cadata) + + context = FakeContext() + monkeypatch.setattr(ssl, "create_default_context", lambda: context) + connection = PostgresConnection( + "postgres://user:password@db.internal/app?sslmode=verify-full", + "db.internal", + 5432, + "app", + "user", + "password", + "verify-full", + ("test-ca",), + ) + + kwargs = connection.sqlalchemy_async_engine_kwargs() + assert kwargs["url"].endswith("?ssl=verify-full") + assert kwargs["connect_args"] == {"ssl": context} + assert context.loaded == ["test-ca"] + assert context.check_hostname is True + + disabled = PostgresConnection( + "postgres://user:password@localhost/app?sslmode=disable", + "localhost", + 5432, + "app", + "user", + "password", + "disable", + (), + ) + assert disabled.sqlalchemy_async_engine_kwargs()["connect_args"] == {"ssl": False} + + +@pytest.mark.asyncio +async def test_cancelling_one_waiter_does_not_cancel_shared_initialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + release = asyncio.Event() + resolved_handle = object() + calls = 0 + + class FakeBindingsHandle: + async def storage(self, name: str) -> object: + nonlocal calls + assert name == "files" + calls += 1 + started.set() + await release.wait() + return resolved_handle + + monkeypatch.setattr(bindings_module._native, "BindingsHandle", FakeBindingsHandle) + lazy = bindings_module._LazyHandle[object]("storage", "files") + cancelled_waiter = asyncio.create_task(lazy.get()) + await started.wait() + surviving_waiter = asyncio.create_task(lazy.get()) + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + release.set() + + assert await surviving_waiter is resolved_handle + assert await lazy.get() is resolved_handle + assert calls == 1 + + @pytest.mark.asyncio async def test_missing_binding_is_stable_alien_error() -> None: with pytest.raises(AlienError) as raised: @@ -143,6 +227,41 @@ async def test_external_ai_connection_is_typed_and_redacted( assert "test-secret" not in repr(connection) +@pytest.mark.asyncio +async def test_ai_connection_retains_managed_gateway_owner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeAiHandle: + def connection(self) -> SimpleNamespace: + return SimpleNamespace( + base_url="http://127.0.0.1:1234", + api_key="secret", + provider="managed", + ) + + handle = FakeAiHandle() + handle_ref = weakref.ref(handle) + handles = {"assistant": handle} + + class FakeBindingsHandle: + async def ai(self, name: str) -> FakeAiHandle: + assert name == "assistant" + return handles[name] + + monkeypatch.setattr(bindings_module._native, "BindingsHandle", FakeBindingsHandle) + connection = await bindings_module.Ai("assistant").connection() + del handle + handles.clear() + gc.collect() + assert handle_ref() is not None + assert connection == AiConnection("http://127.0.0.1:1234", "secret", "managed") + assert "secret" not in repr(connection) + + del connection + gc.collect() + assert handle_ref() is None + + @pytest.mark.asyncio async def test_worker_invocation_and_public_url() -> None: class Handler(BaseHTTPRequestHandler): diff --git a/crates/alien-bindings/src/bindings.rs b/crates/alien-bindings/src/bindings.rs index 9c2be9db6d..31d75aec04 100644 --- a/crates/alien-bindings/src/bindings.rs +++ b/crates/alien-bindings/src/bindings.rs @@ -10,6 +10,7 @@ use crate::error::Result; use crate::provider::{BindingsProvider, LazyEnvBindingsProvider}; use crate::refreshing::{ RefreshingKey, RefreshingKv, RefreshingQueue, RefreshingStorage, RefreshingVault, + RefreshingWorker, }; use crate::traits::{ BindingsProviderApi, Container, Key, Kv, MessagePayload, Postgres, Queue, QueueMessage, @@ -182,7 +183,11 @@ impl Bindings { /// Loads a linked worker for direct invocation and public URL discovery. pub async fn worker(&self, binding_name: &str) -> Result> { - self.provider.load_worker(binding_name).await + self.provider.load_worker(binding_name).await?; + Ok(Arc::new(RefreshingWorker::new( + self.provider.clone(), + binding_name.to_string(), + ))) } /// Loads the connection details for a linked Postgres database. @@ -220,7 +225,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use crate::error::binding_env_var; - use crate::traits::MessagePayload; + use crate::traits::{MessagePayload, WorkerInvokeRequest}; use alien_core::{ Platform, ENV_ALIEN_DEPLOYMENT_ID, ENV_ALIEN_DEPLOYMENT_SERVICE_ACCOUNT, ENV_ALIEN_DEPLOYMENT_TOKEN, ENV_ALIEN_DEPLOYMENT_TYPE, ENV_ALIEN_MANAGER_URL, @@ -427,6 +432,66 @@ mod tests { ); } + #[tokio::test] + async fn long_lived_worker_handle_refreshes_minted_provider_before_invocation() { + let temp_dir = TempDir::new().expect("tempdir"); + let (manager_url, calls) = spawn_mint_server( + temp_dir + .path() + .to_str() + .expect("tempdir path must be valid UTF-8"), + ) + .await; + let app = Router::new().route("/processor/jobs", post(|| async { "accepted" })); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .expect("bind fake worker server"); + let address = listener.local_addr().expect("read fake worker address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve fake worker endpoint"); + }); + + let worker_url = format!("http://{address}"); + let json = format!(r#"{{"service":"local","workerUrl":"{worker_url}"}}"#); + let env = with_binding(mint_env(&manager_url), "processor", &json); + let bindings = Bindings::from_env_map(env).expect("minting env should construct Bindings"); + let worker = bindings + .worker("processor") + .await + .expect("first binding resolution should mint credentials"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let response = worker + .invoke(WorkerInvokeRequest { + target_worker: "processor".to_string(), + method: "POST".to_string(), + path: "/jobs".to_string(), + headers: Default::default(), + body: Vec::new(), + timeout: None, + }) + .await + .expect("the long-lived handle should refresh and invoke"); + assert_eq!(response.status, 200); + assert_eq!(response.body, b"accepted"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + + assert_eq!( + worker + .get_worker_url() + .await + .expect("the same long-lived handle should resolve its URL"), + Some(worker_url) + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "the refreshed provider should stay cached while fresh" + ); + } + #[tokio::test] async fn queue_delegates_to_local_provider_and_performs_real_io() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/crates/alien-bindings/src/refreshing.rs b/crates/alien-bindings/src/refreshing.rs index e249569406..1539ce40e0 100644 --- a/crates/alien-bindings/src/refreshing.rs +++ b/crates/alien-bindings/src/refreshing.rs @@ -34,7 +34,8 @@ use crate::presigned::PresignedRequest; use crate::remote::RemoteStorage; use crate::traits::{ Binding, BindingsProviderApi, Key, Kv, KvEntry, MessagePayload, PutOptions as KvPutOptions, - Queue, QueueMessage, ScanResult, Storage, Vault, + Queue, QueueMessage, ScanResult, Storage, Vault, Worker, WorkerInvokeRequest, + WorkerInvokeResponse, }; const OBJECT_STORE_NAME: &str = "Alien binding"; @@ -100,6 +101,10 @@ impl Resolver { async fn vault(&self) -> Result> { self.provider.load_vault(&self.binding_name).await } + + async fn worker(&self) -> Result> { + self.provider.load_worker(&self.binding_name).await + } } fn object_store_error(source: AlienError) -> object_store::Error { @@ -543,3 +548,34 @@ impl Vault for RefreshingVault { self.resolver.vault().await?.list_secrets().await } } + +/// Worker handle that resolves a fresh-enough provider for every operation. +#[derive(Debug)] +pub(super) struct RefreshingWorker { + resolver: Resolver, +} + +impl RefreshingWorker { + pub(super) fn new(provider: Arc, binding_name: String) -> Self { + Self { + resolver: Resolver::new(provider, binding_name), + } + } +} + +impl Binding for RefreshingWorker {} + +#[async_trait] +impl Worker for RefreshingWorker { + async fn invoke(&self, request: WorkerInvokeRequest) -> Result { + self.resolver.worker().await?.invoke(request).await + } + + async fn get_worker_url(&self) -> Result> { + self.resolver.worker().await?.get_worker_url().await + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/alien-build/src/toolchain/docker.rs b/crates/alien-build/src/toolchain/docker.rs index d61d518292..1ce6ea0c82 100644 --- a/crates/alien-build/src/toolchain/docker.rs +++ b/crates/alien-build/src/toolchain/docker.rs @@ -64,7 +64,7 @@ impl DockerToolchain { format!("alien-build-{}-{suffix}", std::process::id()) } - fn absolute_path(path: &Path) -> Result { + pub(crate) fn absolute_path(path: &Path) -> Result { if path.is_absolute() { return Ok(path.to_path_buf()); } diff --git a/crates/alien-build/src/toolchain/python.rs b/crates/alien-build/src/toolchain/python.rs index 1b7c441d3e..1f920cef36 100644 --- a/crates/alien-build/src/toolchain/python.rs +++ b/crates/alien-build/src/toolchain/python.rs @@ -1,10 +1,11 @@ use super::docker::DockerToolchain; use super::{Toolchain, ToolchainContext, ToolchainOutput, WorkloadKind}; use crate::error::{ErrorData, Result}; -use alien_error::{AlienError, Context, IntoAlienError}; +use alien_error::{AlienError, Context, ContextError, IntoAlienError, IntoAlienErrorDirect}; use async_trait::async_trait; use std::path::{Path, PathBuf}; use tokio::fs; +use tracing::warn; const SDK_WHEEL_ENV: &str = "ALIEN_PYTHON_SDK_WHEEL"; const DEFAULT_PYTHON_VERSION: &str = "3.12"; @@ -21,6 +22,13 @@ pub struct PythonToolchain { pub command: Vec, } +#[derive(Debug)] +struct PythonBuildInputs { + root: PathBuf, + dockerfile: PathBuf, + sdk_context: Option, +} + pub(crate) fn sdk_wheel_path() -> Result> { let Some(value) = std::env::var_os(SDK_WHEEL_ENV) else { return Ok(None); @@ -70,6 +78,78 @@ impl PythonToolchain { version = self.version(), )) } + + async fn prepare_build_inputs( + &self, + context: &ToolchainContext, + wheel: Option<&Path>, + ) -> Result { + let root = DockerToolchain::absolute_path(&context.build_dir.join(format!( + ".python-{}", + context.build_target.runtime_platform_id() + )))?; + if root.exists() { + fs::remove_dir_all(&root).await.into_alien_error().context( + ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to clear stale Python build inputs".to_string(), + build_output: None, + }, + )?; + } + fs::create_dir_all(&root).await.into_alien_error().context( + ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to create Python build input directory".to_string(), + build_output: None, + }, + )?; + + let dockerfile = root.join("Dockerfile"); + fs::write(&dockerfile, self.dockerfile(wheel.is_some())?) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to write generated Python Dockerfile".to_string(), + build_output: None, + })?; + + let sdk_context = if let Some(wheel) = wheel { + let directory = root.join("sdk"); + fs::create_dir_all(&directory) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to create Python SDK build context".to_string(), + build_output: None, + })?; + let filename = wheel.file_name().ok_or_else(|| { + AlienError::new(ErrorData::InvalidResourceConfig { + resource_id: "python-toolchain".to_string(), + reason: format!("{SDK_WHEEL_ENV} must include a wheel filename"), + }) + })?; + fs::copy(wheel, directory.join(filename)) + .await + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to stage Python SDK wheel".to_string(), + build_output: None, + })?; + Some(directory) + } else { + None + }; + + Ok(PythonBuildInputs { + root, + dockerfile, + sdk_context, + }) + } } fn shell_escape(value: &str) -> String { @@ -108,80 +188,37 @@ impl Toolchain for PythonToolchain { })); } - fs::create_dir_all(&context.build_dir) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to create Python build directory".to_string(), - build_output: None, - })?; let wheel = sdk_wheel_path()?; - let dockerfile_path = context.build_dir.join("Python.Dockerfile"); - fs::write(&dockerfile_path, self.dockerfile(wheel.is_some())?) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to write generated Python Dockerfile".to_string(), - build_output: None, - })?; - - let sdk_context_dir = context.build_dir.join("python-sdk"); - let mut contexts = Vec::new(); - if let Some(wheel) = wheel { - if sdk_context_dir.exists() { - fs::remove_dir_all(&sdk_context_dir) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to clear stale Python SDK build context".to_string(), - build_output: None, - })?; - } - fs::create_dir_all(&sdk_context_dir) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to create Python SDK build context".to_string(), - build_output: None, - })?; - let filename = wheel.file_name().ok_or_else(|| { - AlienError::new(ErrorData::InvalidResourceConfig { - resource_id: "python-toolchain".to_string(), - reason: format!("{SDK_WHEEL_ENV} must include a wheel filename"), - }) - })?; - fs::copy(&wheel, sdk_context_dir.join(filename)) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to stage Python SDK wheel".to_string(), - build_output: None, - })?; - contexts.push(("alien_python_sdk", sdk_context_dir.as_path())); - } + let inputs = self.prepare_build_inputs(context, wheel.as_deref()).await?; + let contexts = inputs + .sdk_context + .as_deref() + .map(|path| vec![("alien_python_sdk", path)]) + .unwrap_or_default(); let result = DockerToolchain { - dockerfile: Some(dockerfile_path.to_string_lossy().into_owned()), + dockerfile: Some(inputs.dockerfile.to_string_lossy().into_owned()), build_args: None, target: None, } .build_with_contexts(context, &contexts) .await; - if sdk_context_dir.exists() { - fs::remove_dir_all(&sdk_context_dir) - .await - .into_alien_error() - .context(ErrorData::ImageBuildFailed { - resource_name: "python-toolchain".to_string(), - reason: "Failed to remove staged Python SDK wheel".to_string(), - build_output: None, - })?; + if let Err(error) = fs::remove_dir_all(&inputs.root).await { + if result.is_ok() { + return Err(error + .into_alien_error() + .context(ErrorData::ImageBuildFailed { + resource_name: "python-toolchain".to_string(), + reason: "Failed to remove Python build inputs".to_string(), + build_output: None, + })); + } + warn!( + path = %inputs.root.display(), + %error, + "Failed to clean up Python build inputs after build failure" + ); } result } @@ -199,8 +236,30 @@ impl Toolchain for PythonToolchain { #[cfg(test)] mod tests { use super::*; + use alien_core::BinaryTarget; use tempfile::tempdir; + fn context(build_dir: PathBuf, target: BinaryTarget) -> ToolchainContext { + ToolchainContext { + src_dir: PathBuf::from("nested/source"), + build_dir, + cache_store: None, + cache_prefix: "test".to_string(), + build_target: target, + runtime_platform_name: "aws".to_string(), + debug_mode: false, + workload: WorkloadKind::Container, + } + } + + fn toolchain() -> PythonToolchain { + PythonToolchain { + python_version: None, + package: None, + command: vec!["python".to_string(), "app.py".to_string()], + } + } + #[test] fn validates_locked_project_and_explicit_command() { let dir = tempdir().expect("temp directory"); @@ -248,4 +307,60 @@ mod tests { toolchain.python_version = Some("3.9".to_string()); assert!(toolchain.validate_source(dir.path(), "example").is_err()); } + + #[tokio::test] + async fn generated_inputs_are_absolute_when_build_directory_is_relative() { + let build_root = tempfile::Builder::new() + .prefix("python-relative-build-") + .tempdir_in(".") + .expect("relative temp directory"); + let relative_build_dir = build_root + .path() + .strip_prefix(std::env::current_dir().expect("current directory")) + .unwrap_or(build_root.path()) + .to_path_buf(); + let inputs = toolchain() + .prepare_build_inputs(&context(relative_build_dir, BinaryTarget::LinuxX64), None) + .await + .expect("build inputs should be prepared"); + + assert!(inputs.root.is_absolute()); + assert!(inputs.dockerfile.is_absolute()); + assert!(inputs.dockerfile.is_file()); + } + + #[tokio::test] + async fn parallel_targets_keep_their_staged_sdk_inputs_isolated() { + let build_root = tempdir().expect("build root"); + let wheel_root = tempdir().expect("wheel root"); + let wheel = wheel_root.path().join("alienplatform-test.whl"); + fs::write(&wheel, b"wheel-bytes") + .await + .expect("write wheel fixture"); + let x64 = context(build_root.path().to_path_buf(), BinaryTarget::LinuxX64); + let arm64 = context(build_root.path().to_path_buf(), BinaryTarget::LinuxArm64); + let toolchain = toolchain(); + + let (x64_inputs, arm64_inputs) = tokio::join!( + toolchain.prepare_build_inputs(&x64, Some(&wheel)), + toolchain.prepare_build_inputs(&arm64, Some(&wheel)), + ); + let x64_inputs = x64_inputs.expect("x64 inputs"); + let arm64_inputs = arm64_inputs.expect("arm64 inputs"); + assert_ne!(x64_inputs.root, arm64_inputs.root); + + fs::remove_dir_all(&x64_inputs.root) + .await + .expect("remove x64 inputs"); + let arm64_wheel = arm64_inputs + .sdk_context + .as_ref() + .expect("arm64 SDK context") + .join("alienplatform-test.whl"); + assert_eq!( + fs::read(arm64_wheel).await.expect("read arm64 wheel"), + b"wheel-bytes" + ); + assert!(arm64_inputs.dockerfile.is_file()); + } } From 0933ee1480fbdae2d923b7fb9fe692922fbc9c12 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 21 Sep 2026 08:47:18 -0700 Subject: [PATCH 10/13] fix: refresh Python binding lockfile version --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 66b63e8fbf..1344c47a6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,7 +371,7 @@ dependencies = [ [[package]] name = "alien-bindings-python" -version = "3.3.22" +version = "3.3.23" dependencies = [ "alien-ai-gateway", "alien-bindings", From 8d0d809862e81817b9a4d59ac01e66819d9c8687 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Mon, 21 Sep 2026 08:59:48 -0700 Subject: [PATCH 11/13] fix: keep Python extension mode out of Rust tests --- crates/alien-bindings-python/Cargo.toml | 2 +- crates/alien-bindings-python/tests/test_bindings.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/alien-bindings-python/Cargo.toml b/crates/alien-bindings-python/Cargo.toml index b556e1c475..e9ab3fc977 100644 --- a/crates/alien-bindings-python/Cargo.toml +++ b/crates/alien-bindings-python/Cargo.toml @@ -28,7 +28,7 @@ alien-core = { workspace = true } alien-error = { workspace = true } futures = { workspace = true } object_store = { workspace = true } -pyo3 = { workspace = true, features = ["extension-module", "abi3-py310"] } +pyo3 = { workspace = true, features = ["abi3-py310"] } pyo3-async-runtimes = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/alien-bindings-python/tests/test_bindings.py b/crates/alien-bindings-python/tests/test_bindings.py index ffe0858727..58a98a078d 100644 --- a/crates/alien-bindings-python/tests/test_bindings.py +++ b/crates/alien-bindings-python/tests/test_bindings.py @@ -249,15 +249,23 @@ async def ai(self, name: str) -> FakeAiHandle: return handles[name] monkeypatch.setattr(bindings_module._native, "BindingsHandle", FakeBindingsHandle) - connection = await bindings_module.Ai("assistant").connection() + resource = bindings_module.Ai("assistant") + connection = await resource.connection() + assert connection._owner is handle + + # Remove the resource factory and its completed lazy-resolution task. The connection must + # independently keep the native gateway alive for as long as callers retain it. + del resource del handle handles.clear() + await asyncio.sleep(0) gc.collect() assert handle_ref() is not None assert connection == AiConnection("http://127.0.0.1:1234", "secret", "managed") assert "secret" not in repr(connection) del connection + await asyncio.sleep(0) gc.collect() assert handle_ref() is None From b311fa1dc9b20243962bf09fc54f4cb1f4a18b5b Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 22 Sep 2026 01:07:53 -0700 Subject: [PATCH 12/13] feat(bindings): add atomic storage creates --- crates/alien-bindings-node/src/error.rs | 25 +++++++ .../alien-bindings-node/src/remote_storage.rs | 5 +- crates/alien-bindings-node/src/storage.rs | 73 +++++++++++++------ crates/alien-bindings-python/README.md | 3 +- .../python/alienplatform/bindings.py | 11 ++- crates/alien-bindings-python/src/error.rs | 4 + crates/alien-bindings-python/src/storage.rs | 44 ++++++++++- .../tests/test_bindings.py | 19 +++++ crates/alien-bindings/src/error.rs | 15 ++++ packages/bindings/README.md | 5 ++ .../bindings/src/__tests__/factories.test.ts | 1 + packages/bindings/src/types.ts | 2 + packages/bindings/tests/storage.test.ts | 19 +++++ 13 files changed, 196 insertions(+), 30 deletions(-) diff --git a/crates/alien-bindings-node/src/error.rs b/crates/alien-bindings-node/src/error.rs index f7be8e8833..cd4f5c471d 100644 --- a/crates/alien-bindings-node/src/error.rs +++ b/crates/alien-bindings-node/src/error.rs @@ -108,6 +108,12 @@ pub fn map_object_store_error( binding_name, operation, }), + object_store::Error::AlreadyExists { .. } => { + AlienError::new(ErrorData::StorageObjectAlreadyExists { + binding_name, + operation, + }) + } _ => AlienError::new(ErrorData::StorageOperationFailed { binding_name, operation, @@ -313,6 +319,25 @@ mod tests { assert!(!serialized.contains("secret URL")); } + #[test] + fn map_object_store_error_classifies_create_conflict_safely() { + let err = object_store::Error::AlreadyExists { + path: "immutable/object.txt".to_string(), + source: "provider detail with a secret URL".into(), + }; + let napi_err = map_object_store_error(err, "files", "put"); + + let env = envelope_of(&napi_err); + assert_eq!(env["code"], "STORAGE_OBJECT_ALREADY_EXISTS"); + assert_eq!(env["retryable"], false); + assert_eq!(env["httpStatusCode"], 409); + assert_eq!(env["context"]["binding_name"], "files"); + assert_eq!(env["context"]["operation"], "put"); + let serialized = env.to_string(); + assert!(!serialized.contains("object.txt")); + assert!(!serialized.contains("secret URL")); + } + /// Permission failures are permanent until the customer repairs IAM and /// must not disclose the provider identity, request URL, or object path. #[test] diff --git a/crates/alien-bindings-node/src/remote_storage.rs b/crates/alien-bindings-node/src/remote_storage.rs index 22224d4c3b..0f4e34cc4f 100644 --- a/crates/alien-bindings-node/src/remote_storage.rs +++ b/crates/alien-bindings-node/src/remote_storage.rs @@ -59,9 +59,8 @@ impl RemoteStorageHandle { let payload = PutPayload::from(data.to_vec()); let result = match options { Some(options) => { - self.inner - .put_opts(&path, payload, object_store_put_options(options)) - .await + let options = object_store_put_options(options)?; + self.inner.put_opts(&path, payload, options).await } None => self.inner.put(&path, payload).await, } diff --git a/crates/alien-bindings-node/src/storage.rs b/crates/alien-bindings-node/src/storage.rs index 8cd32e1e43..c9482a844f 100644 --- a/crates/alien-bindings-node/src/storage.rs +++ b/crates/alien-bindings-node/src/storage.rs @@ -11,7 +11,7 @@ use napi::bindgen_prelude::Buffer; use napi_derive::napi; use object_store::path::Path; use object_store::{ - Attribute, Attributes, GetOptions, ObjectMeta, PutOptions, PutPayload, PutResult, + Attribute, Attributes, GetOptions, ObjectMeta, PutMode, PutOptions, PutPayload, PutResult, }; use std::collections::HashMap; use std::sync::Arc; @@ -65,6 +65,8 @@ pub struct StoragePutAttributesJs { pub struct StoragePutOptionsJs { /// Object attributes to persist with the payload. pub attributes: Option, + /// Atomic write precondition. The only supported value is `"absent"`. + pub condition: Option, } /// Provider-neutral attributes returned with a stored object. @@ -101,29 +103,40 @@ pub struct StoragePutResultJs { pub version: Option, } -pub(crate) fn object_store_put_options(options: StoragePutOptionsJs) -> PutOptions { - let Some(options) = options.attributes else { - return PutOptions::default(); +pub(crate) fn object_store_put_options(options: StoragePutOptionsJs) -> napi::Result { + let mode = match options.condition.as_deref() { + None => PutMode::Overwrite, + Some("absent") => PutMode::Create, + Some(condition) => { + return Err(map_alien_error(AlienError::new(ErrorData::InvalidInput { + operation_context: "storage.put".to_string(), + details: format!("unsupported condition '{condition}', expected 'absent'"), + field_name: Some("condition".to_string()), + }))); + } }; let mut attributes = Attributes::new(); - for (attribute, value) in [ - (Attribute::ContentType, options.content_type), - (Attribute::ContentDisposition, options.content_disposition), - (Attribute::ContentEncoding, options.content_encoding), - (Attribute::ContentLanguage, options.content_language), - (Attribute::CacheControl, options.cache_control), - ] { - if let Some(value) = value { - attributes.insert(attribute, value.into()); + if let Some(options) = options.attributes { + for (attribute, value) in [ + (Attribute::ContentType, options.content_type), + (Attribute::ContentDisposition, options.content_disposition), + (Attribute::ContentEncoding, options.content_encoding), + (Attribute::ContentLanguage, options.content_language), + (Attribute::CacheControl, options.cache_control), + ] { + if let Some(value) = value { + attributes.insert(attribute, value.into()); + } + } + for (key, value) in options.metadata.unwrap_or_default() { + attributes.insert(Attribute::Metadata(key.into()), value.into()); } } - for (key, value) in options.metadata.unwrap_or_default() { - attributes.insert(Attribute::Metadata(key.into()), value.into()); - } - PutOptions { + Ok(PutOptions { + mode, attributes, ..Default::default() - } + }) } pub(crate) fn object_attributes_to_js(attributes: &Attributes) -> StorageObjectAttributesJs { @@ -242,9 +255,8 @@ impl StorageHandle { let payload = PutPayload::from(data.to_vec()); let result = match options { Some(options) => { - store - .put_opts(&location, payload, object_store_put_options(options)) - .await + let options = object_store_put_options(options)?; + store.put_opts(&location, payload, options).await } None => store.put(&location, payload).await, } @@ -449,7 +461,9 @@ mod tests { ("source".to_string(), "inbound".to_string()), ])), }), - }); + condition: Some("absent".to_string()), + }) + .expect("valid storage options"); let expected = Attributes::from_iter([ ( @@ -477,6 +491,21 @@ mod tests { ]); assert_eq!(options.attributes, expected); + assert_eq!(options.mode, PutMode::Create); + } + + #[test] + fn storage_put_options_reject_unknown_conditions() { + let error = object_store_put_options(StoragePutOptionsJs { + attributes: None, + condition: Some("changed".to_string()), + }) + .expect_err("unknown conditions must fail before writing"); + + let envelope: serde_json::Value = + serde_json::from_str(&error.reason).expect("structured error envelope"); + assert_eq!(envelope["code"], "INVALID_INPUT"); + assert_eq!(envelope["context"]["field_name"], "condition"); } #[test] diff --git a/crates/alien-bindings-python/README.md b/crates/alien-bindings-python/README.md index cdc4d89a22..ca3016e50f 100644 --- a/crates/alien-bindings-python/README.md +++ b/crates/alien-bindings-python/README.md @@ -26,8 +26,9 @@ from alienplatform import ai, container, key, kv, postgres, queue, sandbox, stor from sqlalchemy.ext.asyncio import create_async_engine files = storage("files") -await files.put("reports/today.json", b"{}") +await files.put("reports/today.json", b"{}", condition="absent") assert await files.get("reports/today.json") == b"{}" +assert await files.get_optional("reports/missing.json") is None cache = kv("cache") await cache.put("status", b"ready", condition="absent") diff --git a/crates/alien-bindings-python/python/alienplatform/bindings.py b/crates/alien-bindings-python/python/alienplatform/bindings.py index 7e03028a8d..16b2a00c0b 100644 --- a/crates/alien-bindings-python/python/alienplatform/bindings.py +++ b/crates/alien-bindings-python/python/alienplatform/bindings.py @@ -100,8 +100,15 @@ async def get(self, path: str) -> bytes: return bytes(await (await self._handle.get()).get(path)) @translate_errors - async def put(self, path: str, data: bytes) -> None: - await (await self._handle.get()).put(path, list(data)) + async def get_optional(self, path: str) -> bytes | None: + value = await (await self._handle.get()).get_optional(path) + return bytes(value) if value is not None else None + + @translate_errors + async def put( + self, path: str, data: bytes, *, condition: Literal["absent"] | None = None + ) -> None: + await (await self._handle.get()).put(path, list(data), condition) @translate_errors async def delete(self, path: str) -> None: diff --git a/crates/alien-bindings-python/src/error.rs b/crates/alien-bindings-python/src/error.rs index edd11ccc84..2f3aba8c4a 100644 --- a/crates/alien-bindings-python/src/error.rs +++ b/crates/alien-bindings-python/src/error.rs @@ -51,6 +51,10 @@ pub(crate) fn map_object_store_error( binding_name: binding_name.to_string(), operation: operation.to_string(), }, + object_store::Error::AlreadyExists { .. } => ErrorData::StorageObjectAlreadyExists { + binding_name: binding_name.to_string(), + operation: operation.to_string(), + }, _ => ErrorData::StorageOperationFailed { binding_name: binding_name.to_string(), operation: operation.to_string(), diff --git a/crates/alien-bindings-python/src/storage.rs b/crates/alien-bindings-python/src/storage.rs index 9d6b689198..5fa1ad2ce3 100644 --- a/crates/alien-bindings-python/src/storage.rs +++ b/crates/alien-bindings-python/src/storage.rs @@ -1,9 +1,11 @@ use crate::error::{map_alien_error, map_object_store_error}; use crate::future_into_py; +use alien_bindings::error::ErrorData; use alien_bindings::Storage; +use alien_error::AlienError; use futures::TryStreamExt; use object_store::path::Path; -use object_store::PutPayload; +use object_store::{PutMode, PutOptions, PutPayload}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyAny; @@ -58,17 +60,55 @@ impl StorageHandle { }) } + fn get_optional<'py>(&self, py: Python<'py>, path: String) -> PyResult> { + let inner = self.inner.clone(); + let binding = self.binding.clone(); + future_into_py(py, async move { + let location = parse_path(path)?; + let result = match inner.get(&location).await { + Ok(result) => result, + Err(object_store::Error::NotFound { .. }) => return Ok(None), + Err(error) => return Err(map_object_store_error(error, &binding, "get")), + }; + let data = result + .bytes() + .await + .map_err(|error| map_object_store_error(error, &binding, "get"))?; + Ok(Some(data.to_vec())) + }) + } + + #[pyo3(signature = (path, data, condition=None))] fn put<'py>( &self, py: Python<'py>, path: String, data: Vec, + condition: Option, ) -> PyResult> { let inner = self.inner.clone(); let binding = self.binding.clone(); future_into_py(py, async move { + let mode = match condition.as_deref() { + None => PutMode::Overwrite, + Some("absent") => PutMode::Create, + Some(other) => { + return Err(map_alien_error(AlienError::new(ErrorData::InvalidInput { + operation_context: "storage.put".to_string(), + details: format!("unsupported condition '{other}', expected 'absent'"), + field_name: Some("condition".to_string()), + }))); + } + }; inner - .put(&parse_path(path)?, PutPayload::from(data)) + .put_opts( + &parse_path(path)?, + PutPayload::from(data), + PutOptions { + mode, + ..Default::default() + }, + ) .await .map_err(|error| map_object_store_error(error, &binding, "put"))?; Ok(()) diff --git a/crates/alien-bindings-python/tests/test_bindings.py b/crates/alien-bindings-python/tests/test_bindings.py index 58a98a078d..3fa8a4aabc 100644 --- a/crates/alien-bindings-python/tests/test_bindings.py +++ b/crates/alien-bindings-python/tests/test_bindings.py @@ -46,6 +46,25 @@ async def test_storage_round_trip_and_structured_missing_error(tmp_path: Path) - await files.get("nested/data.bin") assert raised.value.code == "STORAGE_OBJECT_NOT_FOUND" assert "data.bin" not in str(raised.value) + assert await files.get_optional("nested/data.bin") is None + + +@pytest.mark.asyncio +async def test_storage_conditional_create_is_atomic(tmp_path: Path) -> None: + bind("immutable", {"service": "local-storage", "storagePath": str(tmp_path / "storage")}) + files = storage("immutable") + writes = await asyncio.gather( + files.put("object.bin", b"first", condition="absent"), + files.put("object.bin", b"second", condition="absent"), + return_exceptions=True, + ) + + assert sum(value is None for value in writes) == 1 + [failure] = [value for value in writes if isinstance(value, Exception)] + assert isinstance(failure, AlienError) + assert failure.code == "STORAGE_OBJECT_ALREADY_EXISTS" + assert failure.retryable is False + assert await files.get("object.bin") in {b"first", b"second"} @pytest.mark.asyncio diff --git a/crates/alien-bindings/src/error.rs b/crates/alien-bindings/src/error.rs index d981dc1ceb..540220f980 100644 --- a/crates/alien-bindings/src/error.rs +++ b/crates/alien-bindings/src/error.rs @@ -129,6 +129,21 @@ pub enum ErrorData { operation: String, }, + /// A create-only storage write found an existing object. + #[error( + code = "STORAGE_OBJECT_ALREADY_EXISTS", + message = "Storage object already exists for binding '{binding_name}' while attempting to {operation}", + retryable = "false", + internal = "false", + http_status_code = 409 + )] + StorageObjectAlreadyExists { + /// Name of the storage binding. + binding_name: String, + /// Provider-independent operation name. + operation: String, + }, + /// Build operation failed due to provider issues. #[error( code = "BUILD_OPERATION_FAILED", diff --git a/packages/bindings/README.md b/packages/bindings/README.md index ab7983a5bd..da9b12b7d4 100644 --- a/packages/bindings/README.md +++ b/packages/bindings/README.md @@ -23,6 +23,7 @@ const bindings = await Bindings.forRemoteDeployment({ const archive = bindings.storage("archive") const write = await archive.put("reports/latest.json", Buffer.from(JSON.stringify({ ready: true })), { + condition: "absent", attributes: { contentType: "application/json", cacheControl: "private, max-age=60", @@ -45,6 +46,10 @@ GCS also rejects `contentEncoding: "gzip"`: its decompressive transcoding omits the response length required for byte-exact reads. Other GCS encodings, such as `br`, are preserved. +`condition: "absent"` makes `put` an atomic create-only write. If the object +already exists, the operation fails with `STORAGE_OBJECT_ALREADY_EXISTS` +instead of replacing it. + Remote Storage exposes `get`, `put`, `head`, `list`, and `delete`. It does not expose copy or signed URLs. The same `Bindings` and Storage handles remain valid while the native client refreshes short-lived cloud credentials and periodically diff --git a/packages/bindings/src/__tests__/factories.test.ts b/packages/bindings/src/__tests__/factories.test.ts index ba003109a2..2fc6616262 100644 --- a/packages/bindings/src/__tests__/factories.test.ts +++ b/packages/bindings/src/__tests__/factories.test.ts @@ -443,6 +443,7 @@ describe("createFactories method mapping", () => { } const { storage } = createFactories(() => addonForStorage(storageHandle)) const options = { + condition: "absent" as const, attributes: { contentType: "text/plain", contentDisposition: 'attachment; filename="note.txt"', diff --git a/packages/bindings/src/types.ts b/packages/bindings/src/types.ts index a171d2881c..84d4d416f6 100644 --- a/packages/bindings/src/types.ts +++ b/packages/bindings/src/types.ts @@ -78,6 +78,8 @@ export interface StoragePutAttributes { /** Options for {@link Storage.put}. */ export interface StoragePutOptions { + /** Atomic write precondition. `"absent"` creates only when the path does not exist. */ + condition?: "absent" attributes?: StoragePutAttributes } diff --git a/packages/bindings/tests/storage.test.ts b/packages/bindings/tests/storage.test.ts index 04a9d9ee2e..87dad40ece 100644 --- a/packages/bindings/tests/storage.test.ts +++ b/packages/bindings/tests/storage.test.ts @@ -40,6 +40,25 @@ describe("storage (local file-tree provider)", () => { expect(fetched.attributes).toEqual({ metadata: {} }) }) + it("atomically creates one immutable object without replacing the winner", async () => { + const s = freshStorage() + const writes = await Promise.allSettled([ + s.put("immutable.bin", Buffer.from("first"), { condition: "absent" }), + s.put("immutable.bin", Buffer.from("second"), { condition: "absent" }), + ]) + + expect(writes.filter(result => result.status === "fulfilled")).toHaveLength(1) + const [rejected] = writes.filter(result => result.status === "rejected") + expect(rejected).toMatchObject({ + reason: { + code: "STORAGE_OBJECT_ALREADY_EXISTS", + retryable: false, + httpStatusCode: 409, + }, + }) + expect(["first", "second"]).toContain((await s.get("immutable.bin")).data.toString("utf8")) + }) + it("rejects object attributes when the backend cannot persist them", async () => { const s = freshStorage() const data = Buffer.from("attribute-bearing object") From 72abe63c29859a669bbca55b10569366f3b37717 Mon Sep 17 00:00:00 2001 From: Alon Gubkin Date: Tue, 22 Sep 2026 23:31:26 -0700 Subject: [PATCH 13/13] ci: publish qualified Python wheels to PyPI Promote the exact wheels built by stable release qualification with OIDC authentication and checksum verification. --- .github/workflows/release.yml | 203 ++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9b35594a5..c88191d3d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -834,6 +834,208 @@ jobs: publish_tarball "qualified/client-sdks/alienplatform-platform-api-${VERSION}.tgz" "@alienplatform/platform-api" publish_tarball "qualified/client-sdks/alienplatform-manager-api-${VERSION}.tgz" "@alienplatform/manager-api" + # Python distributions are built during qualification and those exact wheels + # are promoted during publication. abi3-py310 covers supported CPython 3.10+. + build-python-wheels: + needs: prepare + if: inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'qualification' + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: depot-ubuntu-24.04-16 + target: x86_64 + manylinux: "2014" + artifact: linux-x86_64 + smoke: true + - runner: depot-ubuntu-24.04-arm-16 + target: aarch64 + manylinux: "2_28" + artifact: linux-aarch64 + smoke: true + - runner: depot-macos-15 + target: aarch64 + manylinux: "off" + artifact: macos-aarch64 + smoke: true + - runner: depot-macos-15 + target: x86_64 + manylinux: "off" + artifact: macos-x86_64 + smoke: false + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.outputs.source_ref }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - uses: dtolnay/rust-toolchain@stable + + - name: Build Python wheel (${{ matrix.artifact }}) + uses: PyO3/maturin-action@v1 + with: + command: build + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + args: --manifest-path crates/alien-bindings-python/Cargo.toml --release --out dist --no-default-features --features all-platforms + + - name: Validate wheel identity + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python - <<'PY' + import email + import glob + import os + import zipfile + + wheels = glob.glob("dist/*.whl") + if len(wheels) != 1: + raise SystemExit(f"expected one wheel, found {wheels}") + wheel = wheels[0] + with zipfile.ZipFile(wheel) as archive: + paths = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(paths) != 1: + raise SystemExit(f"expected one METADATA file, found {paths}") + metadata = email.message_from_bytes(archive.read(paths[0])) + if metadata["Name"] != "alienplatform": + raise SystemExit(f"unexpected package name: {metadata['Name']}") + if metadata["Version"] != os.environ["VERSION"]: + raise SystemExit( + f"wheel version {metadata['Version']} does not match release {os.environ['VERSION']}" + ) + if "-abi3-" not in os.path.basename(wheel): + raise SystemExit(f"wheel is not abi3: {wheel}") + print(os.path.basename(wheel)) + PY + + - name: Smoke-test installed wheel + if: matrix.smoke + run: | + python -m pip install --force-reinstall dist/*.whl + python -c 'import alienplatform; import alienplatform._native' + + - name: Upload qualified Python wheel + uses: actions/upload-artifact@v7 + with: + name: qualified-python-${{ matrix.artifact }} + retention-days: 30 + if-no-files-found: error + path: dist/*.whl + + publish-python: + needs: prepare + if: ${{ inputs.mode == 'stable' && needs.prepare.outputs.ready == 'true' && inputs.stable_action == 'publish' && !inputs.dry_run }} + runs-on: depot-ubuntu-24.04-arm + timeout-minutes: 15 + environment: + name: pypi + url: https://pypi.org/p/alienplatform + permissions: + contents: read + id-token: write + steps: + - name: Download qualified Python wheels + uses: actions/download-artifact@v8 + with: + run-id: ${{ needs.prepare.outputs.qualification_run_id }} + github-token: ${{ secrets.REPO_ACCESS_TOKEN }} + pattern: qualified-python-* + path: dist + merge-multiple: true + + - name: Check qualified wheels against PyPI + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python - <<'PY' + import glob + import hashlib + import json + import os + import urllib.error + import urllib.request + + wheels = sorted(glob.glob("dist/*.whl")) + if len(wheels) != 4: + raise SystemExit(f"expected four qualified wheels, found {wheels}") + expected = { + os.path.basename(path): hashlib.sha256(open(path, "rb").read()).hexdigest() + for path in wheels + } + url = f"https://pypi.org/pypi/alienplatform/{os.environ['VERSION']}/json" + try: + with urllib.request.urlopen(url) as response: + published = json.load(response) + except urllib.error.HTTPError as error: + if error.code != 404: + raise + published = {"urls": []} + existing = { + item["filename"]: item["digests"]["sha256"] + for item in published["urls"] + if item["filename"] in expected + } + mismatched = { + name: (expected[name], digest) + for name, digest in existing.items() + if expected[name] != digest + } + if mismatched: + raise SystemExit(f"PyPI already contains different wheel contents: {mismatched}") + print(f"qualified wheels: {sorted(expected)}") + print(f"already published with matching hashes: {sorted(existing)}") + PY + + # Resuming a partially completed upload is safe because the preceding + # checksum check rejects an existing filename with different contents. + - name: Publish qualified Python wheels + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 + with: + packages-dir: dist + skip-existing: true + + - name: Verify published wheel hashes + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + for attempt in $(seq 1 30); do + if python - <<'PY' + import glob + import hashlib + import json + import os + import urllib.request + + expected = { + os.path.basename(path): hashlib.sha256(open(path, "rb").read()).hexdigest() + for path in glob.glob("dist/*.whl") + } + with urllib.request.urlopen( + f"https://pypi.org/pypi/alienplatform/{os.environ['VERSION']}/json" + ) as response: + published = json.load(response) + actual = {item["filename"]: item["digests"]["sha256"] for item in published["urls"]} + if any(actual.get(name) != digest for name, digest in expected.items()): + raise SystemExit(1) + print(f"verified {len(expected)} wheels on PyPI") + PY + then + exit 0 + fi + echo "Waiting for PyPI metadata propagation (${attempt}/30)..." + sleep 10 + done + echo "::error::Published Python wheels did not appear with their qualified hashes" + exit 1 + # ─── Native addon prebuilds (@alienplatform/bindings) ────────────── # # The bindings addon publishes as a thin wrapper plus per-platform prebuild @@ -2160,6 +2362,7 @@ jobs: - publish-crates - publish-npm - publish-client-sdks + - publish-python - publish-bindings - publish-ai-gateway - upload-binaries