From 231d285f74a3777f8eaaa2e10cc196171f23e0fd Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 6 Jun 2026 14:43:12 +0200 Subject: [PATCH 01/21] New buildroot image --- tools/buildroot-images/manifest.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tools/buildroot-images/manifest.json b/tools/buildroot-images/manifest.json index ca7e07f81..b269164cc 100644 --- a/tools/buildroot-images/manifest.json +++ b/tools/buildroot-images/manifest.json @@ -1,37 +1,37 @@ { "entries": [ { - "assets_partition_size": "100M", + "assets_partition_size": "30M", "buildroot_apt_deps": "bc bison build-essential ca-certificates cpio curl file flex g++ gfortran genimage git dosfstools e2fsprogs libncurses-dev libssl-dev make mtools perl python3 rsync unzip wget xdg-utils xz-utils", "buildroot_version": "2025.02.13", - "created_at": "2026-06-04T01:08:42.048570+00:00", + "created_at": "2026-06-06T01:27:24.460639+00:00", "defconfig": "raspberrypizero2w_64_defconfig", "docker_image": "debian:bookworm", - "frameos_partition_size": "100M", - "frameos_version": "2026.6.7", - "object_key": "buildroot-images/raspberry-pi-zero-2-w/2026.6.7/raspberry-pi-zero-2-w-2026.6.7-3d9cf693335e8cf3.img.gz", + "frameos_partition_size": "30M", + "frameos_version": "2026.6.8", + "object_key": "buildroot-images/raspberry-pi-zero-2-w/2026.6.8/raspberry-pi-zero-2-w-2026.6.8-26afa6e67f8bd831.img.gz", "partitions": [ { "size": 33554432, "start": 512 }, { - "size": 182751232, + "size": 182464512, "start": 33554944 }, { - "size": 104857600, - "start": 216306176 + "size": 31457280, + "start": 216019456 }, { - "size": 104857600, - "start": 321163776 + "size": 31457280, + "start": 247476736 } ], "platform": "raspberry-pi-zero-2-w", - "sha256": "3d9cf693335e8cf3c9317380d34d227ebcb6723ab3b783146eb545ea6e05f745", - "size": 426021376, - "updated_at": "2026-06-04T01:09:46.048578+00:00" + "sha256": "26afa6e67f8bd831d59bdc2648428e9cf41623f38d9aeef2cffccb60ce347756", + "size": 278934016, + "updated_at": "2026-06-06T01:27:31.284918+00:00" } ] } \ No newline at end of file From dfad392a7d84ce2b533473eedf28d4db1eeb8fab Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 6 Jun 2026 16:44:11 +0200 Subject: [PATCH 02/21] Build in modal sandboxes --- backend/app/api/frames.py | 8 + backend/app/api/settings.py | 71 +++ backend/app/api/system.py | 13 +- backend/app/api/tests/test_settings.py | 100 ++++ backend/app/models/settings.py | 2 + backend/app/schemas/system.py | 7 + backend/app/tasks/binary_builder.py | 51 +- backend/app/tasks/buildroot_image.py | 10 + backend/app/tasks/deploy_agent.py | 33 +- backend/app/tasks/frame_deploy_workflow.py | 33 +- backend/app/tasks/tests/test_cross_compile.py | 69 +++ .../tasks/tests/test_frame_deploy_workflow.py | 54 +- backend/app/utils/build_environment.py | 48 ++ backend/app/utils/build_host.py | 26 + backend/app/utils/cross_compile.py | 81 ++- backend/app/utils/local_exec.py | 143 +++++ backend/app/utils/modal_sandbox.py | 524 +++++++++++++++++ backend/app/utils/system_info.py | 34 +- .../app/utils/tests/test_build_environment.py | 28 + backend/app/utils/tests/test_build_host.py | 71 ++- backend/app/utils/tests/test_modal_sandbox.py | 128 +++++ backend/requirements.docker.in | 1 + backend/requirements.in | 1 + backend/requirements.txt | 57 +- frontend/src/index.css | 81 +++ frontend/src/scenes/frame/frameDeployUtils.ts | 7 +- .../panels/FrameSettings/FrameSettings.tsx | 11 +- frontend/src/scenes/settings/Settings.tsx | 543 +++++++++++++----- .../src/scenes/settings/settingsLogic.tsx | 68 +++ .../src/scenes/settings/systemInfoLogic.ts | 7 + frontend/src/types.tsx | 18 + frontend/src/utils/frameBuildOptions.ts | 4 +- 32 files changed, 2113 insertions(+), 219 deletions(-) create mode 100644 backend/app/utils/build_environment.py create mode 100644 backend/app/utils/modal_sandbox.py create mode 100644 backend/app/utils/tests/test_build_environment.py create mode 100644 backend/app/utils/tests/test_modal_sandbox.py diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index fea570cf5..339e57940 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -112,6 +112,7 @@ ) from app.models.assets import copy_custom_fonts_to_local_source_folder from app.models.settings import get_settings_dict +from app.utils.build_environment import selected_build_environment_provider from app.utils.ssh_key_utils import default_ssh_key_ids from app.utils.timezone import frame_timezone, normalize_timezone, stored_timezone from app.utils.tls import generate_frame_tls_material, parse_certificate_not_valid_after @@ -2436,6 +2437,13 @@ async def api_frame_buildroot_sd_image( if (frame.mode or "rpios") != "buildroot": _bad_request("SD card image generation is only available for Buildroot frames") + settings = get_settings_dict(db, project_id=frame.project_id) + build_environment_provider = selected_build_environment_provider(settings) + if build_environment_provider not in {"docker", "modal"}: + _bad_request( + "Buildroot SD card image generation requires Docker or Modal sandboxes as the global build environment." + ) + try: ensure_buildroot_frame_defaults(frame) except ValueError as exc: diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 463765d24..a551a68c8 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -7,6 +7,9 @@ from app.models.settings import get_settings_dict, Settings from app.schemas.settings import SettingsResponse, SettingsUpdateRequest from app.tenancy import current_project_id +from app.utils.build_environment import selected_build_environment_provider +from app.utils.build_host import BuildHostConfig, BuildHostSession +from app.utils.modal_sandbox import ModalSandboxConfig, ModalSandboxSession from app.utils.posthog import initialize_posthog from . import api_project @@ -21,6 +24,12 @@ async def set_settings(data: SettingsUpdateRequest, db: Session = Depends(get_db if not payload: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="No JSON payload received") + provider = selected_build_environment_provider(payload) + if isinstance(payload.get("buildHost"), dict): + payload["buildHost"] = {**payload["buildHost"], "enabled": provider == "buildHost"} + if isinstance(payload.get("modalSandbox"), dict): + payload["modalSandbox"] = {**payload["modalSandbox"], "enabled": provider == "modal"} + try: current_settings = get_settings_dict(db, project_id=project_id) for key, value in payload.items(): @@ -39,3 +48,65 @@ async def set_settings(data: SettingsUpdateRequest, db: Session = Depends(get_db if "posthog" in payload: initialize_posthog(updated_settings, project_id=project_id) return updated_settings + + +@api_project.post("/settings/test_build_host") +async def test_build_host(data: SettingsUpdateRequest): + payload = data.to_dict() + raw_build_host_settings = payload.get("buildHost") if isinstance(payload, dict) else None + build_host_config = BuildHostConfig.from_settings( + {**raw_build_host_settings, "enabled": True} if isinstance(raw_build_host_settings, dict) else raw_build_host_settings + ) + if build_host_config is None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Select build host via SSH and enter a host, user, and private SSH key first", + ) + + try: + async with BuildHostSession(build_host_config) as build_host: + status, out, err = await build_host.run( + "echo frameos-build-host-ok && command -v docker >/dev/null && docker buildx version >/dev/null", + log_command=False, + log_output=False, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Build host connection failed: {exc}") from exc + + if status != 0: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=err or out or "Build host is missing Docker or the Docker Buildx plugin", + ) + + return {"ok": True, "output": (out or "").strip()} + + +@api_project.post("/settings/test_modal_sandbox") +async def test_modal_sandbox(data: SettingsUpdateRequest): + payload = data.to_dict() + raw_modal_settings = payload.get("modalSandbox") if isinstance(payload, dict) else None + modal_config = ModalSandboxConfig.from_settings(raw_modal_settings) + if modal_config is None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Select Modal sandboxes and enter a token ID and token secret first", + ) + + try: + async with ModalSandboxSession(modal_config) as sandbox: + status, out, err = await sandbox.run( + "command -v nimble && nimble --version >/dev/null && echo frameos-modal-sandbox-ok", + log_command=False, + log_output=False, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Modal sandbox test failed: {exc}") from exc + + if status != 0: + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, + detail=err or out or "Modal sandbox image is missing the FrameOS Nim toolchain", + ) + + return {"ok": True, "output": (out or "").strip()} diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 1b5807b65..2ec79c1c2 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1,5 +1,5 @@ from app.api import api_user -from app.schemas.system import CacheInfo, DatabaseInfo, DiskInfo, LoadInfo, MemoryInfo, SystemInfoResponse, SystemMetricsResponse +from app.schemas.system import CacheInfo, DatabaseInfo, DiskInfo, DockerInfo, LoadInfo, MemoryInfo, SystemInfoResponse, SystemMetricsResponse from app.utils.system_info import get_system_info, get_system_metrics @@ -27,15 +27,24 @@ def _database_to_schema(database) -> DatabaseInfo: ) +def _docker_to_schema(docker) -> DockerInfo: + return DockerInfo( + cliAvailable=docker.cli_available, + daemonAvailable=docker.daemon_available, + error=docker.error, + ) + + @api_user.get("/system/info", response_model=SystemInfoResponse) def system_info(): - disk, caches, database, memory, load = get_system_info() + disk, caches, database, memory, load, docker = get_system_info() return SystemInfoResponse( disk=_disk_to_schema(disk), caches=_cache_to_schema(caches), database=_database_to_schema(database), memory=_memory_to_schema(memory), load=_load_to_schema(load), + docker=_docker_to_schema(docker), ) diff --git a/backend/app/api/tests/test_settings.py b/backend/app/api/tests/test_settings.py index c5e2f4617..19a91a7ec 100644 --- a/backend/app/api/tests/test_settings.py +++ b/backend/app/api/tests/test_settings.py @@ -17,8 +17,108 @@ async def test_set_settings(async_client): assert updated["some_setting"] == "hello" +@pytest.mark.asyncio +async def test_set_settings_normalizes_build_environment_enabled_flags(async_client): + payload = { + "buildEnvironment": {"provider": "modal"}, + "buildHost": {"enabled": True, "host": "builder.local"}, + "modalSandbox": {"tokenId": "ak-test", "tokenSecret": "as-test"}, + } + response = await async_client.post('/api/settings', json=payload) + assert response.status_code == 200, f"Got {response.status_code} and {response.json()}" + updated = response.json() + assert updated["buildHost"]["enabled"] is False + assert updated["modalSandbox"]["enabled"] is True + + @pytest.mark.asyncio async def test_set_settings_no_payload(async_client): response = await async_client.post('/api/settings', json={}) assert response.status_code == 400 assert response.json()['detail'] == "No JSON payload received" + + +@pytest.mark.asyncio +async def test_modal_sandbox_test_requires_credentials(async_client): + response = await async_client.post('/api/settings/test_modal_sandbox', json={"modalSandbox": {"enabled": True}}) + + assert response.status_code == 400 + assert response.json()["detail"] == "Select Modal sandboxes and enter a token ID and token secret first" + + +@pytest.mark.asyncio +async def test_build_host_test_requires_credentials(async_client): + response = await async_client.post('/api/settings/test_build_host', json={"buildHost": {"host": "builder.local"}}) + + assert response.status_code == 400 + assert response.json()["detail"] == "Select build host via SSH and enter a host, user, and private SSH key first" + + +@pytest.mark.asyncio +async def test_build_host_test_runs_probe(async_client, monkeypatch): + class FakeBuildHostSession: + def __init__(self, config): + self.config = config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def run(self, command, **kwargs): + assert command == "echo frameos-build-host-ok && command -v docker >/dev/null && docker buildx version >/dev/null" + assert kwargs["log_command"] is False + assert kwargs["log_output"] is False + return 0, "frameos-build-host-ok\n", None + + monkeypatch.setattr("app.api.settings.BuildHostSession", FakeBuildHostSession) + + response = await async_client.post( + '/api/settings/test_build_host', + json={ + "buildHost": { + "host": "builder.local", + "user": "ubuntu", + "sshKey": "dummy-key", + } + }, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True, "output": "frameos-build-host-ok"} + + +@pytest.mark.asyncio +async def test_modal_sandbox_test_runs_probe(async_client, monkeypatch): + class FakeModalSandboxSession: + def __init__(self, config): + self.config = config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def run(self, command, **kwargs): + assert command == "command -v nimble && nimble --version >/dev/null && echo frameos-modal-sandbox-ok" + assert kwargs["log_command"] is False + assert kwargs["log_output"] is False + return 0, "frameos-modal-sandbox-ok\n", None + + monkeypatch.setattr("app.api.settings.ModalSandboxSession", FakeModalSandboxSession) + + response = await async_client.post( + '/api/settings/test_modal_sandbox', + json={ + "modalSandbox": { + "enabled": True, + "tokenId": "ak-test", + "tokenSecret": "as-test", + } + }, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True, "output": "frameos-modal-sandbox-ok"} diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index cb60cd48d..b5c184944 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -31,6 +31,8 @@ def default_settings() -> dict: "wifiSSID": "", "wifiPassword": "", }, + "buildEnvironment": {}, + "modalSandbox": {}, } diff --git a/backend/app/schemas/system.py b/backend/app/schemas/system.py index e1815018d..5b07d9da3 100644 --- a/backend/app/schemas/system.py +++ b/backend/app/schemas/system.py @@ -31,12 +31,19 @@ class DatabaseInfo(BaseModel): exists: bool +class DockerInfo(BaseModel): + cliAvailable: bool + daemonAvailable: bool + error: str | None = None + + class SystemInfoResponse(BaseModel): disk: DiskInfo caches: list[CacheInfo] database: DatabaseInfo memory: MemoryInfo load: LoadInfo + docker: DockerInfo class SystemMetricsResponse(BaseModel): diff --git a/backend/app/tasks/binary_builder.py b/backend/app/tasks/binary_builder.py index 122cdbbb0..cf8222d17 100644 --- a/backend/app/tasks/binary_builder.py +++ b/backend/app/tasks/binary_builder.py @@ -26,7 +26,10 @@ precompiled_frameos_release_url, ) from app.tasks.prebuilt_deps import PrebuiltEntry, fetch_prebuilt_manifest, resolve_prebuilt_target -from app.utils.build_host import get_build_host_config +from app.models.settings import get_settings_dict +from app.utils.build_environment import selected_build_environment_provider +from app.utils.build_host import build_executor_display_name, get_build_executor_config +from app.utils.modal_sandbox import ModalSandboxConfig from app.utils.cross_compile import ( TargetMetadata, build_binary_with_cross_toolchain, @@ -46,6 +49,13 @@ ) +def get_build_host_config(*args, **kwargs): # noqa: ANN002, ANN003 + """Compatibility shim for older tests monkeypatching this module symbol.""" + from app.utils.build_host import get_build_host_config as _get_build_host_config + + return _get_build_host_config(*args, **kwargs) + + def should_suggest_clearing_build_cache(error_message: str) -> bool: normalized = error_message.lower() return any(hint in normalized for hint in LINKER_ERROR_HINTS) @@ -64,6 +74,8 @@ class FrameBinaryPlan: prebuilt_entry: PrebuiltEntry | None prebuilt_target: str | None requested_compilation_mode: str | None = None + build_executor: str | None = None + allow_on_device_fallback: bool = True will_attempt_precompiled: bool = False precompiled_release_url: str | None = None precompiled_skip_reason: str | None = None @@ -82,6 +94,8 @@ def to_dict(self) -> dict[str, object]: "force_cross_compile": self.force_cross_compile, "cross_compile_supported": self.cross_compile_supported, "build_host_configured": self.build_host_configured, + "build_executor": self.build_executor, + "allow_on_device_fallback": self.allow_on_device_fallback, "will_attempt_cross_compile": self.will_attempt_cross_compile, "prebuilt_target": self.prebuilt_target, "has_prebuilt_entry": self.prebuilt_entry is not None, @@ -176,6 +190,7 @@ async def plan_build( *, allow_cross_compile: bool = True, force_cross_compile: bool = False, + allow_on_device_fallback: bool = True, target_override: TargetMetadata | None = None, compilation_mode: str | None = None, ) -> FrameBinaryPlan: @@ -217,10 +232,15 @@ async def plan_build( ) project_id = getattr(self.frame, "project_id", None) - build_host = get_build_host_config(self.db, project_id) + build_executor = get_build_executor_config(self.db, project_id) + settings = get_settings_dict(self.db, project_id=project_id) if self.db and project_id is not None else {} + build_environment_provider = selected_build_environment_provider(settings) cross_compile_supported = can_cross_compile_target(target.arch) will_attempt_cross_compile = ( - allow_cross_compile and cross_compile_supported and not will_attempt_precompiled + allow_cross_compile + and build_environment_provider != "none" + and cross_compile_supported + and not will_attempt_precompiled ) return FrameBinaryPlan( @@ -230,7 +250,9 @@ async def plan_build( allow_cross_compile=allow_cross_compile, force_cross_compile=force_cross_compile, cross_compile_supported=cross_compile_supported, - build_host_configured=build_host is not None, + build_host_configured=build_executor is not None, + build_executor=build_executor_display_name(build_executor) if build_executor else None, + allow_on_device_fallback=allow_on_device_fallback, will_attempt_cross_compile=will_attempt_cross_compile, prebuilt_entry=prebuilt_entry, prebuilt_target=prebuilt_target, @@ -247,7 +269,9 @@ async def build( precompiled_install_all_drivers: bool = False, ) -> FrameBinaryBuildResult: project_id = getattr(self.frame, "project_id", None) - build_host = get_build_host_config(self.db, project_id) + build_executor = get_build_executor_config(self.db, project_id) + settings = get_settings_dict(self.db, project_id=project_id) if self.db and project_id is not None else {} + build_environment_provider = selected_build_environment_provider(settings) build_dir = create_build_folder(self.temp_dir, self.deployer.build_id) if plan.will_attempt_precompiled: await self._log("stdout", f"{icon} Using precompiled FrameOS release") @@ -304,10 +328,12 @@ async def build( cross_compiled = False binary_path: str | None = None if plan.will_attempt_cross_compile: - if build_host: + if build_environment_provider in {"buildHost", "modal"} and build_executor is None: + raise RuntimeError(f"Selected build environment '{build_environment_provider}' is not configured") + if build_executor: await self._log( "stdout", - f"{icon} Target supports cross compilation; building binary via build host", + f"{icon} Target supports cross compilation; building binary via {build_executor_display_name(build_executor)}", ) else: await self._log("stdout", f"{icon} Target supports cross compilation; building binary locally") @@ -324,13 +350,14 @@ async def build( prebuilt_target=plan.prebuilt_target, target_override=plan.target, logger=self._log, - build_host=build_host, + build_host=build_executor, ) except Exception as exc: error_message = str(exc) failure_msg = f"Cross compilation failed ({exc})" - if build_host: - failure_msg = f"Cross compilation failed on build host ({exc})" + if build_executor: + executor_name = "Modal sandbox" if isinstance(build_executor, ModalSandboxConfig) else "build host" + failure_msg = f"Cross compilation failed on {executor_name} ({exc})" await self._log( "stderr", f"{icon} {failure_msg}", @@ -355,7 +382,7 @@ async def build( "stderr", f"{icon} If the failure is caused by a stale linker cache, clear the build cache (press ... in logs or settings) and try deploying again.", ) - if plan.force_cross_compile: + if plan.force_cross_compile or not plan.allow_on_device_fallback: raise else: await self._log( @@ -365,7 +392,7 @@ async def build( else: cross_compiled = True await self._log("stdout", f"{icon} Cross compilation succeeded; skipping remote build") - elif plan.force_cross_compile: + elif plan.force_cross_compile or not plan.allow_on_device_fallback: raise RuntimeError("Cross compilation required but not supported for this target") return FrameBinaryBuildResult( diff --git a/backend/app/tasks/buildroot_image.py b/backend/app/tasks/buildroot_image.py index 5ab216ff5..8f847b578 100644 --- a/backend/app/tasks/buildroot_image.py +++ b/backend/app/tasks/buildroot_image.py @@ -52,6 +52,7 @@ ) from app.tasks.utils import get_fresh_frame from app.tasks.prebuilt_deps import resolve_prebuilt_target +from app.utils.build_environment import selected_build_environment_provider from app.utils.cross_compile import CrossCompiler, TargetMetadata from app.utils.ssh_key_utils import select_ssh_keys_for_frame from app.utils.local_exec import exec_local_command @@ -1901,6 +1902,15 @@ def _host_has_compose_tools() -> bool: async def _ensure_buildroot_image(self) -> str: image = self._buildroot_image() resolved_image = self._resolved_buildroot_image() + project_id = getattr(self.frame, "project_id", None) + settings = get_settings_dict(self.db, project_id=project_id) if self.db and project_id is not None else {} + build_environment_provider = selected_build_environment_provider(settings) + if build_environment_provider == "modal": + return resolved_image + if build_environment_provider != "docker": + raise RuntimeError( + "Buildroot SD image generation requires Docker or Modal sandboxes as the global build environment." + ) if not BUILDROOT_FORCE_LOCAL_BUILD: status, _out, _err = await exec_local_command( self.db, diff --git a/backend/app/tasks/deploy_agent.py b/backend/app/tasks/deploy_agent.py index 31fcf7851..e3a51e158 100644 --- a/backend/app/tasks/deploy_agent.py +++ b/backend/app/tasks/deploy_agent.py @@ -14,13 +14,15 @@ from app.models.frame import Frame from app.models.log import new_log as log +from app.models.settings import get_settings_dict +from app.utils.build_environment import selected_build_environment_provider from app.utils.local_exec import exec_local_command from app.utils.remote_exec import RemoteTransport, upload_file from app.tasks._frame_deployer import FrameDeployer from app.tasks.frame_deploy_helpers import sanitize_apt_package_name from app.tasks.prebuilt_deps import resolve_prebuilt_target from app.tasks.precompiled_agent import download_precompiled_agent_release -from app.utils.build_host import get_build_host_config +from app.utils.build_host import build_executor_display_name, get_build_executor_config from app.utils.cross_compile import CrossCompiler, TargetMetadata, can_cross_compile_target from app.utils.versions import current_agent_version, get_versions from .utils import find_nim_v2, find_nimbase_file, get_fresh_frame @@ -32,6 +34,13 @@ REPO_ROOT = Path(__file__).resolve().parents[3] +def get_build_host_config(*args, **kwargs): # noqa: ANN002, ANN003 + """Compatibility shim for older tests monkeypatching this module symbol.""" + from app.utils.build_host import get_build_host_config as _get_build_host_config + + return _get_build_host_config(*args, **kwargs) + + def precompiled_agent_enabled() -> bool: return os.environ.get(PRECOMPILED_AGENT_ENV, "").strip().lower() not in { "0", @@ -366,17 +375,26 @@ async def _try_cross_compile_agent( distro: str, distro_version: str, ) -> bool: + project_id = getattr(self.frame, "project_id", None) + build_executor = get_build_executor_config(self.db, project_id) + settings = get_settings_dict(self.db, project_id=project_id) if self.db and project_id is not None else {} + build_environment_provider = selected_build_environment_provider(settings) + if build_environment_provider == "none": + await self.log("stdout", "- Server-side compilation is disabled; building agent on the device") + return False if not can_cross_compile_target(arch): + if build_environment_provider != "none": + raise RuntimeError(f"Selected build environment cannot cross compile agent for {arch}") await self.log( "stdout", f"- Agent target architecture {arch} does not support cross compilation; building on the device", ) return False - project_id = getattr(self.frame, "project_id", None) - build_host = get_build_host_config(self.db, project_id) - if build_host: - await self.log("stdout", "- Cross compiling agent via build host") + if build_environment_provider in {"buildHost", "modal"} and build_executor is None: + raise RuntimeError(f"Selected build environment '{build_environment_provider}' is not configured") + if build_executor: + await self.log("stdout", f"- Cross compiling agent via {build_executor_display_name(build_executor)}") else: await self.log("stdout", "- Cross compiling agent locally") @@ -390,12 +408,15 @@ async def _try_cross_compile_agent( temp_dir=self.temp_dir, build_dir=build_dir, logger=self.log, - build_host=build_host, + build_host=build_executor, output_name=AGENT_BINARY, compile_script_name="compile_frameos_agent.sh", needs_quickjs=False, ).build(source_dir) except Exception as exc: # noqa: BLE001 + if build_environment_provider != "none": + await self.log("stderr", f"- Agent cross compilation failed ({exc})") + raise await self.log("stderr", f"- Agent cross compilation failed ({exc}); falling back to on-device build") return False diff --git a/backend/app/tasks/frame_deploy_workflow.py b/backend/app/tasks/frame_deploy_workflow.py index 64a9e8b56..ea278d8d6 100644 --- a/backend/app/tasks/frame_deploy_workflow.py +++ b/backend/app/tasks/frame_deploy_workflow.py @@ -37,6 +37,7 @@ render_setup_json_reset_service, setup_json_reset_file_path, ) +from app.utils.build_environment import selected_build_environment_provider from app.utils.frame_http import _fetch_frame_http_bytes from app.utils.remote_exec import upload_file from app.utils.ssh_authorized_keys import _install_authorized_keys @@ -394,6 +395,8 @@ async def _plan_full(self, *, frame_dict: dict[str, Any], previous_frameos_versi drivers = drivers_for_frame(self.frame) driver_names = sorted(drivers.keys()) + settings = _get_frame_settings(self.db, self.frame) + build_environment_provider = selected_build_environment_provider(settings) compile_settings = (self.frame.buildroot if is_buildroot else self.frame.rpios) or {} cross_compilation_setting = ( "always" if is_buildroot else (compile_settings.get("crossCompilation") or "auto").lower() @@ -402,15 +405,22 @@ async def _plan_full(self, *, frame_dict: dict[str, Any], previous_frameos_versi cross_compilation_setting = "auto" compilation_mode = normalize_compilation_mode(compile_settings.get("compilationMode")) - allow_cross_compile = cross_compilation_setting != "never" + if build_environment_provider == "none" and (is_buildroot or cross_compilation_setting == "always"): + raise RuntimeError( + "Server-side compilation is disabled in global settings. Choose Docker, build host, or Modal " + "sandboxes as the build environment, or set this frame to always compile on device." + ) + + allow_cross_compile = cross_compilation_setting != "never" and build_environment_provider != "none" force_cross_compile = is_buildroot or cross_compilation_setting == "always" + allow_on_device_fallback = build_environment_provider == "none" or cross_compilation_setting == "never" binary_plan = await self.binary_builder.plan_build( allow_cross_compile=allow_cross_compile, force_cross_compile=force_cross_compile, + allow_on_device_fallback=allow_on_device_fallback, compilation_mode=compilation_mode, ) - settings = _get_frame_settings(self.db, self.frame) selected_keys = select_ssh_keys_for_frame(self.frame, settings) selected_public_keys = [key.get("public") for key in selected_keys if key.get("public")] known_public_keys = [key.get("public") for key in normalize_ssh_keys(settings) if key.get("public")] @@ -1219,8 +1229,25 @@ async def _install_setup_json_reset_helper(self, setup_json_reset_path: str) -> await self.deployer.exec_command(f"sudo systemctl enable {SETUP_JSON_RESET_SERVICE_NAME}", raise_on_error=False) async def _remove_setup_json_reset_helper(self) -> None: + status, _out, _err = await self.deployer.run_command( + "test -e /etc/systemd/system/frameos-firstboot-setup.service " + "|| test -e /usr/local/bin/frameos-setup-reset.sh " + "|| test -e /srv/frameos/build/frameos-firstboot-setup.service " + "|| test -e /srv/frameos/build/frameos-setup-reset.sh", + log_command=False, + log_output=False, + ) + if status != 0: + return + await self.deployer.log("stdout", f"{icon} Removing setup JSON reset helper") - await self.deployer.exec_command(f"sudo systemctl disable {SETUP_JSON_RESET_SERVICE_NAME}", raise_on_error=False) + await self.deployer.exec_command( + f"if systemctl list-unit-files --type=service --no-legend {SETUP_JSON_RESET_SERVICE_NAME} " + f"| grep -q '^{SETUP_JSON_RESET_SERVICE_NAME}'; then " + f"sudo systemctl disable {SETUP_JSON_RESET_SERVICE_NAME}; " + "fi", + raise_on_error=False, + ) await self._exec_host_root_command( f"rm -f {shlex.quote(SETUP_JSON_RESET_SERVICE_PATH)} {shlex.quote(SETUP_JSON_RESET_SCRIPT_PATH)}", fallback_command=f"sudo rm -f {shlex.quote(SETUP_JSON_RESET_SERVICE_PATH)} {shlex.quote(SETUP_JSON_RESET_SCRIPT_PATH)}", diff --git a/backend/app/tasks/tests/test_cross_compile.py b/backend/app/tasks/tests/test_cross_compile.py index c0f77e3e1..8a4374555 100644 --- a/backend/app/tasks/tests/test_cross_compile.py +++ b/backend/app/tasks/tests/test_cross_compile.py @@ -1,11 +1,13 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace import pytest from app.tasks.prebuilt_deps import PrebuiltEntry, resolve_prebuilt_target from app.utils.cross_compile import CrossCompiler, TargetMetadata +from app.utils.modal_sandbox import ModalSandboxConfig def make_cross_compiler( @@ -191,3 +193,70 @@ async def fake_exec_local_command(_db, _redis, _frame, _cmd, **_kwargs): assert "make -C quickjs clean" in script assert "make -C quickjs libquickjs.a" in script assert script.index("make -C quickjs libquickjs.a") < script.index("make -j\"$make_jobs\"") + + +@pytest.mark.asyncio +async def test_modal_toolchain_build_runs_directly_without_docker(tmp_path, monkeypatch: pytest.MonkeyPatch): + temp_dir = tmp_path / "tmp" + build_dir = tmp_path / "build" + temp_dir.mkdir() + build_dir.mkdir() + calls: list[tuple[str, str]] = [] + + class FakeModalSandboxSession: + def __init__(self, config, *, logger=None): + self.config = config + self.logger = logger + + async def __aenter__(self): + calls.append(("image", self.config.image)) + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def mktemp_dir(self, prefix: str = "frameos-cross-") -> str: + return "/tmp/frameos-cross-test" + + async def sync_dir_tarball(self, local_path: str, remote_path: str) -> None: + calls.append(("sync", f"{local_path}->{remote_path}")) + + async def write_file(self, remote_path: str, content: str, mode: int = 0o644) -> None: + calls.append(("write", remote_path)) + + async def run(self, command: str, **kwargs): + calls.append(("run", command)) + if "find drivers scenes" in command: + return 0, "", "" + return 0, "", "" + + async def download_file(self, remote_path: str, local_path: str) -> None: + calls.append(("download", f"{remote_path}->{local_path}")) + Path(local_path).write_text("binary") + + compiler = CrossCompiler( + db=None, + redis=None, + frame=SimpleNamespace(id=1), + deployer=SimpleNamespace(build_id="build12345678"), + target=TargetMetadata(arch="aarch64", distro="raspios", version="bookworm"), + temp_dir=str(temp_dir), + prebuilt_entry=None, + build_host=ModalSandboxConfig( + enabled=True, + token_id="ak-test", + token_secret="as-test", + image="frameos/frameos:latest", + ), + ) + + monkeypatch.setattr("app.utils.cross_compile.ModalSandboxSession", FakeModalSandboxSession) + + result = await compiler._run_docker_build(str(build_dir)) + + assert result == str(build_dir / "frameos") + image_calls = [value for kind, value in calls if kind == "image"] + assert image_calls[0].startswith("frameos/frameos-cross-toolchain:debian_bookworm-linux_arm64-latest") + run_commands = [value for kind, value in calls if kind == "run"] + assert any("bash /tmp/frameos-cross-test/build.sh" in command for command in run_commands) + assert all("docker " not in command for command in run_commands) diff --git a/backend/app/tasks/tests/test_frame_deploy_workflow.py b/backend/app/tasks/tests/test_frame_deploy_workflow.py index 3b6bf2952..0e3da1efe 100644 --- a/backend/app/tasks/tests/test_frame_deploy_workflow.py +++ b/backend/app/tasks/tests/test_frame_deploy_workflow.py @@ -87,6 +87,12 @@ async def exec_command(self, command: str, **kwargs) -> int: return 1 return 0 + async def run_command(self, command: str, **_kwargs) -> tuple[int, str, str]: + self.commands.append(command) + if "frameos-firstboot-setup.service" in command or "frameos-setup-reset.sh" in command: + return 1, "", "" + return 0, "", "" + async def restart_service(self, service: str) -> None: self.restarted_services.append(service) @@ -274,12 +280,13 @@ async def plan_build(self, **kwargs) -> FrameBinaryPlan: plan = await workflow.plan("full") assert captured_kwargs == [ - { - "allow_cross_compile": True, - "force_cross_compile": True, - "compilation_mode": "static", - } - ] + { + "allow_cross_compile": True, + "force_cross_compile": True, + "allow_on_device_fallback": False, + "compilation_mode": "static", + } + ] assert plan.full_deploy is not None assert plan.full_deploy.target["distro"] == "buildroot" assert plan.full_deploy.package_plans == [] @@ -360,12 +367,13 @@ async def plan_build(self, **kwargs) -> FrameBinaryPlan: assert plan.frame_dict["mode"] == "rpios" assert plan.frame_dict["ssh_user"] == "pi" assert captured_kwargs == [ - { - "allow_cross_compile": False, - "force_cross_compile": False, - "compilation_mode": "precompiled", - } - ] + { + "allow_cross_compile": False, + "force_cross_compile": False, + "allow_on_device_fallback": True, + "compilation_mode": "precompiled", + } + ] assert plan.full_deploy is not None assert plan.full_deploy.target["distro"] == "ubuntu" assert {pkg.name for pkg in plan.full_deploy.package_plans} >= {"hostapd", "imagemagick", "build-essential", "caddy"} @@ -1630,6 +1638,28 @@ def test_legacy_shared_driver_setup_segfault_guard_requires_successful_setup_out ) is False +@pytest.mark.asyncio +async def test_setup_json_reset_cleanup_skips_when_helper_absent(): + frame = SimpleNamespace(id=24, name="NoFirstbootHelper", mode="rpios") + deployer = RecordingDeployer() + deployer.db = None + deployer.redis = None + deployer.frame = frame + workflow = FrameDeployWorkflow( + db=None, + redis=None, + frame=frame, + deployer=deployer, + temp_dir="", + binary_builder=FakeBinaryBuilder(), + ) + + await workflow._remove_setup_json_reset_helper() + + assert not any("Removing setup JSON reset helper" in message for _level, message in deployer.logs) + assert not any("systemctl disable frameos-firstboot-setup.service" in command for command in deployer.commands) + + @pytest.mark.asyncio async def test_remote_build_uses_x86_feature_flags(monkeypatch: pytest.MonkeyPatch, tmp_path): archive_path = tmp_path / "build_build12345678.tar.gz" diff --git a/backend/app/utils/build_environment.py b/backend/app/utils/build_environment.py new file mode 100644 index 000000000..b842d2106 --- /dev/null +++ b/backend/app/utils/build_environment.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Literal + +from sqlalchemy.orm import Session + +from app.models.settings import get_settings_dict + +BuildEnvironmentProvider = Literal["none", "docker", "buildHost", "modal"] +BUILD_ENVIRONMENT_PROVIDERS: set[str] = {"none", "docker", "buildHost", "modal"} + + +def normalize_build_environment_provider(value: object) -> BuildEnvironmentProvider | None: + if value in BUILD_ENVIRONMENT_PROVIDERS: + return value # type: ignore[return-value] + return None + + +def selected_build_environment_provider(settings: dict | None) -> BuildEnvironmentProvider: + settings = settings or {} + raw = settings.get("buildEnvironment") + if isinstance(raw, dict): + provider = normalize_build_environment_provider(raw.get("provider")) + if provider: + return provider + + # Backward-compatible inference for existing installations before the + # provider became a single explicit choice. + modal = settings.get("modalSandbox") + if isinstance(modal, dict) and modal.get("enabled"): + return "modal" + build_host = settings.get("buildHost") + if isinstance(build_host, dict) and build_host.get("enabled"): + return "buildHost" + return "docker" + + +def get_selected_build_environment_provider( + db: Session | None, + project_id: int | None = None, +) -> BuildEnvironmentProvider: + if db is None or project_id is None: + return "docker" + return selected_build_environment_provider(get_settings_dict(db, project_id=project_id)) + + +def server_side_compilation_enabled(settings: dict | None) -> bool: + return selected_build_environment_provider(settings) != "none" diff --git a/backend/app/utils/build_host.py b/backend/app/utils/build_host.py index 77aac485d..9ae3f06a2 100644 --- a/backend/app/utils/build_host.py +++ b/backend/app/utils/build_host.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import Session from app.models.settings import get_settings_dict +from app.utils.build_environment import selected_build_environment_provider +from app.utils.modal_sandbox import ModalSandboxConfig, ModalSandboxSession, get_modal_sandbox_config LogFunc = Callable[[str, str], Awaitable[None]] @@ -45,9 +47,23 @@ def get_build_host_config(db: Session | None, project_id: int | None = None) -> if db is None: return None settings = get_settings_dict(db, project_id=project_id) + if selected_build_environment_provider(settings) != "buildHost": + return None return BuildHostConfig.from_settings(settings.get("buildHost")) +def get_build_executor_config(db: Session | None, project_id: int | None = None) -> BuildHostConfig | ModalSandboxConfig | None: + if db is None or project_id is None: + return None + return get_modal_sandbox_config(db, project_id) or get_build_host_config(db, project_id) + + +def build_executor_display_name(config: BuildHostConfig | ModalSandboxConfig) -> str: + if isinstance(config, ModalSandboxConfig): + return f"Modal sandbox app {config.app_name} ({config.image})" + return f"build host {config.user}@{config.host}:{config.port}" + + class BuildHostSession: def __init__( self, @@ -224,3 +240,13 @@ async def download_file(self, remote_path: str, local_path: str) -> None: raise RuntimeError("Build host session is not connected") Path(local_path).parent.mkdir(parents=True, exist_ok=True) await asyncssh.scp((self._conn, remote_path), local_path) + + +def create_build_executor_session( + config: BuildHostConfig | ModalSandboxConfig, + *, + logger: LogFunc | None = None, +) -> BuildHostSession | ModalSandboxSession: + if isinstance(config, ModalSandboxConfig): + return ModalSandboxSession(config, logger=logger) + return BuildHostSession(config, logger=logger) diff --git a/backend/app/utils/cross_compile.py b/backend/app/utils/cross_compile.py index ef6d08b41..6d480e7a4 100644 --- a/backend/app/utils/cross_compile.py +++ b/backend/app/utils/cross_compile.py @@ -8,7 +8,7 @@ import shutil import tarfile import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import lru_cache from pathlib import Path from textwrap import dedent, indent @@ -27,7 +27,13 @@ fetch_prebuilt_manifest, resolve_prebuilt_target, ) -from app.utils.build_host import BuildHostConfig, BuildHostSession +from app.utils.build_host import ( + BuildHostConfig, + BuildHostSession, + build_executor_display_name, + create_build_executor_session, +) +from app.utils.modal_sandbox import ModalSandboxConfig, ModalSandboxSession from app.utils.local_exec import exec_local_command icon = "🔶" @@ -168,7 +174,7 @@ def __init__( prebuilt_target: str | None = None, logger: LogFunc | None = None, build_dir: str | Path | None = None, - build_host: BuildHostConfig | None = None, + build_host: BuildHostConfig | ModalSandboxConfig | None = None, output_name: str = "frameos", compile_script_name: str = "compile_frameos.sh", needs_quickjs: bool = True, @@ -195,7 +201,7 @@ def __init__( self.prebuilt_timeout = PREBUILT_TIMEOUT self.logger = logger self.build_host = build_host - self._build_host_session: BuildHostSession | None = None + self._build_host_session: BuildHostSession | ModalSandboxSession | None = None self._remote_root: Path | None = None self.output_name = output_name self.compile_script_name = compile_script_name @@ -206,17 +212,17 @@ def __init__( (self.sysroot_dir / rel).mkdir(parents=True, exist_ok=True) async def build(self, source_dir: str) -> str: - if self.build_host: + if self.build_host and not isinstance(self.build_host, ModalSandboxConfig): await self._log( "stdout", - f"{icon} Connecting to build host {self.build_host.user}@{self.build_host.host}:{self.build_host.port}", + f"{icon} Connecting to {build_executor_display_name(self.build_host)}", ) - async with BuildHostSession(self.build_host, logger=self._log) as session: + async with create_build_executor_session(self.build_host, logger=self._log) as session: self._build_host_session = session self._remote_root = Path(await session.mktemp_dir("frameos-cross-")) await self._log( "stdout", - f"🟢 Connected to build host {self.build_host.user}@{self.build_host.host}:{self.build_host.port} for cross compilation", + f"Connected to {build_executor_display_name(self.build_host)} for cross compilation", ) try: return await self._build_with_context(source_dir) @@ -341,6 +347,9 @@ async def _run_docker_build(self, build_dir: str) -> str: image = await self._ensure_toolchain_image() + if isinstance(self.build_host, ModalSandboxConfig): + return await self._run_modal_toolchain_build(build_dir, script_content, image) + if self._build_host_session: return await self._run_remote_docker_build(build_dir, script_content, image) @@ -371,6 +380,58 @@ async def _run_docker_build(self, build_dir: str) -> str: raise RuntimeError(f"Cross compilation failed: {err or 'see logs'}") return os.path.join(build_dir, self.output_name) + async def _run_modal_toolchain_build( + self, build_dir: str, script_content: str, image: str + ) -> str: + if not isinstance(self.build_host, ModalSandboxConfig): + raise RuntimeError("Modal sandbox configuration unavailable during cross compilation") + + config = replace(self.build_host, image=image, enable_docker=False) + await self._log("stdout", f"{icon} Starting Modal cross-toolchain sandbox from {image}") + async with ModalSandboxSession(config, logger=self._log) as sandbox: + remote_root = Path(await sandbox.mktemp_dir("frameos-cross-")) + remote_build_dir = str(remote_root / "src") + remote_sysroot_dir = str(remote_root / "sysroot") + remote_script_path = str(remote_root / "build.sh") + + build_dir_size = self._dir_size_bytes(Path(build_dir)) + await self._log( + "stdout", + f"{icon} Syncing build directory ({self._format_size(build_dir_size)}) to Modal toolchain sandbox", + ) + await sandbox.sync_dir_tarball(build_dir, remote_build_dir) + sysroot_size = self._dir_size_bytes(self.sysroot_dir) + await self._log( + "stdout", + f"{icon} Syncing sysroot ({self._format_size(sysroot_size)}) to Modal toolchain sandbox", + ) + await sandbox.sync_dir_tarball(str(self.sysroot_dir), remote_sysroot_dir) + await sandbox.write_file(remote_script_path, script_content, mode=0o755) + + status, _out, err = await sandbox.run( + f"cd {shlex.quote(remote_build_dir)} && bash {shlex.quote(remote_script_path)}", + log_command="Modal cross-toolchain build", + ) + if status != 0: + raise RuntimeError(f"Cross compilation failed: {err or 'see logs'}") + + local_binary = os.path.join(build_dir, self.output_name) + await sandbox.download_file(f"{remote_build_dir}/{self.output_name}", local_binary) + status, stdout, _err = await sandbox.run( + f"cd {shlex.quote(remote_build_dir)} && find drivers scenes -type f -name '*.so' 2>/dev/null || true", + log_command=False, + log_output=False, + ) + if status == 0 and stdout: + for rel_path in stdout.splitlines(): + rel_path = rel_path.strip() + if not rel_path: + continue + local_path = os.path.join(build_dir, rel_path) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + await sandbox.download_file(f"{remote_build_dir}/{rel_path}", local_path) + return local_binary + async def _run_remote_docker_build( self, build_dir: str, script_content: str, image: str ) -> str: @@ -895,6 +956,8 @@ def _legacy_toolchain_image(self) -> str: async def _ensure_toolchain_image(self) -> str: image = self._toolchain_image() resolved_image = self._resolved_toolchain_image() + if isinstance(self.build_host, ModalSandboxConfig): + return resolved_image if not TOOLCHAIN_FORCE_LOCAL_BUILD: status, _out, _err = await self._run_command( f"docker image inspect {shlex.quote(resolved_image)} >/dev/null 2>&1", @@ -1029,7 +1092,7 @@ async def build_binary_with_cross_toolchain( prebuilt_target: str | None = None, target_override: TargetMetadata | None = None, logger: LogFunc | None = None, - build_host: BuildHostConfig | None = None, + build_host: BuildHostConfig | ModalSandboxConfig | None = None, ) -> str: arch: str | None distro: str | None diff --git a/backend/app/utils/local_exec.py b/backend/app/utils/local_exec.py index 4a7da4ca4..2d5bd4a75 100644 --- a/backend/app/utils/local_exec.py +++ b/backend/app/utils/local_exec.py @@ -1,10 +1,19 @@ from arq import ArqRedis import asyncio +from dataclasses import replace +import shlex from typing import Literal, Optional, Tuple from sqlalchemy.orm import Session from app.models.log import new_log as log from app.models.frame import Frame +from app.utils.modal_sandbox import ( + ModalSandboxConfig, + ModalSandboxSession, + get_modal_sandbox_config, + parse_docker_run_command, + sandbox_sync_paths_for_command, +) async def exec_local_command( db: Session | None, @@ -15,6 +24,19 @@ async def exec_local_command( log_output: bool = True, stderr_log_tag: Literal["stderr", "stdout"] = "stderr", ) -> Tuple[int, Optional[str], Optional[str]]: + project_id = getattr(frame, "project_id", None) + modal_config = get_modal_sandbox_config(db, project_id) if project_id is not None else None + if modal_config: + return await exec_modal_sandbox_command( + db, + redis, + frame, + command, + modal_config=modal_config, + log_command=log_command, + log_output=log_output, + stderr_log_tag=stderr_log_tag, + ) if log_command: if db and redis: @@ -91,3 +113,124 @@ async def _flush(segment: str, *, terminated: bool): "".join(out_buf) or None, "".join(err_buf) or None, ) + + +async def exec_modal_sandbox_command( + db: Session | None, + redis: ArqRedis | None, + frame: Frame, + command: str, + *, + modal_config, + log_command: str | bool = True, + log_output: bool = True, + stderr_log_tag: Literal["stderr", "stdout"] = "stderr", +) -> Tuple[int, Optional[str], Optional[str]]: + async def sandbox_log(level: str, message: str) -> None: + tag = "stdout" if level == "stderr" and stderr_log_tag == "stdout" else level + if db and redis: + await log(db, redis, int(frame.id), tag, message) + else: + print(message) + + docker_run = parse_docker_run_command(command) + if docker_run: + return await exec_modal_docker_run_command( + db, + redis, + frame, + command, + modal_config=modal_config, + log_command=log_command, + log_output=log_output, + stderr_log_tag=stderr_log_tag, + ) + + sync_paths = sandbox_sync_paths_for_command(command) + async with ModalSandboxSession(modal_config, logger=sandbox_log) as sandbox: + if sync_paths: + await sandbox_log( + "stdout", + f"Preparing Modal sandbox with {len(sync_paths)} local path" + + ("s" if len(sync_paths) != 1 else ""), + ) + for path in sync_paths: + if path.is_dir(): + await sandbox.sync_dir_tarball(str(path), str(path)) + elif path.is_file(): + await sandbox.sync_file(str(path), str(path)) + + status, out, err = await sandbox.run( + command, + log_command=log_command, + log_output=log_output, + ) + + for path in sync_paths: + if path.is_dir(): + await sandbox.download_dir_tarball(str(path), str(path)) + elif path.is_file(): + await sandbox.download_file(str(path), str(path)) + + return status, out, err + + +async def exec_modal_docker_run_command( + db: Session | None, + redis: ArqRedis | None, + frame: Frame, + command: str, + *, + modal_config: ModalSandboxConfig, + log_command: str | bool = True, + log_output: bool = True, + stderr_log_tag: Literal["stderr", "stdout"] = "stderr", +) -> Tuple[int, Optional[str], Optional[str]]: + docker_run = parse_docker_run_command(command) + if docker_run is None: + raise RuntimeError("Expected a docker run command") + + async def sandbox_log(level: str, message: str) -> None: + tag = "stdout" if level == "stderr" and stderr_log_tag == "stdout" else level + if db and redis: + await log(db, redis, int(frame.id), tag, message) + else: + print(message) + + config = replace(modal_config, image=docker_run.image, enable_docker=False) + args = docker_run.args or [] + run_command = " ".join(shlex.quote(arg) for arg in args) if args else "true" + if docker_run.workdir: + run_command = f"cd {shlex.quote(docker_run.workdir)} && {run_command}" + if docker_run.env: + exports = " ".join(f"{key}={shlex.quote(value)}" for key, value in docker_run.env.items()) + run_command = f"export {exports}; {run_command}" + + async with ModalSandboxSession(config, logger=sandbox_log) as sandbox: + if docker_run.mounts: + await sandbox_log( + "stdout", + f"Preparing Modal sandbox from {docker_run.image} with {len(docker_run.mounts)} mount" + + ("s" if len(docker_run.mounts) != 1 else ""), + ) + for mount in docker_run.mounts: + if mount.source.is_dir(): + await sandbox.sync_dir_tarball(str(mount.source), mount.target) + elif mount.source.is_file(): + await sandbox.sync_file(str(mount.source), mount.target) + + status, out, err = await sandbox.run( + run_command, + log_command=log_command if log_command is not True else f"Modal run {docker_run.image}", + log_output=log_output, + ) + + for mount in docker_run.mounts: + if mount.read_only: + continue + if mount.source.is_dir(): + await sandbox.download_dir_tarball(mount.target, str(mount.source)) + elif mount.source.is_file(): + await sandbox.download_file(mount.target, str(mount.source)) + + return status, out, err diff --git a/backend/app/utils/modal_sandbox.py b/backend/app/utils/modal_sandbox.py new file mode 100644 index 000000000..065969ba5 --- /dev/null +++ b/backend/app/utils/modal_sandbox.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import asyncio +import inspect +import os +import re +import shlex +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Awaitable, Callable + +from sqlalchemy.orm import Session + +from app.models.settings import get_settings_dict +from app.utils.build_environment import selected_build_environment_provider + +LogFunc = Callable[[str, str], Awaitable[None]] + +REPO_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_MODAL_APP_NAME = os.environ.get("FRAMEOS_MODAL_SANDBOX_APP", "frameos-build") +DEFAULT_MODAL_IMAGE = os.environ.get("FRAMEOS_MODAL_SANDBOX_IMAGE", "frameos/frameos:latest") +DEFAULT_MODAL_TIMEOUT = int(os.environ.get("FRAMEOS_MODAL_SANDBOX_TIMEOUT", str(6 * 60 * 60))) +DEFAULT_MODAL_IDLE_TIMEOUT = int(os.environ.get("FRAMEOS_MODAL_SANDBOX_IDLE_TIMEOUT", str(15 * 60))) +FRAMEOS_SANDBOX_PATH = "/opt/nim/bin:/root/.nimble/bin:/app/backend/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +PATH_TOKEN_RE = re.compile(r"(? ModalSandboxConfig | None: + if not isinstance(raw, dict): + return None + enabled = bool(raw.get("enabled")) + token_id = str(raw.get("tokenId") or raw.get("token_id") or "").strip() + token_secret = str(raw.get("tokenSecret") or raw.get("token_secret") or "").strip() + if not enabled or not token_id or not token_secret: + return None + + def optional_int(key: str, default: int) -> int: + try: + return int(raw.get(key) or default) + except (TypeError, ValueError): + return default + + def optional_float(key: str) -> float | None: + value = raw.get(key) + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + return cls( + token_id=token_id, + token_secret=token_secret, + app_name=str(raw.get("appName") or raw.get("app_name") or DEFAULT_MODAL_APP_NAME).strip() + or DEFAULT_MODAL_APP_NAME, + image=str(raw.get("image") or DEFAULT_MODAL_IMAGE).strip() or DEFAULT_MODAL_IMAGE, + enabled=True, + timeout=optional_int("timeout", DEFAULT_MODAL_TIMEOUT), + idle_timeout=optional_int("idleTimeout", optional_int("idle_timeout", DEFAULT_MODAL_IDLE_TIMEOUT)), + cpu=optional_float("cpu"), + memory=optional_int("memory", 0) or None, + region=str(raw.get("region") or "").strip() or None, + cloud=str(raw.get("cloud") or "").strip() or None, + environment_name=str(raw.get("environmentName") or raw.get("environment_name") or "").strip() or None, + enable_docker=raw.get("enableDocker", raw.get("enable_docker", True)) is not False, + ) + + +@dataclass(slots=True) +class DockerRunMount: + source: Path + target: str + read_only: bool = False + + +@dataclass(slots=True) +class DockerRunSpec: + image: str + args: list[str] + mounts: list[DockerRunMount] + env: dict[str, str] + workdir: str | None = None + platform: str | None = None + + +def get_modal_sandbox_config(db: Session | None, project_id: int | None = None) -> ModalSandboxConfig | None: + if db is None or project_id is None: + return None + settings = get_settings_dict(db, project_id=project_id) + if selected_build_environment_provider(settings) != "modal": + return None + return ModalSandboxConfig.from_settings(settings.get("modalSandbox")) + + +def parse_docker_run_command(command: str) -> DockerRunSpec | None: + try: + tokens = shlex.split(command) + except ValueError: + return None + if len(tokens) < 3 or tokens[0:2] != ["docker", "run"]: + return None + + mounts: list[DockerRunMount] = [] + env: dict[str, str] = {} + workdir: str | None = None + platform: str | None = None + index = 2 + while index < len(tokens): + token = tokens[index] + if token == "--": + index += 1 + break + if token in {"--rm", "-i", "-t", "-it", "--init"}: + index += 1 + continue + if token in {"-v", "--volume"} and index + 1 < len(tokens): + mount = _parse_docker_mount(tokens[index + 1]) + if mount: + mounts.append(mount) + index += 2 + continue + if token.startswith("--volume="): + mount = _parse_docker_mount(token.split("=", 1)[1]) + if mount: + mounts.append(mount) + index += 1 + continue + if token.startswith("-v") and len(token) > 2: + mount = _parse_docker_mount(token[2:]) + if mount: + mounts.append(mount) + index += 1 + continue + if token in {"-e", "--env"} and index + 1 < len(tokens): + _add_docker_env(env, tokens[index + 1]) + index += 2 + continue + if token.startswith("--env="): + _add_docker_env(env, token.split("=", 1)[1]) + index += 1 + continue + if token in {"-w", "--workdir"} and index + 1 < len(tokens): + workdir = tokens[index + 1] + index += 2 + continue + if token.startswith("--workdir="): + workdir = token.split("=", 1)[1] + index += 1 + continue + if token == "--platform" and index + 1 < len(tokens): + platform = tokens[index + 1] + index += 2 + continue + if token.startswith("--platform="): + platform = token.split("=", 1)[1] + index += 1 + continue + if token in {"--ulimit", "--name", "--user", "-u", "--entrypoint"} and index + 1 < len(tokens): + index += 2 + continue + if token.startswith("--ulimit=") or token.startswith("--name=") or token.startswith("--user=") or token.startswith("--entrypoint="): + index += 1 + continue + if token.startswith("-"): + return None + image = token + return DockerRunSpec(image=image, args=tokens[index + 1 :], mounts=mounts, env=env, workdir=workdir, platform=platform) + return None + + +def _parse_docker_mount(value: str) -> DockerRunMount | None: + parts = value.split(":") + if len(parts) < 2 or not parts[0].startswith("/") or not parts[1].startswith("/"): + return None + options = set(parts[2].split(",")) if len(parts) > 2 else set() + return DockerRunMount(source=Path(parts[0]), target=parts[1], read_only="ro" in options) + + +def _add_docker_env(env: dict[str, str], value: str) -> None: + if "=" in value: + key, env_value = value.split("=", 1) + if key: + env[key] = env_value + elif value in os.environ: + env[value] = os.environ[value] + + +async def _maybe_await(value): + if hasattr(value, "__await__"): + return await value + return value + + +async def _call_modal(method, *args, **kwargs): + aio = getattr(method, "aio", None) + if aio is not None: + return await aio(*args, **kwargs) + result = method(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result + + +class ModalSandboxSession: + def __init__(self, config: ModalSandboxConfig, *, logger: LogFunc | None = None) -> None: + self.config = config + self._logger = logger + self._modal = None + self._client = None + self._sandbox = None + self._cleanup_paths: list[str] = [] + + async def __aenter__(self) -> "ModalSandboxSession": + await self._connect() + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 + if self._sandbox: + try: + await _call_modal(self._sandbox.terminate, wait=True) + except Exception: + pass + try: + await _call_modal(self._sandbox.detach) + except Exception: + pass + + async def _connect(self) -> None: + try: + import modal + except ImportError as exc: # pragma: no cover - depends on deployment deps + raise RuntimeError("Modal sandbox execution requires the 'modal' Python package") from exc + + self._modal = modal + self._client = modal.Client.from_credentials(self.config.token_id, self.config.token_secret) + app = await _call_modal( + modal.App.lookup, + self.config.app_name, + client=self._client, + environment_name=self.config.environment_name, + create_if_missing=True, + ) + image = ( + modal.Image.from_registry( + self.config.image, + setup_dockerfile_commands=[f"ENV PATH={FRAMEOS_SANDBOX_PATH}"], + ) + if self.config.image + else None + ) + kwargs = { + "app": app, + "client": self._client, + "image": image, + "env": {"PATH": FRAMEOS_SANDBOX_PATH, "HOME": "/root"}, + "timeout": self.config.timeout, + "idle_timeout": self.config.idle_timeout, + "cpu": self.config.cpu, + "memory": self.config.memory, + "region": self.config.region, + "cloud": self.config.cloud, + "environment_name": self.config.environment_name, + "experimental_options": {"enable_docker": True} if self.config.enable_docker else None, + } + kwargs = {key: value for key, value in kwargs.items() if value is not None} + self._sandbox = await _call_modal(modal.Sandbox.create, "sleep", str(self.config.timeout), **kwargs) + + async def _log(self, level: str, message: str) -> None: + if self._logger: + await self._logger(level, message) + + async def run( + self, + command: str, + *, + log_command: str | bool = True, + log_output: bool = True, + ) -> tuple[int, str | None, str | None]: + if not self._sandbox: + raise RuntimeError("Modal sandbox session is not connected") + + if log_command: + await self._log("stdout", f"$ {log_command if isinstance(log_command, str) else command}") + + wrapped_command = f"export PATH={shlex.quote(FRAMEOS_SANDBOX_PATH)} HOME=/root; {command}" + proc = await _call_modal(self._sandbox.exec, "bash", "-lc", wrapped_command, timeout=self.config.timeout) + out_buf: list[str] = [] + err_buf: list[str] = [] + + async def pump(stream, level: str, buf: list[str]) -> None: + pending = "" + async for chunk in stream: + text = chunk.decode("utf-8", errors="replace") if isinstance(chunk, (bytes, bytearray)) else str(chunk) + pending += text + while True: + split_index = pending.find("\n") + if split_index == -1: + break + segment = pending[:split_index].rstrip("\r") + pending = pending[split_index + 1 :] + buf.append(f"{segment}\n") + if log_output and segment: + await self._log(level, segment) + pending = pending.rstrip("\r") + if pending: + buf.append(pending) + if log_output: + await self._log(level, pending) + + await asyncio.gather(pump(proc.stdout, "stdout", out_buf), pump(proc.stderr, "stderr", err_buf)) + return_code = await _call_modal(proc.wait) + if return_code and log_output: + await self._log("exit_status", f"The command exited with status {return_code}") + return int(return_code or 0), "".join(out_buf) or None, "".join(err_buf) or None + + async def mktemp_dir(self, prefix: str = "frameos-build-") -> str: + status, out, _err = await self.run(f"mktemp -d -p /tmp {prefix}XXXXXX", log_command=False, log_output=False) + if status != 0 or not out: + raise RuntimeError("Failed to allocate temporary directory in Modal sandbox") + path = out.strip().splitlines()[-1] + self._cleanup_paths.append(path) + return path + + async def ensure_dir(self, remote_path: str) -> None: + await self.run(f"mkdir -p {shlex.quote(remote_path)}", log_command=False, log_output=False) + + async def remove_path(self, remote_path: str) -> None: + await self.run(f"rm -rf {shlex.quote(remote_path)}", log_command=False, log_output=False) + + async def sync_dir_tarball(self, local_path: str, remote_path: str) -> None: + local = Path(local_path) + if not local.exists(): + return + await self.ensure_dir(str(Path(remote_path).parent)) + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: + archive_path = Path(tmp.name) + try: + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(local, arcname=".") + remote_archive = f"{remote_path}.tar.gz" + await self.remove_path(remote_path) + await self._copy_from_local(str(archive_path), remote_archive) + status, _out, err = await self.run( + " ".join( + [ + "mkdir -p", + shlex.quote(remote_path), + "&& tar -xzf", + shlex.quote(remote_archive), + "-C", + shlex.quote(remote_path), + "&& rm -f", + shlex.quote(remote_archive), + ] + ), + log_command=False, + log_output=False, + ) + if status != 0: + raise RuntimeError(f"Failed to extract Modal sandbox archive: {err or 'see logs'}") + finally: + archive_path.unlink(missing_ok=True) + + async def sync_file(self, local_path: str, remote_path: str) -> None: + if not Path(local_path).is_file(): + return + await self.ensure_dir(str(Path(remote_path).parent)) + await self._copy_from_local(local_path, remote_path) + + async def write_file(self, remote_path: str, content: str, mode: int = 0o644) -> None: + await self.ensure_dir(str(Path(remote_path).parent)) + await _call_modal(self._sandbox.filesystem.write_text, content, remote_path) + await self.run(f"chmod {oct(mode)[2:]} {shlex.quote(remote_path)}", log_command=False, log_output=False) + + async def download_file(self, remote_path: str, local_path: str) -> None: + Path(local_path).parent.mkdir(parents=True, exist_ok=True) + await _call_modal(self._sandbox.filesystem.copy_to_local, remote_path, local_path) + + async def download_dir_tarball(self, remote_path: str, local_path: str) -> None: + local = Path(local_path) + with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: + archive_path = Path(tmp.name) + remote_archive = f"/tmp/frameos-download-{os.getpid()}-{abs(hash(remote_path))}.tar.gz" + try: + status, _out, err = await self.run( + f"test -e {shlex.quote(remote_path)} && tar -czf {shlex.quote(remote_archive)} -C {shlex.quote(remote_path)} .", + log_command=False, + log_output=False, + ) + if status != 0: + return + await self.download_file(remote_archive, str(archive_path)) + if local.exists() and local.is_dir(): + for child in local.iterdir(): + if child.is_dir(): + import shutil + + shutil.rmtree(child) + else: + child.unlink() + local.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "r:gz") as tar: + tar.extractall(local) + finally: + archive_path.unlink(missing_ok=True) + await self.remove_path(remote_archive) + + async def _copy_from_local(self, local_path: str, remote_path: str) -> None: + await _call_modal(self._sandbox.filesystem.copy_from_local, local_path, remote_path) + + +def _path_is_allowed(path: Path) -> bool: + try: + resolved = path.resolve() + except OSError: + return False + resolved_str = str(resolved) + if any(resolved_str == prefix or resolved_str.startswith(f"{prefix}/") for prefix in SKIP_PATH_PREFIXES): + return False + if resolved_str == "/tmp" or resolved_str == "/private/tmp": + return False + if resolved_str.startswith(str(REPO_ROOT)): + return True + tmpdir = os.environ.get("TMPDIR") + if tmpdir and resolved_str.startswith(str(Path(tmpdir).resolve())): + return True + return resolved_str.startswith("/tmp/") or resolved_str.startswith("/private/tmp/") + + +def _docker_mount_sources(command: str) -> set[Path]: + paths: set[Path] = set() + try: + tokens = shlex.split(command) + except ValueError: + return paths + for index, token in enumerate(tokens): + value = "" + if token in {"-v", "--volume"} and index + 1 < len(tokens): + value = tokens[index + 1] + elif token.startswith("-v") and len(token) > 2: + value = token[2:] + elif token.startswith("--volume="): + value = token.split("=", 1)[1] + if not value: + continue + source = value.split(":", 1)[0] + if source.startswith("/"): + paths.add(Path(source)) + return paths + + +def _absolute_paths(command: str) -> set[Path]: + paths: set[Path] = set() + for match in PATH_TOKEN_RE.finditer(command): + raw = match.group(0).rstrip(".,;:)") + if raw: + paths.add(Path(raw)) + return paths + + +def sandbox_sync_paths_for_command(command: str) -> list[Path]: + candidates = _docker_mount_sources(command) | _absolute_paths(command) + existing: list[tuple[Path, Path]] = [] + for candidate in candidates: + path = candidate + while not path.exists() and path != path.parent: + path = path.parent + if not path.exists() or not _path_is_allowed(path): + continue + try: + resolved = path.resolve() + except OSError: + continue + if any( + resolved == existing_resolved or str(resolved).startswith(f"{existing_resolved}/") + for _existing_path, existing_resolved in existing + ): + continue + existing = [ + (existing_path, existing_resolved) + for existing_path, existing_resolved in existing + if not str(existing_resolved).startswith(f"{resolved}/") + ] + existing.append((path, resolved)) + return [path for path, _resolved in sorted(existing, key=lambda item: len(str(item[0])))] diff --git a/backend/app/utils/system_info.py b/backend/app/utils/system_info.py index 36228b3b9..a22025a13 100644 --- a/backend/app/utils/system_info.py +++ b/backend/app/utils/system_info.py @@ -52,6 +52,13 @@ class DatabaseUsage: exists: bool +@dataclass(frozen=True) +class DockerInfo: + cli_available: bool + daemon_available: bool + error: str | None = None + + def _safe_stat(path: Path) -> int: try: return path.stat().st_size @@ -174,13 +181,36 @@ def get_cache_usage() -> list[CacheUsage]: return caches -def get_system_info() -> tuple[DiskUsage, list[CacheUsage], DatabaseUsage, MemoryInfo, LoadAverage]: +def get_docker_info() -> DockerInfo: + if shutil.which("docker") is None: + return DockerInfo(cli_available=False, daemon_available=False, error="Docker CLI is not installed") + try: + result = subprocess.run( + ["docker", "info"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except Exception as exc: # noqa: BLE001 + return DockerInfo(cli_available=True, daemon_available=False, error=str(exc)) + if result.returncode == 0: + return DockerInfo(cli_available=True, daemon_available=True) + return DockerInfo( + cli_available=True, + daemon_available=False, + error=(result.stderr or result.stdout or "Docker daemon is not reachable").strip(), + ) + + +def get_system_info() -> tuple[DiskUsage, list[CacheUsage], DatabaseUsage, MemoryInfo, LoadAverage, DockerInfo]: disk = get_disk_usage() caches = get_cache_usage() database = get_database_usage() memory = get_memory_info() load = get_load_average() - return disk, caches, database, memory, load + docker = get_docker_info() + return disk, caches, database, memory, load, docker def get_system_metrics() -> tuple[DiskUsage, MemoryInfo, LoadAverage]: diff --git a/backend/app/utils/tests/test_build_environment.py b/backend/app/utils/tests/test_build_environment.py new file mode 100644 index 000000000..6d916083a --- /dev/null +++ b/backend/app/utils/tests/test_build_environment.py @@ -0,0 +1,28 @@ +from app.utils.build_environment import selected_build_environment_provider + + +def test_selected_build_environment_defaults_to_docker(): + assert selected_build_environment_provider({}) == "docker" + + +def test_selected_build_environment_uses_explicit_provider(): + assert selected_build_environment_provider({"buildEnvironment": {"provider": "none"}}) == "none" + assert selected_build_environment_provider({"buildEnvironment": {"provider": "modal"}}) == "modal" + + +def test_selected_build_environment_infers_legacy_settings(): + assert selected_build_environment_provider({"modalSandbox": {"enabled": True}}) == "modal" + assert selected_build_environment_provider({"buildHost": {"enabled": True}}) == "buildHost" + + +def test_selected_build_environment_explicit_provider_wins_over_legacy_flags(): + assert ( + selected_build_environment_provider( + { + "buildEnvironment": {"provider": "docker"}, + "modalSandbox": {"enabled": True}, + "buildHost": {"enabled": True}, + } + ) + == "docker" + ) diff --git a/backend/app/utils/tests/test_build_host.py b/backend/app/utils/tests/test_build_host.py index 176c1810b..b258ec2d2 100644 --- a/backend/app/utils/tests/test_build_host.py +++ b/backend/app/utils/tests/test_build_host.py @@ -3,7 +3,8 @@ import pytest from app.models.settings import Settings -from app.utils.build_host import BuildHostConfig, BuildHostSession, get_build_host_config +from app.utils.build_host import BuildHostConfig, BuildHostSession, get_build_executor_config, get_build_host_config +from app.utils.modal_sandbox import ModalSandboxConfig @pytest.mark.asyncio @@ -52,6 +53,74 @@ async def test_get_build_host_config_requires_fields(db, default_project): assert get_build_host_config(db, default_project.id) is None +@pytest.mark.asyncio +async def test_get_build_executor_config_prefers_modal_sandbox(db, default_project): + db.query(Settings).delete() + db.add( + Settings( + project_id=default_project.id, + key="buildHost", + value={ + "enabled": True, + "host": "builder.local", + "user": "ubuntu", + "sshKey": "dummy-key", + }, + ) + ) + db.add( + Settings( + project_id=default_project.id, + key="modalSandbox", + value={ + "enabled": True, + "tokenId": "ak-test", + "tokenSecret": "as-test", + "appName": "frameos-test", + "image": "frameos/frameos:test", + }, + ) + ) + db.commit() + + config = get_build_executor_config(db, default_project.id) + assert isinstance(config, ModalSandboxConfig) + assert config.app_name == "frameos-test" + assert config.image == "frameos/frameos:test" + + +@pytest.mark.asyncio +async def test_get_build_executor_config_uses_selected_provider(db, default_project): + db.query(Settings).delete() + db.add(Settings(project_id=default_project.id, key="buildEnvironment", value={"provider": "docker"})) + db.add( + Settings( + project_id=default_project.id, + key="buildHost", + value={ + "enabled": True, + "host": "builder.local", + "user": "ubuntu", + "sshKey": "dummy-key", + }, + ) + ) + db.add( + Settings( + project_id=default_project.id, + key="modalSandbox", + value={ + "enabled": True, + "tokenId": "ak-test", + "tokenSecret": "as-test", + }, + ) + ) + db.commit() + + assert get_build_executor_config(db, default_project.id) is None + + @pytest.mark.asyncio async def test_build_host_run_preserves_buffered_stdout_line_breaks(): class FakeStream: diff --git a/backend/app/utils/tests/test_modal_sandbox.py b/backend/app/utils/tests/test_modal_sandbox.py new file mode 100644 index 000000000..9ee4fc147 --- /dev/null +++ b/backend/app/utils/tests/test_modal_sandbox.py @@ -0,0 +1,128 @@ +from pathlib import Path + +import pytest + +from app.utils.modal_sandbox import ( + FRAMEOS_SANDBOX_PATH, + ModalSandboxConfig, + ModalSandboxSession, + parse_docker_run_command, + sandbox_sync_paths_for_command, +) + + +def test_modal_sandbox_config_requires_credentials(): + assert ModalSandboxConfig.from_settings({"enabled": True, "tokenId": "ak-test"}) is None + assert ModalSandboxConfig.from_settings({"enabled": False, "tokenId": "ak-test", "tokenSecret": "as-test"}) is None + + +def test_modal_sandbox_config_parses_settings(): + config = ModalSandboxConfig.from_settings( + { + "enabled": True, + "tokenId": "ak-test", + "tokenSecret": "as-test", + "appName": "frameos-custom", + "image": "frameos/frameos:custom", + "timeout": "120", + "idleTimeout": "30", + "cpu": "4", + "memory": "8192", + "enableDocker": False, + } + ) + + assert isinstance(config, ModalSandboxConfig) + assert config.app_name == "frameos-custom" + assert config.image == "frameos/frameos:custom" + assert config.timeout == 120 + assert config.idle_timeout == 30 + assert config.cpu == 4 + assert config.memory == 8192 + assert config.enable_docker is False + + +def test_sandbox_sync_paths_for_docker_mounts(tmp_path): + src = tmp_path / "src" + cache = tmp_path / "cache" + src.mkdir() + cache.mkdir() + + command = f"docker run --rm -v {src}:/src -v {cache}:/cache image sh -lc 'echo ok'" + paths = sandbox_sync_paths_for_command(command) + + assert tmp_path in paths + + +def test_sandbox_sync_paths_preserves_command_path_spelling(tmp_path): + real_root = tmp_path / "private" / "var" / "folders" / "frameos-build" + real_root.mkdir(parents=True) + alias_root = tmp_path / "var" + alias_root.symlink_to(tmp_path / "private" / "var") + command_path = alias_root / "folders" / "frameos-build" + + command = f"cd {command_path} && nimble setup" + paths = sandbox_sync_paths_for_command(command) + + assert command_path in paths + assert real_root.resolve() not in paths + + +def test_parse_docker_run_command_extracts_modal_sandbox_shape(tmp_path): + work = tmp_path / "work" + cache = tmp_path / "cache" + command = ( + f"docker run --rm --platform linux/arm64 -v {work}:/work -v {cache}:/cache:ro " + "-e FORCE_UNSAFE_CONFIGURE=1 -w /work frameos/frameos-buildroot:latest bash /work/build.sh" + ) + + spec = parse_docker_run_command(command) + + assert spec is not None + assert spec.image == "frameos/frameos-buildroot:latest" + assert spec.platform == "linux/arm64" + assert spec.workdir == "/work" + assert spec.env == {"FORCE_UNSAFE_CONFIGURE": "1"} + assert [(mount.source, mount.target, mount.read_only) for mount in spec.mounts] == [ + (work, "/work", False), + (cache, "/cache", True), + ] + assert spec.args == ["bash", "/work/build.sh"] + + +@pytest.mark.asyncio +async def test_modal_sandbox_run_exports_frameos_path(monkeypatch): + calls = [] + + class FakeStream: + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + class FakeProcess: + stdout = FakeStream() + stderr = FakeStream() + + def wait(self): + return 0 + + class FakeSandbox: + def exec(self, *args, **kwargs): + calls.append((args, kwargs)) + return FakeProcess() + + session = ModalSandboxSession( + ModalSandboxConfig(enabled=True, token_id="ak-test", token_secret="as-test"), + ) + session._sandbox = FakeSandbox() + + status, _out, _err = await session.run("nimble --version", log_command=False) + + assert status == 0 + args, kwargs = calls[0] + assert args[:2] == ("bash", "-lc") + assert f"export PATH={FRAMEOS_SANDBOX_PATH}" in args[2] + assert args[2].endswith("; nimble --version") + assert kwargs["timeout"] == session.config.timeout diff --git a/backend/requirements.docker.in b/backend/requirements.docker.in index 4396ca876..3b79286cb 100644 --- a/backend/requirements.docker.in +++ b/backend/requirements.docker.in @@ -9,6 +9,7 @@ email_validator fastapi[standard] fonttools jwt +modal>=1.4.0 openai packaging pillow diff --git a/backend/requirements.in b/backend/requirements.in index 11553dab7..3941a7a60 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -8,6 +8,7 @@ fonttools honcho jwt mypy +modal>=1.4.0 packaging pillow pip-tools diff --git a/backend/requirements.txt b/backend/requirements.txt index 82e17557d..11336c190 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,12 @@ # aiofiles==24.1.0 # via -r requirements.in +aiohappyeyeballs==2.6.2 + # via aiohttp +aiohttp==3.14.0 + # via modal +aiosignal==1.4.0 + # via aiohttp alembic==1.11.2 # via -r requirements.in annotated-types==0.7.0 @@ -20,14 +26,19 @@ arq==0.26.1 # via -r requirements.in asyncssh==2.19.0 # via -r requirements.in +attrs==26.1.0 + # via aiohttp backoff==2.2.1 # via posthog build==1.0.3 # via pip-tools +cbor2==6.1.2 + # via modal certifi==2023.7.22 # via # httpcore # httpx + # modal # requests cffi==1.15.1 # via cryptography @@ -38,6 +49,7 @@ charset-normalizer==3.2.0 click==8.1.7 # via # arq + # modal # pip-tools # rich-toolkit # typer @@ -70,14 +82,24 @@ filelock==3.13.1 # via virtualenv fonttools==4.55.3 # via -r requirements.in +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +grpclib==0.4.9 + # via modal h11==0.14.0 # via # httpcore # uvicorn +h2==4.3.0 + # via grpclib hiredis==3.1.0 # via redis honcho==1.1.0 # via -r requirements.in +hpack==4.1.0 + # via h2 httpcore==1.0.7 # via httpx httptools==0.6.4 @@ -86,6 +108,8 @@ httpx==0.28.1 # via # fastapi # openai +hyperframe==6.1.0 + # via h2 identify==2.5.33 # via pre-commit idna==3.4 @@ -94,6 +118,7 @@ idna==3.4 # email-validator # httpx # requests + # yarl iniconfig==2.0.0 # via pytest jinja2==3.1.2 @@ -113,6 +138,13 @@ markupsafe==2.1.3 # werkzeug mdurl==0.1.2 # via markdown-it-py +modal==1.4.3 + # via -r requirements.in +multidict==6.7.1 + # via + # aiohttp + # grpclib + # yarl mypy==1.13.0 # via -r requirements.in mypy-extensions==1.0.0 @@ -138,6 +170,12 @@ posthog==7.5.1 # via -r requirements.in pre-commit==3.6.0 # via -r requirements.in +propcache==0.5.2 + # via + # aiohttp + # yarl +protobuf==6.33.6 + # via modal pyasn1==0.6.1 # via # python-jose @@ -185,6 +223,7 @@ requests==2.31.0 # posthog rich==13.9.4 # via + # modal # rich-toolkit # typer rich-toolkit==0.12.0 @@ -213,10 +252,16 @@ sqlmodel==0.0.22 # via -r requirements.in starlette==0.41.3 # via fastapi +synchronicity==0.12.3 + # via modal +toml==0.10.2 + # via modal tqdm==4.67.1 # via openai typer==0.15.1 # via fastapi-cli +types-certifi==2021.10.8.3 + # via modal types-cffi==1.16.0.20240331 # via types-pyopenssl types-pillow==10.2.0.20240822 @@ -233,12 +278,17 @@ types-requests==2.32.0.20241016 # via -r requirements.in types-setuptools==75.6.0.20241126 # via types-cffi +types-toml==0.10.8.20260518 + # via modal typing-extensions==4.12.2 # via + # aiohttp + # aiosignal # alembic # anyio # asyncssh # fastapi + # modal # mypy # openai # posthog @@ -246,6 +296,7 @@ typing-extensions==4.12.2 # pydantic-core # rich-toolkit # sqlalchemy + # synchronicity # typer urllib3==2.0.4 # via @@ -260,13 +311,17 @@ uvloop==0.21.0 virtualenv==20.25.0 # via pre-commit watchfiles==1.0.3 - # via uvicorn + # via + # modal + # uvicorn websockets==14.1 # via uvicorn werkzeug==3.1.3 # via -r requirements.in wheel==0.41.3 # via pip-tools +yarl==1.24.2 + # via aiohttp # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/frontend/src/index.css b/frontend/src/index.css index 9458ba5e8..48a5225eb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1597,7 +1597,58 @@ html[data-frameos-theme='dark'] .app-compiled-warning { } .settings-page-actions { + position: fixed; + top: 1.5rem; + right: 2rem; + z-index: 50; flex-wrap: wrap; + max-width: min(34rem, calc(100vw - var(--workspace-main-offset, 0px) - 3rem)); + padding: 0.5rem; + border: 1px solid var(--tool-border); + border-radius: 0.875rem; + background: var(--tool-bg-strong); + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14); + backdrop-filter: blur(16px); +} + +.frameos-app-shell .frameos-settings-nav-link-active { + background: var(--frameos-primary-soft); + color: var(--frameos-primary-text); +} + +.settings-nav-divider { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.settings-nav-divider::after { + content: ''; + min-width: 0; + flex: 1; + height: 1px; + background: color-mix(in srgb, currentColor 30%, transparent); +} + +.settings-group-divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 2rem 0 0.75rem; + color: var(--frameos-muted-text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-group-divider::before, +.settings-group-divider::after { + content: ''; + min-width: 0; + flex: 1; + height: 1px; + background: color-mix(in srgb, currentColor 28%, transparent); } @media (min-width: 520px) { @@ -1612,6 +1663,36 @@ html[data-frameos-theme='dark'] .app-compiled-warning { } } +@media (min-width: 640px) and (max-width: 1023px) { + .settings-page-actions { + top: 4.8rem; + right: 1rem; + left: auto; + max-width: none; + justify-content: flex-end; + } +} + +@media (max-width: 639px) { + .settings-page-actions { + top: 4.8rem; + right: 0.75rem; + left: auto; + max-width: none; + justify-content: flex-end; + } +} + +@media (max-width: 499px) { + .settings-page-header { + padding-top: 3.75rem; + } + + .settings-page-actions { + top: 4.65rem; + } +} + .workspace-main-with-right-panel .terminal-command-bar { right: 520px; } diff --git a/frontend/src/scenes/frame/frameDeployUtils.ts b/frontend/src/scenes/frame/frameDeployUtils.ts index 671b35dde..a3657387d 100644 --- a/frontend/src/scenes/frame/frameDeployUtils.ts +++ b/frontend/src/scenes/frame/frameDeployUtils.ts @@ -38,6 +38,7 @@ export interface FullDeployPlanResponse { will_attempt_precompiled?: boolean cross_compile_supported?: boolean build_host_configured?: boolean + build_executor?: string | null prebuilt_target?: string | null has_prebuilt_entry?: boolean precompiled_release_url?: string | null @@ -270,7 +271,7 @@ function inferBuildStrategy(frame?: Partial | null): string { ? 'Build on device' : crossCompilation === 'always' ? 'Cross-compile' - : 'Cross-compile if the detected target supports it, otherwise build on device' + : 'Use the global build environment' if (compilationMode === 'precompiled') { if (crossCompilation === 'always') { @@ -428,8 +429,8 @@ export function buildFullDeployPlanSummary( value: fullPlan.binary.will_attempt_precompiled ? 'Download and install the precompiled FrameOS release' : fullPlan.binary.will_attempt_cross_compile - ? fullPlan.binary.build_host_configured - ? 'Cross-compile on the configured build host' + ? fullPlan.binary.build_executor + ? `Cross-compile via ${fullPlan.binary.build_executor}` : 'Cross-compile locally on this server' : fullPlan.binary.cross_compile_supported ? 'Build on device because cross-compilation is disabled' diff --git a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx index e0ae7b529..71258610d 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx @@ -679,14 +679,11 @@ export function FrameSettings({ tooltip={

