build-cran-binaries/specs/2026-08-07-per-minor-edge-routing-design.md
pat-s 2f732457d2
Some checks failed
ci/crow/manual/trial-build-registry/1 Pipeline is pending
ci/crow/manual/trial-build-registry/3 Pipeline is pending
ci/crow/manual/trial-build-registry/5 Pipeline is pending
ci/crow/manual/trial-build-registry/7 Pipeline is pending
ci/crow/manual/trial-build-registry/9 Pipeline is pending
ci/crow/manual/trial-build-registry/11 Pipeline is pending
ci/crow/manual/trial-build-registry/13 Pipeline is pending
ci/crow/manual/trial-build-registry/15 Pipeline is pending
ci/crow/manual/trial-build-registry/17 Pipeline is pending
ci/crow/manual/repair-built-stamp/2 Pipeline failed
ci/crow/manual/weekly-audit-missing/17 Pipeline was successful
ci/crow/manual/weekly-audit-missing/9 Pipeline was successful
ci/crow/manual/weekly-audit-missing/15 Pipeline was successful
ci/crow/manual/weekly-audit-missing/11 Pipeline was successful
ci/crow/manual/weekly-audit-missing/13 Pipeline was successful
ci/crow/manual/weekly-audit-missing/7 Pipeline was successful
ci/crow/manual/trial-build-registry/4 Pipeline was successful
ci/crow/manual/build-all-versions-install-deps/1 Pipeline was successful
ci/crow/manual/trial-build-registry/6 Pipeline was successful
ci/crow/manual/trial-build-registry/2 Pipeline was successful
ci/crow/manual/build-all-versions/1 Pipeline failed
ci/crow/manual/trial-build-registry/8 Pipeline was successful
ci/crow/manual/trial-build-registry/14 Pipeline was successful
ci/crow/manual/trial-build-registry/12 Pipeline was successful
ci/crow/manual/trial-build-registry/10 Pipeline was successful
ci/crow/manual/build-all-versions/2 Pipeline failed
ci/crow/manual/trial-build-registry/16 Pipeline was successful
ci/crow/manual/trial-build-registry/18 Pipeline was successful
ci/crow/manual/weekly-audit-missing/5 Pipeline was successful
ci/crow/manual/weekly-audit-missing/1 Pipeline was successful
ci/crow/manual/auto-apply-patches Pipeline was successful
ci/crow/manual/weekly-audit-missing/3 Pipeline was successful
ci/crow/manual/weekly-audit-missing/2 Pipeline was successful
ci/crow/manual/weekly-audit-missing/4 Pipeline was successful
ci/crow/manual/weekly-audit-missing/6 Pipeline was successful
ci/crow/manual/weekly-audit-missing/8 Pipeline was successful
ci/crow/manual/weekly-audit-missing/10 Pipeline was successful
ci/crow/manual/weekly-audit-missing/12 Pipeline was successful
ci/crow/manual/weekly-audit-missing/14 Pipeline was successful
ci/crow/manual/weekly-audit-missing/16 Pipeline was successful
ci/crow/manual/weekly-audit-missing/18 Pipeline was successful
ci/crow/manual/weekly-patch-proposals Pipeline was successful
ci/crow/manual/process-updates/6 Pipeline was successful
ci/crow/manual/build-all-versions/4 Pipeline failed
ci/crow/manual/build-all-versions/3 Pipeline failed
ci/crow/manual/weekly-rebuild-missing/13 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/15 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/7 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/9 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/11 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/8 Pipeline was successful
ci/crow/cron/process-updates/13 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/4 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/1 Pipeline was successful
ci/crow/cron/process-updates/15 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/10 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/12 Pipeline was successful
ci/crow/cron/process-updates/11 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/16 Pipeline was successful
ci/crow/cron/process-updates/17 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/14 Pipeline was successful
ci/crow/cron/process-updates/4 Pipeline was successful
ci/crow/cron/process-updates/10 Pipeline was successful
ci/crow/cron/process-updates/14 Pipeline was successful
ci/crow/cron/process-updates/5 Pipeline was successful
ci/crow/cron/process-updates/16 Pipeline was successful
ci/crow/cron/process-updates/18 Pipeline was successful
ci/crow/cron/process-updates/12 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/2 Pipeline was successful
ci/crow/cron/process-updates/6 Pipeline was successful
ci/crow/cron/process-updates/1 Pipeline was successful
ci/crow/cron/process-updates/2 Pipeline was successful
ci/crow/cron/process-updates/7 Pipeline was successful
ci/crow/cron/process-updates/8 Pipeline was successful
ci/crow/cron/process-updates/9 Pipeline was successful
ci/crow/cron/process-updates/3 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/17 Pipeline failed
ci/crow/manual/weekly-rebuild-missing/6 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/5 Pipeline was successful
ci/crow/manual/weekly-rebuild-missing/18 Pipeline was successful
feat(edge): route PACKAGES requests to the per-R-minor slot (#152)
## Problem

`install.packages("curl")` fails in `reg.devxy.io/r/r-alpine:4.5-3.24` with "package 'curl' is not available for this version of R", on both arches.

`curl` is not missing from the repo: it is in `…/latest/src/contrib/4.5/` and `…/4.6/`, the per-minor slots that base R cannot address. The image's repo URL resolves to `…/latest/src/contrib`, whose index does not list it. On `amd64/alpine324` that is 2 886 packages invisible to `install.packages()` (23 on `amd64/noble`) — what issue #63 records as "missing binaries".

Two further findings while investigating:

- The middleware only ever rewrote the bare `cran.rpkgs.com/src/contrib/…` form, and that form was broken for every Linux client on a stock R user agent: `ALPINE_REGEX`/`UBUNTU_REGEX`/`RHEL_REGEX` need a Posit-style UA that carries the distro, so stock R fell through to `extractOs()` and got redirected to `/amd64/linux-musl/latest/…`, a slot that does not exist.
- `PACKAGES*` is served `cdn-cache: BYPASS` (bincraft uploads it `no-store`), so the middleware sees every index request and no purge is needed for routing changes to take effect.

## What this changes

**`edge/rpkgs-router.ts`** — the middleware, now a reviewed file in this repo rather than dashboard state. It routes `PACKAGES`, `PACKAGES.gz` and `PACKAGES.rds` into `…/src/contrib/<x.y>/` for slots listed in `UNION_SLOTS`, and nothing else.

Tarballs are deliberately left alone. R keeps the `contriburl` it *asked for*, not the one the redirect served it, 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.

Also in the script: the phantom `linux-gnu`/`linux-musl` fallback is gone (an unidentifiable distro goes to CRAN, as an unparseable UA already did), and every redirect carries `Cache-Control: no-store` since its target depends on the User-Agent. The macOS branches are unchanged.

**`cdn.tf`** — `bunnynet_compute_script.rpkgs_router` with `content = file("edge/rpkgs-router.ts")`, the `UNION_SLOTS` variable, and `middleware_script` pointing at the resource instead of the literal `29277`.

`UNION_SLOTS` is empty, so merging and applying this changes no client's behaviour. A slot is added only once bincraft has republished its per-minor index as a union (rpkgs/bincraft#97); routing to a raw per-minor index would hide every package it does not carry. Rolling back is a variable edit, not a deploy.

**`specs/`, `plans/`** — the design and the implementation plan, including the two approaches that were rejected (edge-side merge, moving the minor up the path) and why.

## Verification

`just edge-test` runs 13 routing cases against the SDK's local server, so what is tested is the artifact that gets deployed; pass-through cases proxy to the real origin. All pass.

End to end, with the middleware in front of a locally built union index for `amd64/alpine324` (31 507 records), inside the runtime image:

```
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
```

`tofu validate` passes. `tofu plan` has not been run: no `BUNNYNET_API_KEY` available in this environment.

## Before applying

The script pre-dates this configuration, so it must be adopted, not created:

```sh
tofu import bunnynet_compute_script.rpkgs_router 29277
tofu plan
```

The plan should show an in-place `content` update and no replacement of the pull zone. Without the import, tofu creates a second script and repoints the zone at it. Note that `name = "rpkgs-router"` will rename the existing script on apply.

## Not fixed here

`install.packages("curl")` on `alpine324` will now *resolve*, then fail to build: that slot's tarballs are byte-identical CRAN **source** tarballs (no `Meta/`, no `Built:` in DESCRIPTION) which the index nevertheless stamps `Built: R 4.5.3; …-linux-musl`. Sampled: `amd64/alpine324` 3/12 binary, `arm64/alpine324` 13/30, `amd64/noble` 12/12, `amd64/alpine323` 17/20. That slot needs a rebuild, tracked separately.

Reviewed-on: #152
2026-08-07 14:12:22 +00:00

174 lines
10 KiB
Markdown

# 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.