History

Fix beta distribution release flow

Rhys Sullivan committed 7 months ago

Commit ab58a3e1 parent 17ab9314 files · +209 −54Browse files

Unified · Split

4 files
  1. apps/executor/src/distribution/artifact.ts +72 −13
  2. apps/executor/src/distribution/distribution.test.ts +20 −30
  3. apps/executor/src/distribution/harness.ts +89 −0
  4. plugins/keychain-secret-store/sdk/index.ts +28 −11
apps/executor/src/distribution/artifact.ts+72−13
@@ -1,6 +1,6 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import { existsSync } from "node:fs";2import { existsSync } from "node:fs";
3import { chmod, cp, mkdir, rm, writeFile } from "node:fs/promises";3import { chmod, cp, mkdir, rename, rm, writeFile } from "node:fs/promises";
4import { createRequire } from "node:module";4import { createRequire } from "node:module";
5import { dirname, join, resolve } from "node:path";5import { dirname, join, resolve } from "node:path";
6 6
@@ -136,10 +136,77 @@ const createPackageJson = (input: { · 129 unmodified lines
136 136
137const createLauncherSource = () => [137const createLauncherSource = () => [
138 "#!/usr/bin/env node",138 "#!/usr/bin/env node",
139 'import "./executor.mjs";',139 'import { readFileSync, readdirSync } from "node:fs";',
140 'import { dirname, join } from "node:path";',
141 'import { fileURLToPath } from "node:url";',
142 "",
143 "const isMusl = () => {",
144 ' try {',
145 ' return readFileSync("/usr/bin/ldd", "utf8").includes("musl");',
146 " } catch {}",
147 "",
148 ' if (typeof process.report?.getReport === "function") {',
149 " const report = process.report.getReport();",
150 " if (report?.header?.glibcVersionRuntime) {",
151 " return false;",
152 " }",
153 " if (Array.isArray(report?.sharedObjects)) {",
154 ' return report.sharedObjects.some((file) => file.includes("libc.musl-") || file.includes("ld-musl-"));',
155 " }",
156 " }",
157 "",
158 " return false;",
159 "};",
160 "",
161 "const resolveBundledKeyringNativeLibraryPath = () => {",
162 ' if (process.platform !== "linux" || process.arch !== "x64") {',
163 " return null;",
164 " }",
165 "",
166 " const binDir = dirname(fileURLToPath(import.meta.url));",
167 ' const pattern = isMusl()',
168 ' ? /^keyring\\.linux-x64-musl-.*\\.node$/',
169 ' : /^keyring\\.linux-x64-gnu-.*\\.node$/;',
170 ' const match = readdirSync(binDir).find((entry) => pattern.test(entry));',
171 " return match ? join(binDir, match) : null;",
172 "};",
173 "",
174 "const bundledKeyringNativeLibraryPath = resolveBundledKeyringNativeLibraryPath();",
175 'if (bundledKeyringNativeLibraryPath && !process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {',
176 " process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bundledKeyringNativeLibraryPath;",
177 "}",
178 "",
179 'await import("./executor.mjs");',
140 "",180 "",
141].join("\n");181].join("\n");
142 182
183const buildCliBundle = async (input: {
184 binDir: string;
185 bundlePath: string;
186}): Promise<void> => {
187 const builtEntrypointPath = join(input.binDir, "main.js");
188
189 await runCommand({
190 command: "bun",
191 args: [
192 "build",
193 "./apps/executor/src/cli/main.ts",
194 "--target",
195 "node",
196 "--outdir",
197 input.binDir,
198 ],
199 cwd: repoRoot,
200 });
201
202 if (!existsSync(builtEntrypointPath)) {
203 throw new Error(`Missing bundled CLI entrypoint at ${builtEntrypointPath}`);
204 }
205
206 await rm(input.bundlePath, { force: true });
207 await rename(builtEntrypointPath, input.bundlePath);
208};
209
143export const buildDistributionPackage = async (210export const buildDistributionPackage = async (
144 options: BuildDistributionPackageOptions = {},211 options: BuildDistributionPackageOptions = {},
145): Promise<DistributionPackageArtifact> => {212): Promise<DistributionPackageArtifact> => {
@@ -172,17 +239,9 @@ export const buildDistributionPackage = async ( · 26 unmodified lines
172 throw new Error(`Missing built web assets at ${webDistDir}`);239 throw new Error(`Missing built web assets at ${webDistDir}`);
173 }240 }
174 241
175 await runCommand({242 await buildCliBundle({
176 command: "bun",243 binDir,
177 args: [244 bundlePath,
178 "build",
179 "./apps/executor/src/cli/main.ts",
180 "--target",
181 "node",
182 "--outfile",
183 bundlePath,
184 ],
185 cwd: repoRoot,
186 });245 });
187 246
188 await cp(webDistDir, webDir, { recursive: true });247 await cp(webDistDir, webDir, { recursive: true });
apps/executor/src/distribution/distribution.test.ts+20−30
@@ -25,37 +25,17 @@ describe("distribution flow", () => { · 24 unmodified lines
25}25}
26`);26`);
27 27
28 const initialDoctor = yield* runCommand([28 expect(yield* harness.isReachable()).toBe(false);
29 "doctor",
30 "--json",
31 "--base-url",
32 harness.baseUrl,
33 ]);
34 const initialDoctorJson = JSON.parse(initialDoctor.stdout) as {
35 ok: boolean;
36 checks: Record<string, { ok: boolean }>;
37 };
38 expect(initialDoctorJson.ok).toBe(false);
39 expect(initialDoctorJson.checks.webAssets?.ok).toBe(true);
40 expect(initialDoctorJson.checks.database?.ok).toBe(true);
41
42 yield* runCommand(["up", "--base-url", harness.baseUrl]);
43 29
44 const statusResult = yield* runCommand([30 const initialCall = yield* runCommand([
45 "status",31 "call",
46 "--json",32 "return 1 + 1;",
47 "--base-url",33 "--base-url",
48 harness.baseUrl,34 harness.baseUrl,
35 "--no-open",
49 ]);36 ]);
50 const status = JSON.parse(statusResult.stdout) as {37 expect(initialCall.stdout).toBe("2");
51 reachable: boolean;38 expect(yield* harness.isReachable()).toBe(true);
52 pidRunning: boolean;
53 installation: { workspaceId: string; accountId: string } | null;
54 };
55
56 expect(status.reachable).toBe(true);
57 expect(status.pidRunning).toBe(true);
58 expect(status.installation).not.toBeNull();
59 39
60 const html = yield* harness.fetchText("/");40 const html = yield* harness.fetchText("/");
61 expect(html.status).toBe(200);41 expect(html.status).toBe(200);
@@ -80,8 +60,17 @@ describe("distribution flow", () => { · 18 unmodified lines
80 );60 );
81 expect(sesCall.stderr).toContain("fetch is disabled in SES executor");61 expect(sesCall.stderr).toContain("fetch is disabled in SES executor");
82 62
83 yield* runCommand(["down", "--base-url", harness.baseUrl]);63 yield* harness.stopServer();
84 yield* runCommand(["up", "--base-url", harness.baseUrl]);64 expect(yield* harness.isReachable()).toBe(false);
65
66 const restartedCall = yield* runCommand([
67 "call",
68 "return 3;",
69 "--base-url",
70 harness.baseUrl,
71 "--no-open",
72 ]);
73 expect(restartedCall.stdout).toBe("3");
85 74
86 const installationAfterRestartResponse = yield* harness.fetchText("/v1/local/installation");75 const installationAfterRestartResponse = yield* harness.fetchText("/v1/local/installation");
87 expect(installationAfterRestartResponse.status).toBe(200);76 expect(installationAfterRestartResponse.status).toBe(200);
@@ -95,7 +84,8 @@ describe("distribution flow", () => { · 7 unmodified lines
95 expect(installationAfterRestart.scopeId).toBe(installation.scopeId);84 expect(installationAfterRestart.scopeId).toBe(installation.scopeId);
96 expect(installationAfterRestart.actorScopeId).toBe(installation.actorScopeId);85 expect(installationAfterRestart.actorScopeId).toBe(installation.actorScopeId);
97 86
98 yield* runCommand(["down", "--base-url", harness.baseUrl]);87 yield* harness.stopServer();
88 expect(yield* harness.isReachable()).toBe(false);
99 });89 });
100 90
101 it.live("boots a staged package artifact in a fresh home", () =>91 it.live("boots a staged package artifact in a fresh home", () =>
apps/executor/src/distribution/harness.ts+89−0
@@ -53,9 +53,14 @@ export class DistributionHarness extends Context.Tag( · 52 unmodified lines
53 readonly body: string;53 readonly body: string;
54 readonly contentType: string | null;54 readonly contentType: string | null;
55 }, Error, never>;55 }, Error, never>;
56 readonly isReachable: () => Effect.Effect<boolean, Error, never>;
57 readonly stopServer: () => Effect.Effect<void, Error, never>;
56 }58 }
57>() {}59>() {}
58 60
61const SERVER_WAIT_TIMEOUT_MS = 5_000;
62const SERVER_POLL_INTERVAL_MS = 100;
63
59const runCommand = (input: {64const runCommand = (input: {
60 readonly command: string;65 readonly command: string;
61 readonly args: ReadonlyArray<string>;66 readonly args: ReadonlyArray<string>;
@@ -174,6 +179,36 @@ const packPackage = (packageDir: string, outputDir: string) => · 112 unmodified lines
174 }),179 }),
175 );180 );
176 181
182const waitForReachability = (input: {
183 baseUrl: string;
184 expected: boolean;
185}): Effect.Effect<void, Error, never> =>
186 Effect.gen(function* () {
187 const startedAt = Date.now();
188
189 while (Date.now() - startedAt < SERVER_WAIT_TIMEOUT_MS) {
190 const reachable = yield* Effect.tryPromise({
191 try: async () => {
192 const response = await fetch(new URL("/", input.baseUrl));
193 return response.ok;
194 },
195 catch: toError,
196 }).pipe(Effect.catchAll(() => Effect.succeed(false)));
197
198 if (reachable === input.expected) {
199 return;
200 }
201
202 yield* Effect.sleep(`${SERVER_POLL_INTERVAL_MS} millis`);
203 }
204
205 return yield* Effect.fail(
206 new Error(
207 `Timed out waiting for executor server to become ${input.expected ? "reachable" : "unreachable"} at ${input.baseUrl}`,
208 ),
209 );
210 });
211
177export const LocalDistributionHarnessLive = Layer.scoped(212export const LocalDistributionHarnessLive = Layer.scoped(
178 DistributionHarness,213 DistributionHarness,
179 Effect.gen(function* () {214 Effect.gen(function* () {
@@ -264,6 +299,58 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 84 unmodified lines
264 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),299 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
265 });300 });
266 301
302 const isReachable = () =>
303 Effect.tryPromise({
304 try: async () => {
305 const response = await fetch(new URL("/", baseUrl));
306 return response.ok;
307 },
308 catch: toError,
309 }).pipe(Effect.catchAll(() => Effect.succeed(false)));
310
311 const stopServer = () =>
312 Effect.gen(function* () {
313 const pidFile = join(executorHome, "run", "server.pid");
314 const exists = yield* fs.exists(pidFile).pipe(Effect.mapError(toError));
315 if (!exists) {
316 return;
317 }
318
319 const contents = yield* fs.readFileString(pidFile).pipe(
320 Effect.mapError(toError),
321 );
322 const parsed = JSON.parse(contents) as { pid?: number };
323 const pid = parsed.pid;
324
325 if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) {
326 yield* Effect.try({
327 try: () => process.kill(pid, "SIGTERM"),
328 catch: toError,
329 }).pipe(Effect.catchAll(() => Effect.void));
330
331 const stopped = yield* waitForReachability({
332 baseUrl,
333 expected: false,
334 }).pipe(
335 Effect.as(true),
336 Effect.catchAll(() => Effect.succeed(false)),
337 );
338 if (stopped) {
339 return;
340 }
341
342 yield* Effect.try({
343 try: () => process.kill(pid, "SIGKILL"),
344 catch: toError,
345 }).pipe(Effect.catchAll(() => Effect.void));
346 }
347
348 yield* waitForReachability({
349 baseUrl,
350 expected: false,
351 });
352 });
353
267 const writeProjectConfig = (contents: string) =>354 const writeProjectConfig = (contents: string) =>
268 Effect.forEach(355 Effect.forEach(
269 [stagedWorkspaceRoot, installedWorkspaceRoot],356 [stagedWorkspaceRoot, installedWorkspaceRoot],
@@ -291,6 +378,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 21 unmodified lines
291 run,378 run,
292 runInstalled,379 runInstalled,
293 fetchText,380 fetchText,
381 isReachable,
382 stopServer,
294 });383 });
295 }).pipe(Effect.provide(NodeFileSystem.layer)),384 }).pipe(Effect.provide(NodeFileSystem.layer)),
296);385);
plugins/keychain-secret-store/sdk/index.ts+28−11
@@ -1,7 +1,4 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import {
3 Entry,
4} from "@napi-rs/keyring";
5 2
6import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
7 4
@@ -51,12 +48,13 @@ const keychainDisplayName = () => · 43 unmodified lines
51 ? "Windows Credential Manager"48 ? "Windows Credential Manager"
52 : "Desktop Keyring";49 : "Desktop Keyring";
53 50
54const createKeyringEntry = (input: {51let keyringEntryConstructorPromise: Promise<
55 providerHandle: string;52 (typeof import("@napi-rs/keyring"))["Entry"]
56 keychainServiceName: string;53> | null = null;
57}) =>54
58 Effect.try({55const loadKeyringEntryConstructor = () =>
59 try: () => {56 Effect.tryPromise({
57 try: async () => {
60 if (!isSupportedPlatform()) {58 if (!isSupportedPlatform()) {
61 throw runtimeEffectError(59 throw runtimeEffectError(
62 "plugin-keychain-secret-store",60 "plugin-keychain-secret-store",
@@ -64,11 +62,30 @@ const createKeyringEntry = (input: { · 1 unmodified line
64 );62 );
65 }63 }
66 64
67 return new Entry(input.keychainServiceName, input.providerHandle);65 keyringEntryConstructorPromise ??= import("@napi-rs/keyring").then(
66 ({ Entry }) => Entry,
67 );
68
69 return await keyringEntryConstructorPromise;
68 },70 },
69 catch: toError,71 catch: (cause) =>
72 runtimeEffectError(
73 "plugin-keychain-secret-store",
74 `system-keyring: failed loading native keyring support: ${toError(cause).message}`,
75 ),
70 });76 });
71 77
78const createKeyringEntry = (input: {
79 providerHandle: string;
80 keychainServiceName: string;
81}) =>
82 Effect.flatMap(loadKeyringEntryConstructor(), (Entry) =>
83 Effect.try({
84 try: () => new Entry(input.keychainServiceName, input.providerHandle),
85 catch: toError,
86 })
87 );
88
72const readKeychainSecretValue = (input: {89const readKeychainSecretValue = (input: {
73 providerHandle: string;90 providerHandle: string;
74 keychainServiceName: string;91 keychainServiceName: string;