build-cran-binaries/edge/rpkgs-router.ts
pat-s 2f732457d2
Some checks failed
ci/crow/manual/trial-build-registry/1 Pipeline is pending
ci/crow/manual/trial-build-registry/3 Pipeline is pending
ci/crow/manual/trial-build-registry/5 Pipeline is pending
ci/crow/manual/trial-build-registry/7 Pipeline is pending
ci/crow/manual/trial-build-registry/9 Pipeline is pending
ci/crow/manual/trial-build-registry/11 Pipeline is pending
ci/crow/manual/trial-build-registry/13 Pipeline is pending
ci/crow/manual/trial-build-registry/15 Pipeline is pending
ci/crow/manual/trial-build-registry/17 Pipeline is pending
ci/crow/manual/repair-built-stamp/2 Pipeline failed
ci/crow/manual/weekly-audit-missing/17 Pipeline was successful
ci/crow/manual/weekly-audit-missing/9 Pipeline was successful
ci/crow/manual/weekly-audit-missing/15 Pipeline was successful
ci/crow/manual/weekly-audit-missing/11 Pipeline was successful
ci/crow/manual/weekly-audit-missing/13 Pipeline was successful
ci/crow/manual/weekly-audit-missing/7 Pipeline was successful
ci/crow/manual/trial-build-registry/4 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/manual/trial-build-registry/6 Pipeline was successful
ci/crow/manual/trial-build-registry/2 Pipeline was successful
ci/crow/manual/build-all-versions/1 Pipeline failed
ci/crow/manual/trial-build-registry/8 Pipeline was successful
ci/crow/manual/trial-build-registry/14 Pipeline was successful
ci/crow/manual/trial-build-registry/12 Pipeline was successful
ci/crow/manual/trial-build-registry/10 Pipeline was successful
ci/crow/manual/build-all-versions/2 Pipeline failed
ci/crow/manual/trial-build-registry/16 Pipeline was successful
ci/crow/manual/trial-build-registry/18 Pipeline was successful
ci/crow/manual/weekly-audit-missing/5 Pipeline was successful
ci/crow/manual/weekly-audit-missing/1 Pipeline was successful
ci/crow/manual/auto-apply-patches Pipeline was successful
ci/crow/manual/weekly-audit-missing/3 Pipeline was successful
ci/crow/manual/weekly-audit-missing/2 Pipeline was successful
ci/crow/manual/weekly-audit-missing/4 Pipeline was successful
ci/crow/manual/weekly-audit-missing/6 Pipeline was successful
ci/crow/manual/weekly-audit-missing/8 Pipeline was successful
ci/crow/manual/weekly-audit-missing/10 Pipeline was successful
ci/crow/manual/weekly-audit-missing/12 Pipeline was successful
ci/crow/manual/weekly-audit-missing/14 Pipeline was successful
ci/crow/manual/weekly-audit-missing/16 Pipeline was successful
ci/crow/manual/weekly-audit-missing/18 Pipeline was successful
ci/crow/manual/weekly-patch-proposals Pipeline was successful
ci/crow/manual/process-updates/6 Pipeline was successful
ci/crow/manual/build-all-versions/4 Pipeline failed
ci/crow/manual/build-all-versions/3 Pipeline failed
ci/crow/manual/weekly-rebuild-missing/13 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/15 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/7 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/9 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/11 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/8 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/4 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/1 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/10 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/12 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/16 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/14 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/2 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was 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/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/17 Pipeline failed
ci/crow/manual/weekly-rebuild-missing/6 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/5 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/18 Pipeline was successful
feat(edge): route PACKAGES requests to the per-R-minor slot (#152)
## Problem

`install.packages("curl")` fails in `reg.devxy.io/r/r-alpine:4.5-3.24` with "package 'curl' is not available for this version of R", on both arches.

`curl` is not missing from the repo: it is in `…/latest/src/contrib/4.5/` and `…/4.6/`, the per-minor slots that base R cannot address. The image's repo URL resolves to `…/latest/src/contrib`, whose index does not list it. On `amd64/alpine324` that is 2 886 packages invisible to `install.packages()` (23 on `amd64/noble`) — what issue #63 records as "missing binaries".

Two further findings while investigating:

- The middleware only ever rewrote the bare `cran.rpkgs.com/src/contrib/…` form, and that form was broken for every Linux client on a stock R user agent: `ALPINE_REGEX`/`UBUNTU_REGEX`/`RHEL_REGEX` need a Posit-style UA that carries the distro, so stock R fell through to `extractOs()` and got redirected to `/amd64/linux-musl/latest/…`, a slot that does not exist.
- `PACKAGES*` is served `cdn-cache: BYPASS` (bincraft uploads it `no-store`), so the middleware sees every index request and no purge is needed for routing changes to take effect.

## What this changes

**`edge/rpkgs-router.ts`** — the middleware, now a reviewed file in this repo rather than dashboard state. It routes `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` into `…/src/contrib/<x.y>/` for slots listed in `UNION_SLOTS`, and nothing else.

Tarballs are deliberately left alone. R keeps the `contriburl` it *asked for*, not the one the redirect served it, 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.

Also in the script: the phantom `linux-gnu`/`linux-musl` fallback is gone (an unidentifiable distro goes to CRAN, as an unparseable UA already did), and every redirect carries `Cache-Control: no-store` since its target depends on the User-Agent. The macOS branches are unchanged.

**`cdn.tf`** — `bunnynet_compute_script.rpkgs_router` with `content = file("edge/rpkgs-router.ts")`, the `UNION_SLOTS` variable, and `middleware_script` pointing at the resource instead of the literal `29277`.

`UNION_SLOTS` is empty, so merging and applying this changes no client's behaviour. A slot is added only once bincraft has republished its per-minor index as a union (rpkgs/bincraft#97); routing to a raw per-minor index would hide every package it does not carry. Rolling back is a variable edit, not a deploy.

**`specs/`, `plans/`** — the design and the implementation plan, including the two approaches that were rejected (edge-side merge, moving the minor up the path) and why.

## Verification

`just edge-test` runs 13 routing cases against the SDK's local server, so what is tested is the artifact that gets deployed; pass-through cases proxy to the real origin. All pass.

End to end, with the middleware in front of a locally built union index for `amd64/alpine324` (31 507 records), inside the runtime image:

```
curl:     7.1.0  -> …/latest/src/contrib/4.5  -> curl_7.1.0.tar.gz      717 725 B
jsonlite: 2.0.0  -> …/latest/src/contrib      -> jsonlite_2.0.0.tar.gz  1 055 849 B
```

`tofu validate` passes. `tofu plan` has not been run: no `BUNNYNET_API_KEY` available in this environment.

## Before applying

The script pre-dates this configuration, so it must be adopted, not created:

```sh
tofu import bunnynet_compute_script.rpkgs_router 29277
tofu plan
```

The plan should show an in-place `content` update and no replacement of the pull zone. Without the import, tofu creates a second script and repoints the zone at it. Note that `name = "rpkgs-router"` will rename the existing script on apply.

## Not fixed here

`install.packages("curl")` on `alpine324` will now *resolve*, then fail to build: that slot's tarballs are byte-identical CRAN **source** tarballs (no `Meta/`, no `Built:` in DESCRIPTION) which the index nevertheless stamps `Built: R 4.5.3; …-linux-musl`. Sampled: `amd64/alpine324` 3/12 binary, `arm64/alpine324` 13/30, `amd64/noble` 12/12, `amd64/alpine323` 17/20. That slot needs a rebuild, tracked separately.

Reviewed-on: #152
2026-08-07 14:12:22 +00:00

235 lines
7.9 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';
/** 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);
});