- Choose how to build the FrameOS binary: auto will try to cross-compile and fall back to on-device - builds, always will fail if cross compilation is unavailable, and never will always build on the - device. -

-

- If you're running FrameOS via Docker, you may need to configure a build host for cross-compilation - on the global settings page. + Choose how to build the FrameOS binary: auto follows the build environment selected in global + settings, always fails if server-side compilation is disabled or unavailable, and never always + builds on the device.

+

Configure Docker, a build host, or Modal sandboxes from global settings.

} > diff --git a/frontend/src/scenes/settings/Settings.tsx b/frontend/src/scenes/settings/Settings.tsx index 03e33dfc1..57e17d238 100644 --- a/frontend/src/scenes/settings/Settings.tsx +++ b/frontend/src/scenes/settings/Settings.tsx @@ -1,5 +1,7 @@ import { useActions, useValues } from 'kea' import { Form, Group } from 'kea-forms' +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import clsx from 'clsx' import { Box } from '../../components/Box' import { settingsLogic } from './settingsLogic' import { Spinner } from '../../components/Spinner' @@ -26,22 +28,48 @@ import { isMobileWorkspaceViewport, workspaceLogic } from '../workspace/workspac import { accountLogic } from './accountLogic' import versions from '../../../../versions.json' import { timezoneOptions } from '../../decorators/timezones' +import { systemInfoLogic } from './systemInfoLogic' -const settingsNavItems = [ - ['Account', '#settings-account'], - ['Defaults', '#settings-defaults'], - ['SSH Keys', '#settings-ssh'], - ['FrameOS Gallery', '#settings-gallery'], - ['OpenAI', '#settings-openai'], - ['PostHog', '#settings-posthog'], - ['Home Assistant', '#settings-home-assistant'], - ['GitHub', '#settings-github'], - ['Unsplash API', '#settings-unsplash'], - ['Cross-compilation build host', '#settings-build-host'], - ['System information', '#settings-system'], - ['Custom fonts', '#settings-fonts'], +type SettingsNavItem = readonly [string, string] +type SettingsNavSection = { + label: string + items: readonly SettingsNavItem[] +} +type SettingsSectionId = string + +const settingsNavSections: readonly SettingsNavSection[] = [ + { + label: '', + items: [['Account', '#settings-account']], + }, + { + label: 'Settings', + items: [ + ['Defaults', '#settings-defaults'], + ['SSH Keys', '#settings-ssh'], + ['Build environment', '#settings-build-environment'], + ['Custom fonts', '#settings-fonts'], + ], + }, + { + label: 'Services', + items: [ + ['FrameOS Gallery', '#settings-gallery'], + ['OpenAI', '#settings-openai'], + ['PostHog', '#settings-posthog'], + ['Home Assistant', '#settings-home-assistant'], + ['GitHub', '#settings-github'], + ['Unsplash API', '#settings-unsplash'], + ], + }, + { + label: 'Information', + items: [['System information', '#settings-system']], + }, ] as const +const settingsNavItems = settingsNavSections.flatMap((section) => section.items) + const frameosVersion = typeof versions.frameos === 'string' ? versions.frameos.split('+')[0] : null const frameosVersionLabel = frameosVersion ? `FrameOS ${frameosVersion}` : 'FrameOS' @@ -61,12 +89,8 @@ function scrollToSettingsSection(sectionId: string, attempt = 0): void { window.requestAnimationFrame(() => { const section = document.getElementById(sectionId) if (section) { - if (isMobileWorkspaceViewport()) { - const top = section.getBoundingClientRect().top + window.scrollY - settingsHeaderOffset() - window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) - } else { - section.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } + const top = section.getBoundingClientRect().top + window.scrollY - settingsHeaderOffset() + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) return } @@ -76,7 +100,38 @@ function scrollToSettingsSection(sectionId: string, attempt = 0): void { }) } -function AccountSettingsSection(): JSX.Element { +function activeSettingsSectionId(): SettingsSectionId { + const viewportTop = settingsHeaderOffset() + 8 + const viewportBottom = typeof window === 'undefined' ? viewportTop : window.innerHeight + const candidates = settingsNavItems + .map(([_label, href]) => { + const section = document.getElementById(href.slice(1)) + if (!section) { + return null + } + const rect = section.getBoundingClientRect() + return { href, rect } + }) + .filter((candidate): candidate is { href: SettingsSectionId; rect: DOMRect } => { + return !!candidate && candidate.rect.bottom >= viewportTop && candidate.rect.top <= viewportBottom + }) + + if (candidates.length === 0) { + return settingsNavItems[0][1] + } + + const current = + candidates + .filter((candidate) => candidate.rect.top <= viewportTop) + .toSorted((first, second) => second.rect.top - first.rect.top)[0] ?? + candidates.toSorted( + (first, second) => Math.abs(first.rect.top - viewportTop) - Math.abs(second.rect.top - viewportTop) + )[0] + + return current.href +} + +function AccountSettingsSection({ onLogout }: { onLogout: () => void }): JSX.Element { const { account, accountEmail, @@ -96,12 +151,15 @@ function AccountSettingsSection(): JSX.Element { return (
-
- Account -
+
+
Account
+ +
-
-
+
+
{emailEditorOpen ? ( @@ -190,8 +248,8 @@ function AccountSettingsSection(): JSX.Element {
) : ( -
-
+
+
@@ -224,6 +282,14 @@ function IngressAccountSettingsSection(): JSX.Element { ) } +function SettingsGroupDivider({ label }: { label: string }): JSX.Element { + return ( + + ) +} + export function Settings() { const { settings, @@ -237,6 +303,8 @@ export function Settings() { customFonts, generatingSshKeyId, sshKeyExpandedIds, + isTestingBuildHost, + isTestingModalSandbox, } = useValues(settingsLogic) const { framesList } = useValues(framesModel) const { @@ -246,49 +314,106 @@ export function Settings() { generateSshKey, removeSshKey, newBuildHostKey, + testBuildHost, + testModalSandbox, deleteCustomFont, setSettingsValue, toggleSshKeyExpanded, toggleOpenAiModelOverrides, } = useActions(settingsLogic) + const { systemInfo } = useValues(systemInfoLogic) + const { loadSystemInfo } = useActions(systemInfoLogic) const { isHassioIngress } = useValues(sceneLogic) const { logout } = useActions(sceneLogic) const { closeSecondarySidebar } = useActions(workspaceLogic) const defaultSshKeyIds = getDefaultSshKeyIds(settings?.ssh_keys) + const buildEnvironmentProvider = settings?.buildEnvironment?.provider || 'docker' + const [activeSettingsSection, setActiveSettingsSection] = useState(settingsNavItems[0][1]) + const settingsNavLinkRefs = useRef>({}) const framesUsingKey = (keyId: string) => framesList.filter((frame) => (frame.ssh_keys ?? defaultSshKeyIds).includes(keyId)) - const settingsTree = ( -
- {settingsNavItems.map(([label, href]) => ( - { - if (!isMobileWorkspaceViewport()) { - return - } - event.preventDefault() - closeSecondarySidebar() - window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${href}`) - scrollToSettingsSection(href.slice(1)) - }} - className="frameos-settings-nav-link block rounded-xl px-3 py-2.5 text-base font-medium text-slate-700 transition hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400" - > - {label} - + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + let frame: number | null = null + const scheduleUpdate = (): void => { + if (frame !== null) { + return + } + frame = window.requestAnimationFrame(() => { + frame = null + setActiveSettingsSection(activeSettingsSectionId()) + }) + } + + scheduleUpdate() + window.addEventListener('scroll', scheduleUpdate, { passive: true }) + window.addEventListener('resize', scheduleUpdate) + return () => { + if (frame !== null) { + window.cancelAnimationFrame(frame) + } + window.removeEventListener('scroll', scheduleUpdate) + window.removeEventListener('resize', scheduleUpdate) + } + }, [buildEnvironmentProvider, customFonts.length, openAiModelOverridesExpanded, savedSettingsLoading]) + + useEffect(() => { + settingsNavLinkRefs.current[activeSettingsSection]?.scrollIntoView({ block: 'nearest' }) + }, [activeSettingsSection]) + + const handleSettingsNavClick = (event: ReactMouseEvent, href: SettingsSectionId): void => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return + } + + event.preventDefault() + setActiveSettingsSection(href) + if (isMobileWorkspaceViewport()) { + closeSecondarySidebar() + } + window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${href}`) + scrollToSettingsSection(href.slice(1)) + } + + const settingsTree = ( + ) const settingsActions = (
-
{frameosVersionLabel}
- {!isHassioIngress ? ( - - ) : null}