Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Cases that use the remote cache backend are ignored because it runs on
# Node.js. Windows is skipped because the PTY launcher cannot execute pnpm
# command shims. Each of these cases starts its own backend first, runs
# commands against it with `remote-cache-server run`, and stops it last.
# commands against it with `remote-cache-server run`, and stops it last. The
# backend only accepts uploads with a GitHub Actions token, so steps that
# upload run with `--github-actions`, as a job of a push to the main branch.
# Whether an upload to the backend is still running when the tasks finish
# depends on timing, so steps that upload hide the message about pending
# uploads. The cases that test it stall the uploads instead, with
Expand All @@ -18,6 +20,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand All @@ -34,6 +37,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -61,6 +65,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -105,6 +110,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -132,6 +138,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -189,6 +196,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -242,6 +250,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -292,6 +301,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -353,6 +363,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vt",
"run",
"build",
Expand Down Expand Up @@ -433,6 +444,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vtt",
"stalled-remote-cache",
"--stall",
Expand Down Expand Up @@ -655,6 +667,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vtt",
"stalled-remote-cache",
"--stall",
Expand Down Expand Up @@ -699,6 +712,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vtt",
"stalled-remote-cache",
"--stall",
Expand Down Expand Up @@ -733,6 +747,7 @@ steps = [
{ argv = [
"remote-cache-server",
"run",
"--github-actions",
"vtt",
"stalled-remote-cache",
"--stall",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write remote-cache-server run vtt stalled-remote-cache --stall /store vt run build`
## `VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vtt stalled-remote-cache --stall /store vt run build`

The proxy forwards the fetch to the backend, which has no entry, but never forwards the upload. Ctrl-C cancels it while vt run waits.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write remote-cache-server run vtt stalled-remote-cache --stall /store vt run fail-after-build`
## `VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vtt stalled-remote-cache --stall /store vt run fail-after-build`

The proxy never forwards the upload. fail-after-build exits after build finishes, which doesn't cancel build's upload, so vt run waits for it until Ctrl-C.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vtt stalled-remote-cache --stall /store vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vtt stalled-remote-cache --stall /store vt run build`

The proxy never forwards the upload. vt run waits for it until Ctrl-C, without the message about pending uploads.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write remote-cache-server run vtt stalled-remote-cache --stall /store vt run all`
## `VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vtt stalled-remote-cache --stall /store vt run all`

The proxy never forwards the uploads. check doesn't wait for build's upload, so both are still running when check finishes, and vt run waits for them until Ctrl-C.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

The fetch finds no entry. The new execution is uploaded with one store request, and vt run waits for it after the task finishes.

Expand All @@ -16,7 +16,7 @@ $ vtt write-file dist/output.txt built
[remote-cache] POST /store 200
```

## `VP_REMOTE_CACHE=read-write remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vt run build`

A local hit makes no requests.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand All @@ -24,7 +24,7 @@ $ vtt write-file dist/output.txt built
```
```

## `VP_REMOTE_CACHE=read-write remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vt run build`

A remote hit downloads the output archive. Hits never upload.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
```
```

## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run vt run build`
## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build`

```
$ vtt write-file dist/output.txt built
Expand Down
13 changes: 10 additions & 3 deletions packages/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ Run `pnpm install` at the repository root to install `remote-cache-server` into

```sh
remote-cache-server start
VP_REMOTE_CACHE=read-write remote-cache-server run vt run build
VP_REMOTE_CACHE=read-write remote-cache-server run --github-actions vt run build
remote-cache-server stop
```

An E2E case starts its own backend in its first step and stops it in its last, so the backend keeps its state for the whole case. Each subcommand works in the current directory, the case's directory:

- `remote-cache-server start` starts the backend in the background on free loopback ports and returns once it's ready. The backend runs in its own session and doesn't use the terminal, so the step can finish and Ctrl-C in later steps doesn't reach it. It writes its endpoint, `http://127.0.0.1:<port>/projects/test`, to `remote-cache/server.json`, and its output to `remote-cache/server.log`.
- `remote-cache-server run COMMAND [ARGS...]` runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint. The fixed base path gives the endpoint a namespace path. The command inherits stdio and handles Ctrl-C, which `run` ignores. `run` exits with the command's exit code.
- `remote-cache-server run [--github-actions] COMMAND [ARGS...]` runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint. The fixed base path gives the endpoint a namespace path. With `--github-actions`, the command runs as if in a GitHub Actions job with `id-token: write`: `ACTIONS_ID_TOKEN_REQUEST_URL` points to the backend's stand-in for GitHub's token service, and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` defaults to `main-push`. The command inherits stdio and handles Ctrl-C, which `run` ignores. `run` exits with the command's exit code.
- `remote-cache-server corrupt-blob NUMBER` overwrites a stored blob, numbered as in the request lines, with other bytes.
- `remote-cache-server stop` stops the backend. It fails if the backend answered a request with a 5xx status or couldn't be reached.

Expand All @@ -28,9 +28,16 @@ The endpoint is a tap in front of the backend. It forwards every request and res
[remote-cache] GET /blob/1 200
```

Like the public cache service, the backend only accepts a store with a GitHub Actions token for a push to the main branch of its registered repository, whose audience is the endpoint. It answers other stores as the service does: `401` with `Invalid credentials` for a missing or invalid token, and `403` with `Write not permitted` for a token that the write policy doesn't allow. The stand-in signs tokens with a key that the backend trusts in place of GitHub's, for the workflow run that the request token stands for:

| Request token | Workflow run |
| -------------- | ----------------------------------------- |
| `main-push` | A push to `main` of `owner/repository` |
| `pull-request` | A pull request against `owner/repository` |

The backend keeps its state in `remote-cache/`. `state.json` holds the entries and associations, with keys and values hex-encoded. Each blob is a file in `remote-cache/blobs/` named by its blob ID, a random UUID.

The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache server API](https://github.com/voidzero-dev/vite-task/pull/713). A fetch that matches neither key gets a `404` with the plain-text body `Not found`. Keys, values, and blobs are opaque bytes without length limits. There is no authentication.
The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache server API](https://github.com/voidzero-dev/vite-task/pull/713). A fetch that matches neither key gets a `404` with the plain-text body `Not found`. Keys, values, and blobs are opaque bytes without length limits. Writes need a token as described above.

Run `pnpm --filter vite-task-tools check` for type checking. Run `cargo test -p vt_bin --test e2e_snapshots -- remote_cache --ignored` for the snapshots that use the backend.

Expand Down
78 changes: 77 additions & 1 deletion packages/tools/src/remote-cache/backend.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Busboy } from '@fastify/busboy';
import { decode } from 'cbor2/decoder';
import { encode } from 'cbor2/encoder';
import { randomUUID } from 'node:crypto';
import { randomUUID, verify } from 'node:crypto';
import { once } from 'node:events';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import { join } from 'node:path';
import { issuer, type repository, type TrustedKey } from './github.ts';

interface Entry {
value: string;
Expand Down Expand Up @@ -91,6 +92,51 @@ async function readParts(body: Buffer, contentType: string): Promise<Map<string,
});
}

