Skip to content

Commit 6337622

Browse files
authored
Merge pull request #8806 from Couchers-org/backend/feature/native-ota-serve
Serve pre-signed native OTA manifests from the CDN
2 parents be8d9ce + 26968bb commit 6337622

2 files changed

Lines changed: 101 additions & 59 deletions

File tree

app/backend/src/couchers/servicers/bugs.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import time
33
from datetime import UTC, datetime
4+
from functools import lru_cache
45
from typing import Any, cast
56

67
import grpc
@@ -44,6 +45,20 @@ def part(name: str, body: str, content_type: str) -> str:
4445
return body.encode("utf-8")
4546

4647

48+
def _native_ota_manifest_url(*, cdn_root: str, version: str, platform: str) -> str:
49+
return f"{cdn_root}/{version}/{platform}/manifest"
50+
51+
52+
@lru_cache(maxsize=64)
53+
def _fetch_signed_manifest(url: str) -> tuple[str, bytes]:
54+
# The publish job signs each manifest and uploads it under its immutable version, so the
55+
# bytes never change once published: fetch once, cache forever, and serve them (signature
56+
# and all) untouched so the on-device signature check sees exactly what was signed.
57+
response = requests.get(url, timeout=10)
58+
response.raise_for_status()
59+
return response.headers["content-type"], response.content
60+
61+
4762
class Bugs(bugs_pb2_grpc.BugsServicer):
4863
def _version(self) -> str:
4964
return cast(str, config["VERSION"])
@@ -125,33 +140,24 @@ def GetNativeUpdateManifest(
125140
platform = cast(str, context.headers.get("expo-platform", "")) or "ios"
126141
runtime_version = cast(str, context.headers.get("expo-runtime-version", ""))
127142

128-
bundles: dict[str, Any] = context.get_object_value("native_ota_bundles", {})
129-
bundle = bundles.get(platform)
130-
if bundle is None or runtime_version == "":
143+
# {platform: {"version": <ota_version>, "runtime_version": <fingerprint>}}: which published
144+
# OTA is live per platform, and the build fingerprint it was cut for.
145+
releases: dict[str, Any] = context.get_object_value("native_ota_bundles", {})
146+
release = releases.get(platform)
147+
148+
# A signed manifest carries a fixed runtimeVersion and Expo rejects it on a build with any
149+
# other fingerprint, so we only point a client at the OTA published for its own build.
150+
if release is None or runtime_version == "" or release.get("runtime_version") != runtime_version:
131151
directive = {"type": "noUpdateAvailable"}
132152
return httpbody_pb2.HttpBody(
133153
content_type=f"multipart/mixed; boundary={_OTA_BOUNDARY}",
134154
data=_ota_multipart_body("directive", directive),
135155
)
136156

137-
manifest = {
138-
"id": bundle["id"],
139-
"createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
140-
"runtimeVersion": runtime_version,
141-
"launchAsset": {
142-
"key": bundle["launch_asset"]["key"],
143-
"contentType": "application/javascript",
144-
"url": bundle["launch_asset"]["url"],
145-
},
146-
"assets": bundle.get("assets", []),
147-
"metadata": {},
148-
"extra": {"expoClient": {}},
149-
}
150-
151-
return httpbody_pb2.HttpBody(
152-
content_type=f"multipart/mixed; boundary={_OTA_BOUNDARY}",
153-
data=_ota_multipart_body("manifest", manifest),
154-
)
157+
cdn_root = context.get_string_value("native_ota_cdn_root", "https://cdn.couchers.org/native/ota")
158+
url = _native_ota_manifest_url(cdn_root=cdn_root, version=release["version"], platform=platform)
159+
content_type, body = _fetch_signed_manifest(url)
160+
return httpbody_pb2.HttpBody(content_type=content_type, data=body)
155161

156162
def ReportDiagnostics(
157163
self, request: bugs_pb2.ReportDiagnosticsReq, context: CouchersContext, session: Session

app/backend/src/tests/test_bugs.py

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from datetime import UTC, datetime
3-
from unittest.mock import patch
3+
from unittest.mock import MagicMock, patch
44

55
import grpc
66
import pytest
@@ -13,6 +13,7 @@
1313
from couchers.models.logging import EventLog, EventSource
1414
from couchers.proto import bugs_pb2
1515
from couchers.proto.google.api import httpbody_pb2
16+
from couchers.servicers.bugs import _fetch_signed_manifest
1617
from tests.fixtures.db import generate_user
1718
from tests.fixtures.sessions import bugs_session, real_bugs_session
1819

@@ -403,56 +404,80 @@ def _multipart_part_json(body, name):
403404
return json.loads(body[start:end])
404405

405406

406-
_OTA_BUNDLES = {
407-
"ios": {
408-
"id": "00000000-0000-0000-0000-000000000000",
409-
"launch_asset": {"key": "ios-key", "url": "https://cdn.example/ios/bundle.hbc"},
410-
"assets": [],
411-
},
412-
"android": {
413-
"id": "00000000-0000-0000-0000-000000000000",
414-
"launch_asset": {"key": "android-key", "url": "https://cdn.example/android/bundle.hbc"},
415-
"assets": [],
416-
},
407+
_OTA_RELEASES = {
408+
"ios": {"version": "v1.2.18355.fc38c23d", "runtime_version": "ios-fingerprint"},
409+
"android": {"version": "v1.2.18356.ab12cd34", "runtime_version": "android-fingerprint"},
417410
}
418411

412+
# A stand-in for the pre-signed multipart body the CDN serves; the servicer must hand it back
413+
# byte-for-byte, signature and all, so the test asserts on identity rather than on its contents.
414+
_SIGNED_MANIFEST = b'--COUCHERS_OTA_BOUNDARY\r\ncontent-disposition: form-data; name="manifest"\r\nexpo-signature: sig="abc", keyid="main", alg="rsa-v1_5-sha256"\r\n\r\n{}\r\n--COUCHERS_OTA_BOUNDARY--\r\n'
415+
_CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY"
416+
417+
418+
def _fake_cdn_response():
419+
response = MagicMock()
420+
response.headers = {"content-type": _CDN_CONTENT_TYPE}
421+
response.content = _SIGNED_MANIFEST
422+
response.raise_for_status = MagicMock()
423+
return response
424+
425+
426+
def test_native_update_manifest_serves_cdn_manifest_verbatim(db, feature_flags):
427+
feature_flags.set("native_ota_bundles", _OTA_RELEASES)
428+
feature_flags.set("native_ota_cdn_root", "https://cdn.testing.invalid/native/ota")
429+
_fetch_signed_manifest.cache_clear()
430+
with patch("couchers.servicers.bugs.requests.get", return_value=_fake_cdn_response()) as mock_get:
431+
with real_bugs_session() as (bugs, metadata_interceptor):
432+
res = bugs.GetNativeUpdateManifest(
433+
httpbody_pb2.HttpBody(),
434+
metadata=(("expo-platform", "ios"), ("expo-runtime-version", "ios-fingerprint")),
435+
)
419436

420-
def test_native_update_manifest(db, feature_flags):
421-
feature_flags.set("native_ota_bundles", _OTA_BUNDLES)
422-
with real_bugs_session() as (bugs, metadata_interceptor):
423-
res = bugs.GetNativeUpdateManifest(
424-
httpbody_pb2.HttpBody(),
425-
metadata=(("expo-platform", "ios"), ("expo-runtime-version", "my-fingerprint")),
426-
)
427-
428-
assert res.content_type.startswith("multipart/mixed; boundary=")
429-
body = res.data.decode()
430-
431-
manifest = _multipart_part_json(body, "manifest")
432-
# the manifest echoes the build's fingerprint so the client never rejects it
433-
assert manifest["runtimeVersion"] == "my-fingerprint"
434-
assert manifest["launchAsset"]["contentType"] == "application/javascript"
435-
assert manifest["launchAsset"]["url"].endswith("/ios/bundle.hbc")
436-
assert _multipart_part_json(body, "extensions") == {"assetRequestHeaders": {}}
437+
# the signed manifest is forwarded untouched, content type and bytes
438+
assert res.content_type == _CDN_CONTENT_TYPE
439+
assert res.data == _SIGNED_MANIFEST
440+
# fetched from the live release's immutable per-platform CDN path
441+
mock_get.assert_called_once()
442+
assert mock_get.call_args.args[0] == "https://cdn.testing.invalid/native/ota/v1.2.18355.fc38c23d/ios/manifest"
437443

438444
# the client requires these response headers or it rejects the manifest
439445
assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
440446
assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0"
441447

442448

443449
def test_native_update_manifest_android(db, feature_flags):
444-
feature_flags.set("native_ota_bundles", _OTA_BUNDLES)
445-
with real_bugs_session() as (bugs, _metadata_interceptor):
446-
res = bugs.GetNativeUpdateManifest(
447-
httpbody_pb2.HttpBody(),
448-
metadata=(("expo-platform", "android"), ("expo-runtime-version", "fp")),
449-
)
450+
feature_flags.set("native_ota_bundles", _OTA_RELEASES)
451+
feature_flags.set("native_ota_cdn_root", "https://cdn.testing.invalid/native/ota")
452+
_fetch_signed_manifest.cache_clear()
453+
with patch("couchers.servicers.bugs.requests.get", return_value=_fake_cdn_response()) as mock_get:
454+
with real_bugs_session() as (bugs, _metadata_interceptor):
455+
bugs.GetNativeUpdateManifest(
456+
httpbody_pb2.HttpBody(),
457+
metadata=(("expo-platform", "android"), ("expo-runtime-version", "android-fingerprint")),
458+
)
459+
460+
assert mock_get.call_args.args[0] == "https://cdn.testing.invalid/native/ota/v1.2.18356.ab12cd34/android/manifest"
461+
450462

451-
manifest = _multipart_part_json(res.data.decode(), "manifest")
452-
assert manifest["launchAsset"]["url"].endswith("/android/bundle.hbc")
463+
def test_native_update_manifest_runtime_mismatch_returns_directive(db, feature_flags):
464+
feature_flags.set("native_ota_bundles", _OTA_RELEASES)
465+
_fetch_signed_manifest.cache_clear()
466+
# the live release targets a different build fingerprint than this client is running
467+
with patch("couchers.servicers.bugs.requests.get") as mock_get:
468+
with real_bugs_session() as (bugs, _metadata_interceptor):
469+
res = bugs.GetNativeUpdateManifest(
470+
httpbody_pb2.HttpBody(),
471+
metadata=(("expo-platform", "ios"), ("expo-runtime-version", "some-other-fingerprint")),
472+
)
473+
474+
assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}
475+
# a mismatch must not even fetch — the manifest would be rejected on this build
476+
mock_get.assert_not_called()
453477

454478

455-
def test_native_update_manifest_without_runtime_version_returns_directive(db):
479+
def test_native_update_manifest_without_runtime_version_returns_directive(db, feature_flags):
480+
feature_flags.set("native_ota_bundles", _OTA_RELEASES)
456481
with real_bugs_session() as (bugs, metadata_interceptor):
457482
res = bugs.GetNativeUpdateManifest(
458483
httpbody_pb2.HttpBody(),
@@ -462,3 +487,14 @@ def test_native_update_manifest_without_runtime_version_returns_directive(db):
462487
body = res.data.decode()
463488
assert _multipart_part_json(body, "directive") == {"type": "noUpdateAvailable"}
464489
assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1"
490+
491+
492+
def test_native_update_manifest_unconfigured_platform_returns_directive(db, feature_flags):
493+
feature_flags.set("native_ota_bundles", {})
494+
with real_bugs_session() as (bugs, _metadata_interceptor):
495+
res = bugs.GetNativeUpdateManifest(
496+
httpbody_pb2.HttpBody(),
497+
metadata=(("expo-platform", "ios"), ("expo-runtime-version", "ios-fingerprint")),
498+
)
499+
500+
assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"}

0 commit comments

Comments
 (0)