feat(edge): route PACKAGES requests to the per-R-minor slot
- add edge/rpkgs-router.ts, which redirects PACKAGES, PACKAGES.gz and PACKAGES.rds into …/src/contrib/<x.y>/ for slots listed in UNION_SLOTS - leave tarballs alone: R keeps the pre-redirect contrib URL, so the union index steers per-minor tarballs with a Path field instead - stop resolving an unidentifiable distro to a phantom linux-gnu/linux-musl slot and send those clients to CRAN - mark every redirect no-store, since the target depends on the User-Agent - cover the routing matrix in edge/rpkgs-router.test.ts, run by just edge-test - correct the spec and plan: the union index carries Path: <x.y> on per-minor records, not Path: .. on flat ones
This commit is contained in:
parent
5a0a4fa200
commit
628d19d650
2 changed files with 440 additions and 22 deletions
160
edge/rpkgs-router.test.ts
Normal file
160
edge/rpkgs-router.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Routing matrix for `edge/rpkgs-router.ts`.
|
||||
*
|
||||
* The script is exercised through the SDK's local server rather than by
|
||||
* importing its internals, so what is tested is the artifact that gets
|
||||
* deployed. Requests that the script passes through are proxied to the real
|
||||
* origin, which keeps the "no redirect" cases honest: they assert that the
|
||||
* client reached the flat slot, not merely that no `Location` was set.
|
||||
*
|
||||
* Run with `just edge-test`.
|
||||
*/
|
||||
import { assertEquals } from 'jsr:@std/assert@1';
|
||||
|
||||
const SCRIPT = new URL('./rpkgs-router.ts', import.meta.url).pathname;
|
||||
const BASE = 'http://127.0.0.1:8080';
|
||||
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_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_DARWIN = 'R (4.5.1 aarch64-apple-darwin20 aarch64 darwin20)';
|
||||
const UA_CURL = 'curl/8.0.1';
|
||||
|
||||
const SLOT = '/amd64/alpine324/latest/src/contrib';
|
||||
const OTHER_SLOT = '/amd64/noble/latest/src/contrib';
|
||||
|
||||
interface Probe {
|
||||
status: number;
|
||||
location: string | null;
|
||||
cacheControl: string | null;
|
||||
}
|
||||
|
||||
async function probe(path: string, userAgent: string): Promise<Probe> {
|
||||
const res = await fetch(BASE + path, {
|
||||
headers: { 'User-Agent': userAgent },
|
||||
redirect: 'manual',
|
||||
});
|
||||
await res.body?.cancel();
|
||||
return {
|
||||
status: res.status,
|
||||
location: res.headers.get('location'),
|
||||
cacheControl: res.headers.get('cache-control'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Kill tolerantly: the child has already exited if the script failed to load. */
|
||||
async function stopServer(child: Deno.ChildProcess): Promise<void> {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
await child.status;
|
||||
}
|
||||
|
||||
async function startServer(): Promise<Deno.ChildProcess> {
|
||||
const child = new Deno.Command(Deno.execPath(), {
|
||||
args: ['run', '-A', SCRIPT],
|
||||
env: { UNION_SLOTS },
|
||||
stdout: 'null',
|
||||
stderr: 'inherit',
|
||||
}).spawn();
|
||||
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/`, {
|
||||
headers: { 'User-Agent': UA_CURL },
|
||||
redirect: 'manual',
|
||||
});
|
||||
await res.body?.cancel();
|
||||
return child;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
await stopServer(child);
|
||||
throw new Error('edge script did not start listening on ' + BASE);
|
||||
}
|
||||
|
||||
Deno.test('rpkgs-router', async (t) => {
|
||||
const server = await startServer();
|
||||
|
||||
try {
|
||||
await t.step("routes an index request to the client's R minor", async () => {
|
||||
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.status, 302);
|
||||
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`);
|
||||
});
|
||||
|
||||
await t.step('routes R 4.6 to its own slot', async () => {
|
||||
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R46_MUSL);
|
||||
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.6/PACKAGES.gz`);
|
||||
});
|
||||
|
||||
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);
|
||||
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/${file}`, `expected ${file} to be routed`);
|
||||
}
|
||||
});
|
||||
|
||||
await t.step('marks the redirect uncacheable', async () => {
|
||||
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.cacheControl, 'no-store');
|
||||
});
|
||||
|
||||
await t.step('leaves a slot outside UNION_SLOTS alone', async () => {
|
||||
const res = await probe(`${OTHER_SLOT}/PACKAGES.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.location, null);
|
||||
assertEquals(res.status, 200);
|
||||
});
|
||||
|
||||
await t.step('never routes a tarball', async () => {
|
||||
const res = await probe(`${SLOT}/jsonlite_2.0.0.tar.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.location, null);
|
||||
assertEquals(res.status, 200);
|
||||
});
|
||||
|
||||
await t.step('does not redirect a path already under a minor', async () => {
|
||||
const res = await probe(`${SLOT}/4.5/PACKAGES.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.location, null);
|
||||
assertEquals(res.status, 200);
|
||||
});
|
||||
|
||||
await t.step('leaves a client without an R version alone', async () => {
|
||||
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_CURL);
|
||||
assertEquals(res.location, null);
|
||||
assertEquals(res.status, 200);
|
||||
});
|
||||
|
||||
await t.step('resolves the bare root to slot and minor', async () => {
|
||||
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_ALPINE);
|
||||
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`);
|
||||
});
|
||||
|
||||
await t.step('sends an unidentifiable distro to CRAN', async () => {
|
||||
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_MUSL);
|
||||
assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz');
|
||||
});
|
||||
|
||||
await t.step('keeps the macOS rewrite', async () => {
|
||||
const res = await probe('/src/contrib/foo_1.0.tar.gz', UA_R45_DARWIN);
|
||||
assertEquals(res.location, 'https://cran.rpkgs.com/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz');
|
||||
});
|
||||
|
||||
await t.step('keeps the macOS binary passthrough to CRAN', async () => {
|
||||
const path = '/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz';
|
||||
const res = await probe(path, UA_R45_DARWIN);
|
||||
assertEquals(res.location, `https://cran.r-project.org${path}`);
|
||||
});
|
||||
|
||||
await t.step('collapses duplicate slashes before matching', async () => {
|
||||
const res = await probe(`/amd64/alpine324//latest/src/contrib//PACKAGES.gz`, UA_R45_MUSL);
|
||||
assertEquals(res.location, `https://cran.rpkgs.com${SLOT}/4.5/PACKAGES.gz`);
|
||||
});
|
||||
} finally {
|
||||
await stopServer(server);
|
||||
}
|
||||
});
|
||||
235
edge/rpkgs-router.ts
Normal file
235
edge/rpkgs-router.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
/** 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),
|
||||
);
|
||||
|
||||
/** `/<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\/(.+)$/;
|
||||
|
||||
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_CODENAMES: Record<string, string> = {
|
||||
'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 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 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 known; 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 ? `/${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') || '';
|
||||
|
||||
// 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(`${PUBLIC_CDN_ORIGIN}/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);
|
||||
}
|
||||
|
||||
const target = contribPath(slot, rest, userAgent);
|
||||
if (target === path) {
|
||||
return Promise.resolve(ctx.request);
|
||||
}
|
||||
return Promise.resolve(redirectTo(`${PUBLIC_CDN_ORIGIN}${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}`));
|
||||
}
|
||||
|
||||
const rest = srcContrib ? srcContrib[1] : '';
|
||||
return Promise.resolve(redirectTo(`${PUBLIC_CDN_ORIGIN}${contribPath(slot, rest, userAgent)}`));
|
||||
}
|
||||
|
||||
return Promise.resolve(ctx.request);
|
||||
})
|
||||
.onOriginResponse((ctx) => {
|
||||
ctx.response.headers.append('X-Via', 'MyMiddleware');
|
||||
return Promise.resolve(ctx.response);
|
||||
});
|
||||
Loading…
Reference in a new issue