kaxil commented on code in PR #71672: URL: https://github.com/apache/airflow/pull/71672#discussion_r3963844130
########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM +mkfifo "$dir/out" "$dir/err" "$dir/out-tail" "$dir/err-tail" || exit 70 +tail -c "$2" <"$dir/out-tail" >"$dir/out-result" & +out_tail_pid=$! +tail -c "$2" <"$dir/err-tail" >"$dir/err-result" & +err_tail_pid=$! +tee "$dir/out-tail" <"$dir/out" | wc -c >"$dir/out-count" & +out_drain_pid=$! +tee "$dir/err-tail" <"$dir/err" | wc -c >"$dir/err-count" & +err_drain_pid=$! +sh -lc "$1" >"$dir/out" 2>"$dir/err" +status=$? +wait "$out_drain_pid" "$err_drain_pid" "$out_tail_pid" "$err_tail_pid" || exit 70 Review Comment: This `wait` only returns once the tee/wc drains hit EOF, and any process the command backgrounds inherits the fifo write end, so EOF never comes. Running the wrapper under `sh` in a Debian slim image, `sleep 25 & echo started` took 25s to return and `nohup sleep 300 & echo server-started` never returned at all. Past the deadline `_destroy_after_timeout` deletes the microVM, so the model is told its sandbox was replaced and its files are gone, for a command whose foreground part finished instantly. `sbx.py` hits the same wall and bounds it with daemon drain threads plus a deadline-limited join; the wrapper needs a similar bound, such as a watchdog that kills the drains once `$status` is captured. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 Review Comment: `umask 077` guards the scratch dir but it is still in effect for `sh -lc "$1"`, so it changes the mode of everything the agent creates. Same image and command: through the wrapper `touch f; mkdir d` gives `-rw-------` and `drwx------`; without it, `-rw-r--r--` and `drwxr-xr-x`. Resetting the umask before running the command, or dropping it and doing `chmod 700 "$dir"` after `mkdir`, keeps the scratch dir private without that side effect. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM Review Comment: A POSIX trap handler with no `exit` hands control back to the script, so on `TERM` this removes `$dir` and then keeps running. Sending `TERM` mid-command gave me stdout of just the flag line, stderr filled with `cat: .../out-count: No such file or directory` and `[: Illegal number:`, and an exit status of 0. Whether that reaches the model depends on the status Islo reports for a `timeout_secs` kill, but `trap 'rm -rf "$dir"; exit 143' HUP INT TERM`, keeping the bare `EXIT` trap for cleanup, would take the question off the table. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM +mkfifo "$dir/out" "$dir/err" "$dir/out-tail" "$dir/err-tail" || exit 70 +tail -c "$2" <"$dir/out-tail" >"$dir/out-result" & +out_tail_pid=$! +tail -c "$2" <"$dir/err-tail" >"$dir/err-result" & +err_tail_pid=$! +tee "$dir/out-tail" <"$dir/out" | wc -c >"$dir/out-count" & +out_drain_pid=$! +tee "$dir/err-tail" <"$dir/err" | wc -c >"$dir/err-count" & +err_drain_pid=$! +sh -lc "$1" >"$dir/out" 2>"$dir/err" +status=$? +wait "$out_drain_pid" "$err_drain_pid" "$out_tail_pid" "$err_tail_pid" || exit 70 +if [ "$(cat "$dir/out-count")" -gt "$2" ]; then printf '1\\n'; else printf '0\\n'; fi +cat "$dir/out-result" +if [ "$(cat "$dir/err-count")" -gt "$2" ]; then printf '1\\n' >&2; else printf '0\\n' >&2; fi +cat "$dir/err-result" >&2 +exit "$status" +""" + + +@contextmanager +def _translate_islo_errors(operation: str) -> Iterator[None]: + try: + yield + except SandboxError: + raise + except Exception as e: + try: + from islo.core.api_error import ApiError + except ImportError: + raise SandboxTerminalError( + 'The Islo SDK is not installed. Install "apache-airflow-providers-common-ai[sandbox-islo]".' + ) from e + if isinstance(e, ApiError): + status = f" (HTTP {e.status_code})" if e.status_code is not None else "" + raise SandboxTerminalError(f"Islo could not {operation}{status}.") from e + raise SandboxTerminalError(f"Islo could not {operation}: {type(e).__name__}.") from e + + +def _bound_result_stream(text: str, max_bytes: int, *, server_truncated: bool) -> tuple[str, bool]: + flag, separator, payload = text.partition("\n") + if separator and flag in {"0", "1"}: + truncated = flag == "1" or server_truncated + else: + payload = text + truncated = True + + encoded = payload.encode("utf-8") + if len(encoded) > max_bytes: + payload = encoded[-max_bytes:].decode("utf-8", errors="ignore") + truncated = True + return payload, truncated + + +class IsloSandboxBackend(SandboxBackend): + """ + Sandbox backend that runs agent commands in an `islo.dev <https://islo.dev>`__ microVM. + + Islo is a hosted API with no local daemon or host-virtualization requirement, + so this backend works from an Airflow worker running in a container. + Credentials resolve lazily from an Airflow connection on first use. + + Connection fields: ``password`` is the Islo API key (required), ``host`` the + compute URL (optional), and the extra may set ``base_url`` and ``timeout`` + (request timeout in seconds). + + File reads and writes use Islo's native streaming APIs. Directory listings + and command-output bounding require common Unix command-line tools in the + sandbox image, including ``sh``, ``mkfifo``, ``tail``, ``tee`` and a ``find`` + implementation with ``-printf`` support. + + :param islo_conn_id: Airflow connection ID for Islo. ``None`` lets the SDK + resolve credentials from its own environment variables (``ISLO_API_KEY``). + :param image: Sandbox image. ``None`` (default) uses the server default. + :param vcpus: Number of virtual CPUs. ``None`` uses the server default. + :param memory_mb: Memory in MB. ``None`` uses the server default. + :param delete_after: Server-side TTL in seconds after which the sandbox is + deleted even if the worker never got to destroy it. Default ``3600``. + """ + + name = "islo" + + def __init__( + self, + islo_conn_id: str | None = "islo_default", + *, + image: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + delete_after: int = 3600, + ) -> None: + _validate_positive_finite(delete_after, "delete_after") + if vcpus is not None: + _validate_positive_finite(vcpus, "vcpus") + if memory_mb is not None: + _validate_positive_finite(memory_mb, "memory_mb") + if image == "": + raise ValueError("image must not be empty.") + self._islo_conn_id = islo_conn_id + self._image = image + self._vcpus = vcpus + self._memory_mb = memory_mb + self._delete_after = delete_after + self._client: Islo | None = None + + def _get_client(self) -> Islo: + if self._client is not None: + return self._client + with _translate_islo_errors("initialize its client"): + from islo import Islo + + if self._islo_conn_id is None: + self._client = Islo() + return self._client + conn = BaseHook.get_connection(self._islo_conn_id) + api_key = (conn.password or "").strip() + if not api_key: + raise SandboxTerminalError( + f"Connection {self._islo_conn_id!r} has no password; set it to the Islo API key." + ) + kwargs: dict[str, Any] = {"api_key": api_key} + if conn.host: + kwargs["compute_url"] = conn.host + extra = conn.extra_dejson + if extra.get("base_url"): + kwargs["base_url"] = extra["base_url"] + if extra.get("timeout") is not None: + try: + request_timeout = float(extra["timeout"]) + _validate_positive_finite(request_timeout, "connection extra timeout") + except (TypeError, ValueError) as e: + raise SandboxTerminalError( + "The Islo connection extra timeout must be a positive finite number." + ) from e + kwargs["timeout"] = request_timeout + self._client = Islo(**kwargs) + return self._client + + @staticmethod + def _request_options(*, timeout: float, chunk_size: int | None = None) -> dict[str, int]: + options = {"timeout_in_seconds": max(1, math.ceil(timeout)), "max_retries": 0} + if chunk_size is not None: + options["chunk_size"] = chunk_size + return options + + def create(self, *, spec: SandboxSpec | None = None) -> str: + if spec is not None and spec.allow_egress_to: + raise SandboxTerminalError( + "The Islo backend cannot apply a per-domain egress allowlist; it can only turn " + "outbound access on or off. Drop allow_egress_to, or use a backend with " + "per-domain network rules." + ) + with _translate_islo_errors("create a sandbox"): + from islo.types import LifecyclePolicy + + kwargs: dict[str, Any] = { + "internet_enabled": False if spec is None else not spec.block_network, + "lifecycle": LifecyclePolicy(delete_after=self._delete_after), + } + if self._image is not None: + kwargs["image"] = self._image + if self._vcpus is not None: + kwargs["vcpus"] = self._vcpus + if self._memory_mb is not None: + kwargs["memory_mb"] = self._memory_mb + if spec is not None and spec.env: + kwargs["env"] = dict(spec.env) Review Comment: Is `env` set at creation visible to later `exec_in_sandbox` calls, or only to the sandbox's own init? `exec_in_sandbox` takes its own `env`, so carrying `spec.env` on the backend and passing it per exec would remove the doubt. Worth pinning down, because `base.py` treats silently dropping a `SandboxSpec` field as a contract violation, and neither the unit tests nor the system test ever set one. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM +mkfifo "$dir/out" "$dir/err" "$dir/out-tail" "$dir/err-tail" || exit 70 +tail -c "$2" <"$dir/out-tail" >"$dir/out-result" & Review Comment: `tail -c` cuts at a byte boundary, so the first record it keeps is normally a fragment, and neither `_bound_result_stream` nor `truncate_output` drops it. Pushing 1000 numbered lines through the wrapper with a 100 byte cap, the model ends up with `88` on a line of its own, which is the tail of `line988`. `output.py`'s own header says truncation never emits a partial line, and `sbx.py`'s drain drops the leading partial line for exactly this reason. The second cut at line 106 has the same gap, and `list_directory` shares the path, where a mid-record cut can yield a mangled entry name. ########## providers/common/ai/tests/unit/common/ai/sandbox/test_islo.py: ########## @@ -0,0 +1,459 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from types import SimpleNamespace +from unittest import mock + +import pytest + +pytest.importorskip("islo") + +from islo.core.api_error import ApiError +from islo.errors import NotFoundError + +from airflow.providers.common.ai.sandbox.base import ( + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxSpec, + SandboxTerminalError, +) +from airflow.providers.common.ai.sandbox.islo import IsloSandboxBackend + +_BASE_HOOK_PATH = "airflow.providers.common.ai.sandbox.islo.BaseHook" +_ISLO_PATH = "islo.Islo" + + +def _connection(password="secret-key", host=None, extra=None): + return SimpleNamespace(password=password, host=host, extra_dejson=extra or {}) + + +def _exec_result(status="completed", exit_code=0, stdout="0\n", stderr="0\n", truncated=False): + return SimpleNamespace( + status=status, exit_code=exit_code, stdout=stdout, stderr=stderr, truncated=truncated + ) + + +def _backend_with_client(**kwargs) -> tuple[IsloSandboxBackend, mock.MagicMock]: + backend = IsloSandboxBackend(**kwargs) + client = mock.MagicMock(spec=["sandboxes"]) + client.sandboxes = mock.MagicMock( + spec=[ + "create_sandbox", + "delete_sandbox", + "download_file", + "exec_in_sandbox", + "get_exec_result", + "get_sandbox", + "upload_file", + ] + ) + client.sandboxes.exec_in_sandbox.return_value = SimpleNamespace(exec_id="exec-1") + client.sandboxes.create_sandbox.return_value = SimpleNamespace(name="box-1") + client.sandboxes.get_exec_result.return_value = _exec_result() + backend._client = client + return backend, client + + +class TestCredentials: + @mock.patch(_ISLO_PATH, autospec=True) + @mock.patch(_BASE_HOOK_PATH, autospec=True) + def test_api_key_and_allowlisted_connection_options_are_forwarded(self, hook, islo): + backend = IsloSandboxBackend(islo_conn_id="my_islo") + hook.get_connection.return_value = _connection( + password=" key ", + host="https://compute", + extra={"base_url": "https://api", "timeout": 12}, + ) + + backend._get_client() + + hook.get_connection.assert_called_once_with("my_islo") + islo.assert_called_once_with( + api_key="key", compute_url="https://compute", base_url="https://api", timeout=12.0 + ) + + @mock.patch(_ISLO_PATH, autospec=True) + @mock.patch(_BASE_HOOK_PATH, autospec=True) + def test_client_is_resolved_once_and_cached(self, hook, _islo): + backend = IsloSandboxBackend() + hook.get_connection.return_value = _connection() + + backend._get_client() + backend._get_client() + + hook.get_connection.assert_called_once_with("islo_default") + + @mock.patch(_BASE_HOOK_PATH, autospec=True) + def test_missing_api_key_is_terminal(self, hook): + backend = IsloSandboxBackend() + hook.get_connection.return_value = _connection(password="") + + with pytest.raises(SandboxTerminalError, match="has no password"): + backend._get_client() + + @mock.patch(_ISLO_PATH, autospec=True) + def test_none_conn_id_defers_to_the_sdk_environment(self, islo): + backend = IsloSandboxBackend(islo_conn_id=None) + + backend._get_client() + + islo.assert_called_once_with() + + @mock.patch(_BASE_HOOK_PATH, autospec=True) + def test_connection_resolution_failure_is_terminal(self, hook): + backend = IsloSandboxBackend() + hook.get_connection.side_effect = RuntimeError("secret backend down") + + with pytest.raises(SandboxTerminalError, match="initialize its client"): + backend._get_client() + + @mock.patch(_BASE_HOOK_PATH, autospec=True) + def test_invalid_connection_timeout_is_terminal_and_actionable(self, hook): + backend = IsloSandboxBackend() + hook.get_connection.return_value = _connection(extra={"timeout": "never"}) + + with pytest.raises(SandboxTerminalError, match="timeout must be a positive finite number"): + backend._get_client() + + [email protected]( + ("kwargs", "message"), + [ + ({"image": ""}, "image"), + ({"vcpus": 0}, "vcpus"), + ({"memory_mb": 0}, "memory_mb"), + ({"delete_after": 0}, "delete_after"), + ], +) +def test_constructor_rejects_invalid_values(kwargs, message): + with pytest.raises(ValueError, match=message): + IsloSandboxBackend(**kwargs) + + +class TestCreate: + def test_refuses_a_per_domain_egress_allowlist(self): + backend, _ = _backend_with_client() + + with pytest.raises(SandboxTerminalError, match="per-domain egress allowlist"): + backend.create(spec=SandboxSpec(allow_egress_to=["example.com"])) + + @pytest.mark.parametrize( + ("spec", "expected"), + [ + (None, False), + (SandboxSpec(), False), + (SandboxSpec(block_network=True), False), + (SandboxSpec(block_network=False), True), + ], + ) + def test_block_network_maps_to_internet_enabled(self, spec, expected): + backend, client = _backend_with_client() + + backend.create(spec=spec) + + assert client.sandboxes.create_sandbox.call_args.kwargs["internet_enabled"] is expected + + def test_spec_and_sizing_are_passed_at_creation(self): + backend, client = _backend_with_client(image="python", vcpus=2, memory_mb=1024, delete_after=120) + + name = backend.create(spec=SandboxSpec(env={"TOKEN": "value"})) + + assert name == "box-1" + kwargs = client.sandboxes.create_sandbox.call_args.kwargs + assert kwargs["image"] == "python" + assert kwargs["vcpus"] == 2 + assert kwargs["memory_mb"] == 1024 + assert kwargs["env"] == {"TOKEN": "value"} + assert kwargs["lifecycle"].delete_after == 120 + assert kwargs["request_options"] == {"timeout_in_seconds": 120, "max_retries": 0} + + def test_omitted_sizing_is_left_to_the_server(self): + backend, client = _backend_with_client() + + backend.create() + + assert not {"image", "vcpus", "memory_mb"} & client.sandboxes.create_sandbox.call_args.kwargs.keys() + + def test_api_failure_is_terminal(self): + backend, client = _backend_with_client() + client.sandboxes.create_sandbox.side_effect = ApiError(status_code=503) + + with pytest.raises(SandboxTerminalError, match="HTTP 503"): + backend.create() + + +class TestRunCommand: Review Comment: `_backend_with_client` mocks `exec_in_sandbox` outright, so `_COMMAND_WRAPPER` is never executed by anything except the system test, which needs a live API key and so will not run in ordinary CI. It is the riskiest code in the PR (four background jobs, four fifos, a trap, and a `wait`), and running it through a local `sh` subprocess needs no Islo access at all. A test like that would have caught the drain that blocks on a backgrounded child, the umask reaching the agent's command, and the partial leading line. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM +mkfifo "$dir/out" "$dir/err" "$dir/out-tail" "$dir/err-tail" || exit 70 +tail -c "$2" <"$dir/out-tail" >"$dir/out-result" & +out_tail_pid=$! +tail -c "$2" <"$dir/err-tail" >"$dir/err-result" & +err_tail_pid=$! +tee "$dir/out-tail" <"$dir/out" | wc -c >"$dir/out-count" & +out_drain_pid=$! +tee "$dir/err-tail" <"$dir/err" | wc -c >"$dir/err-count" & +err_drain_pid=$! +sh -lc "$1" >"$dir/out" 2>"$dir/err" +status=$? +wait "$out_drain_pid" "$err_drain_pid" "$out_tail_pid" "$err_tail_pid" || exit 70 +if [ "$(cat "$dir/out-count")" -gt "$2" ]; then printf '1\\n'; else printf '0\\n'; fi +cat "$dir/out-result" +if [ "$(cat "$dir/err-count")" -gt "$2" ]; then printf '1\\n' >&2; else printf '0\\n' >&2; fi +cat "$dir/err-result" >&2 +exit "$status" +""" + + +@contextmanager +def _translate_islo_errors(operation: str) -> Iterator[None]: + try: + yield + except SandboxError: + raise + except Exception as e: + try: + from islo.core.api_error import ApiError + except ImportError: + raise SandboxTerminalError( + 'The Islo SDK is not installed. Install "apache-airflow-providers-common-ai[sandbox-islo]".' + ) from e + if isinstance(e, ApiError): + status = f" (HTTP {e.status_code})" if e.status_code is not None else "" + raise SandboxTerminalError(f"Islo could not {operation}{status}.") from e + raise SandboxTerminalError(f"Islo could not {operation}: {type(e).__name__}.") from e + + +def _bound_result_stream(text: str, max_bytes: int, *, server_truncated: bool) -> tuple[str, bool]: + flag, separator, payload = text.partition("\n") + if separator and flag in {"0", "1"}: + truncated = flag == "1" or server_truncated + else: + payload = text + truncated = True + + encoded = payload.encode("utf-8") + if len(encoded) > max_bytes: + payload = encoded[-max_bytes:].decode("utf-8", errors="ignore") + truncated = True + return payload, truncated + + +class IsloSandboxBackend(SandboxBackend): + """ + Sandbox backend that runs agent commands in an `islo.dev <https://islo.dev>`__ microVM. + + Islo is a hosted API with no local daemon or host-virtualization requirement, + so this backend works from an Airflow worker running in a container. + Credentials resolve lazily from an Airflow connection on first use. + + Connection fields: ``password`` is the Islo API key (required), ``host`` the + compute URL (optional), and the extra may set ``base_url`` and ``timeout`` + (request timeout in seconds). + + File reads and writes use Islo's native streaming APIs. Directory listings + and command-output bounding require common Unix command-line tools in the + sandbox image, including ``sh``, ``mkfifo``, ``tail``, ``tee`` and a ``find`` + implementation with ``-printf`` support. + + :param islo_conn_id: Airflow connection ID for Islo. ``None`` lets the SDK + resolve credentials from its own environment variables (``ISLO_API_KEY``). + :param image: Sandbox image. ``None`` (default) uses the server default. + :param vcpus: Number of virtual CPUs. ``None`` uses the server default. + :param memory_mb: Memory in MB. ``None`` uses the server default. + :param delete_after: Server-side TTL in seconds after which the sandbox is + deleted even if the worker never got to destroy it. Default ``3600``. + """ + + name = "islo" + + def __init__( + self, + islo_conn_id: str | None = "islo_default", + *, + image: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + delete_after: int = 3600, + ) -> None: + _validate_positive_finite(delete_after, "delete_after") + if vcpus is not None: + _validate_positive_finite(vcpus, "vcpus") + if memory_mb is not None: + _validate_positive_finite(memory_mb, "memory_mb") + if image == "": + raise ValueError("image must not be empty.") + self._islo_conn_id = islo_conn_id + self._image = image + self._vcpus = vcpus + self._memory_mb = memory_mb + self._delete_after = delete_after + self._client: Islo | None = None + + def _get_client(self) -> Islo: + if self._client is not None: + return self._client + with _translate_islo_errors("initialize its client"): + from islo import Islo + + if self._islo_conn_id is None: + self._client = Islo() + return self._client + conn = BaseHook.get_connection(self._islo_conn_id) + api_key = (conn.password or "").strip() + if not api_key: + raise SandboxTerminalError( + f"Connection {self._islo_conn_id!r} has no password; set it to the Islo API key." + ) + kwargs: dict[str, Any] = {"api_key": api_key} + if conn.host: + kwargs["compute_url"] = conn.host + extra = conn.extra_dejson + if extra.get("base_url"): + kwargs["base_url"] = extra["base_url"] + if extra.get("timeout") is not None: + try: + request_timeout = float(extra["timeout"]) + _validate_positive_finite(request_timeout, "connection extra timeout") + except (TypeError, ValueError) as e: + raise SandboxTerminalError( + "The Islo connection extra timeout must be a positive finite number." + ) from e + kwargs["timeout"] = request_timeout + self._client = Islo(**kwargs) + return self._client + + @staticmethod + def _request_options(*, timeout: float, chunk_size: int | None = None) -> dict[str, int]: + options = {"timeout_in_seconds": max(1, math.ceil(timeout)), "max_retries": 0} Review Comment: `max_retries: 0` also lands on `delete_sandbox`, which is idempotent, and that is the one call whose failure `_destroy_after_timeout` converts into a task failure. So a single transient error during cleanup fails a run whose command merely timed out, even though `delete_after` is going to reclaim the microVM regardless. `sbx.py` only logs a warning on the same path, and it has no TTL to fall back on. Letting the delete keep the SDK's retries, at least on the cleanup path, would fit the backstop this backend already has. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) +_POLL_INITIAL = 0.2 +_POLL_MAX = 2.0 +_POLL_BACKOFF = 1.5 +_FILE_OP_TIMEOUT = 120.0 +_HELPER_OUTPUT_CAP = 1024 * 1024 +_COMMAND_WRAPPER = """\ +umask 077 +dir="${TMPDIR:-/tmp}/airflow-sandbox-$$" +mkdir "$dir" || exit 70 +trap 'rm -rf "$dir"' EXIT HUP INT TERM +mkfifo "$dir/out" "$dir/err" "$dir/out-tail" "$dir/err-tail" || exit 70 +tail -c "$2" <"$dir/out-tail" >"$dir/out-result" & +out_tail_pid=$! +tail -c "$2" <"$dir/err-tail" >"$dir/err-result" & +err_tail_pid=$! +tee "$dir/out-tail" <"$dir/out" | wc -c >"$dir/out-count" & +out_drain_pid=$! +tee "$dir/err-tail" <"$dir/err" | wc -c >"$dir/err-count" & +err_drain_pid=$! +sh -lc "$1" >"$dir/out" 2>"$dir/err" +status=$? +wait "$out_drain_pid" "$err_drain_pid" "$out_tail_pid" "$err_tail_pid" || exit 70 +if [ "$(cat "$dir/out-count")" -gt "$2" ]; then printf '1\\n'; else printf '0\\n'; fi +cat "$dir/out-result" +if [ "$(cat "$dir/err-count")" -gt "$2" ]; then printf '1\\n' >&2; else printf '0\\n' >&2; fi +cat "$dir/err-result" >&2 +exit "$status" +""" + + +@contextmanager +def _translate_islo_errors(operation: str) -> Iterator[None]: + try: + yield + except SandboxError: + raise + except Exception as e: + try: + from islo.core.api_error import ApiError + except ImportError: + raise SandboxTerminalError( + 'The Islo SDK is not installed. Install "apache-airflow-providers-common-ai[sandbox-islo]".' + ) from e + if isinstance(e, ApiError): + status = f" (HTTP {e.status_code})" if e.status_code is not None else "" + raise SandboxTerminalError(f"Islo could not {operation}{status}.") from e + raise SandboxTerminalError(f"Islo could not {operation}: {type(e).__name__}.") from e + + +def _bound_result_stream(text: str, max_bytes: int, *, server_truncated: bool) -> tuple[str, bool]: + flag, separator, payload = text.partition("\n") + if separator and flag in {"0", "1"}: + truncated = flag == "1" or server_truncated + else: + payload = text + truncated = True + + encoded = payload.encode("utf-8") + if len(encoded) > max_bytes: + payload = encoded[-max_bytes:].decode("utf-8", errors="ignore") + truncated = True + return payload, truncated + + +class IsloSandboxBackend(SandboxBackend): + """ + Sandbox backend that runs agent commands in an `islo.dev <https://islo.dev>`__ microVM. + + Islo is a hosted API with no local daemon or host-virtualization requirement, + so this backend works from an Airflow worker running in a container. + Credentials resolve lazily from an Airflow connection on first use. + + Connection fields: ``password`` is the Islo API key (required), ``host`` the + compute URL (optional), and the extra may set ``base_url`` and ``timeout`` + (request timeout in seconds). + + File reads and writes use Islo's native streaming APIs. Directory listings + and command-output bounding require common Unix command-line tools in the + sandbox image, including ``sh``, ``mkfifo``, ``tail``, ``tee`` and a ``find`` + implementation with ``-printf`` support. + + :param islo_conn_id: Airflow connection ID for Islo. ``None`` lets the SDK + resolve credentials from its own environment variables (``ISLO_API_KEY``). + :param image: Sandbox image. ``None`` (default) uses the server default. + :param vcpus: Number of virtual CPUs. ``None`` uses the server default. + :param memory_mb: Memory in MB. ``None`` uses the server default. + :param delete_after: Server-side TTL in seconds after which the sandbox is + deleted even if the worker never got to destroy it. Default ``3600``. + """ + + name = "islo" + + def __init__( + self, + islo_conn_id: str | None = "islo_default", + *, + image: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + delete_after: int = 3600, + ) -> None: + _validate_positive_finite(delete_after, "delete_after") + if vcpus is not None: + _validate_positive_finite(vcpus, "vcpus") + if memory_mb is not None: + _validate_positive_finite(memory_mb, "memory_mb") + if image == "": + raise ValueError("image must not be empty.") + self._islo_conn_id = islo_conn_id + self._image = image + self._vcpus = vcpus + self._memory_mb = memory_mb + self._delete_after = delete_after + self._client: Islo | None = None + + def _get_client(self) -> Islo: + if self._client is not None: + return self._client + with _translate_islo_errors("initialize its client"): + from islo import Islo + + if self._islo_conn_id is None: + self._client = Islo() + return self._client + conn = BaseHook.get_connection(self._islo_conn_id) + api_key = (conn.password or "").strip() + if not api_key: + raise SandboxTerminalError( + f"Connection {self._islo_conn_id!r} has no password; set it to the Islo API key." + ) + kwargs: dict[str, Any] = {"api_key": api_key} + if conn.host: + kwargs["compute_url"] = conn.host + extra = conn.extra_dejson + if extra.get("base_url"): + kwargs["base_url"] = extra["base_url"] + if extra.get("timeout") is not None: + try: + request_timeout = float(extra["timeout"]) + _validate_positive_finite(request_timeout, "connection extra timeout") + except (TypeError, ValueError) as e: + raise SandboxTerminalError( + "The Islo connection extra timeout must be a positive finite number." + ) from e + kwargs["timeout"] = request_timeout + self._client = Islo(**kwargs) + return self._client + + @staticmethod + def _request_options(*, timeout: float, chunk_size: int | None = None) -> dict[str, int]: + options = {"timeout_in_seconds": max(1, math.ceil(timeout)), "max_retries": 0} + if chunk_size is not None: + options["chunk_size"] = chunk_size + return options + + def create(self, *, spec: SandboxSpec | None = None) -> str: + if spec is not None and spec.allow_egress_to: + raise SandboxTerminalError( + "The Islo backend cannot apply a per-domain egress allowlist; it can only turn " + "outbound access on or off. Drop allow_egress_to, or use a backend with " + "per-domain network rules." + ) + with _translate_islo_errors("create a sandbox"): + from islo.types import LifecyclePolicy + + kwargs: dict[str, Any] = { + "internet_enabled": False if spec is None else not spec.block_network, + "lifecycle": LifecyclePolicy(delete_after=self._delete_after), + } + if self._image is not None: + kwargs["image"] = self._image + if self._vcpus is not None: + kwargs["vcpus"] = self._vcpus + if self._memory_mb is not None: + kwargs["memory_mb"] = self._memory_mb + if spec is not None and spec.env: + kwargs["env"] = dict(spec.env) + sandbox = self._get_client().sandboxes.create_sandbox( + name=_new_sandbox_name(), Review Comment: `_new_sandbox_name()` is generated inline, so if `create_sandbox` raises after the server has already provisioned the microVM (a response timeout, a reset, a 5xx after creation), the handle is gone and nothing can delete it or even log which sandbox leaked. `sbx.py` binds the name first and best-effort removes it under `except BaseException`. `delete_after` does bound the damage, but it is documented as the backstop for a worker killed mid-run, and here the worker is alive and able to clean up immediately. ########## providers/common/ai/src/airflow/providers/common/ai/sandbox/islo.py: ########## @@ -0,0 +1,397 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""islo.dev microVM backend for :class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.""" + +from __future__ import annotations + +import math +import shlex +import time +from contextlib import contextmanager, suppress +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.ai.sandbox.base import ( + SandboxBackend, + SandboxError, + SandboxExecResult, + SandboxFileTooLargeError, + SandboxTerminalError, + _new_sandbox_name, + _validate_positive_finite, +) +from airflow.providers.common.compat.sdk import BaseHook + +if TYPE_CHECKING: + from collections.abc import Iterator + + from islo import Islo + from islo.errors import NotFoundError + + from airflow.providers.common.ai.sandbox.base import SandboxSpec + +_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "timeout"}) Review Comment: The SDK types the exec `status` as a plain `str` with `extra="allow"`, and the vendor's other status enums carry values like `cancelled` and `dead`, so these three may not be the full vocabulary. Anything outside the set reads as still-running here, so `_await_exec` polls to the deadline and then destroys the sandbox and reports a timeout. Treating an unrecognised status as terminal-failed instead would keep a mislabelled status from costing the agent its files. ########## providers/common/ai/docs/toolsets.rst: ########## @@ -793,6 +793,60 @@ Constructor parameters: guarantee this backend cannot make. Set ``"deny-all"`` after running ``sbx policy init deny-all``, or ``"allow-all"`` to state that egress is open. +Islo backend Review Comment: The `sbx` warning box just above (line 753) still says a hosted backend plugs in through `SandboxBackend` but none ships with the provider yet, and `sbx.py`'s class docstring says the same; both are now false. The credentials paragraph below also reads as unconditional, but `islo_conn_id=None` sends the SDK to its own environment variable, which is the worker environment the sentence says the key stays out of, and it is the path this PR's own system test and README use. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
