laneyard 0.1.0 → 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.
Files changed (60) hide show
  1. package/README.md +120 -21
  2. package/dist/src/cli/secret.js +133 -0
  3. package/dist/src/config/schema.js +21 -3
  4. package/dist/src/db/cache.js +8 -8
  5. package/dist/src/db/open.js +15 -0
  6. package/dist/src/db/runs.js +51 -6
  7. package/dist/src/db/schema.sql +20 -2
  8. package/dist/src/db/secrets.js +64 -0
  9. package/dist/src/fastfile/store.js +53 -0
  10. package/dist/src/git/workspace.js +95 -5
  11. package/dist/src/heuristics/blocking-actions.js +45 -0
  12. package/dist/src/heuristics/readiness.js +193 -0
  13. package/dist/src/logs/redact.js +86 -0
  14. package/dist/src/main.js +32 -3
  15. package/dist/src/runner/orchestrate.js +60 -10
  16. package/dist/src/runner/pty.js +27 -15
  17. package/dist/src/runner/queue.js +114 -0
  18. package/dist/src/secrets/cipher.js +28 -0
  19. package/dist/src/secrets/key.js +40 -0
  20. package/dist/src/secrets/vault.js +68 -0
  21. package/dist/src/server/app.js +70 -0
  22. package/dist/src/server/routes/fastfile.js +131 -0
  23. package/dist/src/server/routes/projects.js +8 -1
  24. package/dist/src/server/routes/readiness.js +89 -0
  25. package/dist/src/server/routes/runs.js +27 -45
  26. package/dist/src/server/routes/secrets.js +51 -0
  27. package/dist/src/sidecar/lanes.js +2 -2
  28. package/dist/src/sidecar/uses.js +40 -0
  29. package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
  30. package/dist/web/assets/index-D9_EL8LA.js +62 -0
  31. package/dist/web/assets/index-ZUqTX6d1.css +1 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +13 -5
  34. package/ruby/introspect.rb +80 -0
  35. package/dist/tests/cli/add.test.js +0 -64
  36. package/dist/tests/cli/detect.test.js +0 -63
  37. package/dist/tests/config/load.test.js +0 -68
  38. package/dist/tests/config/resolve.test.js +0 -44
  39. package/dist/tests/config/store.test.js +0 -58
  40. package/dist/tests/db/runs.test.js +0 -54
  41. package/dist/tests/e2e/full-thread.test.js +0 -65
  42. package/dist/tests/fixtures/repos.js +0 -30
  43. package/dist/tests/git/workspace.test.js +0 -66
  44. package/dist/tests/heuristics/error-summary.test.js +0 -31
  45. package/dist/tests/logs/store.test.js +0 -38
  46. package/dist/tests/main.test.js +0 -27
  47. package/dist/tests/ruby/introspect.test.js +0 -59
  48. package/dist/tests/runner/artifacts.test.js +0 -49
  49. package/dist/tests/runner/live-steps.test.js +0 -35
  50. package/dist/tests/runner/orchestrate.test.js +0 -124
  51. package/dist/tests/runner/pty.test.js +0 -53
  52. package/dist/tests/runner/report.test.js +0 -91
  53. package/dist/tests/server/api.test.js +0 -132
  54. package/dist/tests/server/auth.test.js +0 -53
  55. package/dist/tests/server/ws.test.js +0 -43
  56. package/dist/tests/sidecar/lanes.test.js +0 -52
  57. package/dist/tests/sidecar/ruby-env.test.js +0 -25
  58. package/dist/tests/smoke.test.js +0 -7
  59. package/dist/web/assets/index-583x0xuo.css +0 -1
  60. package/dist/web/assets/index-BEpABKPS.js +0 -61
@@ -1,5 +1,6 @@
1
1
  import { createReadStream } from "node:fs";
