feat(edge): route PACKAGES requests to the per-R-minor slot #152
6 changed files with 773 additions and 1 deletions
25
cdn.tf
25
cdn.tf
|
|
@ -52,6 +52,29 @@
|
||||||
|
|
||||||
### cran.rpkgs.com
|
### cran.rpkgs.com
|
||||||
|
|
||||||
|
# The edge middleware that resolves the bare cran.rpkgs.com form to an
|
||||||
|
# <arch>/<os> slot and routes PACKAGES* to the per-R-minor slot. The source of
|
||||||
|
# truth is edge/rpkgs-router.ts; `tofu apply` publishes a new release.
|
||||||
|
#
|
||||||
|
# The script pre-dates this configuration, so it is adopted rather than created:
|
||||||
|
# tofu import bunnynet_compute_script.rpkgs_router 29277
|
||||||
|
resource "bunnynet_compute_script" "rpkgs_router" {
|
||||||
|
type = "middleware"
|
||||||
|
name = "rpkgs-router"
|
||||||
|
content = file("${path.module}/edge/rpkgs-router.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Slots ("<arch>/<os>", comma separated) whose per-minor index bincraft has
|
||||||
|
# already republished as a union of the per-minor and flat slots. Routing to a
|
||||||
|
# slot that is not listed here would hide every package the per-minor index does
|
||||||
|
# not carry, so this stays empty until a slot has been backfilled.
|
||||||
|
resource "bunnynet_compute_script_variable" "rpkgs_router_union_slots" {
|
||||||
|
script = bunnynet_compute_script.rpkgs_router.id
|
||||||
|
name = "UNION_SLOTS"
|
||||||
|
default_value = ""
|
||||||
|
required = false
|
||||||
|
}
|
||||||
|
|
||||||
resource "bunnynet_pullzone" "cran_rpkgs_com" {
|
resource "bunnynet_pullzone" "cran_rpkgs_com" {
|
||||||
name = "cran-rpkgs"
|
name = "cran-rpkgs"
|
||||||
|
|
||||||
|
|
@ -64,7 +87,7 @@ resource "bunnynet_pullzone" "cran_rpkgs_com" {
|
||||||
origin {
|
origin {
|
||||||
type = "OriginUrl"
|
type = "OriginUrl"
|
||||||
url = "https://devxy-rpkgs-binaries.s3.eu-central-003.backblazeb2.com"
|
url = "https://devxy-rpkgs-binaries.s3.eu-central-003.backblazeb2.com"
|
||||||
middleware_script = 29277
|
middleware_script = bunnynet_compute_script.rpkgs_router.id
|
||||||
}
|
}
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
|
|
|
||||||
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);
|
||||||
|
});
|
||||||
14
justfile
14
justfile
|
|
@ -73,3 +73,17 @@ rebuild os tag arch package *versions:
|
||||||
--build-arg CACHEBUST="$(date +%s)" \
|
--build-arg CACHEBUST="$(date +%s)" \
|
||||||
-f docker/build-one.Dockerfile \
|
-f docker/build-one.Dockerfile \
|
||||||
local
|
local
|
||||||
|
|
||||||
|
# run the edge middleware routing matrix (uses a local deno, else the deno image)
|
||||||
|
edge-test:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if command -v deno >/dev/null 2>&1; then
|
||||||
|
deno test -A edge/rpkgs-router.test.ts
|
||||||
|
else
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/w" -w /w \
|
||||||
|
-v deno-cache:/deno-dir \
|
||||||
|
denoland/deno:latest \
|
||||||
|
deno test -A edge/rpkgs-router.test.ts
|
||||||
|
fi
|
||||||
|
|
|
||||||
166
plans/2026-08-07-per-minor-edge-routing.md
Normal file
166
plans/2026-08-07-per-minor-edge-routing.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Per-R-minor edge routing implementation plan
|
||||||
|
|
||||||
|
Spec: `specs/2026-08-07-per-minor-edge-routing-design.md`
|
||||||
|
|
||||||
|
**Goal:** let a stock `install.packages()` see the per-minor packages by routing `PACKAGES*` requests to `…/src/contrib/<x.y>/`, where `bincraft` publishes a union index.
|
||||||
|
|
||||||
|
**Architecture:** the union is built in `bincraft`; the edge script only redirects index requests, gated on a `UNION_SLOTS` script variable; the script lives in this repo and is applied by OpenTofu.
|
||||||
|
|
||||||
|
**Tech stack:** Deno / TypeScript (Bunny Edge Scripting, SDK 0.12), OpenTofu with `BunnyWay/bunnynet` 0.17, R (bincraft).
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Redirect only `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds`; never a tarball, because the union index already carries the correct tarball URL for both classes of package.
|
||||||
|
- Every redirect carries `Cache-Control: no-store`; redirect targets stay UA-independent.
|
||||||
|
- `UNION_SLOTS` is empty by default, so deploying the script is a no-op until a slot is backfilled.
|
||||||
|
- A slot is `<arch>/<os>`, e.g. `amd64/alpine324`.
|
||||||
|
- Verified prerequisites: `PACKAGES*` is served `cdn-cache: BYPASS`, so the script sees every index request; `Deno.env.get()` reads script variables; the SDK local server listens on `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Edge script and its test matrix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `edge/rpkgs-router.ts`
|
||||||
|
- Create: `edge/rpkgs-router.test.ts`
|
||||||
|
- Modify: `justfile` (add `edge-test`)
|
||||||
|
|
||||||
|
**Produces:** a single-file script deployable as `bunnynet_compute_script.content`, reading `UNION_SLOTS` from the environment.
|
||||||
|
|
||||||
|
- [ ] **Step 1: write the test matrix first**
|
||||||
|
|
||||||
|
`edge/rpkgs-router.test.ts` spawns `deno run -A edge/rpkgs-router.ts` with `UNION_SLOTS=amd64/alpine324`, waits for `127.0.0.1:8080`, and issues requests with `redirect: "manual"`.
|
||||||
|
|
||||||
|
Cases, asserted on the `location` header (or its absence):
|
||||||
|
|
||||||
|
| # | path | User-Agent | expectation |
|
||||||
|
| --- | ------------------------------------------------------ | --------------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| 1 | `/amd64/alpine324/latest/src/contrib/PACKAGES.gz` | `R (4.5.3 x86_64-pc-linux-musl …)` | 302 → `…/src/contrib/4.5/PACKAGES.gz` |
|
||||||
|
| 2 | same | `R (4.6.0 …)` | 302 → `…/src/contrib/4.6/PACKAGES.gz` |
|
||||||
|
| 3 | same, but slot `amd64/noble` | `R (4.5.3 …)` | no redirect (slot not in `UNION_SLOTS`) |
|
||||||
|
| 4 | `…/src/contrib/curl_7.1.0.tar.gz` | `R (4.5.3 …)` | no redirect |
|
||||||
|
| 5 | `…/src/contrib/4.5/PACKAGES.gz` | `R (4.5.3 …)` | no redirect (loop guard) |
|
||||||
|
| 6 | `…/src/contrib/PACKAGES.gz` | `curl/8.0` | no redirect (no R minor) |
|
||||||
|
| 7 | `/src/contrib/PACKAGES.gz` | alpine UA with `Alpine Linux … 3.24` | 302 → `/amd64/alpine324/latest/src/contrib/4.5/PACKAGES.gz` |
|
||||||
|
| 8 | `/src/contrib/PACKAGES.gz` | `R (4.5.3 x86_64-pc-linux-musl …)`, no distro | 302 → `cran.r-project.org`, **not** a `linux-musl` slot |
|
||||||
|
| 9 | `/src/contrib/foo_1.0.tar.gz` | `R (4.5.1 aarch64-apple-darwin20 …)` | 302 → `/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz` |
|
||||||
|
| 10 | `/bin/macosx/big-sur-arm64/contrib/4.5/foo_1.0.tar.gz` | any | 302 → `cran.r-project.org` |
|
||||||
|
| 11 | any redirect above | — | `cache-control: no-store` |
|
||||||
|
|
||||||
|
- [ ] **Step 2: run the tests and watch them fail**
|
||||||
|
|
||||||
|
`just edge-test` → every case fails, because `edge/rpkgs-router.ts` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: write `edge/rpkgs-router.ts`**
|
||||||
|
|
||||||
|
Order of evaluation in `onOriginRequest`:
|
||||||
|
|
||||||
|
1. normalise `//` runs in the path
|
||||||
|
2. darwin `/src/contrib/*` → `/bin/macosx/<flavour>/contrib/<x.y>/`
|
||||||
|
3. `/bin/macosx/**` → CRAN
|
||||||
|
4. `/{arch}/{os}/latest/src/contrib/<rest>`: pass through if `rest` already starts with `<x.y>/`, or is not an index file, or the slot is not in `UNION_SLOTS`, or the UA has no R minor; otherwise redirect into `<x.y>/`
|
||||||
|
5. `/`, `/src/contrib`, `/src/contrib/**`: resolve arch+os from the UA, redirect to CRAN when the distro is unidentifiable, otherwise redirect to the qualified path, adding `<x.y>/` under the same index-file rule
|
||||||
|
6. anything else: pass through
|
||||||
|
|
||||||
|
The R minor comes from either `R/4.5.3` or `R (4.5.3 …)`, so a stock UA is enough. The `linux-gnu` / `linux-musl` fallback in `parseUserAgent` is deleted: those are not slot names.
|
||||||
|
|
||||||
|
- [ ] **Step 4: run the tests until they pass**
|
||||||
|
|
||||||
|
`just edge-test`
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add edge/rpkgs-router.ts edge/rpkgs-router.test.ts justfile
|
||||||
|
git commit -m "feat(edge): route PACKAGES requests to the per-R-minor slot"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Manage the script from OpenTofu
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `cdn.tf`
|
||||||
|
|
||||||
|
**Consumes:** `edge/rpkgs-router.ts` from Task 1.
|
||||||
|
|
||||||
|
- [ ] **Step 1: add the resources**
|
||||||
|
|
||||||
|
```terraform
|
||||||
|
resource "bunnynet_compute_script" "rpkgs_router" {
|
||||||
|
type = "middleware"
|
||||||
|
name = "rpkgs-router"
|
||||||
|
content = file("${path.module}/edge/rpkgs-router.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "bunnynet_compute_script_variable" "rpkgs_router_union_slots" {
|
||||||
|
script = bunnynet_compute_script.rpkgs_router.id
|
||||||
|
name = "UNION_SLOTS"
|
||||||
|
default_value = ""
|
||||||
|
required = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and replace `middleware_script = 29277` with `middleware_script = bunnynet_compute_script.rpkgs_router.id`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: validate**
|
||||||
|
|
||||||
|
`tofu init -backend=false && tofu validate`
|
||||||
|
|
||||||
|
- [ ] **Step 3: import the existing script (needs `BUNNYNET_API_KEY`)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tofu import bunnynet_compute_script.rpkgs_router 29277
|
||||||
|
tofu plan
|
||||||
|
```
|
||||||
|
|
||||||
|
The plan must show an in-place `content` update and **no** replacement of the pull zone. A replacement means the import did not take.
|
||||||
|
|
||||||
|
- [ ] **Step 4: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cdn.tf
|
||||||
|
git commit -m "feat(cdn): manage the edge middleware script from this repo"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Union index writer in bincraft
|
||||||
|
|
||||||
|
**Files (repo `codefloe.com/rpkgs/bincraft`):**
|
||||||
|
|
||||||
|
- Modify: `R/package_index.R`
|
||||||
|
- Test: `tests/testthat/test-package_index.R`
|
||||||
|
|
||||||
|
**Produces:** `write_union_index(flat_records, minor_records)` returning the merged records, called from `upload_package_index()` when `r_minor` is set.
|
||||||
|
|
||||||
|
- [ ] **Step 1: write the failing tests**
|
||||||
|
|
||||||
|
- a package present in both slots keeps the per-minor record, with `Path = "4.5"`
|
||||||
|
- a package only in the flat slot survives with no `Path`
|
||||||
|
- a package only in the per-minor slot survives with `Path = "4.5"`
|
||||||
|
- a union smaller than the flat input raises an error rather than returning
|
||||||
|
|
||||||
|
- [ ] **Step 2: run them and watch them fail**
|
||||||
|
|
||||||
|
`Rscript -e 'testthat::test_file("tests/testthat/test-package_index.R")'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: implement `write_union_index()` and call it from `upload_package_index()`**
|
||||||
|
|
||||||
|
After `update_PACKAGES()` has written the per-minor index, read the flat slot's `PACKAGES.rds`, set `Path = <r_minor>` on the per-minor records, drop the flat records for packages the per-minor slot already has, and rewrite `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` in the per-minor slot.
|
||||||
|
|
||||||
|
- [ ] **Step 4: run the tests until they pass**
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit and open the PR against bincraft**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Roll out slot by slot
|
||||||
|
|
||||||
|
- [ ] Re-index one slot (`amd64/alpine324`, R 4.5) and confirm the union index lists both `curl` (per-minor, `Path: 4.5`) and `jsonlite` (flat, no `Path`).
|
||||||
|
- [ ] Set `UNION_SLOTS = "amd64/alpine324"` and confirm in `reg.devxy.io/r/r-alpine:4.5-3.24` that `available.packages()` returns the union count and `"curl" %in% rownames(...)`.
|
||||||
|
- [ ] Add `arm64/alpine324`, then the remaining slots.
|
||||||
|
|
||||||
|
`install.packages("curl")` will still fail to build on `alpine324` until that slot's source tarballs are replaced with real binaries. That is tracked separately.
|
||||||
174
specs/2026-08-07-per-minor-edge-routing-design.md
Normal file
174
specs/2026-08-07-per-minor-edge-routing-design.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Design: Routing clients to per-R-minor binary slots
|
||||||
|
|
||||||
|
Date: 2026-08-07
|
||||||
|
Status: Approved (pending spec review)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`bincraft` routes ABI-"risky" packages to a per-minor slot `…/latest/src/contrib/<x.y>/` and indexes every directory independently (`upload_package_index()` calls `cranlike::update_PACKAGES()` on one prefix at a time).
|
||||||
|
Nothing unions those indices, and `contrib.url()` only ever yields `<repos>/src/contrib`, so no value of `options(repos)` can address a per-minor slot.
|
||||||
|
Only `uvr` resolves per-minor URLs, which means the per-minor slots are invisible to `install.packages()` by construction.
|
||||||
|
|
||||||
|
Measured on 2026-08-07:
|
||||||
|
|
||||||
|
| slot | flat `src/contrib` | `src/contrib/4.5` | unique packages only in the per-minor slot |
|
||||||
|
| ----------------- | ------------------ | ----------------- | ------------------------------------------ |
|
||||||
|
| `amd64/alpine324` | 21 640 | 3 310 | 2 886 |
|
||||||
|
| `amd64/noble` | 24 495 | 398 | 23 |
|
||||||
|
|
||||||
|
This is what issue #63 records as "missing binaries" on `alpine324`.
|
||||||
|
The packages are not missing; they are in a directory base R cannot reach.
|
||||||
|
The user-visible symptom in `reg.devxy.io/r/r-alpine:4.5-3.24` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
> install.packages("curl")
|
||||||
|
Warning message:
|
||||||
|
package 'curl' is not available for this version of R
|
||||||
|
```
|
||||||
|
|
||||||
|
A second, unrelated defect exists on the same slot and is **out of scope here**: many `alpine324` tarballs are byte-identical CRAN _source_ tarballs that the index nevertheless stamps `Built: R 4.5.3; …-linux-musl`.
|
||||||
|
Routing exposes `curl`; only a rebuild of that slot makes it install.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a stock `install.packages()` see one complete package list for its own R minor, without duplicating tarballs and without an R-version-varying cache key anywhere in the CDN.
|
||||||
|
|
||||||
|
## Key constraint that drives the design
|
||||||
|
|
||||||
|
R resolves a package's download URL from the index, not from the request path, and it keeps the `contriburl` it _asked for_ rather than the one it was redirected to.
|
||||||
|
Measured with `options(repos = …/latest)` against a middleware that redirects the index into `4.5/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl available: TRUE
|
||||||
|
curl repo: …/latest/src/contrib # the flat URL, not the 4.5 one it was served from
|
||||||
|
```
|
||||||
|
|
||||||
|
So the union index is always addressed relative to the **flat** directory, whatever path it was fetched from.
|
||||||
|
`available.packages()` honours a `Path:` field and folds it into the `Repository` column, which gives the whole routing for free:
|
||||||
|
|
||||||
|
- a per-minor record carries `Path: <x.y>`, so its tarball is fetched from `…/src/contrib/<x.y>/`
|
||||||
|
- a flat record carries no `Path`, so its tarball is fetched from `…/src/contrib/`
|
||||||
|
|
||||||
|
Verified end to end against the live CDN with a locally built union index for `amd64/alpine324` (31 507 records):
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
The corollary is that the edge script must **not** rewrite tarball requests: every tarball URL is already correct when it leaves the client, and redirecting one into `<x.y>/` would break exactly the flat packages the union is meant to preserve.
|
||||||
|
|
||||||
|
The complementary trick does not work: R's `gzcon()` reads only the first member of a concatenated gzip stream (10 291 of an expected 31 931 records), so an edge-side merge would have to fully decompress and recompress both indices and additionally 404 `PACKAGES.rds` to stop R preferring it.
|
||||||
|
That is why the union is produced in `bincraft`, not at the edge.
|
||||||
|
|
||||||
|
## Approaches considered
|
||||||
|
|
||||||
|
| Approach | Where the union lives | Verdict |
|
||||||
|
| ----------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **A. Union index written by `bincraft` (chosen)** | per-minor `PACKAGES*`, per-minor entries carry `Path: <x.y>` | Edge does one redirect; `PACKAGES.rds` stays correct; no duplication |
|
||||||
|
| B. Merge at the edge | middleware fetches both indices, recompresses | ~2 MB decompress/recompress per cache fill, cache key must include the R minor, breaks R's `.rds` fast path |
|
||||||
|
| C. Move the minor up the path (`latest/<x.y>/src/contrib/`) | addressable by `options(repos)` directly | No edge logic at all, but a full layout migration and breaks the published URL contract |
|
||||||
|
|
||||||
|
Chosen: **A**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### bincraft: union index (separate PR)
|
||||||
|
|
||||||
|
After writing a per-minor index, republish it as a union of that slot and the flat slot:
|
||||||
|
|
||||||
|
1. Read the flat slot's `PACKAGES.rds` and the per-minor slot's own records.
|
||||||
|
2. Set `Path: <x.y>` on every per-minor record, so its tarball resolves into the per-minor directory.
|
||||||
|
3. Drop every flat record whose package is already present in the per-minor slot, so the per-minor build always wins, and leave the survivors without a `Path`.
|
||||||
|
4. Write the merged `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` into `…/src/contrib/<x.y>/`.
|
||||||
|
|
||||||
|
Guard: refuse to publish a union with fewer records than the flat index it was built from.
|
||||||
|
A truncated union is worse than no union, because it silently removes packages from every client on that minor.
|
||||||
|
|
||||||
|
### Edge script (this repo)
|
||||||
|
|
||||||
|
The script routes `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` requests, and nothing else.
|
||||||
|
|
||||||
|
```
|
||||||
|
normalize path
|
||||||
|
parseClient(UA) -> { rMinor, arch, os } # rMinor from "R (4.5.3 …)" or "R/4.5.3"
|
||||||
|
darwin branches # unchanged
|
||||||
|
if path is /{arch}/{os}/latest/src/contrib/PACKAGES*
|
||||||
|
already under /<x.y>/ ? pass through # loop guard
|
||||||
|
rMinor known && slot in UNION_SLOTS ? 302 -> …/src/contrib/<rMinor>/PACKAGES*
|
||||||
|
else pass through # flat slot, today's behaviour
|
||||||
|
if path is /src/contrib/… # bare root
|
||||||
|
resolve arch + os; unknown -> 302 to CRAN
|
||||||
|
then apply the same PACKAGES* rule
|
||||||
|
else pass through
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirects carry `Cache-Control: no-store`.
|
||||||
|
Every cacheable URL is therefore UA-independent, and no cache key has to vary by R version.
|
||||||
|
|
||||||
|
### Repaired bare-root detection
|
||||||
|
|
||||||
|
The bare `https://cran.rpkgs.com` form is currently broken for every Linux client that uses a stock R user agent.
|
||||||
|
`ALPINE_REGEX`, `UBUNTU_REGEX` and `RHEL_REGEX` only match a Posit-style user agent the user has to set by hand; stock R never carries the distro, so the script falls through to `extractOs()` and redirects to a slot that does not exist:
|
||||||
|
|
||||||
|
```
|
||||||
|
UA: R (4.5.3 x86_64-pc-linux-musl …) -> 302 /amd64/linux-musl/latest/… (404)
|
||||||
|
UA: R (4.5.3 x86_64-pc-linux-gnu …) -> 302 /amd64/linux-gnu/latest/… (404)
|
||||||
|
```
|
||||||
|
|
||||||
|
The fallback to a phantom `linux-musl` / `linux-gnu` slot is removed.
|
||||||
|
An unidentifiable distro redirects to CRAN, which is the existing behaviour for an unparseable user agent.
|
||||||
|
The R _minor_ is always present in a stock user agent, so per-minor routing itself does not depend on distro detection.
|
||||||
|
|
||||||
|
### Rollout gate
|
||||||
|
|
||||||
|
`UNION_SLOTS` is a `bunnynet_compute_script_variable` listing the slots whose per-minor index is already a union.
|
||||||
|
It is empty by default, so deploying the script changes nothing until `bincraft` has backfilled a slot, and a rollback is a variable edit rather than a code deploy.
|
||||||
|
All slots currently carry `4.4`, `4.5` and `4.6`; a client on any other minor falls through to the flat slot.
|
||||||
|
|
||||||
|
### Deployment from this repo
|
||||||
|
|
||||||
|
The script is a file in the repo, applied by the existing OpenTofu configuration:
|
||||||
|
|
||||||
|
```
|
||||||
|
edge/rpkgs-router.ts # the script
|
||||||
|
edge/rpkgs-router.test.ts # UA x path -> expected Location matrix
|
||||||
|
cdn.tf # bunnynet_compute_script + _variable
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider `BunnyWay/bunnynet` v0.17.0 (already pinned) ships `bunnynet_compute_script` with `content` loadable via `file()`, plus `bunnynet_compute_script_variable`.
|
||||||
|
`middleware_script = bunnynet_compute_script.rpkgs_router.id` replaces the hard-coded `29277`, after a one-time `tofu import` of the existing script.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Unknown R minor, or a slot not listed in `UNION_SLOTS`: pass through to the flat slot.
|
||||||
|
The client sees exactly today's behaviour.
|
||||||
|
- Unparseable distro on the bare-root form: redirect to CRAN.
|
||||||
|
- A request already under `…/src/contrib/<x.y>/`: pass through, so a redirect can never loop.
|
||||||
|
- A per-minor slot that does not exist for a listed minor: the client gets the origin's 404.
|
||||||
|
`UNION_SLOTS` is the operator's assertion that the slot is ready, so this is a configuration error, not a runtime condition to paper over.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Local, before any apply: `deno run -A edge/rpkgs-router.ts` serves the middleware against the real origin, so `edge/rpkgs-router.test.ts` drives the whole matrix against that local server.
|
||||||
|
|
||||||
|
- User agent matrix: R 4.4 / 4.5 / 4.6 on musl and gnu, both arches, Posit-style and stock forms, plus a darwin UA and a non-R UA.
|
||||||
|
- Path matrix: `PACKAGES`, `PACKAGES.gz`, `PACKAGES.rds`, a tarball, a path already under `4.5/`, and `/src/contrib/…` on the bare root.
|
||||||
|
- Assertion is the `Location` header (or its absence), not the body.
|
||||||
|
|
||||||
|
After apply, a smoke test against `cran.rpkgs.com`:
|
||||||
|
|
||||||
|
- `available.packages()` inside `reg.devxy.io/r/r-alpine:4.5-3.24` returns the union count, and `"curl" %in% rownames(...)` is `TRUE`.
|
||||||
|
- A flat-slot package still downloads from `…/src/contrib/`, and a per-minor package downloads from `…/src/contrib/<x.y>/`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `Meta/archive.rds` stays flat-only, so `remotes::install_version()` does not see per-minor archives.
|
||||||
|
- The `alpine324` source-tarball defect: that slot serves CRAN sources stamped as binaries, and needs a rebuild independent of this work.
|
||||||
|
- Any change to how `uvr` resolves per-minor URLs; it already addresses the slots directly.
|
||||||
|
|
||||||
|
## Split of work
|
||||||
|
|
||||||
|
1. `bincraft`: union index writer plus its guard, and a re-index of one slot to validate.
|
||||||
|
2. This repo: `edge/rpkgs-router.ts`, its test matrix, and the `cdn.tf` resources with `UNION_SLOTS` empty.
|
||||||
|
3. Enable `UNION_SLOTS` slot by slot as `bincraft` backfills them.
|
||||||
Loading…
Reference in a new issue