Falling back to the flat index for an excluded minor is the silent case: risky packages there are built under another minor and fail at load time, far from the cause. Send those clients to CRAN for sources instead, which is what the router already does for an unidentifiable distro. The whole interaction has to move, not just the index. R resolves tarball URLs against the repo it was configured with, so serving the index from CRAN and tarballs from here would hand R a binary where it expects a source tarball. A client reporting no R minor at all is not excluded: mirror scripts and image builds keep getting the flat slot. - Declare the supported window once in cdn.tf as local.rpkgs_supported_minors and set KNOWN_MINORS from it on both zones, so the router cannot drift from build-env-images' LATEST/PREV1/PREV2 unnoticed. - Split the verification script into supported and excluded minors: the former must have published indexes, the latter must have none and must redirect to CRAN under --live.
209 lines
8.2 KiB
TypeScript
209 lines
8.2 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`);
|
|
});
|
|
|
|
// We publish binaries only for the supported window. An excluded minor has
|
|
// no slot we can serve safely, so it goes to CRAN for sources rather than
|
|
// to a 404 or to binaries built under another minor.
|
|
await t.step('sends an excluded R minor to CRAN for the index', async () => {
|
|
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R43_MUSL);
|
|
assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz');
|
|
});
|
|
|
|
await t.step('sends a future R minor to CRAN too', async () => {
|
|
const res = await probe(`${SLOT}/PACKAGES.gz`, UA_R47_MUSL);
|
|
assertEquals(res.location, 'https://cran.r-project.org/src/contrib/PACKAGES.gz');
|
|
});
|
|
|
|
// The index and the tarballs R resolves against it have to come from the
|
|
// same place. Serving one from CRAN and the other from here would hand R a
|
|
// binary where it expects a source tarball.
|
|
await t.step('sends an excluded minor to CRAN for tarballs as well', async () => {
|
|
const res = await probe(`${SLOT}/foo_1.0.tar.gz`, UA_R43_MUSL);
|
|
assertEquals(res.location, 'https://cran.r-project.org/src/contrib/foo_1.0.tar.gz');
|
|
});
|
|
|
|
await t.step('leaves an excluded minor alone on a slot outside UNION_SLOTS', async () => {
|
|
const res = await probe(`${OTHER_SLOT}/PACKAGES.gz`, UA_R43_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);
|
|
}
|
|
});
|