diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 292aab6d6..6682a02c7 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -24,6 +24,7 @@ from .assets import * # noqa: E402, F403 from .chats import * # noqa: E402, F403 from .cloud import * # noqa: E402, F403 +from .cloud_backups import * # noqa: E402, F403 from .embedded_device import * # noqa: E402, F403 from .frame_bootstrap import * # noqa: E402, F403 from .frames import * # noqa: E402, F403 diff --git a/backend/app/api/cloud.py b/backend/app/api/cloud.py index a32f40106..c10000567 100644 --- a/backend/app/api/cloud.py +++ b/backend/app/api/cloud.py @@ -28,6 +28,7 @@ from app.schemas.cloud import ( CloudConnectRequest, CloudFeaturesRequest, + CloudBackupFeaturesRequest, CloudLocalFallbackRequest, CloudLoginOptionsResponse, CloudLoginStartRequest, @@ -125,6 +126,8 @@ def _status_payload(db: Session, link: CloudBackendLink | None, user: User | Non "can_edit_provider": status == "disconnected", "poll_error": link.poll_error if link else None, "local_fallback_enabled": link.local_fallback_enabled if link else True, + "backup_scenes_enabled": link.backup_scenes_enabled if link else False, + "backup_frames_enabled": link.backup_frames_enabled if link else False, "connection": None, "link": None, "identity": _identity_payload(db, user), @@ -912,6 +915,28 @@ async def set_local_fallback( return _status_payload(db, link, current_user) +@api_user.post("/cloud/backup-features", response_model=CloudStatusResponse) +async def set_backup_features( + data: CloudBackupFeaturesRequest, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + """Turn cloud backups on or off locally. The backup scopes come with every + cloud account, but nothing is uploaded while these switches are off.""" + if current_user is None: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Log in first") + link = current_cloud_backend_link(db) + if link is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + if data.scenes is not None: + link.backup_scenes_enabled = data.scenes + if data.frames is not None: + link.backup_frames_enabled = data.frames + link.updated_at = _now() + db.commit() + return _status_payload(db, link, current_user) + + # ---- enabled features (in-place scope changes) -------------------------------- BASE_LINK_SCOPES = ("backend:link", "backend:read") @@ -919,7 +944,8 @@ async def set_local_fallback( # Features included with every cloud account. Only security-sensitive features # (cloud login, later remote access/telemetry) get an opt-in toggle in the UI; # these safe scopes are always kept on the link, so a feature change can never -# drop them. +# drop them. The backup scopes are a permission only: nothing is uploaded +# until the matching backup_*_enabled switch on the link is turned on. INCLUDED_FEATURE_SCOPES = ("backup:scenes", "backup:frames", "store:publish") diff --git a/backend/app/api/cloud_backups.py b/backend/app/api/cloud_backups.py new file mode 100644 index 000000000..3092bcd1f --- /dev/null +++ b/backend/app/api/cloud_backups.py @@ -0,0 +1,363 @@ +"""Cloud config backups and the local tarball export (CLOUD-TODO Phase 3). + +Scenes (stored locally as templates) and frame configs can be pushed to / +restored from the linked FrameOS Cloud provider (scopes ``backup:scenes`` / +``backup:frames``). The scopes come with every cloud account, but uploads +also require the matching local switch (``backup_scenes_enabled`` / +``backup_frames_enabled``) so connecting alone never leaks data. Everything +can always be exported as a plain local tarball — the do-it-yourself +alternative that works without any cloud. +""" +from __future__ import annotations + +import base64 +import datetime +import io +import json +import tarfile +from http import HTTPStatus + +from arq import ArqRedis as Redis +from fastapi import Depends, HTTPException +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from app.api.auth import get_current_user +from app.api.templates import parse_template_zip, safe_template_name, template_zip_bytes +from app.database import get_db +from app.models.cloud import current_cloud_backend_link +from app.models.frame import Frame +from app.models.organization import OrganizationMember, Project +from app.models.template import Template +from app.models.user import User +from app.redis import get_redis +from app.schemas.cloud import ( + CloudBackupRestoreRequest, + CloudBackupSaveFrameRequest, + CloudBackupSaveTemplateRequest, + CloudStatusResponse, +) +from app.tenancy import get_user_project +from app.utils import cloud_backup, cloud_link +from app.websockets import publish_message + +from . import api_user + +# Frame columns a cloud restore may write. Everything else (credentials, TLS +# material, tokens) is regenerated or re-entered locally; a cloud backup never +# contained it in the first place (see app/utils/cloud_backup.py). +FRAME_RESTORE_FIELDS = ( + "name", + "mode", + "frame_host", + "frame_port", + "frame_access", + "ssh_user", + "ssh_port", + "server_host", + "server_port", + "server_send_logs", + "width", + "height", + "device", + "device_config", + "color", + "timezone", + "timezone_updater", + "interval", + "metrics_interval", + "max_http_response_bytes", + "scaling_mode", + "image_engine", + "rotate", + "flip", + "background_color", + "debug", + "scenes", + "log_to_file", + "assets_path", + "save_assets", + "upload_fonts", + "reboot", + "control_code", + "schedule", + "gpio_buttons", + "network", + "agent", + "mountpoints", + "error_behavior", + "palette", + "buildroot", + "embedded", + "rpios", +) + + +def _require_user(current_user: User | None) -> User: + if current_user is None: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Log in first") + return current_user + + +def _require_linked(db: Session, scope: str, feature_enabled_attr: str | None = None): + link = current_cloud_backend_link(db) + access_token = cloud_backup.link_access_token(link) + if link is None or access_token is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + if scope not in link.scopes: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"The cloud link is missing the {scope} permission; reconnect with it enabled", + ) + if feature_enabled_attr is not None and not getattr(link, feature_enabled_attr): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Cloud backups are switched off; enable them in Settings → FrameOS Cloud first", + ) + return link, access_token + + +def _user_project_or_404(db: Session, user: User, project_id: int) -> Project: + project = get_user_project(db, user, project_id) + if project is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Project not found") + return project + + +def _user_projects(db: Session, user: User) -> list[Project]: + return ( + db.query(Project) + .join(OrganizationMember, OrganizationMember.organization_id == Project.organization_id) + .filter(OrganizationMember.user_id == user.id) + .order_by(Project.id.asc()) + .all() + ) + + +@api_user.get("/cloud/backups", response_model=CloudStatusResponse) +async def list_cloud_backups( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + _require_user(current_user) + link = current_cloud_backend_link(db) + access_token = cloud_backup.link_access_token(link) + if link is None or access_token is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + has_scope = any(scope in link.scopes for scope in ("backup:scenes", "backup:frames")) + if not has_scope: + return {"backups": [], "missing_scope": True} + try: + status_code, response = await cloud_link.backup_list(link.provider_url, access_token) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + return {"backups": response.get("backups") or [], "missing_scope": False} + + +@api_user.post("/cloud/backups/templates", response_model=CloudStatusResponse) +async def backup_template_to_cloud( + data: CloudBackupSaveTemplateRequest, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + user = _require_user(current_user) + link, access_token = _require_linked(db, "backup:scenes", "backup_scenes_enabled") + template = db.query(Template).filter_by(id=data.template_id).first() + if template is None or get_user_project(db, user, template.project_id) is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Template not found") + + try: + status_code, response = await cloud_backup.push_template_backup( + link, access_token, str(template.id), template.name, template_zip_bytes(template) + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + return {"status": "saved", "backup": response.get("backup")} + + +@api_user.post("/cloud/backups/frames", response_model=CloudStatusResponse) +async def backup_frame_to_cloud( + data: CloudBackupSaveFrameRequest, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + user = _require_user(current_user) + link, access_token = _require_linked(db, "backup:frames", "backup_frames_enabled") + frame = db.query(Frame).filter_by(id=data.frame_id).first() + if frame is None or get_user_project(db, user, frame.project_id) is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found") + + project = db.get(Project, frame.project_id) + try: + status_code, response = await cloud_backup.push_frame_backup( + link, access_token, frame.to_dict(), project.name if project else None + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + return {"status": "saved", "backup": response.get("backup")} + + +@api_user.post("/cloud/backups/restore", response_model=CloudStatusResponse) +async def restore_cloud_backup( + data: CloudBackupRestoreRequest, + db: Session = Depends(get_db), + redis: Redis = Depends(get_redis), + current_user: User | None = Depends(get_current_user), +): + user = _require_user(current_user) + project = _user_project_or_404(db, user, data.project_id) + + link = current_cloud_backend_link(db) + access_token = cloud_backup.link_access_token(link) + if link is None or access_token is None: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="This install is not linked to FrameOS Cloud") + + try: + status_code, response = await cloud_link.backup_get(link.provider_url, access_token, data.backup_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + + backup = response.get("backup") or {} + try: + content = base64.b64decode(backup.get("content_base64") or "") + except (TypeError, ValueError): + content = b"" + if not content: + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail="The backup had no content") + + if backup.get("kind") == "templates": + try: + fields = parse_template_zip(content) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=f"Could not parse the template backup: {exc}" + ) from exc + image = fields.get("image") + template = Template( + project_id=project.id, + name=fields.get("name") or backup.get("name") or "Restored template", + description=fields.get("description"), + scenes=fields.get("scenes") or [], + config=fields.get("config"), + image=image if isinstance(image, bytes) else None, + image_width=fields.get("imageWidth") or fields.get("image_width"), + image_height=fields.get("imageHeight") or fields.get("image_height"), + ) + db.add(template) + db.commit() + db.refresh(template) + return {"status": "restored", "kind": "template", "id": template.id} + + if backup.get("kind") == "frames": + try: + payload = json.loads(content) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Could not parse the frame backup" + ) from exc + frame_dict = payload.get("frame") if isinstance(payload, dict) else None + if not isinstance(frame_dict, dict): + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Could not parse the frame backup") + + from app.models.frame import secure_token + + frame = Frame( + project_id=project.id, + status="uninitialized", + frame_access_key=secure_token(20), + server_api_key=secure_token(32), + ) + for field in FRAME_RESTORE_FIELDS: + if field in frame_dict: + setattr(frame, field, frame_dict[field]) + agent = dict(frame.agent or {}) if isinstance(frame.agent, dict) else {} + agent["agentSharedSecret"] = secure_token(32) + frame.agent = agent + if not frame.name: + frame.name = backup.get("name") or "Restored frame" + if not frame.frame_host: + frame.frame_host = "frame.local" + db.add(frame) + db.commit() + db.refresh(frame) + await publish_message(redis, "new_frame", frame.to_dict()) + return {"status": "restored", "kind": "frame", "id": frame.id} + + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Unknown backup kind") + + +@api_user.get("/backup/export") +async def export_backup_tarball( + db: Session = Depends(get_db), current_user: User | None = Depends(get_current_user) +): + """Everything as a plain tar.gz — the self-service alternative to cloud + backups. Stays local, so unlike cloud frame backups it keeps credentials.""" + user = _require_user(current_user) + projects = _user_projects(db, user) + + now = datetime.datetime.utcnow() + buffer = io.BytesIO() + + def add_file(tar: tarfile.TarFile, path: str, content: bytes) -> None: + info = tarfile.TarInfo(name=path) + info.size = len(content) + info.mtime = int(now.timestamp()) + tar.addfile(info, io.BytesIO(content)) + + manifest: dict = {"format": "frameos-backup-v1", "exported_at": now.isoformat(), "projects": []} + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for project in projects: + frames = db.query(Frame).filter(Frame.project_id == project.id).all() + templates = db.query(Template).filter(Template.project_id == project.id).all() + manifest["projects"].append( + { + "id": project.id, + "name": project.name, + "frames": len(frames), + "templates": len(templates), + } + ) + add_file( + tar, + f"projects/{project.id}/project.json", + json.dumps({"id": project.id, "name": project.name}, indent=2).encode(), + ) + for frame in frames: + add_file( + tar, + f"projects/{project.id}/frames/frame-{frame.id}.json", + json.dumps(frame.to_dict(), indent=2, default=str).encode(), + ) + for template in templates: + add_file( + tar, + f"projects/{project.id}/templates/{template.id} - {safe_template_name(template)}.zip", + template_zip_bytes(template), + ) + add_file(tar, "manifest.json", json.dumps(manifest, indent=2).encode()) + + filename = f"frameos-backup-{now.strftime('%Y%m%d-%H%M%S')}.tar.gz" + return Response( + buffer.getvalue(), + media_type="application/gzip", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index a629fc04b..76cab7451 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -26,18 +26,21 @@ from app.redis import get_redis from app.tenancy import current_project_id, get_user_project from app.utils.jwt_tokens import validate_scoped_token +from app.utils.versions import current_frameos_version from app.api.auth import get_current_user_from_request -def respond_with_template(template: Template): - if not template: - raise HTTPException(status_code=404, detail="Template not found") - +def safe_template_name(template: Template) -> str: template_name = template.name or 'Template' safe_chars = "-_.() %s%s" % (string.ascii_letters, string.digits) template_name = ''.join(c if c in safe_chars else ' ' for c in template_name).strip() - template_name = ' '.join(template_name.split()) or 'Template' + return ' '.join(template_name.split()) or 'Template' + +def template_zip_bytes(template: Template) -> bytes: + """The template interchange zip: {name}/template.json + scenes.json + image.jpg. + Also the payload format for cloud template backups (CLOUD-TODO Phase 3).""" + template_name = safe_template_name(template) template_dict = template.to_dict() template_dict.pop('id', None) in_memory = io.BytesIO() @@ -45,13 +48,50 @@ def respond_with_template(template: Template): scenes = template_dict.pop('scenes', []) template_dict['scenes'] = './scenes.json' template_dict['image'] = './image.jpg' + # The FrameOS version this template was exported with. Informational — + # the store surfaces it so people know what a scene was built/tested on. + template_dict['frameosVersion'] = current_frameos_version() zf.writestr(f"{template_name}/scenes.json", json.dumps(scenes, indent=2)) zf.writestr(f"{template_name}/template.json", json.dumps(template_dict, indent=2)) if template.image: zf.writestr(f"{template_name}/image.jpg", template.image) in_memory.seek(0) + return in_memory.getvalue() + + +def parse_template_zip(zip_bytes: bytes) -> dict: + """Inverse of template_zip_bytes: returns template fields incl. scenes/image.""" + zip_file = zipfile.ZipFile(io.BytesIO(zip_bytes)) + folder_name = '' + for name_in_zip in zip_file.namelist(): + if name_in_zip == 'template.json': + folder_name = '' + break + elif name_in_zip.endswith('/template.json'): + if folder_name == '' or len(name_in_zip) < len(folder_name): + folder_name = name_in_zip[:-len('template.json')] + + data = json.loads(zip_file.read(f'{folder_name}template.json')) + data['scenes'] = json.loads(zip_file.read(f'{folder_name}scenes.json')) + image = data.get('image') + if isinstance(image, str) and image.startswith('./'): + image_path = image[len('./'):] + try: + data['image'] = zip_file.read(f'{folder_name}{image_path}') + except KeyError: + data['image'] = None + else: + data['image'] = None + return data + + +def respond_with_template(template: Template): + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + template_name = safe_template_name(template) return Response( - in_memory.getvalue(), + template_zip_bytes(template), media_type='application/zip', headers={"Content-Disposition": f"attachment; filename={template_name}.zip"} ) diff --git a/backend/app/api/tests/test_cloud_backups.py b/backend/app/api/tests/test_cloud_backups.py new file mode 100644 index 000000000..e71e91533 --- /dev/null +++ b/backend/app/api/tests/test_cloud_backups.py @@ -0,0 +1,339 @@ +"""Cloud config backups (Phase 3): push, restore, sanitization, tarball export.""" +import base64 +import io +import json +import tarfile + +import pytest + +from app.models.cloud import CloudBackendLink +from app.models.frame import Frame +from app.models.template import Template +from app.utils import cloud_backup, cloud_link + +PROVIDER = "https://cloud.frameos.net" + +BACKUP_SCOPES = "backend:link backend:read backup:scenes backup:frames" + + +def make_connected_link(db, scope=BACKUP_SCOPES, backups_enabled=True): + link = CloudBackendLink( + provider_url=PROVIDER, + status="connected", + access_token=cloud_link.encrypt_cloud_secret("link-token-secret"), + linked_client_id="lc-1", + scope=scope, + local_origin="http://test", + cloud_account_id="acc-1", + backup_scenes_enabled=backups_enabled, + backup_frames_enabled=backups_enabled, + ) + db.add(link) + db.commit() + return link + + +def make_frame(db, project_id): + frame = Frame( + project_id=project_id, + name="Kitchen frame", + frame_host="10.0.0.5", + ssh_user="pi", + ssh_pass="super-secret-pass", + ssh_keys={"private": "PRIVATE KEY"}, + frame_access_key="frame-access-key-1", + server_api_key="server-api-key-1", + status="ready", + scenes=[{"id": "scene-1", "nodes": []}], + network={"wifiSSID": "HomeWifi", "wifiPassword": "wifi-secret"}, + agent={"agentEnabled": True, "agentSharedSecret": "agent-secret"}, + https_proxy={"enable": True, "certs": {"server_key": "TLS KEY"}}, + terminal_history=["ssh something"], + ) + db.add(frame) + db.commit() + db.refresh(frame) + return frame + + +def make_template(db, project_id): + template = Template( + project_id=project_id, + name="My template", + description="desc", + scenes=[{"id": "scene-1", "nodes": []}], + config={}, + ) + db.add(template) + db.commit() + db.refresh(template) + return template + + +@pytest.fixture +def backup_calls(monkeypatch): + calls = {"save": [], "list": [], "get": [], "delete": []} + responses = { + "save": (200, {"status": "saved", "backup": {"id": "b-1"}}), + "list": (200, {"backups": []}), + "get": (200, {"backup": {}}), + "delete": (200, {"status": "deleted"}), + } + + def make(name): + async def call(*args): + calls[name].append(args) + return responses[name] + + return call + + monkeypatch.setattr(cloud_link, "backup_save", make("save")) + monkeypatch.setattr(cloud_link, "backup_list", make("list")) + monkeypatch.setattr(cloud_link, "backup_get", make("get")) + monkeypatch.setattr(cloud_link, "backup_delete", make("delete")) + return calls, responses + + +def test_sanitize_frame_dict_strips_machine_secrets_but_keeps_app_api_keys(db): + frame_dict = { + "id": 7, + "name": "Kitchen", + "ssh_pass": "x", + "ssh_keys": {"private": "KEY"}, + "frame_access_key": "x", + "server_api_key": "x", + "frame_admin_auth": {"user": "a", "pass": "b"}, + "https_proxy": {"certs": {"server_key": "TLS"}}, + "last_successful_deploy": {"ssh_pass": "x"}, + "terminal_history": ["secrets typed here"], + "network": {"wifiSSID": "Home", "wifiPassword": "hunter2", "wifiHotspotPassword": "x"}, + "agent": {"agentEnabled": True, "agentSharedSecret": "x"}, + "device_config": { + "uploadHeaders": [{"name": "Authorization", "value": "Bearer upload-api-key"}], + }, + "scenes": [{"id": "s", "apiKey": "scene-api-key", "accessToken": "scene-access-token"}], + } + clean = cloud_backup.sanitize_frame_dict(frame_dict) + dumped = json.dumps(clean) + for secret in ("hunter2", "KEY", "TLS", "ssh_pass", "agentSharedSecret", "terminal_history"): + assert secret not in dumped + assert clean["network"]["wifiSSID"] == "Home" + assert clean["agent"]["agentEnabled"] is True + assert clean["device_config"]["uploadHeaders"] == [ + {"name": "Authorization", "value": "Bearer upload-api-key"} + ] + assert clean["scenes"] == [ + {"id": "s", "apiKey": "scene-api-key", "accessToken": "scene-access-token"} + ] + + +@pytest.mark.asyncio +async def test_backup_frame_pushes_sanitized_payload(async_client, db, backup_calls): + calls, _ = backup_calls + make_connected_link(db) + frame = make_frame(db, async_client.project_id) + + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 200, response.text + + provider_url, token, payload = calls["save"][0] + assert payload["kind"] == "frames" + assert payload["item_key"] == f"frame-{frame.id}" + content = json.loads(base64.b64decode(payload["content_base64"])) + assert content["format"] == "frameos-frame-backup-v1" + assert content["frame"]["name"] == "Kitchen frame" + dumped = json.dumps(content) + for secret in ("super-secret-pass", "wifi-secret", "agent-secret", "PRIVATE KEY", "TLS KEY", "frame-access-key-1", "server-api-key-1"): + assert secret not in dumped + + +@pytest.mark.asyncio +async def test_backup_requires_scope(async_client, db, backup_calls): + make_connected_link(db, scope="backend:link backend:read") + frame = make_frame(db, async_client.project_id) + + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 403 + + listing = await async_client.get("/api/cloud/backups") + assert listing.status_code == 200 + assert listing.json()["missing_scope"] is True + + +@pytest.mark.asyncio +async def test_backup_requires_link(async_client, db, backup_calls): + frame = make_frame(db, async_client.project_id) + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_backup_requires_local_switch(async_client, db, backup_calls): + """The scope is a permission; nothing uploads until the feature is on.""" + calls, _ = backup_calls + make_connected_link(db, backups_enabled=False) + frame = make_frame(db, async_client.project_id) + template = make_template(db, async_client.project_id) + + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 403 + response = await async_client.post( + "/api/cloud/backups/templates", json={"template_id": str(template.id)} + ) + assert response.status_code == 403 + assert calls["save"] == [] + + response = await async_client.post("/api/cloud/backup-features", json={"scenes": True}) + assert response.status_code == 200 + data = response.json() + assert data["backup_scenes_enabled"] is True + assert data["backup_frames_enabled"] is False + + response = await async_client.post( + "/api/cloud/backups/templates", json={"template_id": str(template.id)} + ) + assert response.status_code == 200, response.text + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_backup_template_and_list(async_client, db, backup_calls): + calls, responses = backup_calls + make_connected_link(db) + template = make_template(db, async_client.project_id) + + response = await async_client.post( + "/api/cloud/backups/templates", json={"template_id": str(template.id)} + ) + assert response.status_code == 200, response.text + _, _, payload = calls["save"][0] + assert payload["kind"] == "templates" + assert payload["item_key"] == f"template-{template.id}" + assert payload["content_type"] == "application/zip" + + responses["list"] = ( + 200, + {"backups": [{"id": "b-1", "kind": "templates", "item_key": payload["item_key"]}]}, + ) + listing = await async_client.get("/api/cloud/backups") + assert listing.status_code == 200 + assert listing.json()["backups"][0]["id"] == "b-1" + + +@pytest.mark.asyncio +async def test_restore_template_backup(async_client, db, backup_calls): + from app.api.templates import template_zip_bytes + + calls, responses = backup_calls + make_connected_link(db) + template = make_template(db, async_client.project_id) + zip_bytes = template_zip_bytes(template) + db.delete(template) + db.commit() + + responses["get"] = ( + 200, + { + "backup": { + "id": "b-1", + "kind": "templates", + "name": "My template", + "content_base64": base64.b64encode(zip_bytes).decode(), + } + }, + ) + response = await async_client.post( + "/api/cloud/backups/restore", + json={"backup_id": "b-1", "project_id": async_client.project_id}, + ) + assert response.status_code == 200, response.text + assert response.json()["kind"] == "template" + + restored = db.query(Template).first() + assert restored is not None + assert restored.name == "My template" + assert restored.scenes == [{"id": "scene-1", "nodes": []}] + + +@pytest.mark.asyncio +async def test_restore_frame_backup(async_client, db, backup_calls): + calls, responses = backup_calls + make_connected_link(db) + frame = make_frame(db, async_client.project_id) + payload = cloud_backup.frame_backup_payload(frame.to_dict(), "Default Project") + db.delete(frame) + db.commit() + + responses["get"] = ( + 200, + { + "backup": { + "id": "b-2", + "kind": "frames", + "name": "Kitchen frame", + "content_base64": base64.b64encode(json.dumps(payload).encode()).decode(), + } + }, + ) + response = await async_client.post( + "/api/cloud/backups/restore", + json={"backup_id": "b-2", "project_id": async_client.project_id}, + ) + assert response.status_code == 200, response.text + assert response.json()["kind"] == "frame" + + restored = db.query(Frame).first() + assert restored is not None + assert restored.name == "Kitchen frame" + assert restored.status == "uninitialized" + assert restored.scenes == [{"id": "scene-1", "nodes": []}] + # Secrets were never in the backup; fresh ones are generated locally. + assert restored.ssh_pass is None + assert restored.frame_access_key + assert restored.frame_access_key != "frame-access-key-1" + assert restored.server_api_key + assert restored.server_api_key != "server-api-key-1" + assert restored.agent["agentEnabled"] is True + assert restored.agent["agentSharedSecret"] + assert restored.agent["agentSharedSecret"] != "agent-secret" + assert restored.network.get("wifiSSID") == "HomeWifi" + assert "wifiPassword" not in (restored.network or {}) + + +@pytest.mark.asyncio +async def test_export_tarball(async_client, db): + frame = make_frame(db, async_client.project_id) + template = make_template(db, async_client.project_id) + + response = await async_client.get("/api/backup/export") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/gzip" + + with tarfile.open(fileobj=io.BytesIO(response.content), mode="r:gz") as tar: + names = tar.getnames() + assert "manifest.json" in names + frame_path = f"projects/{async_client.project_id}/frames/frame-{frame.id}.json" + assert frame_path in names + assert any(name.startswith(f"projects/{async_client.project_id}/templates/") for name in names) + + manifest = json.loads(tar.extractfile("manifest.json").read()) + assert manifest["format"] == "frameos-backup-v1" + assert manifest["projects"][0]["frames"] == 1 + assert manifest["projects"][0]["templates"] == 1 + + # The local tarball keeps full fidelity, credentials included. + frame_json = json.loads(tar.extractfile(frame_path).read()) + assert frame_json["ssh_pass"] == "super-secret-pass" + + +@pytest.mark.asyncio +async def test_export_requires_login(db): + from httpx import AsyncClient + from httpx._transports.asgi import ASGITransport + from app.fastapi import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.get("/api/backup/export") + assert response.status_code == 401 diff --git a/backend/app/cloud/sync.py b/backend/app/cloud/sync.py index 67b010620..bdc28871b 100644 --- a/backend/app/cloud/sync.py +++ b/backend/app/cloud/sync.py @@ -1,4 +1,4 @@ -"""FrameOS Cloud sync service (CLOUD-TODO Phase 1). +"""FrameOS Cloud sync service (CLOUD-TODO Phases 1 and 3). Runs as a single asyncio task inside the arq worker (same singleton slot as the Home Assistant sync). Responsibilities: @@ -7,6 +7,9 @@ revocation takes effect quickly. A 401 means the link was revoked: the local link resets and local password login is re-enabled so nobody is locked out. - Inventory heartbeat: keeps version/health fresh on the provider. +- Automatic frame backups (scope ``backup:frames``): watches the + ``update_frame`` broadcast for a changed ``last_successful_deploy_at`` — + i.e. a finished deploy — and pushes a sanitized frame backup. """ from __future__ import annotations @@ -20,12 +23,18 @@ from app.config import config from app.database import SessionLocal from app.models.cloud import CloudBackendLink, CloudMembership, current_cloud_backend_link -from app.utils import cloud_link +from app.utils import cloud_backup, cloud_link +BROADCAST_CHANNEL = "broadcast_channel" GRANT_SYNC_INTERVAL_SECONDS = 15 * 60 class CloudSync: + def __init__(self): + # frame_id -> last_successful_deploy_at we already backed up + self._deploys_seen: dict[int, str] = {} + self._deploys_primed = False + # ---- lifecycle ---------------------------------------------------------- async def run(self): @@ -45,7 +54,10 @@ async def _run_once(self): redis_sub = create_redis(config.REDIS_URL, decode_responses=True) try: pubsub = redis_sub.pubsub() - await pubsub.subscribe(CLOUD_SYNC_CHANNEL) + await pubsub.subscribe(BROADCAST_CHANNEL, CLOUD_SYNC_CHANNEL) + # Learn current deploy stamps before listening so a restart neither + # re-pushes every frame nor mistakes old deploys for new ones. + self._prime_deploys_seen() await self._sync_link() next_sync = asyncio.get_running_loop().time() + GRANT_SYNC_INTERVAL_SECONDS while True: @@ -72,6 +84,9 @@ async def _handle_message(self, message: dict): if message.get("channel") == CLOUD_SYNC_CHANNEL: if parsed.get("event") == "sync_now": await self._sync_link() + return + if parsed.get("event") == "update_frame" and isinstance(parsed.get("data"), dict): + await self._maybe_backup_frame(parsed["data"]) # ---- grants + inventory -------------------------------------------------- @@ -79,11 +94,7 @@ def _load_link(self) -> tuple[Optional[CloudBackendLink], Optional[str], int]: db = SessionLocal() try: link = current_cloud_backend_link(db) - token = ( - cloud_link.decrypt_cloud_secret(link.access_token) - if link is not None and link.status == "connected" - else None - ) + token = cloud_backup.link_access_token(link) return link, token, link.id if link else 0 finally: db.close() @@ -194,6 +205,82 @@ def _revoke_link_locally(self, link_id: int): finally: db.close() + # ---- automatic frame backups ---------------------------------------------- + + def _prime_deploys_seen(self): + """Learn current deploy stamps so a worker restart doesn't re-push everything.""" + from app.models.frame import Frame + + db = SessionLocal() + try: + for frame_id, backed_up_at in db.query(Frame.id, Frame.last_cloud_backup_deploy_at).all(): + if backed_up_at is not None: + self._deploys_seen[frame_id] = backed_up_at.isoformat() + finally: + db.close() + self._deploys_primed = True + + def _mark_deploy_backed_up(self, frame_id: int, marker: str): + import datetime + + from app.models.frame import Frame + + backed_up_at = datetime.datetime.fromisoformat(marker.removesuffix("Z")) + db = SessionLocal() + try: + frame = db.get(Frame, frame_id) + if frame is not None: + frame.last_cloud_backup_deploy_at = backed_up_at + db.commit() + finally: + db.close() + + async def _maybe_backup_frame(self, frame_dict: dict): + frame_id = frame_dict.get("id") + deployed_at = frame_dict.get("last_successful_deploy_at") + if frame_id is None or not deployed_at: + return + if not self._deploys_primed: + self._prime_deploys_seen() + # The isoformat here comes from Frame.to_dict() (UTC-stamped), while the + # primed value is the naive column isoformat; compare loosely. + marker = str(deployed_at).replace("+00:00", "") + if self._deploys_seen.get(frame_id) == marker: + return + + link, access_token, _link_id = self._load_link() + if link is None or access_token is None or "backup:frames" not in link.scopes: + return + if not link.backup_frames_enabled: + # The scope is a permission; the local switch is the feature. + return + project_name = self._project_name(frame_dict.get("project_id")) + try: + status_code, response = await cloud_backup.push_frame_backup( + link, access_token, frame_dict, project_name + ) + if status_code == 200: + self._mark_deploy_backed_up(frame_id, marker) + self._deploys_seen[frame_id] = marker + print(f"🟢 FrameOS Cloud: backed up frame {frame_id} after deploy") + else: + detail = response.get("error") or status_code + print(f"🟡 FrameOS Cloud: frame {frame_id} backup failed: {detail}") + except Exception as e: # noqa: BLE001 + print(f"🟡 FrameOS Cloud: frame {frame_id} backup failed: {e}") + + def _project_name(self, project_id) -> Optional[str]: + if project_id is None: + return None + from app.models.organization import Project + + db = SessionLocal() + try: + project = db.get(Project, project_id) + return project.name if project else None + finally: + db.close() + def _frameos_version() -> str: from app.utils.versions import current_frameos_version diff --git a/backend/app/cloud/tests/test_sync.py b/backend/app/cloud/tests/test_sync.py index e2b102669..d2b5a9709 100644 --- a/backend/app/cloud/tests/test_sync.py +++ b/backend/app/cloud/tests/test_sync.py @@ -1,14 +1,19 @@ -"""The cloud sync singleton: grants sync and revocation handling.""" +"""The cloud sync singleton: grants sync, revocation handling, auto backups.""" +import base64 +import datetime +import json + import pytest from app.cloud.sync import CloudSync +from app.models import new_frame, update_frame from app.models.cloud import CloudBackendLink, CloudMembership from app.utils import cloud_link PROVIDER = "https://cloud.frameos.net" -def make_connected_link(db, scope="backend:link backend:read"): +def make_connected_link(db, scope="backend:link backend:read backup:frames", backup_frames_enabled=True): link = CloudBackendLink( provider_url=PROVIDER, status="connected", @@ -17,6 +22,7 @@ def make_connected_link(db, scope="backend:link backend:read"): scope=scope, local_origin="http://test", local_fallback_enabled=False, + backup_frames_enabled=backup_frames_enabled, ) db.add(link) db.commit() @@ -31,7 +37,7 @@ def service(): @pytest.fixture def cloud_calls(monkeypatch): - calls = {"grants": [], "inventory": []} + calls = {"grants": [], "inventory": [], "backup_save": []} responses = { "grants": ( 200, @@ -43,6 +49,7 @@ def cloud_calls(monkeypatch): }, ), "inventory": (200, {"status": "synced"}), + "backup_save": (200, {"status": "saved", "backup": {"id": "b-1"}}), } def make(name): @@ -57,6 +64,7 @@ async def call(*args): monkeypatch.setattr(cloud_link, "backend_grants", make("grants")) monkeypatch.setattr(cloud_link, "backend_inventory", make("inventory")) + monkeypatch.setattr(cloud_link, "backup_save", make("backup_save")) return calls, responses @@ -115,3 +123,112 @@ async def boom(*_args): db.expire_all() link = db.get(CloudBackendLink, link.id) assert link.status == "connected" + + +@pytest.mark.asyncio +async def test_deploy_broadcast_triggers_backup(db, redis, service, cloud_calls): + calls, _ = cloud_calls + make_connected_link(db) + frame = await new_frame(db, redis, "Kitchen", "localhost", "localhost") + service._prime_deploys_seen() # startup does this before listening + + # A frame update without a deploy stamp does nothing. + await service._maybe_backup_frame(frame.to_dict()) + assert calls["backup_save"] == [] + + frame.last_successful_deploy_at = datetime.datetime.utcnow() + await update_frame(db, redis, frame) + await service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 1 + db.refresh(frame) + assert frame.last_cloud_backup_deploy_at == frame.last_successful_deploy_at + _provider, _token, payload = calls["backup_save"][0] + assert payload["kind"] == "frames" + assert payload["item_key"] == f"frame-{frame.id}" + content = json.loads(base64.b64decode(payload["content_base64"])) + assert content["frame"]["name"] == "Kitchen" + assert "ssh_pass" not in content["frame"] + + # The same deploy stamp is not pushed twice. + await service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 1 + + # A new deploy is. + frame.last_successful_deploy_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=5) + await update_frame(db, redis, frame) + await service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 2 + + +@pytest.mark.asyncio +async def test_deploy_backup_needs_the_local_switch(db, redis, service, cloud_calls): + """The backup:frames scope alone must not upload anything.""" + calls, _ = cloud_calls + make_connected_link(db, backup_frames_enabled=False) + frame = await new_frame(db, redis, "Kitchen", "localhost", "localhost") + service._prime_deploys_seen() + + frame.last_successful_deploy_at = datetime.datetime.utcnow() + await update_frame(db, redis, frame) + await service._maybe_backup_frame(frame.to_dict()) + assert calls["backup_save"] == [] + + +@pytest.mark.asyncio +async def test_failed_deploy_backup_retries_after_worker_restart(db, redis, service, cloud_calls): + calls, responses = cloud_calls + make_connected_link(db) + frame = await new_frame(db, redis, "Kitchen", "localhost", "localhost") + service._prime_deploys_seen() + frame.last_successful_deploy_at = datetime.datetime.utcnow() + await update_frame(db, redis, frame) + + responses["backup_save"] = RuntimeError("connection reset") + await service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 1 + db.refresh(frame) + assert frame.last_cloud_backup_deploy_at is None + + restarted_service = CloudSync() + restarted_service._prime_deploys_seen() + responses["backup_save"] = (503, {"error": "temporarily_unavailable"}) + await restarted_service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 2 + + responses["backup_save"] = (200, {"status": "saved"}) + await restarted_service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 3 + db.refresh(frame) + assert frame.last_cloud_backup_deploy_at == frame.last_successful_deploy_at + + await restarted_service._maybe_backup_frame(frame.to_dict()) + assert len(calls["backup_save"]) == 3 + + +@pytest.mark.asyncio +async def test_deploy_backup_needs_scope(db, redis, service, cloud_calls): + calls, _ = cloud_calls + make_connected_link(db, scope="backend:link backend:read") + frame = await new_frame(db, redis, "Kitchen", "localhost", "localhost") + service._prime_deploys_seen() + frame.last_successful_deploy_at = datetime.datetime.utcnow() + await update_frame(db, redis, frame) + + await service._maybe_backup_frame(frame.to_dict()) + assert calls["backup_save"] == [] + + +@pytest.mark.asyncio +async def test_priming_prevents_startup_backup_storm(db, redis, service, cloud_calls): + calls, _ = cloud_calls + make_connected_link(db) + frame = await new_frame(db, redis, "Kitchen", "localhost", "localhost") + frame.last_successful_deploy_at = datetime.datetime.utcnow() + frame.last_cloud_backup_deploy_at = frame.last_successful_deploy_at + await update_frame(db, redis, frame) + + # Simulate a worker restart: the first event after priming carries a stamp + # that predates the restart, so nothing is pushed. + service._prime_deploys_seen() + await service._maybe_backup_frame(frame.to_dict()) + assert calls["backup_save"] == [] diff --git a/backend/app/models/cloud.py b/backend/app/models/cloud.py index 7c2836a90..4b32793df 100644 --- a/backend/app/models/cloud.py +++ b/backend/app/models/cloud.py @@ -62,6 +62,10 @@ class CloudBackendLink(Base): local_organization_id = mapped_column(Integer, ForeignKey("organization.id", ondelete="SET NULL"), nullable=True) local_project_id = mapped_column(Integer, ForeignKey("project.id", ondelete="SET NULL"), nullable=True) local_fallback_enabled = mapped_column(Boolean, nullable=False, default=True) + # Local feature switches: the backup scopes come with every cloud account, + # but no data leaves this install until the user turns the feature on. + backup_scenes_enabled = mapped_column(Boolean, nullable=False, default=False) + backup_frames_enabled = mapped_column(Boolean, nullable=False, default=False) last_inventory_sync_at = mapped_column(DateTime, nullable=True) last_grant_sync_at = mapped_column(DateTime, nullable=True) revoked_at = mapped_column(DateTime, nullable=True) diff --git a/backend/app/models/frame.py b/backend/app/models/frame.py index 3b03eee21..b0a258ad3 100644 --- a/backend/app/models/frame.py +++ b/backend/app/models/frame.py @@ -341,6 +341,7 @@ class Frame(Base): scenes = mapped_column(JSON, nullable=True, default=list) last_successful_deploy = mapped_column(JSON, nullable=True) # contains frame.to_dict() of last successful deploy last_successful_deploy_at = mapped_column(DateTime, nullable=True) + last_cloud_backup_deploy_at = mapped_column(DateTime, nullable=True) schedule = mapped_column(JSON, nullable=True) gpio_buttons = mapped_column(JSON, nullable=True) network = mapped_column(JSON, nullable=True) diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index cfca70347..fd13107f5 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -40,3 +40,23 @@ class CloudLocalFallbackRequest(BaseModel): class CloudFeaturesRequest(BaseModel): # The full desired set of feature scopes (base link scopes are implied). scopes: list[str] + + +class CloudBackupFeaturesRequest(BaseModel): + # Local on/off switches for what actually gets uploaded; omitted fields + # stay unchanged. The backup scopes themselves come with the account. + scenes: bool | None = None + frames: bool | None = None + + +class CloudBackupSaveTemplateRequest(BaseModel): + template_id: str + + +class CloudBackupSaveFrameRequest(BaseModel): + frame_id: int + + +class CloudBackupRestoreRequest(BaseModel): + backup_id: str + project_id: int diff --git a/backend/app/utils/cloud_backup.py b/backend/app/utils/cloud_backup.py new file mode 100644 index 000000000..5f33bac9a --- /dev/null +++ b/backend/app/utils/cloud_backup.py @@ -0,0 +1,108 @@ +"""Config backups to a FrameOS Cloud provider (CLOUD-TODO Phase 3). + +Two kinds ship in this phase, mirrored by the provider's /api/backends/backups +endpoints (docs/cloud-link.md): + +- ``templates`` (scope ``backup:scenes``): the scene/template interchange zip. + The kind string predates the templates→scenes rename and stays for protocol + stability; every user-facing label says "scene". +- ``frames`` (scope ``backup:frames``): frame metadata + scene JSON. Local + machine credentials (SSH, frame access, TLS, agent secrets) are stripped, + while app API keys/tokens and configured upload headers are intentionally + retained so restored scenes keep working. Asset backups (Phase 4) will be + client-side encrypted instead. +""" +from __future__ import annotations + +import base64 +import datetime +import json +from typing import Any + +from app.models.cloud import CloudBackendLink +from app.utils import cloud_link as cloud + +FRAME_BACKUP_FORMAT = "frameos-frame-backup-v1" + +# Top-level Frame.to_dict() fields that never leave the install. +SENSITIVE_FRAME_FIELDS = { + "ssh_pass", + "ssh_keys", + "frame_access_key", + "server_api_key", + "frame_admin_auth", + "https_proxy", # contains TLS private keys + "last_successful_deploy", # a full nested snapshot incl. the same secrets + "terminal_history", +} + +# Key-name fragments scrubbed recursively from nested JSON blobs +# (network.wifiPassword, agent.agentSharedSecret, ...). +SENSITIVE_KEY_FRAGMENTS = ("password", "secret", "psk") + + +def _scrub(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _scrub(item) + for key, item in value.items() + if not any(fragment in key.lower() for fragment in SENSITIVE_KEY_FRAGMENTS) + } + if isinstance(value, list): + return [_scrub(item) for item in value] + return value + + +def sanitize_frame_dict(frame_dict: dict) -> dict: + """Frame metadata with machine credentials removed and app API keys kept.""" + cleaned = {key: value for key, value in frame_dict.items() if key not in SENSITIVE_FRAME_FIELDS} + return _scrub(cleaned) + + +def frame_backup_payload(frame_dict: dict, project_name: str | None = None) -> dict: + return { + "format": FRAME_BACKUP_FORMAT, + "saved_at": datetime.datetime.utcnow().isoformat(), + "project_name": project_name, + "frame": sanitize_frame_dict(frame_dict), + } + + +def link_access_token(link: CloudBackendLink | None) -> str | None: + if link is None or link.status != "connected": + return None + return cloud.decrypt_cloud_secret(link.access_token) + + +async def push_frame_backup( + link: CloudBackendLink, access_token: str, frame_dict: dict, project_name: str | None = None +) -> tuple[int, dict]: + payload = frame_backup_payload(frame_dict, project_name) + content = json.dumps(payload).encode() + return await cloud.backup_save( + link.provider_url, + access_token, + { + "kind": "frames", + "item_key": f"frame-{frame_dict.get('id')}", + "name": frame_dict.get("name") or f"Frame {frame_dict.get('id')}", + "content_base64": base64.b64encode(content).decode(), + "content_type": "application/json", + }, + ) + + +async def push_template_backup( + link: CloudBackendLink, access_token: str, template_id: str, template_name: str | None, zip_bytes: bytes +) -> tuple[int, dict]: + return await cloud.backup_save( + link.provider_url, + access_token, + { + "kind": "templates", + "item_key": f"template-{template_id}", + "name": template_name or "Template", + "content_base64": base64.b64encode(zip_bytes).decode(), + "content_type": "application/zip", + }, + ) diff --git a/backend/app/utils/cloud_link.py b/backend/app/utils/cloud_link.py index 7f81df5a1..e89da7c3a 100644 --- a/backend/app/utils/cloud_link.py +++ b/backend/app/utils/cloud_link.py @@ -166,3 +166,34 @@ async def frameos_login_token( return await cloud_request( "POST", provider_url, "/api/frameos/login/token", access_token=access_token, json_body={"code": code} ) + + +# ---- config backups (Phase 3) ------------------------------------------------ + + +async def backup_list(provider_url: str, access_token: str) -> tuple[int, dict[str, Any]]: + return await cloud_request("GET", provider_url, "/api/backends/backups", access_token=access_token) + + +async def backup_save( + provider_url: str, access_token: str, payload: dict[str, Any] +) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "POST", provider_url, "/api/backends/backups", access_token=access_token, json_body=payload + ) + + +async def backup_get( + provider_url: str, access_token: str, backup_id: str +) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "GET", provider_url, f"/api/backends/backups/{backup_id}", access_token=access_token + ) + + +async def backup_delete( + provider_url: str, access_token: str, backup_id: str +) -> tuple[int, dict[str, Any]]: + return await cloud_request( + "DELETE", provider_url, f"/api/backends/backups/{backup_id}", access_token=access_token + ) diff --git a/backend/migrations/versions/a9d2c1e4f6b3_cloud_backup_feature_toggles.py b/backend/migrations/versions/a9d2c1e4f6b3_cloud_backup_feature_toggles.py new file mode 100644 index 000000000..5d0218a77 --- /dev/null +++ b/backend/migrations/versions/a9d2c1e4f6b3_cloud_backup_feature_toggles.py @@ -0,0 +1,34 @@ +"""local on/off switches for cloud scene + frame backups + +The backup scopes are included with every cloud account, but backups must not +upload anything until the user explicitly enables the feature. + +Revision ID: a9d2c1e4f6b3 +Revises: f2c4d6e8a0b1 +Create Date: 2026-07-15 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "a9d2c1e4f6b3" +down_revision = "f2c4d6e8a0b1" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("cloud_backend_link") as batch_op: + batch_op.add_column( + sa.Column("backup_scenes_enabled", sa.Boolean(), nullable=False, server_default=sa.false()) + ) + batch_op.add_column( + sa.Column("backup_frames_enabled", sa.Boolean(), nullable=False, server_default=sa.false()) + ) + + +def downgrade(): + with op.batch_alter_table("cloud_backend_link") as batch_op: + batch_op.drop_column("backup_frames_enabled") + batch_op.drop_column("backup_scenes_enabled") diff --git a/backend/migrations/versions/f2c4d6e8a0b1_track_cloud_backup_deploy.py b/backend/migrations/versions/f2c4d6e8a0b1_track_cloud_backup_deploy.py new file mode 100644 index 000000000..5893b76da --- /dev/null +++ b/backend/migrations/versions/f2c4d6e8a0b1_track_cloud_backup_deploy.py @@ -0,0 +1,32 @@ +"""track the deploy timestamp successfully backed up to cloud + +Revision ID: f2c4d6e8a0b1 +Revises: e3a1b5c7d9f2 +Create Date: 2026-07-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "f2c4d6e8a0b1" +down_revision = "e3a1b5c7d9f2" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("frame") as batch_op: + batch_op.add_column(sa.Column("last_cloud_backup_deploy_at", sa.DateTime(), nullable=True)) + + # Existing deploys predate durable success tracking. Treat them as seen so + # upgrading a worker does not upload every frame at once. + op.execute( + "UPDATE frame SET last_cloud_backup_deploy_at = last_successful_deploy_at " + "WHERE last_successful_deploy_at IS NOT NULL" + ) + + +def downgrade(): + with op.batch_alter_table("frame") as batch_op: + batch_op.drop_column("last_cloud_backup_deploy_at") diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png index 6a84f5a5b..8eb4eb5ef 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png index ef5b07536..09a78153c 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png index 200c43785..ccab3f6f9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png index c0dd81bd1..b9c470a1d 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png index 749857c4e..a5c7929e9 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png index e1511251c..d4af88ead 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png differ diff --git a/frontend/src/scenes/settings/CloudSettings.tsx b/frontend/src/scenes/settings/CloudSettings.tsx index 91e185890..b4d878baf 100644 --- a/frontend/src/scenes/settings/CloudSettings.tsx +++ b/frontend/src/scenes/settings/CloudSettings.tsx @@ -58,6 +58,12 @@ export function CloudSettingsSection({ headingId = 'settings-cloud' }: { heading enabledFeatureDraft, featureChangesPending, isFeatureChangeSubmitting, + cloudBackups, + cloudBackupsLoading, + isCloudBackupRunning, + restoringBackupId, + hasBackupScope, + anyBackupEnabled, } = useValues(cloudLogic) const { connectCloud, @@ -70,6 +76,10 @@ export function CloudSettingsSection({ headingId = 'settings-cloud' }: { heading linkCloudIdentity, unlinkCloudIdentity, setLocalFallback, + setBackupFeature, + loadCloudBackups, + backupAllToCloud, + restoreCloudBackup, } = useActions(cloudLogic) const frameAdminMode = isInFrameAdminMode() @@ -158,16 +168,32 @@ export function CloudSettingsSection({ headingId = 'settings-cloud' }: { heading ) : ( <> - {availableCloudFeatures().map(({ scope, label, description, control }) => ( + {availableCloudFeatures().map(({ scope, label, description, control, localKey }) => (