Skip to content
Source
Sign inSign up

smoke-cli-archive.ts

229 lines8.2 KB

1#!/usr/bin/env node
2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import * as Schedule from "effect/Schedule";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import { Command, Flag } from "effect/cli";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16​
17import * as NetService from "@t3tools/shared/Net";
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import { windowsSystemTar } from "./lib/windows-tar.ts";
20import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
21​
22export class CliArchiveSmokeError extends Schema.TaggedError<CliArchiveSmokeError>()(
23 "CliArchiveSmokeError",
24 { step: Schema.String, detail: Schema.String },
25) {
26 override get message(): string {
27 return `CLI archive smoke test failed while ${this.step}: ${this.detail}`;
28 }
30​
31const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
32 stream.pipe(
33 Stream.decodeText(),
34 Stream.runFold(
35 () => "",
36 (acc, chunk) => acc + chunk,
37 ),
38 );
39​
40const runExecutable = Effect.fn("runExecutable")(function* (
41 executable: string,
42 args: ReadonlyArray<string>,
43 cwd: string,
44) {
45 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
46 const child = yield* spawner.spawn(
47 ChildProcess.make(executable, args, {
48 cwd,
49 // Isolate system Node and inherited launcher context from the archive smoke check.
50 env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd },
51 extendEnv: false,
52 }),
53 );
54 const [stdout, stderr, exitCode] = yield* Effect.all(
55 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
56 { concurrency: "unbounded" },
57 );
58 return { stdout, stderr, exitCode };
59});
60​
61const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: {
62 readonly archive: string;
63 readonly expectVersion: string;
64}) {
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const platform = yield* HostProcess.Platform;
68 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69 const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" });
70 // Windows can briefly retain executable locks; cleanup retries but must not fail a successful smoke check.
71 yield* Effect.addFinalizer(() =>
72 fs.remove(scratch, { recursive: true }).pipe(
73 Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }),
74 Effect.catch((error) =>
75 Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`),
76 ),
77 ),
78 );
79​
80 // Windows bsdtar reads zip; Git Bash's GNU tar does not.
81 const tar = platform === "win32" ? windowsSystemTar() : "tar";
82 const extract = yield* spawner
83 .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch]))
84 .pipe(Effect.flatMap((child) => child.exitCode));
85 if (Number(extract) !== 0) {
86 return yield* new CliArchiveSmokeError({
87 step: "extracting the archive",
88 detail: `tar exited with ${String(extract)}`,
89 });
90 }
91 const [root] = yield* fs.readDirectory(scratch);
92 if (root === undefined) {
93 return yield* new CliArchiveSmokeError({
94 step: "extracting the archive",
95 detail: "the archive was empty",
96 });
97 }
98 const contentDir = path.join(scratch, root);
99 const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3");
100 const bundledLibatomic = path.join(contentDir, "lib/libatomic.so.1");
101 for (const required of [
102 executable,
103 path.join(contentDir, "client/index.html"),
104 ...(platform === "linux" ? [bundledLibatomic, `${bundledLibatomic}.LICENSE`] : []),
105 ]) {
106 if (!(yield* fs.exists(required))) {
107 return yield* new CliArchiveSmokeError({
108 step: "checking the archive layout",
109 detail: `missing ${path.relative(contentDir, required)}`,
110 });
111 }
112 }
113​
114 // The runner has libatomic installed; fresh Ubuntu servers do not.
115 if (platform === "linux") {
116 const ldd = yield* spawner.string(ChildProcess.make("ldd", [executable]));
117 // Older glibc prints the path as given, newer prints it resolved; compare both resolved.
118 const loaded = /libatomic\.so\.1 => (\/.*) \(0x[0-9a-f]+\)/.exec(ldd)?.[1];
119 if (
120 loaded === undefined ||
121 (yield* fs.realPath(loaded)) !== (yield* fs.realPath(bundledLibatomic))
122 ) {
123 return yield* new CliArchiveSmokeError({
124 step: "checking that t3 loads the bundled libatomic",
125 detail: ldd,
126 });
127 }
128 }
129​
130 const version = yield* runExecutable(executable, ["--version"], contentDir);
131 if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) {
132 return yield* new CliArchiveSmokeError({
133 step: "running --version",
134 detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`,
135 });
136 }
137​
138 // The service launcher loads outside the embedded CLI; without T3CODE_HOME it exits right away.
139 const launcher = yield* runExecutable(executable, ["__service-launcher"], contentDir);
140 if (launcher.exitCode !== 1 || !launcher.stderr.includes("T3CODE_HOME is required")) {
141 return yield* new CliArchiveSmokeError({
142 step: "running __service-launcher",
143 detail: `exit ${String(launcher.exitCode)}\n${launcher.stdout}${launcher.stderr}`,
144 });
145 }
146​
147 const net = yield* NetService.NetService;
148 const port = yield* net.findAvailablePort(47700);
149 const home = path.join(scratch, "home");
150 const server = yield* spawner.spawn(
151 ChildProcess.make(
152 executable,
153 ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"],
154 {
155 cwd: contentDir,
156 env: {
157 PATH: "",
158 HOME: home,
159 USERPROFILE: home,
160 TMPDIR: scratch,
161 TEMP: scratch,
162 T3CODE_HOME: home,
163 // The archive has a release channel built in, so its events would
164 // look like a real new install.
165 T3CODE_TELEMETRY_ENABLED: "false",
166 },
167 extendEnv: false,
168 },
169 ),
170 );
171 const output = yield* Effect.forkScoped(
172 Effect.all([collect(server.stdout), collect(server.stderr)]),
173 );
174 const httpClient = yield* HttpClient.HttpClient;
175 // Probes can hang during server initialization; bound each request as SSH readiness does.
176 const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe(
177 Effect.map((response) => response.status === 200),
178 Effect.timeout(Duration.seconds(2)),
179 Effect.orElseSucceed(() => false),
180 );
181 const pollUntilReady = Effect.gen(function* () {
182 while (!(yield* probe)) {
183 yield* Effect.sleep(Duration.millis(250));
184 }
185 return true;
186 });
187 const ready = yield* pollUntilReady.pipe(
188 Effect.timeout(Duration.seconds(30)),
189 Effect.orElseSucceed(() => false),
190 );
191 yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore);
192 yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore);
193 const [stdout, stderr] = yield* Fiber.join(output).pipe(
194 Effect.timeout(Duration.seconds(5)),
195 Effect.orElseSucceed(() => ["", ""] as const),
196 );
197 if (!ready) {
198 return yield* new CliArchiveSmokeError({
199 step: "serving from the extracted archive",
200 detail: `no 200 from / within 30s\n${stdout}${stderr}`,
201 });
202 }
203 yield* Effect.log(
204 `[cli-smoke] ${root}: --version and __service-launcher passed, and serve answered on ${String(port)}.`,
205 );
206});
207​
208const command = Command.make(
209 "smoke-cli-archive",
210 {
211 archive: Flag.String("archive"),
212 expectVersion: Flag.String("expect-version"),
213 },
214 (input) => smokeCliArchive(input).pipe(Effect.scoped),
215).pipe(Command.withDescription("Extract a CLI archive and run its executable."));
216​
217if (import.meta.main) {
218 Command.run(command, { version: "0.0.0" }).pipe(
219 Effect.provide(
220 Layer.mergeAll(
221 Logger.layer([Logger.consolePretty()]),
222 NodeServices.layer,
223 NetService.layer,
224 FetchHttpClient.layer,
225 ),
226 ),
227 NodeRuntime.runMain,
228 );