function decodePart(part: string): Record<string, unknown> | undefined {
try {
const value: unknown = JSON.parse(Buffer.from(part, 'base64url').toString());
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}

/**
* The claims of `token`, if it's an RS256 JSON Web Token that `key` signed for
* GitHub's issuer and it's valid now, with the time bounds the service uses.
*/
function verifiedClaims(token: string, key: TrustedKey): Record<string, unknown> | undefined {
const [header, payload, signature] = token.split('.') as [string, string, string];
const protectedHeader = decodePart(header);
if (protectedHeader?.['alg'] !== 'RS256' || protectedHeader['kid'] !== key.kid) return undefined;
const signed = Buffer.from(`${header}.${payload}`);
if (!verify('sha256', signed, key.publicKey, Buffer.from(signature, 'base64url')))
return undefined;
const claims = decodePart(payload);
const [exp, nbf, iat] = [claims?.['exp'], claims?.['nbf'], claims?.['iat']];
const now = Date.now() / 1000;
if (
claims?.['iss'] !== issuer ||
!Number.isSafeInteger(exp) ||
!Number.isSafeInteger(nbf) ||
!Number.isSafeInteger(iat)
) {
return undefined;
}
const [expires, notBefore, issued] = [exp as number, nbf as number, iat as number];
const valid =
expires > now &&
notBefore <= now + 30 &&
issued <= now + 30 &&
issued >= now - 930 &&
notBefore <= expires &&
issued < expires &&
expires - issued <= 900;
return valid ? claims : undefined;
}

function cbor(response: ServerResponse, value: unknown): void {
response.writeHead(200, { 'content-type': 'application/cbor' });
response.end(encode(value));
Expand All @@ -110,20 +156,49 @@ export interface Backend {
* `directory`: entries and associations in `state.json`, and each blob in
* `blobs/` under its ID, a random UUID. Keys, values, and blobs remain opaque
* bytes. A fetch that matches neither key gets a plain-text 404.
*
* Like the public cache service, the backend only accepts a store with a
* GitHub Actions token whose audience is `endpoint`, for a push to the main
* branch of `registered`. It trusts tokens signed with `key` in place of
* GitHub's. It answers other stores as the service does: 401 for a missing or
* invalid token, and 403 for a token that the write policy doesn't allow.
*/
export async function startBackend({
basePath,
directory,
endpoint,
key,
registered,
}: {
basePath: string;
directory: string;
endpoint: string;
key: TrustedKey;
registered: typeof repository;
}): Promise<Backend> {
const stateFile = join(directory, 'state.json');
const blobDirectory = join(directory, 'blobs');
const state: State = existsSync(stateFile)
? JSON.parse(readFileSync(stateFile, 'utf8'))
: { entries: {}, associations: {} };
const entries = new Map(Object.entries(state.entries));

function authorize(authorization: string | undefined): void {
const token = /^Bearer ([\w-]+\.[\w-]+\.[\w-]+)$/i.exec(authorization ?? '')?.[1];
const claims = token === undefined ? undefined : verifiedClaims(token, key);
if (claims === undefined) throw new RequestError(401, 'Invalid credentials');
if (
claims['aud'] !== endpoint ||
claims['repository_id'] !== registered.id ||
claims['repository_owner_id'] !== registered.ownerId ||
claims['repository_visibility'] !== 'public' ||
claims['ref'] !== registered.branch ||
claims['ref_type'] !== 'branch' ||
claims['event_name'] !== 'push'
) {
throw new RequestError(403, 'Write not permitted');
}
}
const associations = new Map(Object.entries(state.associations));

async function handle(
Expand All @@ -142,6 +217,7 @@ export async function startBackend({
throw new RequestError(404, 'Route not found');
}

if (path === `${basePath}/store`) authorize(request.headers.authorization);
const contentType = request.headers['content-type'] ?? '';
const body = await readBody(request);
if (path === `${basePath}/fetch`) {
Expand Down
Loading
Loading