Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .craft.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
minVersion: "2.21.0"
changelog:
policy: auto
# Nothing reads a version from a file. The git tag is the version.
preReleaseCommand: ""
artifactProvider:
name: none
# Without a preReleaseCommand, Craft writes the version to package.json
# for the npm target.
targets:
# The client and the vitest integration. CI uploads the tarball of each
# commit as the artifact `<sha>`.
- name: npm
access: public
# The service image. The Image workflow pushes `:<sha>` for the release
# branch, and Craft copies it to `:<version>`.
- name: docker
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,25 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage

package:
name: package
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm pack
# Craft publishes the tarball of the release commit to npm.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ github.sha }}
path: "*.tgz"
if-no-files-found: error
120 changes: 95 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,44 @@ Roach started in
It uses Node built-ins and the `openssl` command. Only the service uses the
Sentry SDK (`@sentry/node`).

## Use with vitest

Install `@sentry/roach`, add a `roach.json` (see [Config file](#config-file)),
and add two lines to the vitest config:

```ts
export default defineConfig({
test: {
globalSetup: ["@sentry/roach/vitest"],
setupFiles: ["@sentry/roach/vitest/setup"],
},
});
```

The global setup (`src/vitest.ts`) starts one run for the invocation and
gives its proxy variables to the test workers. The setup file
(`src/vitest-setup.ts`) opens one session for each test, so test files
record in parallel workers at the same time. A passed test writes its new
recordings. A failed test drops them. In `replay` mode, a test with a miss
fails. At the end, the global setup writes the stats of the run to stdout
and to the job summary.

- `ROACH_TOKEN`: the write token. Only the global setup reads it. Without
it, the run can only replay, as in jobs of forks.
- `ROACH_URL`: the service. Default: `https://roach-proxy.getsentry.net`.
- `ROACH_MODE`: `auto`, `replay`, `record`, or `off`. Default: `auto` with
the token, `replay` without it. With `off`, the global setup starts no run,
so requests go direct and need no service and no token.

A project that has its own global setup calls `startRoach(project)` from
`@sentry/roach/vitest` in it, and returns its teardown.

- It needs Node 24.14 or later (`http.setGlobalProxyFromEnv`).
- The tests of one file share one process, so they run one at a time. Do
not use `test.concurrent`.
- The package has TypeScript sources. Vitest compiles them, but Node does
not strip types in `node_modules`, so plain Node scripts cannot import it.

## Use the service

```ts
Expand Down Expand Up @@ -43,7 +81,7 @@ const run = await startRemoteRun(
spawn("vitest", { env: { ...process.env, ...run.env } });

// Once per test, in a worker that has the `controlUrl` and `token` of the
// run.
// run. Send the requests of the test through `session.url`.
const session = await connectRoach({ controlUrl, token }).startSession(
testName,
);
Expand All @@ -62,7 +100,8 @@ A client that does not use `client.ts` can do the same with plain HTTP. See

## Use the GitHub Action

A repository adds one step and a `roach.json` file:
A repository adds one step and a `roach.json` file (see
[Config file](#config-file)):

```yaml
- uses: getsentry/roach@v0
Expand All @@ -71,38 +110,56 @@ A repository adds one step and a `roach.json` file:
run: pnpm test
```

`roach.json` is at the root of the repository. It has the `allow` origins
and the `rules` of the run (`RunConfig` in `src/service.ts`):
The action (`src/action.ts`) starts a run with the token in `auto` mode, or
without it in `replay` mode. It runs `run` in bash with the proxy variables
of the run, then ends the run and writes its stats to the job summary.

- Only `run` uses the proxy. Later steps, such as cache and artifact
uploads, do not. `run` does not get the write token.
- `run` is one session. When it fails, the run drops its new recordings. A
miss in `replay` mode fails the step. The summary names the job, not the
test, of each miss. For a session per test, use
[vitest](#use-with-vitest) in `run`.
- `NODE_EXTRA_CA_CERTS` works only for Node. Python and `curl` need a CA
bundle that also has the system certificates.

## Config file

The action and the vitest integration read `roach.json` in the working
directory or the closest parent directory (`src/config.ts`):

```json
{
"allow": ["https://ai-gateway.vercel.sh"],
"allow": ["https://api.anthropic.com"],
"secretEnv": ["ANTHROPIC_API_KEY"],
"rules": [
{
"name": "model",
"match": { "method": "POST", "url": "https://ai-gateway.vercel.sh/" },
"keyHeaders": ["ai-model-id"]
"match": { "method": "POST", "url": "https://api.anthropic.com/v1/" },
"values": {
"uuid": { "builtin": "uuid" },
"build": "build-[0-9]+"
}
}
]
}
```

The action (`src/action.ts`) starts a run of the tenant
`GITHUB_REPOSITORY`, in `auto` mode with the token or `replay` mode without
it. It runs `run` in bash with the proxy variables of the run, then ends
the run and writes its stats to the job summary.
- `allow` and `rules` are those of the run. See [Config](#config).
- A value is a regular expression source, or `{ "builtin": "<name>" }`
for a pattern of `VALUE_PATTERNS` in `values.ts`.
- `secretEnv`: environment variables that hold credentials. Their values
are the `secrets` of the run.
- `tenant`: default `GITHUB_REPOSITORY`. Set it, so local runs use the
recordings of CI.
- `noProxy`: hosts that do not use the proxy. Default: loopback.

- Only `run` uses the proxy. Later steps, such as cache and artifact
uploads, do not. `run` does not get the write token.
- `run` is one session. When it fails, the run drops its new recordings. A
miss in `replay` mode fails the step. The summary names the job, not the
test, of each miss.
- `NODE_EXTRA_CA_CERTS` works only for Node. Python and `curl` need a CA
bundle that also has the system certificates.
The `name` of the run is `<GITHUB_RUN_ID>-<GITHUB_RUN_ATTEMPT>` in GitHub
Actions.

## Config

A run takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`,
A run takes `RunConfig` (`src/types.ts`): `tenant`, `mode`, `rules`,
`allow`, `name`, and `secrets`.

- `mode`: `auto` replays recordings and records misses. `replay` replays
Expand Down Expand Up @@ -141,8 +198,10 @@ headers, and the body. A JSON body has sorted keys. A recording keeps the
response and the session that recorded it. It does not keep the request
body.

- One session is open at a time. The proxy keeps the new recordings of a
session in memory. A passed session writes them, and a failed session
- Many sessions can be open at once. A request belongs to the session of
its proxy URL (`url` of the session). A request through the proxy URL of
the run belongs to the open session when only one is open. The proxy
keeps the new recordings of a session in memory. A passed session writes them, and a failed session
drops them, so a bad sample is never replayed.
- A request belongs to the session that was open when it started. If it
ends after its session ended, it follows the result of that session.
Expand Down Expand Up @@ -410,7 +469,10 @@ It starts the service and prints its URL as one JSON line.
The calls of a run are under `/__roach/runs/<id>`. They need
`Authorization: Bearer <run token>`. `client.ts` calls them.

- `POST /session` with `{"name": "..."}`: open a session.
- `POST /session` with `{"name": "..."}`: open a session. Returns
`{"url": "..."}`, the proxy URL of the session. Its user is
`<run id>-<session id>`. An open session with the same name fails
first. A run can have 64 open sessions. Another one gets HTTP 429.
- `POST /session/end` with `{"name": "...", "passed": true}`: end it.
Returns `{"missed": 0}`, the misses that `replay` mode failed. Returns
HTTP 409 when the session is not open.
Expand All @@ -434,9 +496,12 @@ manager accepts it, Craft:
- copies the image `ghcr.io/getsentry/roach:<sha>` to `:<version>`.
- makes the GitHub release `v<version>`, with the changelog, and moves the
`v<major>` tag. Repositories use the action as `getsentry/roach@v<major>`.
- publishes `@sentry/roach` to npm: the tarball that the `package` job of
`CI` uploads for the release commit. Craft writes the version to
`package.json` on the release branch.

The action runs `src/action.ts` from the tag, so a release has no build
step.
The action runs `src/action.ts` from the tag, and the package has the
sources, so a release has no build step.

The `Release` workflow uses the token of Sentry Release Bot. A push with
`GITHUB_TOKEN` starts no workflows, so the release branch gets no checks and
Expand All @@ -454,6 +519,8 @@ pnpm check # format, lint, types, unused code, and tests, as CI runs them
- `tests/recording.test.ts` tests modes, sessions, changing values, and
misses.
- `tests/service.test.ts` tests the service in the test process.
- `tests/vitest.test.ts` runs vitest with the integration on a small
project, against a service in the test process.
- `tests/deployed.test.ts` runs the service as it runs in production: from
the command line, behind a TLS server that does what the load balancer
does, with a local GCS server, metadata server, and Sentry server. Each
Expand All @@ -465,7 +532,7 @@ pnpm check # format, lint, types, unused code, and tests, as CI runs them
The proxy is in `src/`. Tests are in `tests/`. The production setup is in
`deploy/gcp/` and `Dockerfile`.

- `types.ts`: rules, modes, and the results of a run.
- `types.ts`: rules, modes, runs, sessions, and the results of a run.
- `server.ts`: sockets, HTTPS interception, the allow list, and the control
API.
- `addresses.ts`: the public addresses that the proxy can send requests to.
Expand All @@ -481,6 +548,9 @@ The proxy is in `src/`. Tests are in `tests/`. The production setup is in
- `secrets.ts`: redaction of credentials.
- `certificates.ts`: the certificate authority for HTTPS.
- `client.ts`: starts a run and calls the control API.
- `config.ts`: `roach.json`, for the action and vitest.
- `vitest.ts`: the vitest global setup: one run for each invocation.
- `vitest-setup.ts`: the vitest setup file: one session for each test.
- `report.ts`: text reports of a run.
- `action.ts`: the GitHub Action (`action.yml` at the root).
- `cli.ts`: the command line.
2 changes: 1 addition & 1 deletion deploy/gcp/roach.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ domain = "roach-proxy.getsentry.net"
deploy_repository = "getsentry/roach"
deploy_repository_id = "1412275249"

# Runs send their own origins and value patterns (RunConfig in src/service.ts),
# Runs send their own origins and value patterns (RunConfig in src/types.ts),
# so a tenant needs no change here.

# The service sends metrics and spans to the sentry/roach project.
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"$schema": "https://unpkg.com/knip@6/schema.json",
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignoreBinaries": ["openssl"],
"entry": ["src/action.ts"]
"entry": ["src/action.ts", "src/cli.ts"]
}
23 changes: 18 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
{
"name": "@sentry/roach",
"private": true,
"version": "0.1.0",
"description": "A recording HTTPS proxy for tests, as a shared service.",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/getsentry/roach.git"
},
"bin": {
"roach": "./src/cli.ts"
},
"files": [
"src"
],
"type": "module",
"exports": {
"./client": "./src/client.ts",
"./report": "./src/report.ts",
"./service": "./src/service.ts",
"./types": "./src/types.ts",
"./values": "./src/values.ts"
"./values": "./src/values.ts",
"./vitest": "./src/vitest.ts",
"./vitest/setup": "./src/vitest-setup.ts"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm knip && pnpm test",
Expand Down Expand Up @@ -48,6 +53,14 @@
"undici": "^8.11.2",
"vitest": "^5.0.3"
},
"peerDependencies": {
"vitest": ">=3"
},
"peerDependenciesMeta": {
"vitest": {
"optional": true
}
},
"simple-git-hooks": {
"pre-commit": "pnpm exec lint-staged",
"pre-push": "pnpm typecheck"
Expand Down
37 changes: 10 additions & 27 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
* `README.md`.
*
* It starts a run, runs the command of the step with the proxy variables of
* the run, and ends the run. The rules of the run come from `roach.json`.
* the run, and ends the run. The run config comes from `roach.json`
* (`config.ts`).
* The runner gives each input as `INPUT_<NAME>`.
*/
import { spawn } from "node:child_process";
import { appendFile, readFile } from "node:fs/promises";
import { startRemoteRun } from "./client.ts";
import { describeRecordingMisses, describeRecordingStats } from "./report.ts";
import type { RunConfig } from "./service.ts";
import { readEnv, readRoachFile, runConfigOf } from "./config.ts";
import { writeRunReport } from "./report.ts";

/**
* Variables that the command does not get: the inputs, which have the token,
Expand Down Expand Up @@ -51,22 +51,13 @@ function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
const command = process.env.INPUT_RUN;
if (!command) throw new Error("The run input is required");
// The runner gives an empty string for an input that the step does not set.
const token =
process.env.INPUT_TOKEN === "" ? undefined : process.env.INPUT_TOKEN;
// Without the token, the service only allows `replay`, as for forks.
const mode = token ? "auto" : "replay";
const config = JSON.parse(await readFile("roach.json", "utf8")) as Pick<
RunConfig,
"allow" | "rules"
>;
const token = readEnv("INPUT_TOKEN");
const file = await readRoachFile();
const config = runConfigOf(file, { canWrite: token !== undefined });
const run = await startRemoteRun(
{ url: process.env.INPUT_URL!, token },
{
...config,
tenant: process.env.GITHUB_REPOSITORY!,
mode,
name: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
},
config,
file.noProxy ? { noProxy: file.noProxy } : {},
);
// The proxy URL has the run token. Keep it out of the log.
process.stdout.write(`::add-mask::${run.token}\n`);
Expand All @@ -85,14 +76,6 @@ try {
const { missed } = await session.end(exitCode === 0);
if (missed > 0 && exitCode === 0) exitCode = 1;
} finally {
const stats = await run.close();
const report = [
`Roach (${mode}): ${describeRecordingStats(stats)}`,
...describeRecordingMisses(stats.misses).map((miss) => `- ${miss}`),
].join("\n");
process.stdout.write(`${report}\n`);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${report}\n`);
}
await writeRunReport(`Roach (${config.mode})`, await run.close());
}
process.exitCode = exitCode;
Loading
Loading