Skip to content

Commit b54cfad

Browse files
mariusandraFrameOS Bot
andauthored
Build in modal sandboxes (#226)
* New buildroot image * Build in modal sandboxes * Fix modal PR CI failures * Update frontend visual snapshots * Allow Buildroot data partitions to grow * Normalize build provider settings on partial updates * Support build host SD image generation * Executor abstraction * Route command execution through build executor * Clarify Modal sandbox builds * Log Modal sandbox timeouts * Forward Modal image build logs * Run arm64 Modal builds through nested Docker * Add SD card build action to frame menu * Run Modal arm builds with cross compilers * Tune Modal sandbox build resources * better text * Update frontend visual snapshots * Quiet QuickJS cross-compile logging * Show SD image build progress updates * Bake target cross toolchains into Modal images --------- Co-authored-by: FrameOS Bot <git@frameos.net>
1 parent bc8c45f commit b54cfad

52 files changed

Lines changed: 4355 additions & 611 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/frameos-cross-toolchain.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,15 @@ jobs:
5656
import os
5757
import re
5858
import subprocess
59+
import sys
5960
from pathlib import Path
6061
62+
sys.path.insert(0, str(Path("backend").resolve()))
63+
from app.utils.cross_toolchain_packages import ( # noqa: E402
64+
TARGET_CROSS_TOOLCHAIN_DPKG_ARCHS,
65+
TARGET_CROSS_TOOLCHAIN_PACKAGES,
66+
)
67+
6168
def sanitize(value: str) -> str:
6269
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)
6370
@@ -72,6 +79,11 @@ jobs:
7279
safe_platform = sanitize(target["platform"].replace("/", "_"))
7380
image = f"{image_repo}:{safe_base}-{safe_platform}-{image_tag}"
7481
metadata_file = Path(f"/tmp/toolchain-metadata-{index}.json")
82+
target_cross_dpkg_archs = ""
83+
target_cross_packages = ""
84+
if target["platform"] == "linux/amd64":
85+
target_cross_dpkg_archs = " ".join(TARGET_CROSS_TOOLCHAIN_DPKG_ARCHS)
86+
target_cross_packages = " ".join(TARGET_CROSS_TOOLCHAIN_PACKAGES)
7587
7688
subprocess.run(
7789
[
@@ -82,6 +94,10 @@ jobs:
8294
target["platform"],
8395
"--build-arg",
8496
f"BASE_IMAGE={base_image}",
97+
"--build-arg",
98+
f"TARGET_CROSS_DPKG_ARCHS={target_cross_dpkg_archs}",
99+
"--build-arg",
100+
f"TARGET_CROSS_PACKAGES={target_cross_packages}",
85101
"--tag",
86102
image,
87103
"--push",

backend/app/api/frames.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@
112112
)
113113
from app.models.assets import copy_custom_fonts_to_local_source_folder
114114
from app.models.settings import get_settings_dict
115+
from app.utils.build_environment import selected_build_environment_provider
116+
from app.utils.build_host import get_build_executor_config
117+
from app.utils.build_executor import build_environment_requires_executor_config
115118
from app.utils.ssh_key_utils import default_ssh_key_ids
116119
from app.utils.timezone import frame_timezone, normalize_timezone, stored_timezone
117120
from app.utils.tls import generate_frame_tls_material, parse_certificate_not_valid_after
@@ -2436,6 +2439,15 @@ async def api_frame_buildroot_sd_image(
24362439
if (frame.mode or "rpios") != "buildroot":
24372440
_bad_request("SD card image generation is only available for Buildroot frames")
24382441

2442+
settings = get_settings_dict(db, project_id=frame.project_id)
2443+
build_environment_provider = selected_build_environment_provider(settings)
2444+
if build_environment_provider == "none":
2445+
_bad_request(
2446+
"Buildroot SD card image generation requires Docker, build host, or Modal sandboxes as the global build environment."
2447+
)
2448+
if build_environment_requires_executor_config(build_environment_provider) and get_build_executor_config(db, frame.project_id) is None:
2449+
_bad_request(f"Selected build environment '{build_environment_provider}' is not configured")
2450+
24392451
try:
24402452
ensure_buildroot_frame_defaults(frame)
24412453
except ValueError as exc:

backend/app/api/settings.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from http import HTTPStatus
2+
from types import SimpleNamespace
3+
24
from fastapi import Depends, HTTPException
35
from sqlalchemy.exc import SQLAlchemyError
46
from sqlalchemy.orm import Session
@@ -7,6 +9,10 @@
79
from app.models.settings import get_settings_dict, Settings
810
from app.schemas.settings import SettingsResponse, SettingsUpdateRequest
911
from app.tenancy import current_project_id
12+
from app.utils.build_environment import selected_build_environment_provider
13+
from app.utils.build_executor import create_build_executor
14+
from app.utils.build_host import BuildHostConfig
15+
from app.utils.modal_sandbox import ModalSandboxConfig
1016
from app.utils.posthog import initialize_posthog
1117
from . import api_project
1218

@@ -23,6 +29,13 @@ async def set_settings(data: SettingsUpdateRequest, db: Session = Depends(get_db
2329

2430
try:
2531
current_settings = get_settings_dict(db, project_id=project_id)
32+
merged_settings = {**current_settings, **payload}
33+
provider = selected_build_environment_provider(merged_settings)
34+
if isinstance(payload.get("buildHost"), dict):
35+
payload["buildHost"] = {**payload["buildHost"], "enabled": provider == "buildHost"}
36+
if isinstance(payload.get("modalSandbox"), dict):
37+
payload["modalSandbox"] = {**payload["modalSandbox"], "enabled": provider == "modal"}
38+
2639
for key, value in payload.items():
2740
if value != current_settings.get(key):
2841
setting = db.query(Settings).filter_by(project_id=project_id, key=key).first()
@@ -39,3 +52,76 @@ async def set_settings(data: SettingsUpdateRequest, db: Session = Depends(get_db
3952
if "posthog" in payload:
4053
initialize_posthog(updated_settings, project_id=project_id)
4154
return updated_settings
55+
56+
57+
@api_project.post("/settings/test_build_host")
58+
async def test_build_host(data: SettingsUpdateRequest):
59+
payload = data.to_dict()
60+
raw_build_host_settings = payload.get("buildHost") if isinstance(payload, dict) else None
61+
build_host_config = BuildHostConfig.from_settings(
62+
{**raw_build_host_settings, "enabled": True} if isinstance(raw_build_host_settings, dict) else raw_build_host_settings
63+
)
64+
if build_host_config is None:
65+
raise HTTPException(
66+
status_code=HTTPStatus.BAD_REQUEST,
67+
detail="Select build host via SSH and enter a host, user, and private SSH key first",
68+
)
69+
70+
try:
71+
async with create_build_executor(
72+
build_host_config,
73+
db=None,
74+
redis=None,
75+
frame=SimpleNamespace(id=0),
76+
workspace_prefix="frameos-build-host-test-",
77+
) as executor:
78+
status, out, err = await executor.run(
79+
"echo frameos-build-host-ok && command -v docker >/dev/null && docker buildx version >/dev/null",
80+
log_command=False,
81+
log_output=False,
82+
)
83+
except Exception as exc: # noqa: BLE001
84+
raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Build host connection failed: {exc}") from exc
85+
86+
if status != 0:
87+
raise HTTPException(
88+
status_code=HTTPStatus.BAD_GATEWAY,
89+
detail=err or out or "Build host is missing Docker or the Docker Buildx plugin",
90+
)
91+
92+
return {"ok": True, "output": (out or "").strip()}
93+
94+
95+
@api_project.post("/settings/test_modal_sandbox")
96+
async def test_modal_sandbox(data: SettingsUpdateRequest):
97+
payload = data.to_dict()
98+
raw_modal_settings = payload.get("modalSandbox") if isinstance(payload, dict) else None
99+
modal_config = ModalSandboxConfig.from_settings(raw_modal_settings)
100+
if modal_config is None:
101+
raise HTTPException(
102+
status_code=HTTPStatus.BAD_REQUEST,
103+
detail="Select Modal sandboxes and enter a token ID and token secret first",
104+
)
105+
106+
try:
107+
async with create_build_executor(
108+
modal_config,
109+
db=None,
110+
redis=None,
111+
frame=SimpleNamespace(id=0),
112+
) as executor:
113+
status, out, err = await executor.run(
114+
"command -v nimble && nimble --version >/dev/null && echo frameos-modal-sandbox-ok",
115+
log_command=False,
116+
log_output=False,
117+
)
118+
except Exception as exc: # noqa: BLE001
119+
raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Modal sandbox test failed: {exc}") from exc
120+
121+
if status != 0:
122+
raise HTTPException(
123+
status_code=HTTPStatus.BAD_GATEWAY,
124+
detail=err or out or "Modal sandbox image is missing the FrameOS Nim toolchain",
125+
)
126+
127+
return {"ok": True, "output": (out or "").strip()}

backend/app/api/system.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from app.api import api_user
2-
from app.schemas.system import CacheInfo, DatabaseInfo, DiskInfo, LoadInfo, MemoryInfo, SystemInfoResponse, SystemMetricsResponse
2+
from app.schemas.system import CacheInfo, DatabaseInfo, DiskInfo, DockerInfo, LoadInfo, MemoryInfo, SystemInfoResponse, SystemMetricsResponse
33
from app.utils.system_info import get_system_info, get_system_metrics
44

55

@@ -27,15 +27,24 @@ def _database_to_schema(database) -> DatabaseInfo:
2727
)
2828

2929

30+
def _docker_to_schema(docker) -> DockerInfo:
31+
return DockerInfo(
32+
cliAvailable=docker.cli_available,
33+
daemonAvailable=docker.daemon_available,
34+
error=docker.error,
35+
)
36+
37+
3038
@api_user.get("/system/info", response_model=SystemInfoResponse)
3139
def system_info():
32-
disk, caches, database, memory, load = get_system_info()
40+
disk, caches, database, memory, load, docker = get_system_info()
3341
return SystemInfoResponse(
3442
disk=_disk_to_schema(disk),
3543
caches=_cache_to_schema(caches),
3644
database=_database_to_schema(database),
3745
memory=_memory_to_schema(memory),
3846
load=_load_to_schema(load),
47+
docker=_docker_to_schema(docker),
3948
)
4049

4150

backend/app/api/tests/test_frames.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app.models.frame import Frame
1818
from app.models.log import Log
1919
from app.models.metrics import Metrics
20+
from app.models.settings import Settings
2021
from app.models.user import User
2122
from app.tenancy import ensure_default_project_for_user
2223
from app.tasks.buildroot_image import BUILDROOT_SD_IMAGE_CUSTOMIZATION_VERSION, buildroot_sd_image_config_fingerprint
@@ -1167,6 +1168,47 @@ async def fake_buildroot_sd_image(id, _redis, *, request_id=None, queue_job_id=N
11671168
assert frame.agent['deployWithAgent'] is True
11681169

11691170

1171+
@pytest.mark.asyncio
1172+
async def test_api_frame_buildroot_sd_image_accepts_configured_build_host(async_client, db, redis, monkeypatch):
1173+
import app.tasks.buildroot_image as buildroot_image_module
1174+
1175+
frame = await new_frame(db, redis, 'BuildrootFrame', 'frame.local', 'backend.local')
1176+
frame.mode = 'buildroot'
1177+
frame.network = {
1178+
**(frame.network or {}),
1179+
'wifiSSID': 'Test WiFi',
1180+
'wifiPassword': 'secret1234',
1181+
}
1182+
frame.buildroot = {'platform': 'raspberry-pi-zero-2-w'}
1183+
db.add(Settings(project_id=frame.project_id, key='buildEnvironment', value={'provider': 'buildHost'}))
1184+
db.add(
1185+
Settings(
1186+
project_id=frame.project_id,
1187+
key='buildHost',
1188+
value={
1189+
'enabled': True,
1190+
'host': 'builder.local',
1191+
'user': 'ubuntu',
1192+
'sshKey': 'dummy-key',
1193+
},
1194+
)
1195+
)
1196+
db.add(frame)
1197+
db.commit()
1198+
captured: list[int] = []
1199+
1200+
async def fake_buildroot_sd_image(id, _redis, *, request_id=None, queue_job_id=None):
1201+
captured.append(id)
1202+
1203+
monkeypatch.setattr(buildroot_image_module, "buildroot_sd_image", fake_buildroot_sd_image)
1204+
1205+
response = await async_client.post(f'/api/frames/{frame.id}/buildroot/sd_image')
1206+
1207+
assert response.status_code == 200
1208+
assert response.json()['message'] == 'Buildroot SD card image preparation started'
1209+
assert captured == [frame.id]
1210+
1211+
11701212
@pytest.mark.asyncio
11711213
async def test_api_frame_buildroot_sd_image_does_not_publish_previous_error(async_client, db, redis, monkeypatch):
11721214
import app.api.frames as frames_api_module

0 commit comments

Comments
 (0)