build-cran-binaries/edge/rpkgs-router.ts
pat-s 771d3a7768
feat(edge): send unsupported R minors to CRAN instead of serving them
Falling back to the flat index for an excluded minor is the silent case:
risky packages there are built under another minor and fail at load time,
far from the cause. Send those clients to CRAN for sources instead, which
is what the router already does for an unidentifiable distro.

The whole interaction has to move, not just the index. R resolves tarball
URLs against the repo it was configured with, so serving the index from
CRAN and tarballs from here would hand R a binary where it expects a
source tarball.

A client reporting no R minor at all is not excluded: mirror scripts and
image builds keep getting the flat slot.

- Declare the supported window once in cdn.tf as local.rpkgs_supported_minors
  and set KNOWN_MINORS from it on both zones, so the router cannot drift
  from build-env-images' LATEST/PREV1/PREV2 unnoticed.
- Split the verification script into supported and excluded minors: the
  former must have published indexes, the latter must have none and must
  redirect to CRAN under --live.
2026-08-30 15:44:14 +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);
});