Files
radicle-explorer/tests/support/peerManager.ts
T
Sebastian Martinez ef7a2659b5 Fixes e2e test suite relation with seed.radicle.garden
- Due to missing preferredSeed config option we where trying to connect to
seed.radicle.garden on the fixture creation.
- This commit also starts first nodes before httpds due to the config
being updated by the node.
2024-01-29 17:17:03 +01:00

452 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/naming-convention */
import type { BaseUrl } from "@httpd-client";
import type { ExecaChildProcess, Options as ExecaOptions } from "execa";
import * as Fs from "node:fs/promises";
import * as Os from "node:os";
import * as Path from "node:path";
import * as Stream from "node:stream";
import * as Util from "node:util";
import getPort from "get-port";
import matches from "lodash/matches.js";
import waitOn from "wait-on";
import { execa } from "execa";
import * as readline from "node:readline/promises";
import * as Process from "./process.js";
import { randomTag } from "@tests/support/support.js";
import { sleep } from "@app/lib/sleep.js";
import { array, boolean, literal, number, object, string, union, z } from "zod";
import { logPrefix } from "./logPrefix.js";
export type RefsUpdate =
| { updated: { name: string; old: string; new: string } }
| { created: { name: string; oid: string } }
| { deleted: { name: string; oid: string } }
| { skipped: { name: string; oid: string } };
export type NodeEvent =
| {
type: "refsFetched";
remote: string;
rid: string;
updated: RefsUpdate[];
}
| {
type: "refsSynced";
remote: string;
rid: string;
}
| {
type: "seedDiscovered";
rid: string;
nid: string;
}
| {
type: "seedDropped";
nid: string;
rid: string;
}
| {
type: "peerConnected";
nid: string;
}
| {
type: "peerDisconnected";
nid: string;
reason: string;
};
export interface RoutingEntry {
nid: string;
rid: string;
}
interface PeerManagerParams {
dataPath: string;
radSeed: string;
// Name for easy identification. Used on file system and in logs.
name: string;
gitOptions?: Record<string, string>;
outputLog: Stream.Writable;
}
export interface PeerManager {
createPeer(params: {
name: string;
gitOptions?: Record<string, string>;
}): Promise<RadiclePeer>;
/**
* Kill all processes spawned by any of the peers
*/
shutdown(): Promise<void>;
}
export async function createPeerManager(createParams: {
dataDir: string;
outputLog?: Stream.Writable;
}): Promise<PeerManager> {
let outputLog: Stream.Writable;
let outputLogFile: Fs.FileHandle;
if (createParams.outputLog) {
outputLog = createParams.outputLog;
} else {
outputLogFile = await Fs.open(
Path.join(createParams.dataDir, "peerManager.log"),
"a",
);
outputLog = outputLogFile.createWriteStream();
}
const peers: RadiclePeer[] = [];
return {
async createPeer(params) {
const peer = await RadiclePeer.create({
dataPath: createParams.dataDir,
name: params.name,
gitOptions: params.gitOptions,
radSeed: Array(64)
.fill((peers.length + 1).toString())
.join(""),
outputLog,
});
peers.push(peer);
return peer;
},
async shutdown() {
await Promise.all(peers.map(peer => peer.shutdown()));
},
};
}
export const NodeConfigSchema = object({
publicExplorer: string(),
preferredSeeds: array(string()),
node: object({
alias: string(),
peers: union([
object({ type: literal("static") }),
object({ type: literal("dynamic"), target: number() }),
]),
connect: array(string()),
externalAddresses: array(string()),
network: union([literal("main"), literal("test")]),
relay: boolean(),
limits: object({
routingMaxSize: number(),
routingMaxAge: number(),
gossipMaxAge: number(),
fetchConcurrency: number(),
maxOpenFiles: number(),
rate: object({
inbound: object({ fillRate: number(), capacity: number() }),
outbound: object({ fillRate: number(), capacity: number() }),
}),
}),
policy: union([literal("allow"), literal("block")]),
scope: union([literal("followed"), literal("all")]),
}),
});
export interface NodeConfig extends z.infer<typeof NodeConfigSchema> {}
// Options passed to `RadiclePeer#rad` and `RadiclePeer#git` that control how
// the process is spawned.
interface SpawnOptions extends ExecaOptions {
// Sets the prefix for the commands output in the logs. If `null`, does not
// log any output. Defaults to `<peer name> <binary name>`.
logPrefix?: string | null;
}
export class RadiclePeer {
public checkoutPath: string;
public nodeId: string;
#radSeed: string;
#socket: string;
#radHome: string;
#eventRecords: NodeEvent[] = [];
#outputLog: Stream.Writable;
#gitOptions?: Record<string, string>;
#listenSocketAddr?: string;
#httpdBaseUrl?: BaseUrl;
#nodeProcess?: ExecaChildProcess;
#httpdProcess?: ExecaChildProcess;
// Name for easy identification. Used on file system and in logs.
#name: string;
#childProcesses: ExecaChildProcess[] = [];
private constructor(props: {
checkoutPath: string;
nodeId: string;
radSeed: string;
socket: string;
gitOptions?: Record<string, string>;
radHome: string;
logFile: Stream.Writable;
name: string;
}) {
this.checkoutPath = props.checkoutPath;
this.nodeId = props.nodeId;
this.#gitOptions = props.gitOptions;
this.#radSeed = props.radSeed;
this.#socket = props.socket;
this.#radHome = props.radHome;
this.#outputLog = props.logFile;
this.#name = props.name;
}
public async waitForEvent(searchEvent: NodeEvent, timeoutInMs: number) {
const start = new Date().getTime();
// eslint-disable-next-line no-constant-condition
while (true) {
if (this.#eventRecords.find(matches(searchEvent))) {
return;
}
if (new Date().getTime() - start > timeoutInMs) {
throw Error(
`Timeout waiting for event on node ${this.#name} ${Util.inspect(
searchEvent,
{ depth: null },
)}`,
);
}
await sleep(100);
}
}
public static async create({
dataPath,
name,
gitOptions,
radSeed: node,
outputLog: logFile,
}: PeerManagerParams): Promise<RadiclePeer> {
const checkoutPath = Path.join(dataPath, name, "copy");
await Fs.mkdir(checkoutPath, { recursive: true });
const radHome = Path.join(dataPath, name, "home");
await Fs.mkdir(radHome, { recursive: true });
const socketDir = await Fs.mkdtemp(
Path.join(Os.tmpdir(), `radicle-${randomTag()}`),
);
const socket = Path.join(socketDir, "control.sock");
const env = {
...gitOptions,
RAD_HOME: radHome,
RAD_PASSPHRASE: "asdf",
RAD_SEED: node,
RAD_SOCKET: socket,
};
await execa("rad", ["auth", "--alias", name], { env });
const { stdout: nodeId } = await execa("rad", ["self", "--nid"], { env });
return new RadiclePeer({
checkoutPath,
gitOptions,
radSeed: node,
socket,
nodeId,
radHome,
logFile,
name,
});
}
public async startHttpd(port?: number): Promise<void> {
if (!port) {
port = await getPort();
}
this.#httpdBaseUrl = {
hostname: "127.0.0.1",
port,
scheme: "http",
};
this.#httpdProcess = this.spawn("radicle-httpd", [
"--listen",
`${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`,
]);
await waitOn({
resources: [
`tcp:${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`,
],
timeout: 2000,
});
}
public async stopHttpd() {
if (!this.#httpdBaseUrl || !this.#httpdProcess) {
return;
}
this.#httpdProcess.kill("SIGTERM");
await waitOn({
resources: [
`tcp:${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`,
],
reverse: true,
timeout: 2000,
});
this.#httpdBaseUrl = undefined;
}
public async startNode(nodeParams: Partial<NodeConfig["node"]> = {}) {
const listenPort = await getPort();
this.#listenSocketAddr = `0.0.0.0:${listenPort}`;
await updateNodeConfig(this.#radHome, nodeParams);
this.#nodeProcess = this.spawn("radicle-node", [
"--listen",
this.#listenSocketAddr,
]);
await waitOn({
resources: [`socket:${this.#socket}`],
timeout: 2000,
});
const { stdout } = this.rad(["node", "events"], {
cwd: this.#radHome,
logPrefix: null,
});
if (!stdout) {
throw new Error("Could not get stdout to track events");
}
readline
.createInterface({
input: stdout,
terminal: false,
})
.on("line", line => {
let event;
try {
event = JSON.parse(line);
} catch (e) {
console.log("Error parsing event", line);
return;
}
this.#eventRecords.push(event);
for (const line of Util.inspect(event, { depth: null }).split("\n")) {
this.#outputLog.write(
`${logPrefix(`${this.#name} node events`)} ${line}\n`,
);
}
});
}
public async stopNode() {
this.#nodeProcess?.kill("SIGTERM");
await waitOn({
resources: [`socket:${this.#socket}`],
reverse: true,
timeout: 2000,
});
}
/**
* Kill all child processes created with `spawn()`, the node process and the
* HTTP API process.
*/
public async shutdown() {
// We don’t care about proper cleanup. We just want to make sure that no
// processes are running anymore.
this.#childProcesses.forEach(p => p.kill("SIGKILL"));
await Promise.all([this.stopNode(), this.stopHttpd()]);
}
public get address(): string {
if (!this.#listenSocketAddr) {
throw new Error("Remote node has no listen addr yet");
}
return `${this.nodeId}@${this.#listenSocketAddr}`;
}
public uiUrl(): string {
if (!this.#httpdBaseUrl) {
throw new Error("No httpd service running");
}
return `/nodes/${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`;
}
public ridUrl(rid: string): string {
return `/nodes/${this.httpdBaseUrl.hostname}:${this.httpdBaseUrl.port}/${rid}`;
}
public get httpdBaseUrl(): BaseUrl {
if (!this.#httpdBaseUrl) {
throw new Error("No httpd service running");
}
return this.#httpdBaseUrl;
}
public git(args: string[] = [], opts?: SpawnOptions): ExecaChildProcess {
return this.spawn("git", args, { ...opts });
}
public rad(args: string[] = [], opts?: SpawnOptions): ExecaChildProcess {
return this.spawn("rad", args, { ...opts });
}
public spawn(
cmd: string,
args: string[] = [],
opts?: SpawnOptions,
): ExecaChildProcess {
opts = {
...opts,
env: {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
RAD_HOME: this.#radHome,
RAD_PASSPHRASE: "asdf",
RAD_COMMIT_TIME: "1671125284",
RAD_SEED: this.#radSeed,
RAD_SOCKET: this.#socket,
...opts?.env,
...this.#gitOptions,
},
};
const childProcess = Process.spawn(cmd, args, opts);
this.#childProcesses.push(childProcess);
if (opts.logPrefix !== null) {
void Process.prefixOutput(
childProcess,
opts.logPrefix || `${this.#name} ${cmd}`,
this.#outputLog,
);
}
return childProcess;
}
}
async function updateNodeConfig(
radHome: string,
nodeParams: Partial<NodeConfig["node"]>,
) {
const configPath = Path.join(radHome, "config.json");
const configFile = await Fs.readFile(configPath, "utf-8");
const config = NodeConfigSchema.parse(JSON.parse(configFile));
config.preferredSeeds = [];
config.node = {
...config.node,
network: "test",
...nodeParams,
};
await Fs.writeFile(configPath, JSON.stringify(config), "utf-8");
}