2
- import { executeRun } from "../../runner/orchestrate.js";
2
+ /** Statuses a run can still be cancelled from. */
3
+ const CANCELLABLE = ["queued", "preparing", "running"];
3
4
  export async function registerRunRoutes(app, ctx) {
4
5
  app.post("/api/projects/:slug/runs", async (req, reply) => {
5
6
  const { slug } = req.params;
@@ -9,17 +10,6 @@ export async function registerRunRoutes(app, ctx) {
9
10
  return reply.code(404).send({ error: "Unknown project" });
10
11
  if (!body.lane)
11
12
  return reply.code(400).send({ error: "Missing lane" });
12
- // Only one run at a time per project: they share the same git workspace.
13
- // Two concurrent runs would silently trip over each other — one would
14
- // change the commit out from under the other, carry off its artifacts
15
- // and delete its report. The real queue comes at the next milestone;
16
- // this refusal, for its part, already prevents false results.
17
- const last = ctx.runs.listByProject(slug, 1)[0];
18
- if (last && ["queued", "preparing", "running"].includes(last.status)) {
19
- return reply.code(409).send({
20
- error: `Run #${last.id} is still in progress on this project. Wait for it to finish.`,
21
- });
22
- }
23
13
  // We check that the lane genuinely exists before creating a run doomed to fail.
24
14
  try {
25
15
  await ctx.ensureWorkspace(slug);
@@ -38,45 +28,37 @@ export async function registerRunRoutes(app, ctx) {
38
28
  platform: body.platform ?? null,
39
29
  params: body.params ?? {},
40
30
  });
41
- // Launched without waiting: the HTTP response mustn't take as long as a build.
42
- void executeRun({
43
- runId: id,
44
- runs: ctx.runs,
45
- logs: ctx.logs,
46
- workspacePath: ctx.workspacePath(slug),
47
- artifactsDir: ctx.artifactsDir(id),
48
- gitUrl: entry.git_url,
49
- gitAuth: entry.git_auth,
50
- branch: entry.default_branch,
51
- // Resolved after the clone, once the repository's laneyard.yml is finally readable.
52
- resolveSettings: async () => {
53
- const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
54
- return r.settings;
55
- },
56
- env: process.env,
57
- onChunk: (chunk, offset) => app.broadcastRunChunk?.(id, chunk, offset),
58
- })
59
- .then((r) => ctx.sockets?.finish(id, r.status))
60
- .catch((cause) => {
61
- // Last safety net. `executeRun` commits to never throwing, but a
62
- // rejected promise with no handler brings down the whole Node
63
- // process — and with it, the other runs in progress. The cost of
64
- // forgetting this would be disproportionate.
65
- ctx.runs.finish(id, {
66
- status: "failed",
67
- exitCode: null,
68
- errorSummary: `Unexpected failure: ${cause.message}`,
69
- });
70
- ctx.sockets?.finish(id, "failed");
71
- });
72
- return reply.code(201).send({ id });
31
+ // Read before the queue is woken, so the answer describes the line the
32
+ // caller just joined rather than one the worker has already moved on from.
33
+ const queuePosition = ctx.runs.queuePosition(id);
34
+ // The route creates a row and rings the bell; the worker does the rest.
35
+ app.queue.wake();
36
+ return reply.code(201).send({ id, queuePosition });
73
37
  });
74
38
  app.get("/api/runs/:id", async (req, reply) => {
75
39
  const id = Number(req.params.id);
76
40
  const run = ctx.runs.get(id);
77
41
  if (!run)
78
42
  return reply.code(404).send({ error: "Unknown run" });
79
- return { ...run, steps: ctx.runs.steps(id), artifacts: ctx.runs.artifacts(id) };
43
+ return {
44
+ ...run,
45
+ queuePosition: ctx.runs.queuePosition(id),
46
+ steps: ctx.runs.steps(id),
47
+ artifacts: ctx.runs.artifacts(id),
48
+ };
49
+ });
50
+ app.post("/api/runs/:id/cancel", async (req, reply) => {
51
+ const id = Number(req.params.id);
52
+ const run = ctx.runs.get(id);
53
+ if (!run)
54
+ return reply.code(404).send({ error: "Unknown run" });
55
+ if (!CANCELLABLE.includes(run.status)) {
56
+ return reply.code(409).send({ error: `Run #${id} has already finished` });
57
+ }
58
+ // A queued run is finished on the spot; a running one is signalled and ends
59
+ // a few moments later. Either way the caller has nothing left to wait for.
60
+ app.queue.cancel(id);
61
+ return reply.code(204).send();
80
62
  });
81
63
  app.get("/api/runs/:id/log", async (req, reply) => {
82
64
  const id = Number(req.params.id);
@@ -0,0 +1,51 @@
1
+ import { MIN_LENGTH as MIN_REDACTABLE } from "../../logs/redact.js";
2
+ /** POSIX environment variable names. Anything else would never reach fastlane. */
3
+ const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ export async function registerSecretRoutes(app, ctx) {
5
+ const listRoute = (slug) => slug === null ? ctx.vault.listGlobal() : ctx.vault.list(slug);
6
+ app.get("/api/secrets", async () => listRoute(null));
7
+ app.get("/api/projects/:slug/secrets", async (req, reply) => {
8
+ const { slug } = req.params;
9
+ if (!ctx.config.project(slug))
10
+ return reply.code(404).send({ error: "Unknown project" });
11
+ return listRoute(slug);
12
+ });
13
+ const put = async (slug, key, body, reply) => {
14
+ const { value, masked } = (body ?? {});
15
+ if (!VALID_KEY.test(key)) {
16
+ return reply.code(400).send({
17
+ error: `"${key}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
18
+ });
19
+ }
20
+ if (typeof value !== "string" || value === "") {
21
+ return reply.code(400).send({ error: "A value is required" });
22
+ }
23
+ // Accepting the tick box and quietly not honouring it would leave someone
24
+ // believing they are protected. Refusing is the honest answer.
25
+ if (masked !== false && value.length < MIN_REDACTABLE) {
26
+ return reply.code(400).send({
27
+ error: `A value kept out of the logs must be at least ${MIN_REDACTABLE} characters. ` +
28
+ "Shorter than that, removing it would shred the log without hiding anything. " +
29
+ "Store it unmasked if you accept it appearing in the output.",
30
+ });
31
+ }
32
+ await ctx.vault.set(slug, key, value, masked !== false);
33
+ return reply.code(204).send();
34
+ };
35
+ app.put("/api/secrets/:key", async (req, reply) => put(null, req.params.key, req.body, reply));
36
+ app.put("/api/projects/:slug/secrets/:key", async (req, reply) => {
37
+ const { slug, key } = req.params;
38
+ if (!ctx.config.project(slug))
39
+ return reply.code(404).send({ error: "Unknown project" });
40
+ return put(slug, key, req.body, reply);
41
+ });
42
+ app.delete("/api/secrets/:key", async (req, reply) => {
43
+ const removed = ctx.vault.remove(null, req.params.key);
44
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
45
+ });
46
+ app.delete("/api/projects/:slug/secrets/:key", async (req, reply) => {
47
+ const { slug, key } = req.params;
48
+ const removed = ctx.vault.remove(slug, key);
49
+ return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
50
+ });
51
+ }
@@ -27,14 +27,14 @@ export class LaneReader {
27
27
  }
28
28
  async read(slug, workspacePath, fastlaneDir) {
29
29
  const hash = await hashFastlaneDir(workspacePath, fastlaneDir);
30
- const cached = this.cache.get(slug, hash);
30
+ const cached = this.cache.get(slug, "lanes", hash);
31
31
  if (cached)
32
32
  return cached;
33
33
  const res = await this.invoke("lanes", workspacePath, fastlaneDir);
34
34
  if (!res.ok)
35
35
  throw new Error(res.error);
36
36
  const lanes = res["lanes"];
37
- this.cache.put(slug, hash, lanes);
37
+ this.cache.put(slug, "lanes", hash, lanes);
38
38
  return lanes;
39
39
  }
40
40
  }
@@ -0,0 +1,40 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Hash of the whole fastlane folder, not just the Fastfile:
6
+ * an Appfile, a Pluginfile, or an imported file change the lanes just as much.
7
+ */
8
+ async function hashFastlaneDir(root, fastlaneDir) {
9
+ const dir = join(root, fastlaneDir);
10
+ const hash = createHash("sha256");
11
+ const entries = (await readdir(dir, { withFileTypes: true, recursive: true }))
12
+ .filter((e) => e.isFile())
13
+ .map((e) => join(e.parentPath, e.name))
14
+ .sort();
15
+ for (const file of entries) {
16
+ hash.update(file);
17
+ hash.update(await readFile(file));
18
+ }
19
+ return hash.digest("hex");
20
+ }
21
+ export class UsesReader {
22
+ cache;
23
+ invoke;
24
+ constructor(cache, invoke) {
25
+ this.cache = cache;
26
+ this.invoke = invoke;
27
+ }
28
+ async read(slug, workspacePath, fastlaneDir) {
29
+ const hash = await hashFastlaneDir(workspacePath, fastlaneDir);
30
+ const cached = this.cache.get(slug, "uses", hash);
31
+ if (cached)
32
+ return cached;
33
+ const res = await this.invoke("uses", workspacePath, fastlaneDir);
34
+ if (!res.ok)
35
+ throw new Error(res.error);
36
+ const lanes = res["lanes"];
37
+ this.cache.put(slug, "uses", hash, lanes);
38
+ return lanes;
39
+ }
40
+ }