- 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
160 lines
5.7 KiB
TypeScript
160 lines
5.7 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_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);
|
|
}
|
|
});
|