@sentry/roach 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +561 -2
- package/package.json +74 -3
- package/src/action.ts +81 -0
- package/src/addresses.ts +103 -0
- package/src/certificates.ts +169 -0
- package/src/cli.ts +35 -0
- package/src/client.ts +211 -0
- package/src/config.ts +141 -0
- package/src/gcs.ts +145 -0
- package/src/recorder.ts +464 -0
- package/src/recordings.ts +42 -0
- package/src/report.ts +55 -0
- package/src/request-key.ts +165 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +544 -0
- package/src/service.ts +572 -0
- package/src/store.ts +47 -0
- package/src/streams.ts +115 -0
- package/src/types.ts +130 -0
- package/src/usage.ts +120 -0
- package/src/values.ts +229 -0
- package/src/vitest-setup.ts +66 -0
- package/src/vitest.ts +108 -0
package/src/action.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub Action of Roach (`action.yml`). See "Use the GitHub Action" in
|
|
3
|
+
* `README.md`.
|
|
4
|
+
*
|
|
5
|
+
* It starts a run, runs the command of the step with the proxy variables of
|
|
6
|
+
* the run, and ends the run. The run config comes from `roach.json`
|
|
7
|
+
* (`config.ts`).
|
|
8
|
+
* The runner gives each input as `INPUT_<NAME>`.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { startRemoteRun } from "./client.ts";
|
|
12
|
+
import { readEnv, readRoachFile, runConfigOf } from "./config.ts";
|
|
13
|
+
import { writeRunReport } from "./report.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Variables that the command does not get: the inputs, which have the token,
|
|
17
|
+
* and the proxy variables of the job.
|
|
18
|
+
*/
|
|
19
|
+
const HIDDEN_VARIABLE = /^(INPUT_.*|https?_proxy|no_proxy|all_proxy)$/i;
|
|
20
|
+
|
|
21
|
+
/** Run the command in bash, and return its exit code. */
|
|
22
|
+
function runCommand(command: string, env: NodeJS.ProcessEnv): Promise<number> {
|
|
23
|
+
// `detached` puts bash and its children in a new process group.
|
|
24
|
+
const child = spawn("bash", ["-e", "-o", "pipefail", "-c", command], {
|
|
25
|
+
detached: true,
|
|
26
|
+
env,
|
|
27
|
+
stdio: "inherit",
|
|
28
|
+
});
|
|
29
|
+
// A canceled job sends a signal. Bash does not pass it to its children, so
|
|
30
|
+
// send it to the whole process group. Then end the run.
|
|
31
|
+
const stop = (signal: NodeJS.Signals) => {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(-child.pid!, signal);
|
|
34
|
+
} catch {
|
|
35
|
+
// The process group has already stopped.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
process.on("SIGINT", stop);
|
|
39
|
+
process.on("SIGTERM", stop);
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
child.once("error", reject);
|
|
42
|
+
child.once("exit", (code) => {
|
|
43
|
+
// Stop what the command left in the background, such as `cmd &`. Bash
|
|
44
|
+
// makes it ignore `SIGINT`, and the run ends next.
|
|
45
|
+
stop("SIGTERM");
|
|
46
|
+
resolve(code ?? 1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const command = process.env.INPUT_RUN;
|
|
52
|
+
if (!command) throw new Error("The run input is required");
|
|
53
|
+
// The runner gives an empty string for an input that the step does not set.
|
|
54
|
+
const token = readEnv("INPUT_TOKEN");
|
|
55
|
+
const file = await readRoachFile();
|
|
56
|
+
const config = runConfigOf(file, { canWrite: token !== undefined });
|
|
57
|
+
const run = await startRemoteRun(
|
|
58
|
+
{ url: process.env.INPUT_URL!, token },
|
|
59
|
+
config,
|
|
60
|
+
file.noProxy ? { noProxy: file.noProxy } : {},
|
|
61
|
+
);
|
|
62
|
+
// The proxy URL has the run token. Keep it out of the log.
|
|
63
|
+
process.stdout.write(`::add-mask::${run.token}\n`);
|
|
64
|
+
|
|
65
|
+
let exitCode = 1;
|
|
66
|
+
try {
|
|
67
|
+
const session = await run.startSession(process.env.GITHUB_JOB ?? "command");
|
|
68
|
+
exitCode = await runCommand(command, {
|
|
69
|
+
...Object.fromEntries(
|
|
70
|
+
Object.entries(process.env).filter(
|
|
71
|
+
([name]) => !HIDDEN_VARIABLE.test(name),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
...run.env,
|
|
75
|
+
});
|
|
76
|
+
const { missed } = await session.end(exitCode === 0);
|
|
77
|
+
if (missed > 0 && exitCode === 0) exitCode = 1;
|
|
78
|
+
} finally {
|
|
79
|
+
await writeRunReport(`Roach (${config.mode})`, await run.close());
|
|
80
|
+
}
|
|
81
|
+
process.exitCode = exitCode;
|
package/src/addresses.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The addresses that the proxy can send requests to.
|
|
3
|
+
*
|
|
4
|
+
* A run names its own origins (`service.ts`). So without this check, a run
|
|
5
|
+
* could make the service send requests into its own network: to the GCP
|
|
6
|
+
* metadata server, which gives tokens of the VM service account, or to
|
|
7
|
+
* other hosts in the VPC. The proxy sends requests only to public
|
|
8
|
+
* addresses.
|
|
9
|
+
*
|
|
10
|
+
* The check is on the address that the connection uses, after DNS. A host
|
|
11
|
+
* name that resolves to a public address for the check and to a private
|
|
12
|
+
* one for the connection (DNS rebinding) thus does not get through.
|
|
13
|
+
*/
|
|
14
|
+
import { lookup as dnsLookup, type LookupAddress } from "node:dns";
|
|
15
|
+
import { BlockList, isIP, type LookupFunction } from "node:net";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Addresses that are not on the public internet: this host, private
|
|
19
|
+
* networks, link-local (with the metadata server), shared, documentation,
|
|
20
|
+
* multicast, and reserved ranges. IPv6 forms that hold an IPv4 address
|
|
21
|
+
* (NAT64, 6to4, Teredo) are refused too, because they can lead to a
|
|
22
|
+
* private IPv4 address. `BlockList` checks IPv4-mapped IPv6 addresses,
|
|
23
|
+
* such as `::ffff:127.0.0.1`, as IPv4.
|
|
24
|
+
*/
|
|
25
|
+
const NOT_PUBLIC = new BlockList();
|
|
26
|
+
for (const [network, prefix] of [
|
|
27
|
+
["0.0.0.0", 8],
|
|
28
|
+
["10.0.0.0", 8],
|
|
29
|
+
["100.64.0.0", 10],
|
|
30
|
+
["127.0.0.0", 8],
|
|
31
|
+
["169.254.0.0", 16],
|
|
32
|
+
["172.16.0.0", 12],
|
|
33
|
+
["192.0.0.0", 24],
|
|
34
|
+
["192.0.2.0", 24],
|
|
35
|
+
["192.88.99.0", 24],
|
|
36
|
+
["192.168.0.0", 16],
|
|
37
|
+
["198.18.0.0", 15],
|
|
38
|
+
["198.51.100.0", 24],
|
|
39
|
+
["203.0.113.0", 24],
|
|
40
|
+
["224.0.0.0", 4],
|
|
41
|
+
["240.0.0.0", 4],
|
|
42
|
+
] as const) {
|
|
43
|
+
NOT_PUBLIC.addSubnet(network, prefix, "ipv4");
|
|
44
|
+
}
|
|
45
|
+
for (const [network, prefix] of [
|
|
46
|
+
["::", 96],
|
|
47
|
+
["64:ff9b::", 96],
|
|
48
|
+
["64:ff9b:1::", 48],
|
|
49
|
+
["100::", 64],
|
|
50
|
+
["2001::", 23],
|
|
51
|
+
["2001:db8::", 32],
|
|
52
|
+
["2002::", 16],
|
|
53
|
+
["fc00::", 7],
|
|
54
|
+
["fe80::", 10],
|
|
55
|
+
["fec0::", 10],
|
|
56
|
+
["ff00::", 8],
|
|
57
|
+
] as const) {
|
|
58
|
+
NOT_PUBLIC.addSubnet(network, prefix, "ipv6");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check that an IP address is on the public internet. */
|
|
62
|
+
export function isPublicAddress(address: string): boolean {
|
|
63
|
+
const family = isIP(address);
|
|
64
|
+
if (family === 0) return false;
|
|
65
|
+
return !NOT_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The error of a request to an address that is not public. */
|
|
69
|
+
export const notPublicError = (host: string, address: string) =>
|
|
70
|
+
Object.assign(
|
|
71
|
+
new Error(
|
|
72
|
+
`Roach does not send requests to ${host}, because ${address} is not a public address`,
|
|
73
|
+
),
|
|
74
|
+
{ status: 403, code: "ERR_ROACH_NOT_PUBLIC" },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A DNS lookup for `http.request()` that fails when the host has an
|
|
79
|
+
* address that is not public. It checks all addresses of the host, so the
|
|
80
|
+
* choice of the connection does not matter.
|
|
81
|
+
*/
|
|
82
|
+
export const publicLookup: LookupFunction = (hostname, options, callback) => {
|
|
83
|
+
dnsLookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
84
|
+
if (error) {
|
|
85
|
+
callback(error, "", 0);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const blocked = addresses.find(({ address }) => !isPublicAddress(address));
|
|
89
|
+
if (blocked) {
|
|
90
|
+
callback(notPublicError(hostname, blocked.address), "", 0);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (options.all) {
|
|
94
|
+
// The type has only the single-address form of the callback.
|
|
95
|
+
(callback as unknown as (e: null, list: LookupAddress[]) => void)(
|
|
96
|
+
null,
|
|
97
|
+
addresses,
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
callback(null, addresses[0]!.address, addresses[0]!.family);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The certificate authority of Roach.
|
|
3
|
+
*
|
|
4
|
+
* The proxy intercepts HTTPS. It signs one certificate for each host when a
|
|
5
|
+
* client first connects to that host, and signs it again before it
|
|
6
|
+
* expires. Runs name their own hosts, so the authority keeps the
|
|
7
|
+
* certificates of at most `MAX_HOSTS` hosts, and keeps no files for them.
|
|
8
|
+
* Clients must trust the authority certificate. The service loads
|
|
9
|
+
* a fixed one, so that clients can trust it across restarts.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isIP } from "node:net";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import tls from "node:tls";
|
|
18
|
+
|
|
19
|
+
function openssl(args: string[]): Promise<void> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
execFile("openssl", args, (error, _stdout, stderr) => {
|
|
22
|
+
if (error)
|
|
23
|
+
reject(new Error(`openssl failed: ${stderr || error.message}`));
|
|
24
|
+
else resolve();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A certificate authority that signs a certificate for each host. */
|
|
30
|
+
export interface CertificateAuthority {
|
|
31
|
+
/** The PEM certificate that clients must trust. */
|
|
32
|
+
caCert: string;
|
|
33
|
+
/** The TLS context for a host. Signs its certificate on first use. */
|
|
34
|
+
contextFor(host: string): Promise<tls.SecureContext>;
|
|
35
|
+
/** Delete the keys and certificates. */
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A PEM certificate authority, such as the one of a deployed service. */
|
|
40
|
+
export interface CertificateAuthorityPem {
|
|
41
|
+
cert: string;
|
|
42
|
+
key: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Days that a host certificate is valid. */
|
|
46
|
+
const HOST_DAYS = 7;
|
|
47
|
+
/** Sign a host again when its certificate is older than this. */
|
|
48
|
+
const RESIGN_MS = (HOST_DAYS - 1) * 24 * 60 * 60 * 1000;
|
|
49
|
+
/** The hosts with a certificate in memory. More hosts drop the oldest one. */
|
|
50
|
+
const MAX_HOSTS = 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a certificate authority in a new temporary directory. Without
|
|
54
|
+
* `pem`, it creates a new authority that is valid for 7 days.
|
|
55
|
+
*/
|
|
56
|
+
export async function createCertificateAuthority(
|
|
57
|
+
pem?: CertificateAuthorityPem,
|
|
58
|
+
): Promise<CertificateAuthority> {
|
|
59
|
+
const directory = await mkdtemp(path.join(tmpdir(), "roach-ca-"));
|
|
60
|
+
const caKey = path.join(directory, "ca.key");
|
|
61
|
+
const caCertFile = path.join(directory, "ca.crt");
|
|
62
|
+
const hostKey = path.join(directory, "host.key");
|
|
63
|
+
if (pem) {
|
|
64
|
+
await writeFile(caKey, pem.key, { mode: 0o600 });
|
|
65
|
+
await writeFile(caCertFile, pem.cert);
|
|
66
|
+
} else {
|
|
67
|
+
await createAuthority(caKey, caCertFile);
|
|
68
|
+
}
|
|
69
|
+
// All hosts share one key. Only their certificates differ.
|
|
70
|
+
await openssl(["genrsa", "-out", hostKey, "2048"]);
|
|
71
|
+
const hostKeyPem = await readFile(hostKey, "utf8");
|
|
72
|
+
const contexts = new Map<
|
|
73
|
+
string,
|
|
74
|
+
{ signed: number; context: Promise<tls.SecureContext> }
|
|
75
|
+
>();
|
|
76
|
+
|
|
77
|
+
const sign = async (host: string): Promise<tls.SecureContext> => {
|
|
78
|
+
const name = createHash("sha256").update(host).digest("hex").slice(0, 16);
|
|
79
|
+
const csr = path.join(directory, `${name}.csr`);
|
|
80
|
+
const extensions = path.join(directory, `${name}.ext`);
|
|
81
|
+
const cert = path.join(directory, `${name}.crt`);
|
|
82
|
+
const bare = host.replace(/^\[|\]$/g, "");
|
|
83
|
+
const altName = isIP(bare) ? `IP:${bare}` : `DNS:${host}`;
|
|
84
|
+
await writeFile(
|
|
85
|
+
extensions,
|
|
86
|
+
`subjectAltName=${altName}\nextendedKeyUsage=serverAuth\n`,
|
|
87
|
+
);
|
|
88
|
+
await openssl([
|
|
89
|
+
"req",
|
|
90
|
+
"-new",
|
|
91
|
+
"-key",
|
|
92
|
+
hostKey,
|
|
93
|
+
"-subj",
|
|
94
|
+
"/CN=Roach host",
|
|
95
|
+
"-out",
|
|
96
|
+
csr,
|
|
97
|
+
]);
|
|
98
|
+
await openssl([
|
|
99
|
+
"x509",
|
|
100
|
+
"-req",
|
|
101
|
+
"-in",
|
|
102
|
+
csr,
|
|
103
|
+
"-CA",
|
|
104
|
+
caCertFile,
|
|
105
|
+
"-CAkey",
|
|
106
|
+
caKey,
|
|
107
|
+
"-set_serial",
|
|
108
|
+
`0x${randomBytes(8).toString("hex")}`,
|
|
109
|
+
"-days",
|
|
110
|
+
String(HOST_DAYS),
|
|
111
|
+
"-extfile",
|
|
112
|
+
extensions,
|
|
113
|
+
"-out",
|
|
114
|
+
cert,
|
|
115
|
+
]);
|
|
116
|
+
const certPem = await readFile(cert, "utf8");
|
|
117
|
+
await Promise.all(
|
|
118
|
+
[csr, extensions, cert].map((file) => rm(file, { force: true })),
|
|
119
|
+
);
|
|
120
|
+
return tls.createSecureContext({ cert: certPem, key: hostKeyPem });
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
caCert: await readFile(caCertFile, "utf8"),
|
|
125
|
+
contextFor(host) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
let entry = contexts.get(host);
|
|
128
|
+
if (!entry || now - entry.signed > RESIGN_MS) {
|
|
129
|
+
const fresh = { signed: now, context: sign(host) };
|
|
130
|
+
// A failed signature must not stay in the cache.
|
|
131
|
+
fresh.context.catch(() => {
|
|
132
|
+
if (contexts.get(host) === fresh) contexts.delete(host);
|
|
133
|
+
});
|
|
134
|
+
contexts.delete(host);
|
|
135
|
+
contexts.set(host, fresh);
|
|
136
|
+
// A map keeps the order of insertion, so the first key is the oldest.
|
|
137
|
+
if (contexts.size > MAX_HOSTS) {
|
|
138
|
+
contexts.delete(contexts.keys().next().value!);
|
|
139
|
+
}
|
|
140
|
+
entry = fresh;
|
|
141
|
+
}
|
|
142
|
+
return entry.context;
|
|
143
|
+
},
|
|
144
|
+
close: () => rm(directory, { force: true, recursive: true }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Create a new authority that is valid for 7 days. */
|
|
149
|
+
async function createAuthority(caKey: string, caCertFile: string) {
|
|
150
|
+
await openssl([
|
|
151
|
+
"req",
|
|
152
|
+
"-x509",
|
|
153
|
+
"-newkey",
|
|
154
|
+
"rsa:2048",
|
|
155
|
+
"-nodes",
|
|
156
|
+
"-keyout",
|
|
157
|
+
caKey,
|
|
158
|
+
"-out",
|
|
159
|
+
caCertFile,
|
|
160
|
+
"-days",
|
|
161
|
+
"7",
|
|
162
|
+
"-subj",
|
|
163
|
+
"/CN=Roach CA",
|
|
164
|
+
"-addext",
|
|
165
|
+
"basicConstraints=critical,CA:TRUE",
|
|
166
|
+
"-addext",
|
|
167
|
+
"keyUsage=critical,keyCertSign,cRLSign",
|
|
168
|
+
]);
|
|
169
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line of Roach.
|
|
3
|
+
*
|
|
4
|
+
* `cli.ts service <config.json>` starts the shared service (`service.ts`)
|
|
5
|
+
* with the configuration in the file. It prints the URL of the service as
|
|
6
|
+
* one JSON line, and stops on SIGINT or SIGTERM. The deployed container
|
|
7
|
+
* runs this command.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { startRoachService, type RoachServiceConfig } from "./service.ts";
|
|
11
|
+
|
|
12
|
+
const [command, ...args] = process.argv.slice(2);
|
|
13
|
+
if (command !== "service" || args.length !== 1) {
|
|
14
|
+
process.stderr.write("Usage: cli.ts service <config.json>\n");
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = JSON.parse(
|
|
19
|
+
await readFile(args[0]!, "utf8"),
|
|
20
|
+
) as RoachServiceConfig;
|
|
21
|
+
const service = await startRoachService(config);
|
|
22
|
+
process.stdout.write(`${JSON.stringify({ url: service.url })}\n`);
|
|
23
|
+
|
|
24
|
+
/** Stop on SIGINT or SIGTERM. A failed close logs its error and exits 1. */
|
|
25
|
+
const stop = () => {
|
|
26
|
+
service.close().then(
|
|
27
|
+
() => process.exit(0),
|
|
28
|
+
(error: unknown) => {
|
|
29
|
+
process.stderr.write(`[roach] Close failed: ${String(error)}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
process.once("SIGINT", stop);
|
|
35
|
+
process.once("SIGTERM", stop);
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client of Roach. See `README.md`.
|
|
3
|
+
*
|
|
4
|
+
* `startRemoteRun()` starts a run on a Roach service (`service.ts`). It
|
|
5
|
+
* returns `env`, the variables that send the traffic of a process through
|
|
6
|
+
* the run. `connectRoach()` controls a run from another process, such as a
|
|
7
|
+
* test worker.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import https from "node:https";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
CONTROL_PATH,
|
|
16
|
+
type OpenedSession,
|
|
17
|
+
type RecordingStats,
|
|
18
|
+
type RemoteRun,
|
|
19
|
+
type RunConfig,
|
|
20
|
+
} from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/** The open session of one test. */
|
|
23
|
+
export interface RecordingSession {
|
|
24
|
+
/**
|
|
25
|
+
* The proxy URL of the session. Send the requests of the test through
|
|
26
|
+
* it when other sessions of the run can be open at the same time.
|
|
27
|
+
*/
|
|
28
|
+
url: string;
|
|
29
|
+
/**
|
|
30
|
+
* End the session. A passed session writes its new recordings, and a
|
|
31
|
+
* failed one drops them. `missed` counts the requests of the session that
|
|
32
|
+
* `replay` mode failed. A test with a miss must fail.
|
|
33
|
+
*/
|
|
34
|
+
end(passed: boolean): Promise<{ missed: number }>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The control API of a run. */
|
|
38
|
+
export interface RoachControl {
|
|
39
|
+
/**
|
|
40
|
+
* Open the session of one test. Many sessions can be open at once. A
|
|
41
|
+
* request through `url` of the run belongs to the open session only when
|
|
42
|
+
* one is open, so concurrent tests send through `url` of their session.
|
|
43
|
+
*/
|
|
44
|
+
startSession(name: string): Promise<RecordingSession>;
|
|
45
|
+
/** The totals of the run. */
|
|
46
|
+
stats(): Promise<RecordingStats>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Call the control API at `base`, and return the JSON of the response.
|
|
51
|
+
* `token` is the bearer token.
|
|
52
|
+
*/
|
|
53
|
+
function callControl<T>(
|
|
54
|
+
base: string,
|
|
55
|
+
token: string | undefined,
|
|
56
|
+
method: "DELETE" | "GET" | "POST",
|
|
57
|
+
route: string,
|
|
58
|
+
body?: unknown,
|
|
59
|
+
): Promise<T> {
|
|
60
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
61
|
+
const url = route ? `${base}/${route}` : base;
|
|
62
|
+
const client = url.startsWith("https:") ? https : http;
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const request = client.request(
|
|
65
|
+
url,
|
|
66
|
+
{
|
|
67
|
+
// Its own agent, so that a proxy agent of the process is not used.
|
|
68
|
+
agent: false,
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
72
|
+
...(payload ? { "content-type": "application/json" } : {}),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
(response) => {
|
|
76
|
+
const chunks: Buffer[] = [];
|
|
77
|
+
response.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
78
|
+
response.on("end", () => {
|
|
79
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
80
|
+
const status = response.statusCode ?? 500;
|
|
81
|
+
if (status >= 400) {
|
|
82
|
+
reject(
|
|
83
|
+
new Error(
|
|
84
|
+
`Roach ${method} ${route} failed with HTTP ${status}: ${text}`,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
resolve((text ? JSON.parse(text) : undefined) as T);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
response.on("error", reject);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
request.on("error", reject);
|
|
95
|
+
request.end(payload);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Control a run, for example from a test worker. */
|
|
100
|
+
export function connectRoach({
|
|
101
|
+
controlUrl,
|
|
102
|
+
token,
|
|
103
|
+
}: Pick<RemoteRun, "controlUrl" | "token">): RoachControl {
|
|
104
|
+
return {
|
|
105
|
+
async startSession(name) {
|
|
106
|
+
const { url } = await callControl<OpenedSession>(
|
|
107
|
+
controlUrl,
|
|
108
|
+
token,
|
|
109
|
+
"POST",
|
|
110
|
+
"session",
|
|
111
|
+
{ name },
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
url,
|
|
115
|
+
end: (passed) =>
|
|
116
|
+
callControl(controlUrl, token, "POST", "session/end", {
|
|
117
|
+
name,
|
|
118
|
+
passed,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
stats: () => callControl(controlUrl, token, "GET", "stats"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A run on a Roach service (`service.ts`). */
|
|
127
|
+
export interface RemoteRoach extends RemoteRun, RoachControl {
|
|
128
|
+
/**
|
|
129
|
+
* The variables that send the HTTP traffic of a process through the run:
|
|
130
|
+
* `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and
|
|
131
|
+
* `NODE_EXTRA_CA_CERTS`. Give them to a process when it starts. Node
|
|
132
|
+
* reads the last two only at startup.
|
|
133
|
+
*/
|
|
134
|
+
env: Record<string, string>;
|
|
135
|
+
/** End the run. Returns its stats. */
|
|
136
|
+
close(): Promise<RecordingStats>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Start a run on a Roach service, such as `https://roach.example.com`.
|
|
141
|
+
*
|
|
142
|
+
* `token` is the write token of the service. Only this call uses it. Without it,
|
|
143
|
+
* the service only allows `replay` mode, so CI jobs of forks can replay
|
|
144
|
+
* without a secret. The run and its workers use the run token.
|
|
145
|
+
*/
|
|
146
|
+
export async function startRemoteRun(
|
|
147
|
+
service: { url: string; token?: string | undefined },
|
|
148
|
+
config: RunConfig,
|
|
149
|
+
{ noProxy = "localhost,127.0.0.1,::1" }: { noProxy?: string } = {},
|
|
150
|
+
): Promise<RemoteRoach> {
|
|
151
|
+
const run = await callControl<RemoteRun>(
|
|
152
|
+
`${service.url.replace(/\/$/, "")}${CONTROL_PATH}`,
|
|
153
|
+
service.token,
|
|
154
|
+
"POST",
|
|
155
|
+
"runs",
|
|
156
|
+
config,
|
|
157
|
+
);
|
|
158
|
+
const proxyEnv = await createProxyEnv(run, noProxy).catch(async (error) => {
|
|
159
|
+
// End the run, or it stays open until the service times it out.
|
|
160
|
+
await callControl(run.controlUrl, run.token, "DELETE", "").catch(() => {});
|
|
161
|
+
throw error;
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
...run,
|
|
165
|
+
...connectRoach(run),
|
|
166
|
+
env: proxyEnv.env,
|
|
167
|
+
async close() {
|
|
168
|
+
try {
|
|
169
|
+
return await callControl<RecordingStats>(
|
|
170
|
+
run.controlUrl,
|
|
171
|
+
run.token,
|
|
172
|
+
"DELETE",
|
|
173
|
+
"",
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
await proxyEnv.remove();
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The proxy variables of a process, with the CA certificate in a file. The
|
|
184
|
+
* file also has the certificates of `NODE_EXTRA_CA_CERTS` of this process,
|
|
185
|
+
* so the process still trusts them.
|
|
186
|
+
*/
|
|
187
|
+
async function createProxyEnv(
|
|
188
|
+
address: Pick<RemoteRun, "caCert" | "url">,
|
|
189
|
+
noProxy: string,
|
|
190
|
+
): Promise<{ env: Record<string, string>; remove(): Promise<void> }> {
|
|
191
|
+
// `NODE_EXTRA_CA_CERTS` takes a file.
|
|
192
|
+
const caDirectory = await mkdtemp(path.join(tmpdir(), "roach-"));
|
|
193
|
+
const caFile = path.join(caDirectory, "ca.pem");
|
|
194
|
+
const extra = process.env.NODE_EXTRA_CA_CERTS;
|
|
195
|
+
await writeFile(
|
|
196
|
+
caFile,
|
|
197
|
+
extra
|
|
198
|
+
? `${address.caCert.trimEnd()}\n${await readFile(extra, "utf8")}`
|
|
199
|
+
: address.caCert,
|
|
200
|
+
);
|
|
201
|
+
return {
|
|
202
|
+
env: {
|
|
203
|
+
HTTP_PROXY: address.url,
|
|
204
|
+
HTTPS_PROXY: address.url,
|
|
205
|
+
NO_PROXY: noProxy,
|
|
206
|
+
NODE_USE_ENV_PROXY: "1",
|
|
207
|
+
NODE_EXTRA_CA_CERTS: caFile,
|
|
208
|
+
},
|
|
209
|
+
remove: () => rm(caDirectory, { force: true, recursive: true }),
|
|
210
|
+
};
|
|
211
|
+
}
|