Files
BrowserOS/packages/browseros/build/modules/ota/server.py
Nikhil c1b1e53a86 feat(ota): bundle full server resources tree in Sparkle payload (#726)
* feat(ota): bundle full server resources tree (server + third_party bins)

The OTA Sparkle payload now ships the complete resources/ tree the agent
build produced, not just browseros_server. Every third-party binary (bun,
ripgrep, podman, gvproxy, vfkit, krunkit, podman-mac-helper, win-sshproxy)
flows to OTA-updated installs so podman integration works for users on the
OTA channel, matching fresh Chromium-build installs.

Extract the per-binary sign table into build/common/server_binaries.py so
the Chromium-build sign path (modules/sign/) and OTA sign path (modules/ota/)
share a single source of truth. Adding a new third-party dep is now a
one-file edit that both paths pick up automatically; unknown executables
under resources/bin/ are a hard error at release time.

* fix(ota): address review comments on bundle signing flow

- Avoid double-zipping during notarization: add notarize_macos_zip for
  pre-built Sparkle bundles so notarytool submits the zip directly
  instead of re-wrapping it through ditto --keepParent (Apple's service
  does not descend into nested archives). Keep notarize_macos_binary for
  single-binary callers. Share credential setup + submit logic via
  internal helpers.
- Fail fast on unknown executables in sign_server_bundle_macos: collect
  the unknown-files list before any codesign call so a missing shared-
  table entry aborts in seconds, not after a full signing round.
- Drop dead get_entitlements_path helper (no callers remain after the
  bundle refactor).

* fix(ota): address PR review comments (greptile + claude)

- sign_server_bundle_macos filters to executables only (p.is_file() +
  not p.is_symlink() + os.access X_OK) before applying the unknown-file
  guard. Non-Mach-O files (configs, dylibs, etc.) under resources/bin/
  no longer cause misleading 'unknown executable' hard failures.
- sign_server_bundle_windows now hard-errors on a missing expected
  binary instead of silently skipping it. Symmetric with the macOS
  guard — an incomplete bundle must not publish.
- ServerOTAModule.execute() uses tempfile.TemporaryDirectory context
  managers for both the download and staging roots so they are cleaned
  up on every path, including failures.
- Per-platform sign/notarize/Sparkle-sign failures now raise RuntimeError
  instead of silently skipping the platform — a release pipeline can no
  longer omit a target while reporting success.
- Move import os and import shutil to the top of ota/sign_binary.py.
- Drop unused log_error import from ota/server.py.

* chore: bump server
2026-04-16 12:59:49 -07:00

257 lines
9.3 KiB
Python
Generated

#!/usr/bin/env python3
"""Server OTA module for BrowserOS Server binary updates"""
import shutil
import tempfile
from pathlib import Path
from typing import List, Optional
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import (
log_info,
log_success,
log_warning,
IS_MACOS,
IS_WINDOWS,
)
from .common import (
SERVER_PLATFORMS,
SignedArtifact,
sparkle_sign_file,
generate_server_appcast,
parse_existing_appcast,
create_server_bundle_zip,
get_appcast_path,
find_server_resources_dir,
)
from .sign_binary import (
notarize_macos_zip,
sign_server_bundle_macos,
sign_server_bundle_windows,
)
from ..storage import get_r2_client, upload_file_to_r2, download_file_from_r2
from ..storage.download import extract_artifact_zip
# R2 key pattern for server artifact zips
ARTIFACT_R2_KEY = "artifacts/server/latest/browseros-server-resources-{target}.zip"
class ServerOTAModule(CommandModule):
"""OTA update module for BrowserOS Server binaries
Downloads server binaries from R2 (artifacts/server/latest/),
signs them, creates Sparkle update zips, and uploads to R2.
"""
produces = ["server_ota_artifacts", "server_appcast"]
requires = []
description = "Create and upload BrowserOS Server OTA update"
def __init__(
self,
version: str = "",
channel: str = "alpha",
platform_filter: Optional[str] = None,
):
self.version = version
self.channel = channel
self.platform_filter = platform_filter
self._download_dir: Optional[Path] = None
def validate(self, context: Context) -> None:
if not self.version:
raise ValidationError("Version is required")
if self.channel not in ["alpha", "prod"]:
raise ValidationError("Channel must be 'alpha' or 'prod'")
if IS_MACOS():
if not context.env.macos_certificate_name:
raise ValidationError("MACOS_CERTIFICATE_NAME required for signing")
elif IS_WINDOWS():
if not context.env.code_sign_tool_path:
raise ValidationError("CODE_SIGN_TOOL_PATH required for signing")
if not context.env.has_r2_config():
raise ValidationError(
"R2 configuration not set. Required env vars: "
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
)
def _get_platforms(self) -> List[dict]:
"""Get platforms to process based on filter (supports comma-separated)"""
if self.platform_filter:
requested = [p.strip() for p in self.platform_filter.split(",")]
return [p for p in SERVER_PLATFORMS if p["name"] in requested]
return SERVER_PLATFORMS
def _download_artifacts(self, ctx: Context, download_dir: Path) -> None:
"""Download and extract server artifact zips from R2 into ``download_dir``."""
r2_client = get_r2_client(ctx.env)
if not r2_client:
raise RuntimeError("Failed to create R2 client")
bucket = ctx.env.r2_bucket
platforms = self._get_platforms()
log_info("📥 Downloading server artifacts from R2...")
for platform in platforms:
target = platform["target"]
r2_key = ARTIFACT_R2_KEY.format(target=target)
zip_path = download_dir / f"{target}.zip"
extract_dir = download_dir / target
log_info(f" Downloading {target}...")
if not download_file_from_r2(r2_client, r2_key, zip_path, bucket):
raise RuntimeError(f"Failed to download artifact: {r2_key}")
extract_artifact_zip(zip_path, extract_dir)
zip_path.unlink()
log_success(f"Downloaded {len(platforms)} artifact(s)")
def execute(self, context: Context) -> None:
ctx = context
log_info(f"\n🚀 BrowserOS Server OTA v{self.version} ({self.channel})")
log_info("=" * 70)
with tempfile.TemporaryDirectory(prefix="ota_artifacts_") as dl, \
tempfile.TemporaryDirectory(prefix="ota_staging_") as st:
binaries_dir = Path(dl)
temp_dir = Path(st)
log_info(f"Temp directory: {temp_dir}")
self._download_artifacts(ctx, binaries_dir)
signed_artifacts = self._build_platform_artifacts(
ctx, binaries_dir, temp_dir
)
self._finalize_release(ctx, signed_artifacts)
def _build_platform_artifacts(
self, ctx: Context, binaries_dir: Path, temp_dir: Path
) -> List[SignedArtifact]:
"""Sign + zip + Sparkle-sign each platform; fail fast on any error.
Any per-platform failure raises ``RuntimeError`` so a broken
credential or unregistered binary cannot silently omit a platform
from a published release.
"""
signed_artifacts: List[SignedArtifact] = []
for platform in self._get_platforms():
log_info(f"\n📦 Processing {platform['name']}...")
source_resources = find_server_resources_dir(binaries_dir, platform)
if not source_resources:
raise RuntimeError(
f"Resources dir not found for {platform['name']}"
)
staging_resources = temp_dir / platform["name"] / "resources"
shutil.copytree(source_resources, staging_resources)
if not self._sign_bundle(staging_resources, platform, ctx):
raise RuntimeError(f"Signing failed for {platform['name']}")
zip_name = f"browseros_server_{self.version}_{platform['name']}.zip"
zip_path = temp_dir / zip_name
if not create_server_bundle_zip(staging_resources, zip_path):
raise RuntimeError(f"Failed to create bundle for {platform['name']}")
if platform["os"] == "macos" and IS_MACOS():
if not notarize_macos_zip(zip_path, ctx.env):
raise RuntimeError(
f"Notarization failed for {platform['name']}"
)
log_info(f"Signing {zip_name} with Sparkle...")
signature, length = sparkle_sign_file(zip_path, ctx.env)
if not signature:
raise RuntimeError(f"Sparkle signing failed for {platform['name']}")
log_success(f" {platform['name']}: {length} bytes")
signed_artifacts.append(SignedArtifact(
platform=platform["name"],
zip_path=zip_path,
signature=signature,
length=length,
os=platform["os"],
arch=platform["arch"],
))
if not signed_artifacts:
raise RuntimeError("OTA failed - no artifacts processed")
return signed_artifacts
def _finalize_release(
self, ctx: Context, signed_artifacts: List[SignedArtifact]
) -> None:
"""Write the appcast, upload every signed zip to R2, and surface URLs."""
log_info("\n📝 Generating appcast...")
appcast_path = get_appcast_path(self.channel)
existing_appcast = parse_existing_appcast(appcast_path)
appcast_content = generate_server_appcast(
self.version,
signed_artifacts,
self.channel,
existing=existing_appcast,
)
appcast_path.parent.mkdir(parents=True, exist_ok=True)
appcast_path.write_text(appcast_content)
log_success(f"Appcast saved to: {appcast_path}")
log_info("\n📤 Uploading artifacts to R2...")
r2_client = get_r2_client(ctx.env)
if not r2_client:
raise RuntimeError("Failed to create R2 client")
bucket = ctx.env.r2_bucket
for artifact in signed_artifacts:
r2_key = f"server/{artifact.zip_path.name}"
if not upload_file_to_r2(r2_client, artifact.zip_path, r2_key, bucket):
raise RuntimeError(f"Failed to upload {artifact.zip_path.name}")
ctx.artifacts["server_ota_artifacts"] = signed_artifacts
ctx.artifacts["server_appcast"] = appcast_path
log_info("\n" + "=" * 70)
log_success(f"✅ Server OTA v{self.version} ({self.channel}) artifacts ready!")
log_info("=" * 70)
log_info("\nArtifact URLs:")
for artifact in signed_artifacts:
log_info(f" https://cdn.browseros.com/server/{artifact.zip_path.name}")
log_info(f"\nAppcast saved to: {appcast_path}")
log_info("\n📋 Next step: Run 'browseros ota server release-appcast' to make the release live")
def _sign_bundle(
self, staging_resources: Path, platform: dict, ctx: Context
) -> bool:
"""Codesign every binary in the staged resources tree for a platform.
macOS notarization happens separately, on the outer Sparkle zip.
"""
os_type = platform["os"]
if os_type == "macos":
if not IS_MACOS():
log_warning(
f"macOS signing requires macOS - leaving {platform['name']} unsigned"
)
return True
return sign_server_bundle_macos(
staging_resources, ctx.env, ctx.get_entitlements_dir()
)
if os_type == "windows":
return sign_server_bundle_windows(staging_resources, ctx.env)
log_info("No code signing for Linux binaries")
return True