feat(edge): gate per-minor routing on published minors and add a staging zone

Enabling UNION_SLOTS today would break every client on an R minor we do
not publish. contribPath() redirects on any minor the User-Agent carries,
without checking that the target exists and without a fallback, and only
4.4, 4.5 and 4.6 are published: a 4.3 client would be sent to a 404 and
see no packages at all.

- Gate routing on KNOWN_MINORS, falling back to the flat index otherwise.
- Honour EXTRA_PUBLIC_HOSTS so the same script can run on a staging zone
  and redirect within itself instead of into production.
- Add the cran-rpkgs-test pull zone with UNION_SLOTS pre-enabled, served
  on the bunny default hostname so it needs no DNS record.
- Add scripts/verify-r-minor-routing.sh, covering all 16 slots: index
  reachability, the union property against flat, Path: target
  resolution, coverage parity across minors, and (--live) real
  User-Agent routing.
- Cover the fallback in the edge test suite.
This commit is contained in:
Patrick Schratz 2026-08-30 15:24:43 +00:00
commit 4c1e9b773c
No known key found for this signature in database
GPG key ID: 62050D5BC68AB6DC
2 changed files with 416 additions and 12 deletions

View file

@ -17,6 +17,8 @@ const UNION_SLOTS = 'amd64/alpine324';
const UA_R45_MUSL = 'R (4.5.3 x86_64-pc-linux-musl x86_64 linux-musl)';
const UA_R46_MUSL = 'R (4.6.0 x86_64-pc-linux-musl x86_64 linux-musl)';
const UA_R43_MUSL = 'R (4.3.3 x86_64-pc-linux-musl x86_64 linux-musl)';
const UA_R47_MUSL = 'R (4.7.0 x86_64-pc-linux-musl x86_64 linux-musl)';
const UA_R45_ALPINE = 'R/4.5.3 R (4.5.3 x86_64-pc-linux-musl x86_64 linux-musl) Alpine Linux 3.24';
const UA_R45_RESOLUTE = 'R/4.5.3 (Ubuntu 26.04) (aarch64-unknown-linux-gnu aarch64 linux-gnu)';
const UA_R45_FUTURE_UBUNTU =
@ -96,6 +98,21 @@ Deno.test('rpkgs-router', async (t) => {
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.6/PACKAGES.gz`);
});
// No per-minor index is published for 4.3, and contribPath() cannot probe
// the origin. Routing it would send the client to a 404 and it would see no
// packages at all, so an unpublished minor must fall through to flat.
await t.step('falls back to flat for an R minor that is not published', async () => {
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R43_MUSL);
assertEquals(res.location, null);
assertEquals(res.status, 200);
});
await t.step('falls back to flat for a future R minor', async () => {
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R47_MUSL);
assertEquals(res.location, null);
assertEquals(res.status, 200);
});
await t.step('routes PACKAGES and PACKAGES.rds too', async () => {
for (const file of ['PACKAGES', 'PACKAGES.rds']) {
const res = await probe(`${SLOT}/${file}`, UA_R45_MUSL);
@ -146,18 +163,12 @@ Deno.test('rpkgs-router', async (t) => {
await t.step('resolves Ubuntu 26.04 to the resolute slot', async () => {
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_RESOLUTE);
assertEquals(
res.location,
'https://cran.rpkgs.com/arm64/resolute/latest/src/contrib/PACKAGES.gz',
);
assertEquals(res.location, 'https://cran.rpkgs.com/arm64/resolute/latest/src/contrib/PACKAGES.gz');
});
await t.step('resolves a future Ubuntu release from its codename', async () => {
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_FUTURE_UBUNTU);
assertEquals(
res.location,
'https://cran.rpkgs.com/arm64/dynamic-dugong/latest/src/contrib/PACKAGES.gz',
);
assertEquals(res.location, 'https://cran.rpkgs.com/arm64/dynamic-dugong/latest/src/contrib/PACKAGES.gz');
});
await t.step('sends an unidentifiable distro to CRAN', async () => {

View file

@ -27,7 +27,17 @@ import * as BunnySDK from 'https://esm.sh/@bunny.net/edgescript-sdk@0.12';
const PUBLIC_CDN_ORIGIN = 'https://cran.rpkgs.com';
const CRAN_ORIGIN = 'https://cran.r-project.org';
const PUBLIC_CDN_HOSTS = new Set(['cran.rpkgs.com', 'cran.allianceswisspass.devxy.io']);
const PUBLIC_CDN_HOSTS = new Set([
'cran.rpkgs.com',
'cran.allianceswisspass.devxy.io',
// Staging hostnames, so the identical script can run on a test pull zone and
// redirect within itself. Without this a test zone rewrites to
// PUBLIC_CDN_ORIGIN, quietly exercising production instead of itself.
...(Deno.env.get('EXTRA_PUBLIC_HOSTS') ?? '')
.split(',')
.map((host) => host.trim())
.filter((host) => host.length > 0),
]);
/** Slots ("<arch>/<os>", comma separated) whose per-minor index is a union. */
const UNION_SLOTS = new Set(
@ -37,6 +47,21 @@ const UNION_SLOTS = new Set(
.filter((slot) => slot.length > 0),
);
/**
* R minors for which a per-minor index is actually published.
*
* contribPath() has no way to probe the origin, so a minor that is not
* published here must fall back to the flat index. Routing an unlisted minor
* would send that client to a 404 and it would see no packages at all - a
* silent, total failure rather than a degraded one.
*/
const KNOWN_MINORS = new Set(
(Deno.env.get('KNOWN_MINORS') ?? '4.4,4.5,4.6')
.split(',')
.map((minor) => minor.trim())
.filter((minor) => minor.length > 0),
);
/** `/<arch>/<os>/latest/src/contrib[/<rest>]` */
const SLOT_PATH_REGEX = /^\/(amd64|arm64)\/([a-z0-9._-]+)\/latest\/src\/contrib\/?(.*)$/;
@ -177,8 +202,8 @@ function parseMacUserAgent(userAgent: string): { os: string; arch: string; rver:
* The contrib path a request should be served from, relative to the slot.
*
* Returns the per-minor path for an index file when the slot is known to carry
* a union index and the client's R minor is known; otherwise the flat path,
* which is what every client sees today.
* a union index and the client's R minor is one we publish; otherwise the flat
* path, which is what every client sees today.
*/
function contribPath(slot: string, rest: string, userAgent: string): string {
const flat = rest ? `/${slot}/latest/src/contrib/${rest}` : `/${slot}/latest/src/contrib`;
@ -188,7 +213,7 @@ function contribPath(slot: string, rest: string, userAgent: string): string {
}
const rMinor = extractRMinor(userAgent);
return rMinor ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
return rMinor && KNOWN_MINORS.has(rMinor) ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
}
BunnySDK.net.http