laneyard 0.1.0 → 0.3.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 +238 -32
- package/dist/src/cli/detect.js +60 -16
- package/dist/src/cli/prompt.js +36 -0
- package/dist/src/cli/secret.js +133 -0
- package/dist/src/cli/setup.js +282 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +47 -4
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- 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 +65 -0
- package/dist/src/heuristics/platforms.js +77 -0
- package/dist/src/heuristics/readiness.js +336 -0
- package/dist/src/logs/redact.js +86 -0
- package/dist/src/main.js +79 -9
- 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 +135 -13
- package/dist/src/server/auth.js +85 -17
- package/dist/src/server/permissions.js +73 -0
- package/dist/src/server/routes/fastfile.js +131 -0
- package/dist/src/server/routes/projects.js +50 -1
- package/dist/src/server/routes/readiness.js +102 -0
- package/dist/src/server/routes/runs.js +27 -45
- package/dist/src/server/routes/secrets.js +51 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +17 -1
- package/dist/src/sidecar/lanes.js +2 -2
- package/dist/src/sidecar/uses.js +40 -0
- package/dist/web/assets/Editor-DNFBA4gv.js +14 -0
- package/dist/web/assets/index-De6sxx6G.css +1 -0
- package/dist/web/assets/index-TWRQ1cJg.js +62 -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
|
@@ -2,6 +2,7 @@ import { rm } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { Workspace } from "../git/workspace.js";
|
|
4
4
|
import { summarizeFailure } from "../heuristics/error-summary.js";
|
|
5
|
+
import { Redactor, scrub } from "../logs/redact.js";
|
|
5
6
|
import { collectArtifacts } from "./artifacts.js";
|
|
6
7
|
import { LiveStepTracker } from "./live-steps.js";
|
|
7
8
|
import { startPty } from "./pty.js";
|
|
@@ -17,19 +18,47 @@ export async function executeRun(opts) {
|
|
|
17
18
|
const { runId, runs, logs } = opts;
|
|
18
19
|
const writer = await logs.open(runId);
|
|
19
20
|
const tracker = new LiveStepTracker();
|
|
21
|
+
const redactor = new Redactor(opts.maskedValues ?? []);
|
|
22
|
+
// Redaction happens here and nowhere else: this is the single point through
|
|
23
|
+
// which every byte of output passes on its way to the file, the step tracker
|
|
24
|
+
// and the browser. Filtering further downstream would mean filtering three
|
|
25
|
+
// times, and forgetting one of them eventually.
|
|
20
26
|
const emit = async (text) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
const safe = redactor.push(text);
|
|
28
|
+
if (safe === "")
|
|
29
|
+
return;
|
|
30
|
+
const offset = await writer.append(safe);
|
|
31
|
+
tracker.consume(safe, offset);
|
|
32
|
+
opts.onChunk(safe, offset);
|
|
24
33
|
};
|
|
25
|
-
const
|
|
34
|
+
const emitRest = async () => {
|
|
35
|
+
const rest = redactor.flush();
|
|
36
|
+
if (rest === "")
|
|
37
|
+
return;
|
|
38
|
+
const offset = await writer.append(rest);
|
|
39
|
+
tracker.consume(rest, offset);
|
|
40
|
+
opts.onChunk(rest, offset);
|
|
41
|
+
};
|
|
42
|
+
// The error summary is stored in the database and rendered in the interface
|
|
43
|
+
// without passing through the stream above, so it needs its own, one-shot pass.
|
|
44
|
+
const hide = (text) => scrub(text, opts.maskedValues ?? []);
|
|
45
|
+
// Shared by every early exit, so flushing and closing the log writer cannot
|
|
46
|
+
// drift between the failure path and the two cancellation checkpoints below.
|
|
47
|
+
const finishAs = async (status, message) => {
|
|
26
48
|
await emit(`\n${message}\n`);
|
|
49
|
+
await emitRest();
|
|
27
50
|
await writer.close();
|
|
28
|
-
runs.finish(runId, { status
|
|
29
|
-
return { status
|
|
51
|
+
runs.finish(runId, { status, exitCode: null, errorSummary: hide(message) });
|
|
52
|
+
return { status };
|
|
30
53
|
};
|
|
54
|
+
const fail = (message) => finishAs("failed", message);
|
|
31
55
|
// --- Preparation --------------------------------------------------------
|
|
32
56
|
runs.setStatus(runId, "preparing");
|
|
57
|
+
// Cancelled before it even started: no point cloning or fetching a
|
|
58
|
+
// workspace nobody wants built anymore.
|
|
59
|
+
if (opts.signal?.aborted) {
|
|
60
|
+
return finishAs("cancelled", "Cancelled");
|
|
61
|
+
}
|
|
33
62
|
const workspace = new Workspace(opts.workspacePath, opts.gitUrl, opts.gitAuth);
|
|
34
63
|
let commitSha;
|
|
35
64
|
try {
|
|
@@ -50,6 +79,10 @@ export async function executeRun(opts) {
|
|
|
50
79
|
catch (cause) {
|
|
51
80
|
return fail(`Unreadable project settings: ${cause.message}`);
|
|
52
81
|
}
|
|
82
|
+
// Cancelled during preparation: fastlane never gets to start.
|
|
83
|
+
if (opts.signal?.aborted) {
|
|
84
|
+
return finishAs("cancelled", "Cancelled");
|
|
85
|
+
}
|
|
53
86
|
// --- Execution -----------------------------------------------------------
|
|
54
87
|
const useBundle = settings.runtime === "bundle";
|
|
55
88
|
const reportPath = join(opts.workspacePath, settings.fastlane_dir, "report.xml");
|
|
@@ -65,16 +98,20 @@ export async function executeRun(opts) {
|
|
|
65
98
|
cwd: opts.workspacePath,
|
|
66
99
|
env: {
|
|
67
100
|
...opts.env,
|
|
68
|
-
|
|
101
|
+
...(opts.secrets ?? {}),
|
|
102
|
+
// Order matters: secrets come after opts.env so a stored secret wins over
|
|
103
|
+
// a variable that happens to exist in the server's own environment, and
|
|
104
|
+
// before these three fixed variables so no secret can override CI.
|
|
69
105
|
CI: "true",
|
|
70
106
|
FASTLANE_SKIP_UPDATE_CHECK: "1",
|
|
71
107
|
FORCE_COLOR: "1",
|
|
72
108
|
},
|
|
73
109
|
onData: (chunk) => void emit(chunk),
|
|
74
110
|
timeoutMs: settings.timeout_minutes * 60_000,
|
|
111
|
+
signal: opts.signal,
|
|
75
112
|
});
|
|
76
113
|
const outcome = await done;
|
|
77
|
-
await
|
|
114
|
+
await emitRest();
|
|
78
115
|
// --- Timeline -------------------------------------------------------------
|
|
79
116
|
// Everything that follows is after-sales service: the timeline and the
|
|
80
117
|
// artifacts embellish a run that's already finished. A database that
|
|
@@ -86,14 +123,27 @@ export async function executeRun(opts) {
|
|
|
86
123
|
}
|
|
87
124
|
catch (cause) {
|
|
88
125
|
await emit(`\nIncomplete timeline or artifacts: ${cause.message}\n`);
|
|
126
|
+
await emitRest();
|
|
89
127
|
}
|
|
128
|
+
await writer.close();
|
|
90
129
|
if (outcome.exitCode === 0 && !outcome.timedOut) {
|
|
91
130
|
runs.finish(runId, { status: "success", exitCode: 0, errorSummary: null });
|
|
92
131
|
return { status: "success" };
|
|
93
132
|
}
|
|
133
|
+
if (opts.signal?.aborted) {
|
|
134
|
+
// Checked ahead of the failure case: the SIGINT/SIGKILL escalation leaves
|
|
135
|
+
// fastlane with a nonzero exit code, which would otherwise read as a crash
|
|
136
|
+
// rather than the cancellation it actually was.
|
|
137
|
+
runs.finish(runId, {
|
|
138
|
+
status: "cancelled",
|
|
139
|
+
exitCode: outcome.exitCode,
|
|
140
|
+
errorSummary: "Cancelled",
|
|
141
|
+
});
|
|
142
|
+
return { status: "cancelled" };
|
|
143
|
+
}
|
|
94
144
|
const summary = outcome.timedOut
|
|
95
|
-
? `Run interrupted after ${settings.timeout_minutes} minutes`
|
|
96
|
-
: summarizeFailure(await logs.read(runId), outcome.exitCode);
|
|
145
|
+
? hide(`Run interrupted after ${settings.timeout_minutes} minutes`)
|
|
146
|
+
: hide(summarizeFailure(await logs.read(runId), outcome.exitCode));
|
|
97
147
|
runs.finish(runId, { status: "failed", exitCode: outcome.exitCode, errorSummary: summary });
|
|
98
148
|
return { status: "failed" };
|
|
99
149
|
async function recordOutcome() {
|
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
|
@@ -4,16 +4,27 @@ import Fastify from "fastify";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
7
8
|
import { RunStore } from "../db/runs.js";
|
|
8
9
|
import { Workspace } from "../git/workspace.js";
|
|
9
10
|
import { LogStore } from "../logs/store.js";
|
|
10
|
-
import {
|
|
11
|
+
import { executeRun } from "../runner/orchestrate.js";
|
|
12
|
+
import { RunQueue } from "../runner/queue.js";
|
|
13
|
+
import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
14
|
+
import { requiresAdmin } from "./permissions.js";
|
|
15
|
+
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
11
16
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
17
|
+
import { registerReadinessRoutes } from "./routes/readiness.js";
|
|
12
18
|
import { registerRunRoutes } from "./routes/runs.js";
|
|
19
|
+
import { registerSecretRoutes } from "./routes/secrets.js";
|
|
20
|
+
import { registerUserRoutes } from "./routes/users.js";
|
|
13
21
|
import { registerWebSocket } from "./ws.js";
|
|
14
22
|
export async function buildApp(deps) {
|
|
15
23
|
const app = Fastify({ logger: false });
|
|
16
24
|
await app.register(cookie);
|
|
25
|
+
// Declared up front so every request carries the field on the same shape,
|
|
26
|
+
// rather than each one growing a property the first time a hook writes it.
|
|
27
|
+
app.decorateRequest("identity", undefined);
|
|
17
28
|
const workspacePath = (slug) => join(deps.root, "workspaces", slug);
|
|
18
29
|
const ctx = {
|
|
19
30
|
...deps,
|
|
@@ -29,38 +40,149 @@ export async function buildApp(deps) {
|
|
|
29
40
|
await new Workspace(workspacePath(slug), entry.git_url, entry.git_auth).ensureCloned();
|
|
30
41
|
},
|
|
31
42
|
};
|
|
43
|
+
// The queue is assembled here rather than in `createServerFromConfig`: its job
|
|
44
|
+
// needs `runs`, `logs`, the workspace and artifact paths and the sockets, none
|
|
45
|
+
// of which exist before `ctx` does. It is assigned after the context literal
|
|
46
|
+
// because the job it drives closes over that very context — hence the field
|
|
47
|
+
// being optional in the type rather than a cast pretending it is already set.
|
|
48
|
+
ctx.queue = new RunQueue(ctx.runs, async (runId, signal) => {
|
|
49
|
+
const run = ctx.runs.get(runId);
|
|
50
|
+
if (!run)
|
|
51
|
+
return;
|
|
52
|
+
const slug = run.projectSlug;
|
|
53
|
+
const entry = deps.config.project(slug);
|
|
54
|
+
if (!entry) {
|
|
55
|
+
// The project was removed from config.yml while this run waited. Ending it
|
|
56
|
+
// here is what keeps the queue from re-reading the same row for ever.
|
|
57
|
+
ctx.runs.finish(runId, {
|
|
58
|
+
status: "failed",
|
|
59
|
+
exitCode: null,
|
|
60
|
+
errorSummary: `Project "${slug}" is no longer in the configuration`,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await executeRun({
|
|
65
|
+
runId,
|
|
66
|
+
runs: ctx.runs,
|
|
67
|
+
logs: ctx.logs,
|
|
68
|
+
workspacePath: ctx.workspacePath(slug),
|
|
69
|
+
artifactsDir: ctx.artifactsDir(runId),
|
|
70
|
+
gitUrl: entry.git_url,
|
|
71
|
+
gitAuth: entry.git_auth,
|
|
72
|
+
branch: entry.default_branch,
|
|
73
|
+
// Resolved after the clone, once the repository's laneyard.yml is finally readable.
|
|
74
|
+
resolveSettings: async () => {
|
|
75
|
+
const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
76
|
+
return r.settings;
|
|
77
|
+
},
|
|
78
|
+
env: process.env,
|
|
79
|
+
secrets: ctx.vault.resolve(slug),
|
|
80
|
+
maskedValues: ctx.vault.maskedValues(slug),
|
|
81
|
+
signal,
|
|
82
|
+
onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
|
|
83
|
+
})
|
|
84
|
+
.then((r) => ctx.sockets?.finish(runId, r.status))
|
|
85
|
+
.catch((cause) => {
|
|
86
|
+
// Last safety net. `executeRun` commits to never throwing, but the queue
|
|
87
|
+
// cannot afford to depend on that: a run left neither finished nor
|
|
88
|
+
// failed is a row the interface polls until someone gives up.
|
|
89
|
+
ctx.runs.finish(runId, {
|
|
90
|
+
status: "failed",
|
|
91
|
+
exitCode: null,
|
|
92
|
+
errorSummary: ctx.vault.scrub(slug, `Unexpected failure: ${cause.message}`),
|
|
93
|
+
});
|
|
94
|
+
ctx.sockets?.finish(runId, "failed");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// `ctx.queue` is assigned immediately above; naming it here rather than
|
|
98
|
+
// reaching through the optional field is what lets every route treat the
|
|
99
|
+
// queue as simply present.
|
|
100
|
+
const queue = ctx.queue;
|
|
101
|
+
app.decorate("queue", queue);
|
|
102
|
+
// A closed server must stop taking new work: the run in flight is left to
|
|
103
|
+
// finish, but nothing behind it starts on a server nobody is listening to.
|
|
104
|
+
app.addHook("onClose", async () => queue.close());
|
|
32
105
|
const throttle = new LoginThrottle();
|
|
33
106
|
app.post("/api/login", async (req, reply) => {
|
|
34
|
-
const { password } = req.body;
|
|
35
|
-
|
|
36
|
-
|
|
107
|
+
const { name, password } = (req.body ?? {});
|
|
108
|
+
// A body with no name is the 0.2 login form, which knew only a password.
|
|
109
|
+
// It authenticates as `admin`, which is precisely the account a lone
|
|
110
|
+
// `server.password_hash` is loaded as — so an upgraded install keeps
|
|
111
|
+
// working, and the loader still owes nobody an answer about which form
|
|
112
|
+
// the file used.
|
|
113
|
+
const account = name ?? LEGACY_ADMIN_NAME;
|
|
114
|
+
const waitMs = throttle.retryAfterMs(account);
|
|
37
115
|
if (waitMs > 0) {
|
|
38
116
|
return reply
|
|
39
117
|
.code(429)
|
|
40
118
|
.header("retry-after", Math.ceil(waitMs / 1000))
|
|
41
119
|
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
42
120
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
121
|
+
const users = deps.config.server()?.users ?? [];
|
|
122
|
+
const identity = password ? await authenticate(users, account, password) : null;
|
|
123
|
+
if (!identity) {
|
|
124
|
+
throttle.recordFailure(account);
|
|
125
|
+
// One message for a wrong password and for a name that does not exist:
|
|
126
|
+
// telling them apart is telling a stranger which accounts are worth
|
|
127
|
+
// attacking.
|
|
128
|
+
return reply.code(401).send({ error: "Incorrect name or password" });
|
|
46
129
|
}
|
|
47
|
-
throttle.recordSuccess();
|
|
48
|
-
const token = ctx.sessions.issue();
|
|
130
|
+
throttle.recordSuccess(account);
|
|
131
|
+
const token = ctx.sessions.issue(identity);
|
|
49
132
|
return reply
|
|
50
133
|
.setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
|
|
51
|
-
.send({ ok: true });
|
|
134
|
+
.send({ ok: true, name: identity.name, role: identity.role });
|
|
52
135
|
});
|
|
53
|
-
// Every /api route except /api/login requires a session
|
|
136
|
+
// Every /api route except /api/login requires a session, and the routes on
|
|
137
|
+
// the admin list require an admin. Both decided here, once: a permission
|
|
138
|
+
// expressed as an `if` inside a handler is one nobody finds during an audit.
|
|
54
139
|
app.addHook("onRequest", async (req, reply) => {
|
|
55
|
-
if (!req.url.startsWith("/api") || req.url === "/api/login")
|
|
140
|
+
if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
|
|
56
141
|
return;
|
|
57
|
-
|
|
142
|
+
const session = ctx.sessions.get(req.cookies[SESSION_COOKIE]);
|
|
143
|
+
// The session holds who someone was when they signed in; the configuration
|
|
144
|
+
// holds who they are. They part company whenever config.yml is edited —
|
|
145
|
+
// from `laneyard user add`, which is another process entirely, or by hand.
|
|
146
|
+
// So the account is looked up again on every request: an account that is
|
|
147
|
+
// gone has no session, and a demotion takes effect at once rather than at
|
|
148
|
+
// the next restart. It is one find over a handful of entries.
|
|
149
|
+
const account = session && ctx.config.server()?.users.find((u) => u.name === session.name);
|
|
150
|
+
if (!session || !account) {
|
|
58
151
|
return reply.code(401).send({ error: "Session required" });
|
|
59
152
|
}
|
|
153
|
+
const identity = { name: account.name, role: account.role };
|
|
154
|
+
req.identity = identity;
|
|
155
|
+
if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
|
|
156
|
+
return reply.code(403).send({ error: "This action requires the admin role" });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* Signing out ends this session and no other.
|
|
161
|
+
*
|
|
162
|
+
* The same person may be signed in on a laptop and on a phone; pressing sign
|
|
163
|
+
* out on one of them must not be an act on the other. Only removing the
|
|
164
|
+
* account ends every session it has, and that is a different action with a
|
|
165
|
+
* different name.
|
|
166
|
+
*/
|
|
167
|
+
app.post("/api/logout", async (req, reply) => {
|
|
168
|
+
const token = req.cookies[SESSION_COOKIE];
|
|
169
|
+
if (token)
|
|
170
|
+
ctx.sessions.revoke(token);
|
|
171
|
+
return reply.clearCookie(SESSION_COOKIE, { path: "/" }).code(204).send();
|
|
172
|
+
});
|
|
173
|
+
app.get("/api/me", async (req) => {
|
|
174
|
+
// Non-null because the hook above rejected every request that has no
|
|
175
|
+
// identity before this handler could be reached.
|
|
176
|
+
const { name, role } = req.identity;
|
|
177
|
+
return { name, role };
|
|
60
178
|
});
|
|
61
179
|
ctx.sockets = await registerWebSocket(app, ctx);
|
|
62
180
|
await registerProjectRoutes(app, ctx);
|
|
63
181
|
await registerRunRoutes(app, ctx);
|
|
182
|
+
await registerSecretRoutes(app, ctx);
|
|
183
|
+
await registerReadinessRoutes(app, ctx);
|
|
184
|
+
await registerFastfileRoutes(app, ctx);
|
|
185
|
+
await registerUserRoutes(app, ctx);
|
|
64
186
|
// Resolved from the module's location, not from the data folder:
|
|
65
187
|
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
66
188
|
// relative positions, depending on whether we're running on the sources
|