build-cran-binaries/edge/rpkgs-router.ts
pat-s aba2063ea0
All checks were successful
ci/crow/cron/process-updates/2 Pipeline was successful
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
feat(edge): gate per-minor routing on published minors and add a staging zone (#175)
## Motivation

`UNION_SLOTS` is empty, so per-minor routing has never been exercised end to end. Before it can be enabled and advertised, two things were missing: a way to test it without pointing production at it, and evidence that the published indexes actually support it.

Verifying the data first turned up a defect that would have broken users the moment the flag was flipped.

## The defect

`contribPath()` redirects to `contrib/<minor>/` whenever the User-Agent carries any R minor, with no existence check and no fallback:

```ts
const rMinor = extractRMinor(userAgent);
return rMinor ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
```

Only `4.4`, `4.5` and `4.6` are published. `4.3` and `4.2` return 404 on all 16 slots. With `UNION_SLOTS` set, an R 4.3 client would be redirected to a non-existent index and see **zero** packages: a silent, total failure rather than a degraded one. R 4.3 is still advertised as supported on the website and in `docs/configuration.mdoc`, though `build-env-images` now pins only 4.6.0/4.5.3/4.4.3.

## Changes

- **Gate routing on `KNOWN_MINORS`** (default `4.4,4.5,4.6`), falling back to the flat index for anything else. Unknown minor now behaves exactly as today.
- **Honour `EXTRA_PUBLIC_HOSTS`.** `publicCdnOrigin()` falls back to the hardcoded `PUBLIC_CDN_ORIGIN` for any hostname not in `PUBLIC_CDN_HOSTS`, so a staging zone on a `b-cdn.net` hostname would redirect into *production* and silently measure the wrong system. This lets the identical script run on staging and redirect within itself.
- **Add the `cran-rpkgs-test` pull zone** with `UNION_SLOTS` pre-enabled for all 16 slots, same B2 origin, served on the bunny default hostname so it needs no DNS record and is never advertised.
- **Add `scripts/verify-r-minor-routing.sh`**, covering every `<arch>/<os>` slot: index reachability per minor, the union property against flat, `Path:` target resolution, coverage parity across minors, and with `--live` the real User-Agent routing, the non-R User-Agent case, and that tarballs are never rewritten.
- **Cover the fallback in the edge test suite** for both an unpublished minor (4.3) and a future one (4.7).

## Findings from the full run

112 passed, 16 failed across the 16 slots. Every failure is the same: no R 4.3 index.

All 16 slots carry union indexes that are supersets of flat, every sampled `Path:` target resolves, and all indexes were republished within minutes of each other, so the build side is healthy.

Coverage is **not** yet even, which is why "full coverage for ABI-sensitive packages" is not a claim to make yet:

| slot | flat | 4.4 | 4.5 | 4.6 |
|---|---|---|---|---|
| amd64/resolute | 24305 | 24402 | 24748 | 24395 |
| amd64/alpine324 | 24397 | 24457 | 24744 | 24448 |
| amd64/noble | 24780 | 24805 | 24805 | 24805 |

On the R 4.5-built distros (`resolute`, `alpine324`, and their arm64 twins) a 4.4 or 4.6 client sees ~300 fewer packages than a 4.5 client. On `noble`/`jammy`/`rhel9`/`alpine323` the spread is under 5. The new parity check encodes this with a configurable `PARITY_TOLERANCE`.

## Verification

- `just edge-test`: 18 steps pass. The two new steps were confirmed to fail with the `KNOWN_MINORS` gate removed and pass with it.
- `tofu validate`: passes. **Not applied** - no bunny.net or state credentials were available, so the staging zone still needs a `tofu apply`.
- `scripts/verify-r-minor-routing.sh`: full 16-slot run, results above.
- `shellcheck`: clean.

## Not done here

Applying the staging zone, then running `BASE=https://cran-rpkgs-test.b-cdn.net scripts/verify-r-minor-routing.sh --live` against it. Production `UNION_SLOTS` is deliberately left empty.

Reviewed-on: #175
2026-08-30 21:20:16 +00:00

316 lines
11 KiB
TypeScript

/**
* Edge middleware for cran.rpkgs.com.
*
* Two jobs:
*
* 1. Resolve the bare `https://cran.rpkgs.com` form to a concrete
* `<arch>/<os>` slot from the User-Agent, or send the client to CRAN when
* the distro cannot be identified.
* 2. Route `PACKAGES*` requests to the per-R-minor slot
* (`…/latest/src/contrib/<x.y>/`), so a stock `install.packages()` sees the
* packages that only exist there.
*
* Only index files are routed. Tarballs are deliberately left alone: R keeps
* the contrib URL it asked for, not the one it was redirected to, so every
* tarball URL is resolved against the flat directory and the union index steers
* the per-minor ones with a `Path: <x.y>` field. Rewriting a tarball request
* here would send flat-slot packages into a directory that does not hold them.
*
* Routing is gated on UNION_SLOTS. The raw per-minor index holds only the
* ABI-sensitive subset of a slot; it is safe to route to it only once bincraft
* has republished it as a union of the per-minor and flat slots.
*
* Deployed by OpenTofu from this file (`bunnynet_compute_script.rpkgs_router`).
* Test with `just edge-test`.
*/
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',
// 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(
(Deno.env.get('UNION_SLOTS') ?? '')
.split(',')
.map((slot) => slot.trim())
.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\/?(.*)$/;
/** A path that already sits in a per-minor slot, e.g. `4.5/PACKAGES.gz`. */
const MINOR_DIR_REGEX = /^\d+\.\d+\//;
/** The only files this script routes. */
const INDEX_FILE_REGEX = /^PACKAGES(\.gz|\.rds)?$/;
const SRC_CONTRIB_REGEX = /^\/src\/contrib\/(.+)$/;
/** A binary archive URL whose upstream source counterpart CRAN can serve. */
const ARCHIVE_TARBALL_REGEX =
/^\/(?:amd64|arm64)\/[a-z0-9._-]+\/latest\/src\/contrib\/Archive\/([^/]+)\/([^/]+\.tar\.gz)$/;
const MACOS_BIN_REGEX =
/^\/bin\/macosx\/(big-sur-arm64|big-sur-x86_64|monterey-arm64|monterey-x86_64)\/contrib\/([0-9.]+)\/(.+)$/;
const RHEL_REGEX = /(almalinux|rocky)[^\d]*(\d+)/i;
const UBUNTU_REGEX = /Ubuntu ([\d.]+)/i;
const UBUNTU_CODENAME_REGEX = /Ubuntu [\d.]+;\s*codename=([a-z][a-z0-9-]*)/i;
const UBUNTU_CODENAMES: Record<string, string> = {
'26.04': 'resolute',
'24.04': 'noble',
'22.04': 'jammy',
};
const ALPINE_REGEX = /(?:Alpine Linux(?:\s+VERSION_ID=)?|alpine-)\s*(\d+)\.(\d+)/i;
/**
* R's own User-Agent is `R (4.5.3 x86_64-pc-linux-musl …)`; the Posit-style one
* some sites configure is `R/4.5.3 R (…)`. Both carry the minor, which is why
* per-minor routing works without the distro being identifiable.
*/
const R_MINOR_REGEXES = [/\bR\/(\d+)\.(\d+)/, /\bR \((\d+)\.(\d+)/];
function normalizePathname(pathname: string): string {
return pathname.replace(/\/{2,}/g, '/');
}
function redirectTo(location: string, status = 302): Response {
return new Response(null, {
status,
headers: {
Location: location,
// The target depends on the User-Agent, so the redirect itself must
// never be cached; only its target is a cacheable, UA-independent URL.
'Cache-Control': 'no-store',
'X-Via': 'MyMiddleware',
'X-Rewritten-By': 'rpkgs-edge-middleware',
},
});
}
function publicCdnOrigin(url: URL): string {
return PUBLIC_CDN_HOSTS.has(url.hostname) ? url.origin : PUBLIC_CDN_ORIGIN;
}
/**
* True when the client reports an R minor that we deliberately do not serve.
*
* A client that reports no minor at all is not "unsupported": non-R fetchers
* (mirror scripts, image builds) must keep getting the flat slot. Only a
* known-and-excluded minor falls through to CRAN.
*/
function isExcludedMinor(userAgent: string): boolean {
const rMinor = extractRMinor(userAgent);
return rMinor !== null && !KNOWN_MINORS.has(rMinor);
}
function extractRMinor(userAgent: string): string | null {
for (const regex of R_MINOR_REGEXES) {
const match = userAgent.match(regex);
if (match) {
return `${match[1]}.${match[2]}`;
}
}
return null;
}
function mapArch(arch: string): string {
if (arch === 'aarch64') return 'arm64';
if (arch === 'x86_64') return 'amd64';
return arch;
}
function extractArch(userAgent: string): string {
const match = userAgent.match(/(x86_64|aarch64|arm64|i386|i686)/);
return match ? mapArch(match[1]) : '';
}
/**
* Identify the `<arch>/<os>` slot from the User-Agent, or null.
*
* A stock R User-Agent carries only `linux-gnu` / `linux-musl`, which are not
* slot names: returning them produced redirects into slots that do not exist
* (`/amd64/linux-musl/latest/…`, a guaranteed 404). An unidentifiable distro
* is reported as such so the caller can fall back to CRAN.
*/
function parseSlot(userAgent: string): string | null {
const arch = extractArch(userAgent);
if (!arch) {
return null;
}
const rhel = userAgent.match(RHEL_REGEX);
if (rhel) {
return `${arch}/rhel${rhel[2]}`;
}
const ubuntu = userAgent.match(UBUNTU_REGEX);
if (ubuntu) {
const codenameMatch = userAgent.match(UBUNTU_CODENAME_REGEX);
if (codenameMatch) {
return `${arch}/${codenameMatch[1].toLowerCase()}`;
}
const codename = UBUNTU_CODENAMES[ubuntu[1]];
if (codename) {
return `${arch}/${codename}`;
}
}
const alpine = userAgent.match(ALPINE_REGEX);
if (alpine) {
return `${arch}/alpine${alpine[1]}${alpine[2]}`;
}
return null;
}
function parseMacUserAgent(userAgent: string): { os: string; arch: string; rver: string } | null {
const rverMatch = userAgent.match(/R \((\d+)\.(\d+)/);
const archMatch = userAgent.match(/(aarch64|arm64|x86_64)/);
const osMatch = userAgent.match(/darwin(\d+)/);
if (!rverMatch || !archMatch || !osMatch) {
return null;
}
const arch = archMatch[1] === 'aarch64' ? 'arm64' : archMatch[1];
const darwinVer = parseInt(osMatch[1], 10);
const os = darwinVer >= 21 && darwinVer < 22 ? `monterey-${arch}` : `big-sur-${arch}`;
return { os, arch, rver: `${rverMatch[1]}.${rverMatch[2]}` };
}
/**
* 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 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`;
if (!INDEX_FILE_REGEX.test(rest) || !UNION_SLOTS.has(slot)) {
return flat;
}
const rMinor = extractRMinor(userAgent);
return rMinor && KNOWN_MINORS.has(rMinor) ? `/${slot}/latest/src/contrib/${rMinor}/${rest}` : flat;
}
BunnySDK.net.http
.servePullZone({ url: 'https://cran.rpkgs.com/' })
.onOriginRequest((ctx) => {
const url = new URL(ctx.request.url);
const path = normalizePathname(url.pathname);
const userAgent = ctx.request.headers.get('User-Agent') || '';
const publicOrigin = publicCdnOrigin(url);
// macOS clients are served from CRAN's own binary tree.
const srcContrib = path.match(SRC_CONTRIB_REGEX);
if (srcContrib && /darwin/.test(userAgent)) {
const mac = parseMacUserAgent(userAgent);
if (mac) {
return Promise.resolve(redirectTo(`${publicOrigin}/bin/macosx/${mac.os}/contrib/${mac.rver}/${srcContrib[1]}`));
}
}
if (MACOS_BIN_REGEX.test(path)) {
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`));
}
// Already-qualified slot URLs: what the runtime images have baked in.
const slotPath = path.match(SLOT_PATH_REGEX);
if (slotPath) {
const slot = `${slotPath[1]}/${slotPath[2]}`;
const rest = slotPath[3];
// Never rewrite a request that is already in a per-minor slot, or the
// redirect would chase its own tail.
if (MINOR_DIR_REGEX.test(rest)) {
return Promise.resolve(ctx.request);
}
// An R minor outside the supported window has no binaries we can safely
// serve, so the whole interaction goes to CRAN: the index and the
// tarballs R will resolve against it. Serving the index from CRAN but
// tarballs from here would hand R a binary where it expects a source
// tarball, which fails in a far more confusing way than not being
// served at all.
if (UNION_SLOTS.has(slot) && isExcludedMinor(userAgent)) {
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}/src/contrib/${rest}`));
}
const target = contribPath(slot, rest, userAgent);
if (target === path) {
return Promise.resolve(ctx.request);
}
return Promise.resolve(redirectTo(`${publicOrigin}${target}`));
}
// The bare `https://cran.rpkgs.com` form, resolved from the User-Agent.
if (path === '/' || path === '/src/contrib' || path.startsWith('/src/contrib/')) {
const slot = parseSlot(userAgent);
if (!slot) {
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`));
}
if (UNION_SLOTS.has(slot) && isExcludedMinor(userAgent)) {
return Promise.resolve(redirectTo(`${CRAN_ORIGIN}${path}`));
}
const rest = srcContrib ? srcContrib[1] : '';
return Promise.resolve(redirectTo(`${publicOrigin}${contribPath(slot, rest, userAgent)}`));
}
return Promise.resolve(ctx.request);
})
.onOriginResponse(async (ctx) => {
const path = normalizePathname(new URL(ctx.request.url).pathname);
const archive = path.match(ARCHIVE_TARBALL_REGEX);
// Binary archives can be incomplete when an older build never succeeded.
// Preserve renv/remotes version restores by falling back to CRAN's source
// package only for an absent archived tarball. A requested version can be
// either archived upstream or still current, so probe the archive first.
// Other 404s remain visible.
if (ctx.response.status === 404 && archive) {
const archiveUrl = `${CRAN_ORIGIN}/src/contrib/Archive/${archive[1]}/${archive[2]}`;
const archiveResponse = await fetch(archiveUrl, { method: 'HEAD' });
const sourceUrl = archiveResponse.ok ? archiveUrl : `${CRAN_ORIGIN}/src/contrib/${archive[2]}`;
return redirectTo(sourceUrl);
}
ctx.response.headers.append('X-Via', 'MyMiddleware');
return Promise.resolve(ctx.response);
});