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
1 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);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue