Skip to content

Commit 8e4d09c

Browse files
committed
feat(cli): improve startup feedback
1 parent 84cd6fc commit 8e4d09c

File tree

5 files changed

+317
-82
lines changed

5 files changed

+317
-82
lines changed

apps/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@
3030
"execa": "^9.5.2",
3131
"fs-extra": "^11.3.0",
3232
"giget": "^2.0.0",
33+
"listr2": "^8.2.5",
3334
"lookpath": "^1.2.3",
3435
"open": "^10.1.0",
3536
"ora": "^8.2.0",
37+
"p-retry": "^6.2.1",
3638
"progress-stream": "^2.0.0",
3739
"semver": "^7.7.1",
3840
"smol-toml": "^1.3.1",

apps/cli/src/base.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,22 +92,39 @@ export const logPrompt = ({
9292
console.log(`${chalk.green("?")} ${title} ${chalk.cyan(value)}`);
9393
};
9494

95-
export const getServiceState = async (
96-
projectName: string,
97-
serviceName: string,
98-
): Promise<string | undefined> => {
95+
const getServiceInfo = async (options: {
96+
projectName: string;
97+
service: string;
98+
}): Promise<PsResponse | undefined> => {
99+
const { projectName, service } = options;
100+
99101
// get service information
100102
const { stdout } = await execa("docker", [
101103
"compose",
102104
"--project-name",
103105
projectName,
104106
"ps",
105-
serviceName,
107+
service,
106108
"--format",
107109
"json",
108110
]);
109-
const ps = stdout ? (JSON.parse(stdout) as PsResponse) : undefined;
110-
return ps?.State;
111+
return stdout ? (JSON.parse(stdout) as PsResponse) : undefined;
112+
};
113+
114+
export const getServiceState = async (options: {
115+
projectName: string;
116+
service: string;
117+
}): Promise<string | undefined> => {
118+
const info = await getServiceInfo(options);
119+
return info?.State;
120+
};
121+
122+
export const getServiceHealth = async (options: {
123+
projectName: string;
124+
service: string;
125+
}): Promise<string | undefined> => {
126+
const info = await getServiceInfo(options);
127+
return info?.Health;
111128
};
112129

113130
export const parseAddress = (
Lines changed: 159 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,133 @@
11
import { Command, Option } from "@commander-js/extra-typings";
22
import chalk from "chalk";
33
import { execa } from "execa";
4+
import { Listr, ListrTask } from "listr2";
5+
import pRetry from "p-retry";
46
import path from "path";
7+
import { getServiceHealth } from "../../base.js";
58
import { RollupsCommandOpts } from "../rollups.js";
69

710
const commaSeparatedList = (value: string, _previous: string[]) =>
811
value.split(",");
912

10-
const availableServices = [
11-
"bundler",
12-
"explorer",
13-
"graphql",
14-
// "otterscan",
15-
"paymaster",
13+
type Service = {
14+
name: string; // name of the service
15+
file: string; // docker compose file name
16+
healthySemaphore?: string; // service to check if the service is healthy
17+
healthyTitle?: string | ((port: number) => string); // title of the service when it is healthy
18+
waitTitle?: string; // title of the service when it is starting
19+
errorTitle?: string; // title of the service when it is not healthy
20+
};
21+
22+
const host = "http://127.0.0.1";
23+
24+
// services configuration
25+
const standardServices: Service[] = [
26+
{
27+
name: "anvil",
28+
file: "docker-compose-anvil.yaml",
29+
healthySemaphore: "anvil",
30+
healthyTitle: `Service ${chalk.cyan("anvil")} ready at ${chalk.cyan(`${host}:8545`)}`,
31+
waitTitle: `Starting ${chalk.cyan("anvil")}...`,
32+
errorTitle: `Service ${chalk.red("anvil")} failed`,
33+
},
34+
{
35+
name: "proxy",
36+
file: "docker-compose-proxy.yaml",
37+
},
38+
{
39+
name: "database",
40+
file: "docker-compose-database.yaml",
41+
},
42+
{
43+
name: "rollups-node",
44+
file: "docker-compose-node.yaml",
45+
healthySemaphore: "proxy",
46+
healthyTitle: (port) =>
47+
`Service ${chalk.cyan("rollups-node")} ready at ${chalk.cyan(`${host}:${port}/inspect/<address>`)}`,
48+
waitTitle: `Starting ${chalk.cyan("rollups-node")}...`,
49+
errorTitle: `Service ${chalk.red("rollups-node")} failed`,
50+
},
1651
];
1752

53+
const availableServices: Service[] = [
54+
{
55+
name: "bundler",
56+
file: "docker-compose-bundler.yaml",
57+
healthySemaphore: "proxy",
58+
healthyTitle: (port) =>
59+
`Service ${chalk.cyan("bundler")} ready at ${chalk.cyan(`${host}:${port}/bundler/rpc`)}`,
60+
waitTitle: `Starting ${chalk.cyan("bundler")}...`,
61+
errorTitle: `Service ${chalk.red("bundler")} failed`,
62+
},
63+
{
64+
name: "espresso",
65+
file: "docker-compose-espresso.yaml",
66+
healthySemaphore: "proxy",
67+
healthyTitle: (port) =>
68+
`Service ${chalk.cyan("espresso")} ready at ${chalk.cyan(`${host}:${port}/espresso`)}`,
69+
waitTitle: `Starting ${chalk.cyan("espresso")}...`,
70+
errorTitle: `Service ${chalk.red("espresso")} failed`,
71+
},
72+
{
73+
name: "explorer",
74+
file: "docker-compose-explorer.yaml",
75+
healthySemaphore: "proxy",
76+
healthyTitle: (port) =>
77+
`Service ${chalk.cyan("explorer")} ready at ${chalk.cyan(`${host}:${port}/explorer`)}`,
78+
waitTitle: `Starting ${chalk.cyan("explorer")}...`,
79+
errorTitle: `Service ${chalk.red("explorer")} failed`,
80+
},
81+
{
82+
name: "graphql",
83+
file: "docker-compose-graphql.yaml",
84+
healthySemaphore: "proxy",
85+
healthyTitle: (port) =>
86+
`Service ${chalk.cyan("graphql")} ready at ${chalk.cyan(`${host}:${port}/graphql`)}`,
87+
waitTitle: `Starting ${chalk.cyan("graphql")}...`,
88+
errorTitle: `Service ${chalk.red("graphql")} failed`,
89+
},
90+
{
91+
name: "paymaster",
92+
file: "docker-compose-paymaster.yaml",
93+
healthySemaphore: "proxy",
94+
healthyTitle: (port) =>
95+
`Service ${chalk.cyan("paymaster")} ready at ${chalk.cyan(`${host}:${port}/paymaster`)}`,
96+
waitTitle: `Starting ${chalk.cyan("paymaster")}...`,
97+
errorTitle: `Service ${chalk.red("paymaster")} failed`,
98+
},
99+
];
100+
101+
const serviceMonitorTask = (options: {
102+
errorTitle?: string;
103+
healthyTitle?: string;
104+
projectName: string;
105+
service: string;
106+
waitTitle?: string;
107+
}): ListrTask => {
108+
const { errorTitle, healthyTitle, service, waitTitle } = options;
109+
110+
return {
111+
task: async (_ctx, task) => {
112+
await pRetry(
113+
async () => {
114+
const health = await getServiceHealth(options);
115+
if (health !== "healthy") {
116+
throw new Error(
117+
errorTitle ??
118+
`Service ${chalk.cyan(service)} is not healthy`,
119+
);
120+
}
121+
},
122+
{ retries: 100, minTimeout: 500, factor: 1.1 },
123+
);
124+
task.title =
125+
healthyTitle ?? `Service ${chalk.cyan(service)} is ready`;
126+
},
127+
title: waitTitle ?? `Starting ${chalk.cyan(service)}...`,
128+
};
129+
};
130+
18131
export const createStartCommand = () => {
19132
return new Command<[], {}, RollupsCommandOpts>("start")
20133
.description("Start a local rollups node environment.")
@@ -54,7 +167,6 @@ export const createStartCommand = () => {
54167
[],
55168
)
56169
.option("-p, --port <number>", "port to listen on", parseInt, 8080)
57-
.option("-d, --detach", "run in detached mode", false)
58170
.option("--dry-run", "show the docker compose configuration", false)
59171
.option("-v, --verbose", "verbose output", false)
60172
.action(async (options, command) => {
@@ -63,7 +175,6 @@ export const createStartCommand = () => {
63175
blockTime,
64176
cpus,
65177
defaultBlock,
66-
detach,
67178
dryRun,
68179
memory,
69180
port,
@@ -77,24 +188,18 @@ export const createStartCommand = () => {
77188
);
78189

79190
// setup the environment variable used in docker compose
80-
const listenPort = port;
81191
const env: NodeJS.ProcessEnv = {
82192
ANVIL_VERBOSITY: verbose ? "--steps-tracing" : "--silent",
83193
BLOCK_TIME: blockTime.toString(),
84194
CARTESI_BLOCKCHAIN_DEFAULT_BLOCK: defaultBlock,
85195
CARTESI_LOG_LEVEL: verbose ? "info" : "error",
86196
CARTESI_BIN_PATH: binPath,
87-
CARTESI_LISTEN_PORT: listenPort.toString(),
197+
CARTESI_LISTEN_PORT: port.toString(),
88198
CARTESI_ROLLUPS_NODE_CPUS: cpus?.toString(),
89199
CARTESI_ROLLUPS_NODE_MEMORY: memory?.toString(),
90200
};
91201

92-
const composeFiles = [
93-
"docker-compose-anvil.yaml",
94-
"docker-compose-proxy.yaml",
95-
"docker-compose-database.yaml",
96-
"docker-compose-node.yaml",
97-
];
202+
const composeFiles = standardServices.map(({ file }) => file);
98203

99204
// cpu and memory limits, mostly for testing and debuggingpurposes
100205
if (cpus) {
@@ -104,21 +209,16 @@ export const createStartCommand = () => {
104209
composeFiles.push("docker-compose-node-memory.yaml");
105210
}
106211

212+
// select subset of optional services
107213
const optionalServices =
108214
services.length === 1 && services[0] === "all"
109215
? availableServices
110-
: services;
111-
112-
// validate services and add to compose files
113-
for (const service of optionalServices) {
114-
if (!availableServices.includes(service)) {
115-
throw new Error(
116-
`Service ${chalk.cyan(service)} not available`,
117-
);
118-
} else {
119-
composeFiles.push(`docker-compose-${service}.yaml`);
120-
}
121-
}
216+
: availableServices.filter(({ name }) =>
217+
services.includes(name),
218+
);
219+
220+
// add to compose files list
221+
composeFiles.push(...optionalServices.map(({ file }) => file));
122222

123223
// create the "--file <file>" list
124224
const files = composeFiles
@@ -128,63 +228,48 @@ export const createStartCommand = () => {
128228
])
129229
.flat();
130230

131-
const compose_args = [
231+
const composeArgs = [
132232
"compose",
133233
...files,
134234
"--project-name",
135235
projectName,
136236
];
137237

138-
const up_args = [];
238+
// run in detached mode (background)
239+
const upArgs = ["--detach"];
139240

140-
if (detach) {
141-
// run in detached mode (background)
142-
// will need to check logs using docker
143-
up_args.push("--detach");
241+
if (dryRun) {
242+
// show the docker compose configuration
243+
await execa("docker", [...composeArgs, "config"], {
244+
env,
245+
stdio: "inherit",
246+
});
144247
} else {
145-
if (!verbose) {
146-
// attach only to rollups-node and prompt
147-
compose_args.push("--progress", "quiet");
148-
up_args.push("--attach", "rollups-node");
149-
}
150-
}
151-
152-
// XXX: need this handler, so SIGINT can still call the finally block below
153-
process.on("SIGINT", () => {});
154-
155-
try {
156-
if (dryRun) {
157-
// show the docker compose configuration
158-
await execa("docker", [...compose_args, "config"], {
159-
env,
160-
stdio: "inherit",
161-
});
162-
return;
163-
}
164-
165248
// run compose environment
166-
await execa("docker", [...compose_args, "up", ...up_args], {
249+
const up = execa("docker", [...composeArgs, "up", ...upArgs], {
167250
env,
168-
stdio: "inherit",
169251
});
170-
} catch (e: unknown) {
171-
// 130 is a graceful shutdown, so we can swallow it
172-
if ((e as any).exitCode !== 130) {
173-
throw e;
174-
}
175-
} finally {
176-
// if it's detached, exit silently, because it's running in the background
177-
if (!detach) {
178-
// shut it down, including volumes
179-
await execa(
180-
"docker",
181-
[...compose_args, "down", "--volumes"],
182-
{
183-
env,
184-
stdio: "inherit",
185-
},
186-
);
187-
}
252+
253+
// create tasks to monitor services startup
254+
const monitorTasks = [...standardServices, ...optionalServices]
255+
.filter(({ healthySemaphore }) => !!healthySemaphore) // only services with a healthy semaphore
256+
.map((service) => {
257+
const healthyTitle =
258+
typeof service.healthyTitle === "function"
259+
? service.healthyTitle(port)
260+
: service.healthyTitle;
261+
return serviceMonitorTask({
262+
projectName,
263+
service: service.healthySemaphore!,
264+
errorTitle: service.errorTitle,
265+
waitTitle: service.waitTitle,
266+
healthyTitle,
267+
});
268+
});
269+
270+
const tasks = new Listr(monitorTasks, { concurrent: true });
271+
await tasks.run();
272+
await up;
188273
}
189274
});
190275
};

apps/cli/src/commands/rollups/status.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ export const createStatusCommand = () => {
1212
.option("--json", "output in JSON format")
1313
.action(async ({ json }, command) => {
1414
const { projectName } = command.optsWithGlobals();
15-
const status = await getServiceState(projectName, "rollups-node");
15+
const status = await getServiceState({
16+
projectName,
17+
service: "rollups-node",
18+
});
1619
const deployments = await getDeployments({ projectName });
1720

1821
if (json) {

0 commit comments

Comments
 (0)