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.
- package/README.md +120 -21
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/config/schema.js +21 -3
- package/dist/src/db/cache.js +8 -8
- package/dist/src/db/open.js +15 -0
- package/dist/src/db/runs.js +51 -6
- package/dist/src/db/schema.sql +20 -2
- package/dist/src/db/secrets.js +64 -0
- package/dist/src/fastfile/store.js +53 -0
- package/dist/src/git/workspace.js +95 -5
- package/dist/src/heuristics/blocking-actions.js +45 -0
- package/dist/src/heuristics/readiness.js +193 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +32 -3
- package/dist/src/runner/orchestrate.js +60 -10
- package/dist/src/runner/pty.js +27 -15
- package/dist/src/runner/queue.js +114 -0
- package/dist/src/secrets/cipher.js +28 -0
- package/dist/src/secrets/key.js +40 -0
- package/dist/src/secrets/vault.js +68 -0
- package/dist/src/server/app.js +70 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +8 -1
- package/dist/src/server/routes/readiness.js +89 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-D5Q4uj4n.js +14 -0
- package/dist/web/assets/index-D9_EL8LA.js +62 -0
- package/dist/web/assets/index-ZUqTX6d1.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +13 -5
- package/ruby/introspect.rb +80 -0
- package/dist/tests/cli/add.test.js +0 -64
- package/dist/tests/cli/detect.test.js +0 -63
- package/dist/tests/config/load.test.js +0 -68
- package/dist/tests/config/resolve.test.js +0 -44
- package/dist/tests/config/store.test.js +0 -58
- package/dist/tests/db/runs.test.js +0 -54
- package/dist/tests/e2e/full-thread.test.js +0 -65
- package/dist/tests/fixtures/repos.js +0 -30
- package/dist/tests/git/workspace.test.js +0 -66
- package/dist/tests/heuristics/error-summary.test.js +0 -31
- package/dist/tests/logs/store.test.js +0 -38
- package/dist/tests/main.test.js +0 -27
- package/dist/tests/ruby/introspect.test.js +0 -59
- package/dist/tests/runner/artifacts.test.js +0 -49
- package/dist/tests/runner/live-steps.test.js +0 -35
- package/dist/tests/runner/orchestrate.test.js +0 -124
- package/dist/tests/runner/pty.test.js +0 -53
- package/dist/tests/runner/report.test.js +0 -91
- package/dist/tests/server/api.test.js +0 -132
- package/dist/tests/server/auth.test.js +0 -53
- package/dist/tests/server/ws.test.js +0 -43
- package/dist/tests/sidecar/lanes.test.js +0 -52
- package/dist/tests/sidecar/ruby-env.test.js +0 -25
- package/dist/tests/smoke.test.js +0 -7
- package/dist/web/assets/index-583x0xuo.css +0 -1
- package/dist/web/assets/index-BEpABKPS.js +0 -61
package/dist/src/runner/pty.js
CHANGED
|
@@ -28,30 +28,42 @@ export function startPty(opts) {
|
|
|
28
28
|
let timedOut = false;
|
|
29
29
|
let timer;
|
|
30
30
|
proc.onData(opts.onData);
|
|
31
|
+
/**
|
|
32
|
+
* SIGINT first so fastlane can clean up after itself; SIGKILL five seconds
|
|
33
|
+
* later if it will not. One path, so cancelling and timing out cannot drift
|
|
34
|
+
* apart — and so neither can leave the single worker blocked.
|
|
35
|
+
*/
|
|
36
|
+
const stop = () => {
|
|
37
|
+
try {
|
|
38
|
+
proc.kill("SIGINT");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* already gone */
|
|
42
|
+
}
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
try {
|
|
45
|
+
proc.kill("SIGKILL");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* already gone */
|
|
49
|
+
}
|
|
50
|
+
}, 5000);
|
|
51
|
+
};
|
|
52
|
+
opts.signal?.addEventListener("abort", stop, { once: true });
|
|
31
53
|
const done = new Promise((resolve) => {
|
|
32
54
|
if (opts.timeoutMs !== undefined) {
|
|
33
55
|
timer = setTimeout(() => {
|
|
34
56
|
timedOut = true;
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
proc.kill("SIGINT");
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
/* the process may have died in the meantime */
|
|
41
|
-
}
|
|
42
|
-
setTimeout(() => {
|
|
43
|
-
try {
|
|
44
|
-
proc.kill("SIGKILL");
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
/* same */
|
|
48
|
-
}
|
|
49
|
-
}, 5000);
|
|
57
|
+
stop();
|
|
50
58
|
}, opts.timeoutMs);
|
|
51
59
|
}
|
|
52
60
|
proc.onExit(({ exitCode, signal }) => {
|
|
53
61
|
if (timer)
|
|
54
62
|
clearTimeout(timer);
|
|
63
|
+
// The listener would otherwise accumulate on every run sharing a
|
|
64
|
+
// long-lived signal (the queue's per-run AbortController is not one,
|
|
65
|
+
// but nothing here should depend on that).
|
|
66
|
+
opts.signal?.removeEventListener("abort", stop);
|
|
55
67
|
// `waitpid` only reports an exit code for a normal end: a process
|
|
56
68
|
// killed by a signal leaves 0, which would pass a cancellation off
|
|
57
69
|
// as a success. We apply the shell convention, 128 + signal, so an
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Statuses from which a run never moves again. */
|
|
2
|
+
const TERMINAL = ["success", "failed", "cancelled", "interrupted"];
|
|
3
|
+
/**
|
|
4
|
+
* Drains queued runs, one at a time.
|
|
5
|
+
*
|
|
6
|
+
* The queue is not held in memory: a run's `queued` status in the database *is*
|
|
7
|
+
* its place in line. That is what lets the server restart mid-queue and carry
|
|
8
|
+
* on, and what makes the position shown in the interface the truth rather than
|
|
9
|
+
* a second copy of it.
|
|
10
|
+
*
|
|
11
|
+
* One at a time, globally. A mobile build saturates the machine it runs on, and
|
|
12
|
+
* two at once are frequently slower than two in sequence.
|
|
13
|
+
*/
|
|
14
|
+
export class RunQueue {
|
|
15
|
+
runs;
|
|
16
|
+
job;
|
|
17
|
+
draining = false;
|
|
18
|
+
pending = false;
|
|
19
|
+
closed = false;
|
|
20
|
+
idlePromise = Promise.resolve();
|
|
21
|
+
running = new Map();
|
|
22
|
+
constructor(runs, job) {
|
|
23
|
+
this.runs = runs;
|
|
24
|
+
this.job = job;
|
|
25
|
+
}
|
|
26
|
+
/** Tells the queue there may be work. Safe to call at any time, from anywhere. */
|
|
27
|
+
wake() {
|
|
28
|
+
if (this.closed)
|
|
29
|
+
return;
|
|
30
|
+
if (this.draining) {
|
|
31
|
+
// A call arriving mid-drain is remembered rather than dropped: the drain
|
|
32
|
+
// may already have read an empty queue and be on its way out.
|
|
33
|
+
this.pending = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.draining = true;
|
|
37
|
+
this.idlePromise = this.drainUntilQuiet().finally(() => {
|
|
38
|
+
this.draining = false;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async drainUntilQuiet() {
|
|
42
|
+
do {
|
|
43
|
+
this.pending = false;
|
|
44
|
+
await this.drain();
|
|
45
|
+
} while (this.pending && !this.closed);
|
|
46
|
+
}
|
|
47
|
+
/** Resolves when the queue has nothing left to do. For tests and shutdown. */
|
|
48
|
+
async idle() {
|
|
49
|
+
await this.idlePromise;
|
|
50
|
+
}
|
|
51
|
+
async drain() {
|
|
52
|
+
for (;;) {
|
|
53
|
+
if (this.closed)
|
|
54
|
+
return;
|
|
55
|
+
// One at a time: if something is already in flight — including a run this
|
|
56
|
+
// process did not start — the queue waits rather than doubling up.
|
|
57
|
+
if (this.runs.activeCount() > 0)
|
|
58
|
+
return;
|
|
59
|
+
const next = this.runs.queued()[0];
|
|
60
|
+
if (!next)
|
|
61
|
+
return;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
this.running.set(next.id, controller);
|
|
64
|
+
try {
|
|
65
|
+
await this.job(next.id, controller.signal);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A job that throws must not stall every run behind it. `executeRun`
|
|
69
|
+
// promises not to, but the queue cannot afford to depend on that.
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
this.running.delete(next.id);
|
|
73
|
+
// Whatever happened, the run must not still be waiting or in flight.
|
|
74
|
+
//
|
|
75
|
+
// Two failures hide here, and both are worse than a wrong status. If the
|
|
76
|
+
// job returns without moving the run out of `queued` — a project deleted
|
|
77
|
+
// from config.yml while its run waited, say — the next iteration reads
|
|
78
|
+
// the same row and calls the job again, forever. And a job that throws
|
|
79
|
+
// after `executeRun` reached `preparing` leaves a run with no end, which
|
|
80
|
+
// the interface polls until someone gives up.
|
|
81
|
+
const after = this.runs.get(next.id);
|
|
82
|
+
if (after && !TERMINAL.includes(after.status)) {
|
|
83
|
+
this.runs.finish(next.id, {
|
|
84
|
+
status: "failed",
|
|
85
|
+
exitCode: null,
|
|
86
|
+
errorSummary: "The run ended unexpectedly",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Cancels a run whether it has started or not.
|
|
94
|
+
*
|
|
95
|
+
* A run still waiting is finished on the spot: there is nothing to signal, and
|
|
96
|
+
* making someone wait for a build they have cancelled would be absurd.
|
|
97
|
+
*/
|
|
98
|
+
cancel(runId) {
|
|
99
|
+
const active = this.running.get(runId);
|
|
100
|
+
if (active) {
|
|
101
|
+
active.abort();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const run = this.runs.get(runId);
|
|
105
|
+
if (run?.status !== "queued")
|
|
106
|
+
return false;
|
|
107
|
+
this.runs.finish(runId, { status: "cancelled", exitCode: null, errorSummary: "Cancelled before it started" });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** Stops taking new work; the run in flight is left to finish. */
|
|
111
|
+
close() {
|
|
112
|
+
this.closed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* AES-256-GCM, from Node's standard library — no dependency to audit.
|
|
4
|
+
*
|
|
5
|
+
* GCM is authenticated: a modified ciphertext fails to decrypt rather than
|
|
6
|
+
* producing plausible rubbish, which is what you want for a value that will be
|
|
7
|
+
* handed to a build as a password.
|
|
8
|
+
*
|
|
9
|
+
* Payload format: `v1.<iv>.<tag>.<ciphertext>`, each part base64. The version
|
|
10
|
+
* prefix exists so the format can be changed without guessing what old rows are.
|
|
11
|
+
*/
|
|
12
|
+
const VERSION = "v1";
|
|
13
|
+
const IV_BYTES = 12; // 96 bits, the size GCM is specified for.
|
|
14
|
+
export function encrypt(plaintext, key) {
|
|
15
|
+
const iv = randomBytes(IV_BYTES);
|
|
16
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
17
|
+
const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
18
|
+
return [VERSION, iv.toString("base64"), cipher.getAuthTag().toString("base64"), body.toString("base64")].join(".");
|
|
19
|
+
}
|
|
20
|
+
export function decrypt(payload, key) {
|
|
21
|
+
const [version, ivB64, tagB64, bodyB64] = payload.split(".");
|
|
22
|
+
if (version !== VERSION || !ivB64 || !tagB64 || bodyB64 === undefined) {
|
|
23
|
+
throw new Error("Unrecognised secret format");
|
|
24
|
+
}
|
|
25
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
|
|
26
|
+
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
|
|
27
|
+
return Buffer.concat([decipher.update(Buffer.from(bodyB64, "base64")), decipher.final()]).toString("utf8");
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const KEY_BYTES = 32;
|
|
5
|
+
/**
|
|
6
|
+
* Reads the vault key, creating it on first use.
|
|
7
|
+
*
|
|
8
|
+
* The key lives beside the database but never inside it: someone who walks off
|
|
9
|
+
* with `laneyard.db` gets ciphertext and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* A key another user can read is treated as an error rather than a warning —
|
|
12
|
+
* the same stance `ssh` takes on private keys, and for the same reason: silently
|
|
13
|
+
* carrying on would make the encryption decorative.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadOrCreateKey(home) {
|
|
16
|
+
const path = join(home, "key");
|
|
17
|
+
try {
|
|
18
|
+
const info = await stat(path);
|
|
19
|
+
if ((info.mode & 0o077) !== 0) {
|
|
20
|
+
throw new Error(`Vault key ${path} is readable by other users. Run \`chmod 600 ${path}\` and start again.`);
|
|
21
|
+
}
|
|
22
|
+
const key = await readFile(path);
|
|
23
|
+
if (key.byteLength !== KEY_BYTES) {
|
|
24
|
+
throw new Error(`Vault key ${path} is ${key.byteLength} bytes, expected ${KEY_BYTES}. ` +
|
|
25
|
+
"Refusing to guess: move it aside and Laneyard will create a new one, " +
|
|
26
|
+
"but every stored secret will have to be entered again.");
|
|
27
|
+
}
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
catch (cause) {
|
|
31
|
+
if (cause.code !== "ENOENT")
|
|
32
|
+
throw cause;
|
|
33
|
+
}
|
|
34
|
+
await mkdir(home, { recursive: true });
|
|
35
|
+
const key = randomBytes(KEY_BYTES);
|
|
36
|
+
// The mode is set at creation, not after: a `chmod` afterwards leaves a window
|
|
37
|
+
// during which the key exists and is world-readable.
|
|
38
|
+
await writeFile(path, key, { mode: 0o600 });
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { scrub } from "../logs/redact.js";
|
|
2
|
+
import { decrypt, encrypt } from "./cipher.js";
|
|
3
|
+
import { loadOrCreateKey } from "./key.js";
|
|
4
|
+
/**
|
|
5
|
+
* The only component that ever holds a decrypted secret.
|
|
6
|
+
*
|
|
7
|
+
* Everything else — the store, the API, the interface — deals in names and
|
|
8
|
+
* ciphertext. Keeping plaintext to one small file is what makes "a secret never
|
|
9
|
+
* reaches a log" a claim you can check by reading, rather than a hope.
|
|
10
|
+
*/
|
|
11
|
+
export class Vault {
|
|
12
|
+
key;
|
|
13
|
+
store;
|
|
14
|
+
constructor(key, store) {
|
|
15
|
+
this.key = key;
|
|
16
|
+
this.store = store;
|
|
17
|
+
}
|
|
18
|
+
static async open(home, store) {
|
|
19
|
+
return new Vault(await loadOrCreateKey(home), store);
|
|
20
|
+
}
|
|
21
|
+
async set(projectSlug, key, value, masked) {
|
|
22
|
+
this.store.set(projectSlug, key, encrypt(value, this.key), masked);
|
|
23
|
+
}
|
|
24
|
+
remove(projectSlug, key) {
|
|
25
|
+
return this.store.remove(projectSlug, key);
|
|
26
|
+
}
|
|
27
|
+
list(projectSlug) {
|
|
28
|
+
return this.store.list(projectSlug);
|
|
29
|
+
}
|
|
30
|
+
listGlobal() {
|
|
31
|
+
return this.store.listGlobal();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Removes this project's secret values from a piece of text, in one shot.
|
|
35
|
+
*
|
|
36
|
+
* Separate from `Redactor`, which is stateful and belongs to a live stream:
|
|
37
|
+
* reusing that instance here would corrupt its buffer mid-run.
|
|
38
|
+
*/
|
|
39
|
+
scrub(projectSlug, text) {
|
|
40
|
+
return scrub(text, this.maskedValues(projectSlug));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Every secret that applies to a project, ready to become environment variables.
|
|
44
|
+
*
|
|
45
|
+
* A row that will not decrypt is skipped rather than thrown: a key that was
|
|
46
|
+
* rotated or a corrupted row should cost one variable, not the whole build.
|
|
47
|
+
* The run then fails on its own terms, with fastlane saying what was missing.
|
|
48
|
+
*/
|
|
49
|
+
resolve(projectSlug) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [key, payload] of Object.entries(this.store.encrypted(projectSlug))) {
|
|
52
|
+
try {
|
|
53
|
+
out[key] = decrypt(payload, this.key);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Deliberately silent here; the interface reports unreadable secrets.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** The values a run's output must not contain. */
|
|
62
|
+
maskedValues(projectSlug) {
|
|
63
|
+
const masked = this.store.maskedKeys(projectSlug);
|
|
64
|
+
return Object.entries(this.resolve(projectSlug))
|
|
65
|
+
.filter(([key]) => masked.has(key))
|
|
66
|
+
.map(([, value]) => value);
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -7,9 +7,14 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { RunStore } from "../db/runs.js";
|
|
8
8
|
import { Workspace } from "../git/workspace.js";
|
|
9
9
|
import { LogStore } from "../logs/store.js";
|
|
10
|
+
import { executeRun } from "../runner/orchestrate.js";
|
|
11
|
+
import { RunQueue } from "../runner/queue.js";
|
|
10
12
|
import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
|
|
13
|
+
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
11
14
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
15
|
+
import { registerReadinessRoutes } from "./routes/readiness.js";
|
|
12
16
|
import { registerRunRoutes } from "./routes/runs.js";
|
|
17
|
+
import { registerSecretRoutes } from "./routes/secrets.js";
|
|
13
18
|
import { registerWebSocket } from "./ws.js";
|
|
14
19
|
export async function buildApp(deps) {
|
|
15
20
|
const app = Fastify({ logger: false });
|
|
@@ -29,6 +34,68 @@ export async function buildApp(deps) {
|
|
|
29
34
|
await new Workspace(workspacePath(slug), entry.git_url, entry.git_auth).ensureCloned();
|
|
30
35
|
},
|
|
31
36
|
};
|
|
37
|
+
// The queue is assembled here rather than in `createServerFromConfig`: its job
|
|
38
|
+
// needs `runs`, `logs`, the workspace and artifact paths and the sockets, none
|
|
39
|
+
// of which exist before `ctx` does. It is assigned after the context literal
|
|
40
|
+
// because the job it drives closes over that very context — hence the field
|
|
41
|
+
// being optional in the type rather than a cast pretending it is already set.
|
|
42
|
+
ctx.queue = new RunQueue(ctx.runs, async (runId, signal) => {
|
|
43
|
+
const run = ctx.runs.get(runId);
|
|
44
|
+
if (!run)
|
|
45
|
+
return;
|
|
46
|
+
const slug = run.projectSlug;
|
|
47
|
+
const entry = deps.config.project(slug);
|
|
48
|
+
if (!entry) {
|
|
49
|
+
// The project was removed from config.yml while this run waited. Ending it
|
|
50
|
+
// here is what keeps the queue from re-reading the same row for ever.
|
|
51
|
+
ctx.runs.finish(runId, {
|
|
52
|
+
status: "failed",
|
|
53
|
+
exitCode: null,
|
|
54
|
+
errorSummary: `Project "${slug}" is no longer in the configuration`,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await executeRun({
|
|
59
|
+
runId,
|
|
60
|
+
runs: ctx.runs,
|
|
61
|
+
logs: ctx.logs,
|
|
62
|
+
workspacePath: ctx.workspacePath(slug),
|
|
63
|
+
artifactsDir: ctx.artifactsDir(runId),
|
|
64
|
+
gitUrl: entry.git_url,
|
|
65
|
+
gitAuth: entry.git_auth,
|
|
66
|
+
branch: entry.default_branch,
|
|
67
|
+
// Resolved after the clone, once the repository's laneyard.yml is finally readable.
|
|
68
|
+
resolveSettings: async () => {
|
|
69
|
+
const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
70
|
+
return r.settings;
|
|
71
|
+
},
|
|
72
|
+
env: process.env,
|
|
73
|
+
secrets: ctx.vault.resolve(slug),
|
|
74
|
+
maskedValues: ctx.vault.maskedValues(slug),
|
|
75
|
+
signal,
|
|
76
|
+
onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
|
|
77
|
+
})
|
|
78
|
+
.then((r) => ctx.sockets?.finish(runId, r.status))
|
|
79
|
+
.catch((cause) => {
|
|
80
|
+
// Last safety net. `executeRun` commits to never throwing, but the queue
|
|
81
|
+
// cannot afford to depend on that: a run left neither finished nor
|
|
82
|
+
// failed is a row the interface polls until someone gives up.
|
|
83
|
+
ctx.runs.finish(runId, {
|
|
84
|
+
status: "failed",
|
|
85
|
+
exitCode: null,
|
|
86
|
+
errorSummary: ctx.vault.scrub(slug, `Unexpected failure: ${cause.message}`),
|
|
87
|
+
});
|
|
88
|
+
ctx.sockets?.finish(runId, "failed");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
// `ctx.queue` is assigned immediately above; naming it here rather than
|
|
92
|
+
// reaching through the optional field is what lets every route treat the
|
|
93
|
+
// queue as simply present.
|
|
94
|
+
const queue = ctx.queue;
|
|
95
|
+
app.decorate("queue", queue);
|
|
96
|
+
// A closed server must stop taking new work: the run in flight is left to
|
|
97
|
+
// finish, but nothing behind it starts on a server nobody is listening to.
|
|
98
|
+
app.addHook("onClose", async () => queue.close());
|
|
32
99
|
const throttle = new LoginThrottle();
|
|
33
100
|
app.post("/api/login", async (req, reply) => {
|
|
34
101
|
const { password } = req.body;
|
|
@@ -61,6 +128,9 @@ export async function buildApp(deps) {
|
|
|
61
128
|
ctx.sockets = await registerWebSocket(app, ctx);
|
|
62
129
|
await registerProjectRoutes(app, ctx);
|
|
63
130
|
await registerRunRoutes(app, ctx);
|
|
131
|
+
await registerSecretRoutes(app, ctx);
|
|
132
|
+
await registerReadinessRoutes(app, ctx);
|
|
133
|
+
await registerFastfileRoutes(app, ctx);
|
|
64
134
|
// Resolved from the module's location, not from the data folder:
|
|
65
135
|
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
66
136
|
// relative positions, depending on whether we're running on the sources
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { FastfileStore } from "../../fastfile/store.js";
|
|
3
|
+
import { Workspace } from "../../git/workspace.js";
|
|
4
|
+
export async function registerFastfileRoutes(app, ctx) {
|
|
5
|
+
// Stateless: the previous content it keeps in memory during a write lives
|
|
6
|
+
// on the call stack, not on the instance, so sharing one across requests
|
|
7
|
+
// costs nothing and mirrors how the other routes reuse a single `Workspace`.
|
|
8
|
+
const store = new FastfileStore();
|
|
9
|
+
/**
|
|
10
|
+
* Common preamble: the project must exist and its clone must be present —
|
|
11
|
+
* the Fastfile, like the lane list, lives in the repository. Sends the
|
|
12
|
+
* response itself and returns null on failure so callers can bail out with
|
|
13
|
+
* a single early return.
|
|
14
|
+
*/
|
|
15
|
+
const ready = async (slug, reply) => {
|
|
16
|
+
const entry = ctx.config.project(slug);
|
|
17
|
+
if (!entry) {
|
|
18
|
+
reply.code(404).send({ error: "Unknown project" });
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await ctx.ensureWorkspace(slug);
|
|
23
|
+
}
|
|
24
|
+
catch (cause) {
|
|
25
|
+
reply.code(503).send({ error: cause.message });
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const workspacePath = ctx.workspacePath(slug);
|
|
29
|
+
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
30
|
+
return {
|
|
31
|
+
workspacePath,
|
|
32
|
+
fastlaneDir: resolved.settings.fastlane_dir,
|
|
33
|
+
workspace: new Workspace(workspacePath, entry.git_url, entry.git_auth),
|
|
34
|
+
defaultBranch: entry.default_branch,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
app.get("/api/projects/:slug/fastfile", async (req, reply) => {
|
|
38
|
+
const { slug } = req.params;
|
|
39
|
+
const r = await ready(slug, reply);
|
|
40
|
+
if (!r)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const [content, dirty, diff] = await Promise.all([
|
|
44
|
+
store.read(r.workspacePath, r.fastlaneDir),
|
|
45
|
+
r.workspace.isDirty(),
|
|
46
|
+
r.workspace.diff(join(r.fastlaneDir, "Fastfile")),
|
|
47
|
+
]);
|
|
48
|
+
return { content, dirty, diff };
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
// Unreadable Fastfile — deleted by hand, say — is the same kind of
|
|
52
|
+
// "could not tell" as an unreadable lane list elsewhere in the API.
|
|
53
|
+
return reply.code(503).send({ error: cause.message });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
app.put("/api/projects/:slug/fastfile", async (req, reply) => {
|
|
57
|
+
const { slug } = req.params;
|
|
58
|
+
const { content } = (req.body ?? {});
|
|
59
|
+
if (typeof content !== "string") {
|
|
60
|
+
return reply.code(400).send({ error: "content is required" });
|
|
61
|
+
}
|
|
62
|
+
// The one refusal in this file, and not a heuristic: a run in flight is
|
|
63
|
+
// reading the very file this write would replace, the same reason
|
|
64
|
+
// `Workspace.prepare` refuses to touch a dirty workspace.
|
|
65
|
+
if (ctx.runs.hasActiveRun(slug)) {
|
|
66
|
+
return reply.code(409).send({
|
|
67
|
+
error: "A run is in progress for this project. Wait for it to finish before editing the Fastfile.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const r = await ready(slug, reply);
|
|
71
|
+
if (!r)
|
|
72
|
+
return;
|
|
73
|
+
// Asks the sidecar for the lanes: that parses the file and lists what it
|
|
74
|
+
// found, which is exactly the two things that matter here — it still
|
|
75
|
+
// parses, and the lanes are still there. The introspection cache is keyed
|
|
76
|
+
// on a hash of the whole fastlane folder, so the changed content here is
|
|
77
|
+
// what makes the next read of the lane list fresh — no separate
|
|
78
|
+
// invalidation step needed.
|
|
79
|
+
const verify = async () => {
|
|
80
|
+
try {
|
|
81
|
+
await ctx.lanes(slug, r.workspacePath, r.fastlaneDir);
|
|
82
|
+
return { ok: true };
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
return { ok: false, reason: cause.message };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const result = await store.write(r.workspacePath, content, verify, r.fastlaneDir);
|
|
89
|
+
if (!result.ok)
|
|
90
|
+
return reply.code(400).send({ error: result.reason });
|
|
91
|
+
return reply.code(204).send();
|
|
92
|
+
});
|
|
93
|
+
app.get("/api/projects/:slug/changes", async (req, reply) => {
|
|
94
|
+
const { slug } = req.params;
|
|
95
|
+
const r = await ready(slug, reply);
|
|
96
|
+
if (!r)
|
|
97
|
+
return;
|
|
98
|
+
const [files, diff] = await Promise.all([r.workspace.status(), r.workspace.diff()]);
|
|
99
|
+
return { files, diff };
|
|
100
|
+
});
|
|
101
|
+
app.post("/api/projects/:slug/commit", async (req, reply) => {
|
|
102
|
+
const { slug } = req.params;
|
|
103
|
+
const { message } = (req.body ?? {});
|
|
104
|
+
if (!message)
|
|
105
|
+
return reply.code(400).send({ error: "A commit message is required" });
|
|
106
|
+
const r = await ready(slug, reply);
|
|
107
|
+
if (!r)
|
|
108
|
+
return;
|
|
109
|
+
// Exactly what changed, never `git add -A`: a build leaves files
|
|
110
|
+
// scattered in the workspace, and this is the one place that must not
|
|
111
|
+
// scoop them up because they happened to be there.
|
|
112
|
+
const files = await r.workspace.status();
|
|
113
|
+
if (files.length === 0)
|
|
114
|
+
return reply.code(400).send({ error: "Nothing to commit" });
|
|
115
|
+
await r.workspace.commit(message, files);
|
|
116
|
+
return reply.code(204).send();
|
|
117
|
+
});
|
|
118
|
+
app.post("/api/projects/:slug/push", async (req, reply) => {
|
|
119
|
+
const { slug } = req.params;
|
|
120
|
+
const r = await ready(slug, reply);
|
|
121
|
+
if (!r)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
await r.workspace.push(r.defaultBranch);
|
|
125
|
+
return reply.code(204).send();
|
|
126
|
+
}
|
|
127
|
+
catch (cause) {
|
|
128
|
+
return reply.code(400).send({ error: cause.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -30,6 +30,13 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
30
30
|
const { slug } = req.params;
|
|
31
31
|
if (!ctx.config.project(slug))
|
|
32
32
|
return reply.code(404).send({ error: "Unknown project" });
|
|
33
|
-
|
|
33
|
+
// The whole line is read once and the positions are looked up in it, rather
|
|
34
|
+
// than asking the database where each of fifty runs stands. The position is
|
|
35
|
+
// the global one: the queue is shared by every project.
|
|
36
|
+
const line = ctx.runs.queued().map((r) => r.id);
|
|
37
|
+
return ctx.runs.listByProject(slug).map((run) => {
|
|
38
|
+
const at = line.indexOf(run.id);
|
|
39
|
+
return { ...run, queuePosition: at === -1 ? null : at + 1 };
|
|
40
|
+
});
|
|
34
41
|
});
|
|
35
42
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { Workspace } from "../../git/workspace.js";
|
|
6
|
+
import { runChecks } from "../../heuristics/readiness.js";
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* `bundle check` in the workspace, rejecting with what bundler said.
|
|
10
|
+
*
|
|
11
|
+
* Installs nothing: the checklist reports, it does not act. The timeout is
|
|
12
|
+
* generous because bundler resolves the whole Gemfile.lock, and short enough
|
|
13
|
+
* that a wedged bundler does not hold the request open.
|
|
14
|
+
*/
|
|
15
|
+
async function bundleCheck(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await exec("bundle", ["check"], { cwd, timeout: 60_000 });
|
|
18
|
+
return stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
const err = cause;
|
|
22
|
+
throw new Error((err.stdout || err.stderr || err.message).trim());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The path of a `fastlane` a run would find, or null. */
|
|
26
|
+
async function findFastlane() {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await exec("which", ["fastlane"], { timeout: 5_000 });
|
|
29
|
+
return stdout.trim() || null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A non-zero exit from `which` is the answer "no", not a failure.
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const exists = async (path) => access(path).then(() => true, () => false);
|
|
37
|
+
export async function registerReadinessRoutes(app, ctx) {
|
|
38
|
+
/**
|
|
39
|
+
* Computed only when asked for.
|
|
40
|
+
*
|
|
41
|
+
* These checks shell out to git and to bundler, so nothing else in the
|
|
42
|
+
* interface may trigger them: the tab asks when it is opened, and when the
|
|
43
|
+
* user presses refresh. Nothing here is cached either — a stale green tick is
|
|
44
|
+
* worse than a red cross, which is why the answer carries the time it was
|
|
45
|
+
* produced.
|
|
46
|
+
*/
|
|
47
|
+
app.get("/api/projects/:slug/readiness", async (req, reply) => {
|
|
48
|
+
const { slug } = req.params;
|
|
49
|
+
const entry = ctx.config.project(slug);
|
|
50
|
+
if (!entry)
|
|
51
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
52
|
+
const workspacePath = ctx.workspacePath(slug);
|
|
53
|
+
const workspace = new Workspace(workspacePath, entry.git_url, entry.git_auth);
|
|
54
|
+
// What each lane calls lives in the repository, so the clone has to exist.
|
|
55
|
+
// A clone that fails is not an error page: it is the reason two of the five
|
|
56
|
+
// checks cannot answer, and the other three still can.
|
|
57
|
+
let unreachable = null;
|
|
58
|
+
try {
|
|
59
|
+
await ctx.ensureWorkspace(slug);
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
unreachable = cause.message;
|
|
63
|
+
}
|
|
64
|
+
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
65
|
+
const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
|
|
66
|
+
const uses = unreachable !== null
|
|
67
|
+
? { ok: false, reason: unreachable }
|
|
68
|
+
: await ctx
|
|
69
|
+
.uses(slug, workspacePath, fastlaneDir)
|
|
70
|
+
.then((lanes) => ({ ok: true, value: lanes }))
|
|
71
|
+
// Broken Fastfile, no Ruby, no fastlane: all of them are "could not
|
|
72
|
+
// tell", none of them is a 500.
|
|
73
|
+
.catch((cause) => ({ ok: false, reason: cause.message }));
|
|
74
|
+
const checks = await runChecks({
|
|
75
|
+
probeRepository: () => workspace.probeRemote(),
|
|
76
|
+
dependencies: {
|
|
77
|
+
workspace: unreachable !== null
|
|
78
|
+
? { ok: false, reason: unreachable }
|
|
79
|
+
: { ok: true, value: { hasGemfile: await exists(join(workspacePath, "Gemfile")) } },
|
|
80
|
+
bundleCheck: () => bundleCheck(workspacePath),
|
|
81
|
+
findFastlane,
|
|
82
|
+
},
|
|
83
|
+
// Names only: the vault never hands a value to anything but a run.
|
|
84
|
+
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
85
|
+
uses,
|
|
86
|
+
});
|
|
87
|
+
return { checkedAt: new Date().toISOString(), checks };
|
|
88
|
+
});
|
|
89
|
+
}
|