Enabling UNION_SLOTS today would break every client on an R minor we do not publish. contribPath() redirects on any minor the User-Agent carries, without checking that the target exists and without a fallback, and only 4.4, 4.5 and 4.6 are published: a 4.3 client would be sent to a 404 and see no packages at all. - Gate routing on KNOWN_MINORS, falling back to the flat index otherwise. - Honour EXTRA_PUBLIC_HOSTS so the same script can run on a staging zone and redirect within itself instead of into production. - Add the cran-rpkgs-test pull zone with UNION_SLOTS pre-enabled, served on the bunny default hostname so it needs no DNS record. - Add scripts/verify-r-minor-routing.sh, covering all 16 slots: index reachability, the union property against flat, Path: target resolution, coverage parity across minors, and (--live) real User-Agent routing. - Cover the fallback in the edge test suite.
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
/**
|
|
* 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_R43_MUSL = 'R (4.3.3 x86_64-pc-linux-musl x86_64 linux-musl)';
|
|
const UA_R47_MUSL = 'R (4.7.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_RESOLUTE = 'R/4.5.3 (Ubuntu 26.04) (aarch64-unknown-linux-gnu aarch64 linux-gnu)';
|
|
const UA_R45_FUTURE_UBUNTU =
|
|
'R/4.5.3 (Ubuntu 28.04; codename=dynamic-dugong) (aarch64-unknown-linux-gnu aarch64 linux-gnu)';
|
|
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`);
|
|
});
|
|
|
|
// No per-minor index is published for 4.3, and contribPath() cannot probe
|
|
// the origin. Routing it would send the client to a 404 and it would see no
|
|
// packages at all, so an unpublished minor must fall through to flat.
|
|
await t.step('falls back to flat for an R minor that is not published', async () => {
|
|
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R43_MUSL);
|
|
assertEquals(res.location, null);
|
|
assertEquals(res.status, 200);
|
|
});
|
|
|
|
await t.step('falls back to flat for a future R minor', async () => {
|
|
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R47_MUSL);
|
|
assertEquals(res.location, null);
|
|
assertEquals(res.status, 200);
|
|
});
|
|
|
|
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('serves an archived binary when it exists', async () => {
|
|
const path = `${SLOT}/Archive/xml2/xml2_1.5.2.tar.gz`;
|
|
const res = await probe(path, UA_R45_MUSL);
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.location, null);
|
|
});
|
|
|
|
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('resolves Ubuntu 26.04 to the resolute slot', async () => {
|
|
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_RESOLUTE);
|
|
assertEquals(res.location, 'https://cran.rpkgs.com/arm64/resolute/latest/src/contrib/PACKAGES.gz');
|
|
});
|
|
|
|
await t.step('resolves a future Ubuntu release from its codename', async () => {
|
|
const res = await probe('/src/contrib/PACKAGES.gz', UA_R45_FUTURE_UBUNTU);
|
|
assertEquals(res.location, 'https://cran.rpkgs.com/arm64/dynamic-dugong/latest/src/contrib/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);
|
|
}
|
|
});
|