laneyard 0.1.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/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/src/cli/add.js +81 -0
- package/dist/src/cli/detect.js +70 -0
- package/dist/src/config/load.js +46 -0
- package/dist/src/config/resolve.js +29 -0
- package/dist/src/config/schema.js +55 -0
- package/dist/src/config/store.js +71 -0
- package/dist/src/db/cache.js +22 -0
- package/dist/src/db/open.js +12 -0
- package/dist/src/db/runs.js +97 -0
- package/dist/src/db/schema.sql +45 -0
- package/dist/src/git/workspace.js +106 -0
- package/dist/src/heuristics/error-summary.js +43 -0
- package/dist/src/logs/store.js +66 -0
- package/dist/src/main.js +124 -0
- package/dist/src/runner/artifacts.js +59 -0
- package/dist/src/runner/live-steps.js +38 -0
- package/dist/src/runner/orchestrate.js +140 -0
- package/dist/src/runner/pty.js +83 -0
- package/dist/src/runner/report.js +86 -0
- package/dist/src/server/app.js +84 -0
- package/dist/src/server/auth.js +81 -0
- package/dist/src/server/routes/projects.js +35 -0
- package/dist/src/server/routes/runs.js +100 -0
- package/dist/src/server/ws.js +66 -0
- package/dist/src/sidecar/bridge.js +44 -0
- package/dist/src/sidecar/lanes.js +40 -0
- package/dist/src/sidecar/ruby-env.js +63 -0
- package/dist/tests/cli/add.test.js +64 -0
- package/dist/tests/cli/detect.test.js +63 -0
- package/dist/tests/config/load.test.js +68 -0
- package/dist/tests/config/resolve.test.js +44 -0
- package/dist/tests/config/store.test.js +58 -0
- package/dist/tests/db/runs.test.js +54 -0
- package/dist/tests/e2e/full-thread.test.js +65 -0
- package/dist/tests/fixtures/repos.js +30 -0
- package/dist/tests/git/workspace.test.js +66 -0
- package/dist/tests/heuristics/error-summary.test.js +31 -0
- package/dist/tests/logs/store.test.js +38 -0
- package/dist/tests/main.test.js +27 -0
- package/dist/tests/ruby/introspect.test.js +59 -0
- package/dist/tests/runner/artifacts.test.js +49 -0
- package/dist/tests/runner/live-steps.test.js +35 -0
- package/dist/tests/runner/orchestrate.test.js +124 -0
- package/dist/tests/runner/pty.test.js +53 -0
- package/dist/tests/runner/report.test.js +91 -0
- package/dist/tests/server/api.test.js +132 -0
- package/dist/tests/server/auth.test.js +53 -0
- package/dist/tests/server/ws.test.js +43 -0
- package/dist/tests/sidecar/lanes.test.js +52 -0
- package/dist/tests/sidecar/ruby-env.test.js +25 -0
- package/dist/tests/smoke.test.js +7 -0
- package/dist/web/assets/index-583x0xuo.css +1 -0
- package/dist/web/assets/index-BEpABKPS.js +61 -0
- package/dist/web/index.html +13 -0
- package/package.json +70 -0
- package/ruby/introspect.rb +87 -0
- package/scripts/fix-node-pty-permissions.mjs +26 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { Workspace } from "../git/workspace.js";
|
|
4
|
+
import { summarizeFailure } from "../heuristics/error-summary.js";
|
|
5
|
+
import { collectArtifacts } from "./artifacts.js";
|
|
6
|
+
import { LiveStepTracker } from "./live-steps.js";
|
|
7
|
+
import { startPty } from "./pty.js";
|
|
8
|
+
import { readReport } from "./report.js";
|
|
9
|
+
/**
|
|
10
|
+
* Runs a complete run through and sets its state transitions.
|
|
11
|
+
*
|
|
12
|
+
* Never throws: every error is converted into a documented `failed` run,
|
|
13
|
+
* because a run that disappears without a trace is the worst possible
|
|
14
|
+
* behaviour for a build server.
|
|
15
|
+
*/
|
|
16
|
+
export async function executeRun(opts) {
|
|
17
|
+
const { runId, runs, logs } = opts;
|
|
18
|
+
const writer = await logs.open(runId);
|
|
19
|
+
const tracker = new LiveStepTracker();
|
|
20
|
+
const emit = async (text) => {
|
|
21
|
+
const offset = await writer.append(text);
|
|
22
|
+
tracker.consume(text, offset);
|
|
23
|
+
opts.onChunk(text, offset);
|
|
24
|
+
};
|
|
25
|
+
const fail = async (message) => {
|
|
26
|
+
await emit(`\n${message}\n`);
|
|
27
|
+
await writer.close();
|
|
28
|
+
runs.finish(runId, { status: "failed", exitCode: null, errorSummary: message });
|
|
29
|
+
return { status: "failed" };
|
|
30
|
+
};
|
|
31
|
+
// --- Preparation --------------------------------------------------------
|
|
32
|
+
runs.setStatus(runId, "preparing");
|
|
33
|
+
const workspace = new Workspace(opts.workspacePath, opts.gitUrl, opts.gitAuth);
|
|
34
|
+
let commitSha;
|
|
35
|
+
try {
|
|
36
|
+
commitSha = await workspace.prepare(opts.branch, (line) => void emit(`${line}\n`));
|
|
37
|
+
}
|
|
38
|
+
catch (cause) {
|
|
39
|
+
return fail(`Could not prepare the workspace: ${cause.message}`);
|
|
40
|
+
}
|
|
41
|
+
runs.markRunning(runId, { branch: opts.branch, commitSha });
|
|
42
|
+
// The workspace finally exists: only now is the repository's laneyard.yml
|
|
43
|
+
// readable, so only now are the settings known. The resolution is guarded:
|
|
44
|
+
// the project may have disappeared from config.yml during preparation,
|
|
45
|
+
// and a run must never evaporate on an exception.
|
|
46
|
+
let settings;
|
|
47
|
+
try {
|
|
48
|
+
settings = await opts.resolveSettings();
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
return fail(`Unreadable project settings: ${cause.message}`);
|
|
52
|
+
}
|
|
53
|
+
// --- Execution -----------------------------------------------------------
|
|
54
|
+
const useBundle = settings.runtime === "bundle";
|
|
55
|
+
const reportPath = join(opts.workspacePath, settings.fastlane_dir, "report.xml");
|
|
56
|
+
// A report may still be lying around, left by a previous run that fastlane
|
|
57
|
+
// didn't have time to overwrite. Without this cleanup, a run that fails
|
|
58
|
+
// before even reaching fastlane would adopt the previous run's timeline.
|
|
59
|
+
await rm(reportPath, { force: true });
|
|
60
|
+
const { done } = startPty({
|
|
61
|
+
command: useBundle ? "bundle" : "fastlane",
|
|
62
|
+
args: useBundle
|
|
63
|
+
? ["exec", "fastlane", ...laneArgs(opts)]
|
|
64
|
+
: laneArgs(opts),
|
|
65
|
+
cwd: opts.workspacePath,
|
|
66
|
+
env: {
|
|
67
|
+
...opts.env,
|
|
68
|
+
// A non-interactive run fails fast instead of freezing on an invisible prompt.
|
|
69
|
+
CI: "true",
|
|
70
|
+
FASTLANE_SKIP_UPDATE_CHECK: "1",
|
|
71
|
+
FORCE_COLOR: "1",
|
|
72
|
+
},
|
|
73
|
+
onData: (chunk) => void emit(chunk),
|
|
74
|
+
timeoutMs: settings.timeout_minutes * 60_000,
|
|
75
|
+
});
|
|
76
|
+
const outcome = await done;
|
|
77
|
+
await writer.close();
|
|
78
|
+
// --- Timeline -------------------------------------------------------------
|
|
79
|
+
// Everything that follows is after-sales service: the timeline and the
|
|
80
|
+
// artifacts embellish a run that's already finished. A database that
|
|
81
|
+
// refuses an insert or a file that evaporates must not cost the run's
|
|
82
|
+
// verdict, nor let an exception bubble up to the server, which has no one
|
|
83
|
+
// to catch it.
|
|
84
|
+
try {
|
|
85
|
+
await recordOutcome();
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
await emit(`\nIncomplete timeline or artifacts: ${cause.message}\n`);
|
|
89
|
+
}
|
|
90
|
+
if (outcome.exitCode === 0 && !outcome.timedOut) {
|
|
91
|
+
runs.finish(runId, { status: "success", exitCode: 0, errorSummary: null });
|
|
92
|
+
return { status: "success" };
|
|
93
|
+
}
|
|
94
|
+
const summary = outcome.timedOut
|
|
95
|
+
? `Run interrupted after ${settings.timeout_minutes} minutes`
|
|
96
|
+
: summarizeFailure(await logs.read(runId), outcome.exitCode);
|
|
97
|
+
runs.finish(runId, { status: "failed", exitCode: outcome.exitCode, errorSummary: summary });
|
|
98
|
+
return { status: "failed" };
|
|
99
|
+
async function recordOutcome() {
|
|
100
|
+
const report = await readReport(reportPath);
|
|
101
|
+
const live = tracker.steps();
|
|
102
|
+
if (report) {
|
|
103
|
+
// The report is authoritative; live spotting only contributes the offsets.
|
|
104
|
+
const steps = report.map((s, i) => ({
|
|
105
|
+
idx: s.idx,
|
|
106
|
+
name: s.name,
|
|
107
|
+
durationMs: s.durationMs,
|
|
108
|
+
status: s.status,
|
|
109
|
+
logOffset: live[i]?.logOffset ?? null,
|
|
110
|
+
source: "report",
|
|
111
|
+
}));
|
|
112
|
+
runs.replaceSteps(runId, steps);
|
|
113
|
+
await rm(reportPath, { force: true });
|
|
114
|
+
}
|
|
115
|
+
else if (live.length > 0) {
|
|
116
|
+
// Cancelled, timed out, or interrupted run: we keep what was seen, flagging it.
|
|
117
|
+
runs.replaceSteps(runId, live.map((s, i) => ({
|
|
118
|
+
idx: i,
|
|
119
|
+
name: s.name,
|
|
120
|
+
durationMs: null,
|
|
121
|
+
status: "unknown",
|
|
122
|
+
logOffset: s.logOffset,
|
|
123
|
+
source: "live",
|
|
124
|
+
})));
|
|
125
|
+
}
|
|
126
|
+
// --- Artifacts ----------------------------------------------------------
|
|
127
|
+
const collected = await collectArtifacts(opts.workspacePath, settings.artifact_globs, opts.artifactsDir);
|
|
128
|
+
for (const a of collected)
|
|
129
|
+
runs.addArtifact(runId, a);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function laneArgs(opts) {
|
|
133
|
+
const run = opts.runs.get(opts.runId);
|
|
134
|
+
if (!run)
|
|
135
|
+
return [];
|
|
136
|
+
const args = run.platform ? [run.platform, run.lane] : [run.lane];
|
|
137
|
+
for (const [key, value] of Object.entries(run.params))
|
|
138
|
+
args.push(`${key}:${value}`);
|
|
139
|
+
return args;
|
|
140
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import pty from "node-pty";
|
|
2
|
+
/**
|
|
3
|
+
* Runs a command in a pseudo-terminal.
|
|
4
|
+
*
|
|
5
|
+
* The PTY serves two purposes: fastlane believes it's in a real terminal and
|
|
6
|
+
* keeps its usual display, and input remains possible if a run ever needs one.
|
|
7
|
+
*/
|
|
8
|
+
export function startPty(opts) {
|
|
9
|
+
let proc;
|
|
10
|
+
try {
|
|
11
|
+
proc = pty.spawn(opts.command, opts.args, {
|
|
12
|
+
name: "xterm-256color",
|
|
13
|
+
cols: 120,
|
|
14
|
+
rows: 40,
|
|
15
|
+
cwd: opts.cwd,
|
|
16
|
+
env: opts.env,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
// Command not found: depending on the platform, node-pty throws or returns 127.
|
|
21
|
+
// We normalize so the caller only has one case to handle.
|
|
22
|
+
opts.onData(`\nCould not launch: ${cause.message}\n`);
|
|
23
|
+
return {
|
|
24
|
+
handle: { write: () => { }, kill: () => { } },
|
|
25
|
+
done: Promise.resolve({ exitCode: 127, signal: null, timedOut: false }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let timedOut = false;
|
|
29
|
+
let timer;
|
|
30
|
+
proc.onData(opts.onData);
|
|
31
|
+
const done = new Promise((resolve) => {
|
|
32
|
+
if (opts.timeoutMs !== undefined) {
|
|
33
|
+
timer = setTimeout(() => {
|
|
34
|
+
timedOut = true;
|
|
35
|
+
// SIGINT first: fastlane cleans up after itself. SIGKILL if it keeps stalling.
|
|
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);
|
|
50
|
+
}, opts.timeoutMs);
|
|
51
|
+
}
|
|
52
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
53
|
+
if (timer)
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
// `waitpid` only reports an exit code for a normal end: a process
|
|
56
|
+
// killed by a signal leaves 0, which would pass a cancellation off
|
|
57
|
+
// as a success. We apply the shell convention, 128 + signal, so an
|
|
58
|
+
// exit code always stays interpretable.
|
|
59
|
+
const killed = signal !== undefined && signal !== 0;
|
|
60
|
+
resolve({
|
|
61
|
+
exitCode: killed && exitCode === 0 ? 128 + signal : exitCode,
|
|
62
|
+
signal: signal ?? null,
|
|
63
|
+
timedOut,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
const handle = {
|
|
68
|
+
write: (input) => proc.write(input),
|
|
69
|
+
kill: (signal = "SIGINT") => {
|
|
70
|
+
try {
|
|
71
|
+
proc.kill(signal);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* already finished */
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return { handle, done };
|
|
79
|
+
}
|
|
80
|
+
/** Blocking variant, handy for tests and short commands. */
|
|
81
|
+
export async function runInPty(opts) {
|
|
82
|
+
return startPty(opts).done;
|
|
83
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
// The self-closing branch comes first: fastlane writes successful actions
|
|
3
|
+
// as `<testcase … />` and only failed ones have a body. In the other order,
|
|
4
|
+
// `[^>]*` would swallow the final `/` and the lazy body would run up to the
|
|
5
|
+
// next `</testcase>`, merging two actions and blaming the failure on the wrong one.
|
|
6
|
+
const TESTCASE = /<testcase\b([^>]*?)\/>|<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/g;
|
|
7
|
+
// `\b` is mandatory: without it, searching for `name=` first finds the end
|
|
8
|
+
// of `classname=`, which fastlane systematically writes as the first attribute.
|
|
9
|
+
/**
|
|
10
|
+
* Decodes the XML entities of an attribute value.
|
|
11
|
+
*
|
|
12
|
+
* Essential: a `sh` action's name contains the entire command, so it
|
|
13
|
+
* readily includes a `&&` or a redirection, which the report writes as
|
|
14
|
+
* `&&` and `>`. Without decoding, the interface would display the escaping.
|
|
15
|
+
*/
|
|
16
|
+
const ENTITIES = {
|
|
17
|
+
amp: "&",
|
|
18
|
+
lt: "<",
|
|
19
|
+
gt: ">",
|
|
20
|
+
quot: '"',
|
|
21
|
+
apos: "'",
|
|
22
|
+
};
|
|
23
|
+
const decodeXml = (value) => value.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, code) => {
|
|
24
|
+
// An out-of-range code point would make String.fromCodePoint throw: we
|
|
25
|
+
// return the entity as-is rather than crash the report reading.
|
|
26
|
+
const point = code.startsWith("#x") || code.startsWith("#X")
|
|
27
|
+
? parseInt(code.slice(2), 16)
|
|
28
|
+
: code.startsWith("#")
|
|
29
|
+
? Number(code.slice(1))
|
|
30
|
+
: null;
|
|
31
|
+
if (point !== null) {
|
|
32
|
+
return Number.isInteger(point) && point >= 0 && point <= 0x10ffff
|
|
33
|
+
? String.fromCodePoint(point)
|
|
34
|
+
: whole;
|
|
35
|
+
}
|
|
36
|
+
return ENTITIES[code] ?? whole;
|
|
37
|
+
});
|
|
38
|
+
const ATTR = (source, name) => {
|
|
39
|
+
const raw = new RegExp(`\\b${name}="([^"]*)"`).exec(source)?.[1];
|
|
40
|
+
return raw === undefined ? null : decodeXml(raw);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Reads the JUnit report that fastlane writes on every run.
|
|
44
|
+
* It's the authoritative source for names, order, durations, and failures.
|
|
45
|
+
*
|
|
46
|
+
* Returns null if the report is missing or unreadable — the normal case for
|
|
47
|
+
* a cancelled, timed-out, or interrupted run, or one that failed before even
|
|
48
|
+
* reaching fastlane.
|
|
49
|
+
*/
|
|
50
|
+
export async function readReport(path) {
|
|
51
|
+
let xml;
|
|
52
|
+
try {
|
|
53
|
+
xml = await readFile(path, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!xml.includes("<testsuite"))
|
|
59
|
+
return null;
|
|
60
|
+
const steps = [];
|
|
61
|
+
for (const m of xml.matchAll(TESTCASE)) {
|
|
62
|
+
const attrs = m[1] ?? m[2] ?? "";
|
|
63
|
+
const body = m[3] ?? "";
|
|
64
|
+
const rawName = ATTR(attrs, "name");
|
|
65
|
+
if (rawName === null)
|
|
66
|
+
continue;
|
|
67
|
+
// fastlane names its cases "<index>: <action>".
|
|
68
|
+
const named = /^(\d+):\s*(.+)$/.exec(rawName);
|
|
69
|
+
const time = ATTR(attrs, "time");
|
|
70
|
+
steps.push({
|
|
71
|
+
idx: named ? Number(named[1]) : steps.length,
|
|
72
|
+
name: named ? named[2].trim() : rawName.trim(),
|
|
73
|
+
durationMs: time === null ? null : Math.round(Number(time) * 1000),
|
|
74
|
+
status: body.includes("<failure") ? "failed" : "success",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (steps.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
// The index comes from the name, not the position: nothing guarantees its
|
|
80
|
+
// uniqueness. A report with several testsuites, or mixing numbered and
|
|
81
|
+
// unnumbered cases, produces duplicates — and `run_step` has a primary key
|
|
82
|
+
// (run_id, idx). So we renumber after sorting, keeping the announced order.
|
|
83
|
+
return steps
|
|
84
|
+
.sort((a, b) => a.idx - b.idx)
|
|
85
|
+
.map((step, position) => ({ ...step, idx: position }));
|
|
86
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import cookie from "@fastify/cookie";
|
|
2
|
+
import fastifyStatic from "@fastify/static";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { RunStore } from "../db/runs.js";
|
|
8
|
+
import { Workspace } from "../git/workspace.js";
|
|
9
|
+
import { LogStore } from "../logs/store.js";
|
|
10
|
+
import { LoginThrottle, SESSION_COOKIE, SessionStore, verifyPassword } from "./auth.js";
|
|
11
|
+
import { registerProjectRoutes } from "./routes/projects.js";
|
|
12
|
+
import { registerRunRoutes } from "./routes/runs.js";
|
|
13
|
+
import { registerWebSocket } from "./ws.js";
|
|
14
|
+
export async function buildApp(deps) {
|
|
15
|
+
const app = Fastify({ logger: false });
|
|
16
|
+
await app.register(cookie);
|
|
17
|
+
const workspacePath = (slug) => join(deps.root, "workspaces", slug);
|
|
18
|
+
const ctx = {
|
|
19
|
+
...deps,
|
|
20
|
+
runs: new RunStore(deps.db),
|
|
21
|
+
logs: new LogStore(join(deps.root, "logs")),
|
|
22
|
+
sessions: new SessionStore(),
|
|
23
|
+
workspacePath,
|
|
24
|
+
artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
|
|
25
|
+
ensureWorkspace: async (slug) => {
|
|
26
|
+
const entry = deps.config.project(slug);
|
|
27
|
+
if (!entry)
|
|
28
|
+
throw new Error(`Unknown project: ${slug}`);
|
|
29
|
+
await new Workspace(workspacePath(slug), entry.git_url, entry.git_auth).ensureCloned();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const throttle = new LoginThrottle();
|
|
33
|
+
app.post("/api/login", async (req, reply) => {
|
|
34
|
+
const { password } = req.body;
|
|
35
|
+
const hash = deps.config.server()?.password_hash;
|
|
36
|
+
const waitMs = throttle.retryAfterMs();
|
|
37
|
+
if (waitMs > 0) {
|
|
38
|
+
return reply
|
|
39
|
+
.code(429)
|
|
40
|
+
.header("retry-after", Math.ceil(waitMs / 1000))
|
|
41
|
+
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
42
|
+
}
|
|
43
|
+
if (!password || !hash || !(await verifyPassword(password, hash))) {
|
|
44
|
+
throttle.recordFailure();
|
|
45
|
+
return reply.code(401).send({ error: "Incorrect password" });
|
|
46
|
+
}
|
|
47
|
+
throttle.recordSuccess();
|
|
48
|
+
const token = ctx.sessions.issue();
|
|
49
|
+
return reply
|
|
50
|
+
.setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
|
|
51
|
+
.send({ ok: true });
|
|
52
|
+
});
|
|
53
|
+
// Every /api route except /api/login requires a session.
|
|
54
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
55
|
+
if (!req.url.startsWith("/api") || req.url === "/api/login")
|
|
56
|
+
return;
|
|
57
|
+
if (!ctx.sessions.valid(req.cookies[SESSION_COOKIE])) {
|
|
58
|
+
return reply.code(401).send({ error: "Session required" });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
ctx.sockets = await registerWebSocket(app, ctx);
|
|
62
|
+
await registerProjectRoutes(app, ctx);
|
|
63
|
+
await registerRunRoutes(app, ctx);
|
|
64
|
+
// Resolved from the module's location, not from the data folder:
|
|
65
|
+
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
66
|
+
// relative positions, depending on whether we're running on the sources
|
|
67
|
+
// (`src/server`) or on the build (`dist/src/server`); both point to
|
|
68
|
+
// `dist/web`, never the source `web/` folder whose `index.html` isn't built.
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const webRoot = [
|
|
71
|
+
join(here, "..", "..", "dist", "web"),
|
|
72
|
+
join(here, "..", "..", "..", "dist", "web"),
|
|
73
|
+
].find((candidate) => existsSync(join(candidate, "index.html")));
|
|
74
|
+
if (webRoot) {
|
|
75
|
+
await app.register(fastifyStatic, { root: webRoot });
|
|
76
|
+
// Routing lives on the browser side: any unknown URL renders the app.
|
|
77
|
+
app.setNotFoundHandler((req, reply) => {
|
|
78
|
+
if (req.url.startsWith("/api"))
|
|
79
|
+
return reply.code(404).send({ error: "Unknown route" });
|
|
80
|
+
return reply.sendFile("index.html");
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return app;
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* scrypt from the standard library: no extra native dependency, and enough
|
|
4
|
+
* computational resistance for a single local password.
|
|
5
|
+
* Format: scrypt$<hex salt>$<hex key>.
|
|
6
|
+
*/
|
|
7
|
+
export function hashPassword(password) {
|
|
8
|
+
const salt = randomBytes(16);
|
|
9
|
+
const key = scryptSync(password, salt, 32);
|
|
10
|
+
return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Verifies a password without blocking the event loop.
|
|
14
|
+
*
|
|
15
|
+
* scrypt costs some thirty milliseconds per call — that's the point. But in
|
|
16
|
+
* its synchronous form, every login attempt would freeze the whole server
|
|
17
|
+
* for that long: live logs of runs in progress would stop dead. Anyone on
|
|
18
|
+
* the network could paralyze the machine with a curl loop.
|
|
19
|
+
*
|
|
20
|
+
* Never throws: a corrupted `password_hash` must refuse the login, not
|
|
21
|
+
* turn a configuration error into a 500.
|
|
22
|
+
*/
|
|
23
|
+
export async function verifyPassword(password, stored) {
|
|
24
|
+
const [scheme, saltHex, keyHex] = stored.split("$");
|
|
25
|
+
if (scheme !== "scrypt" || !saltHex || !keyHex)
|
|
26
|
+
return false;
|
|
27
|
+
const expected = Buffer.from(keyHex, "hex");
|
|
28
|
+
if (expected.length === 0)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const actual = await new Promise((resolve, reject) => {
|
|
32
|
+
scrypt(password, Buffer.from(saltHex, "hex"), expected.length, (err, key) => err ? reject(err) : resolve(key));
|
|
33
|
+
});
|
|
34
|
+
return timingSafeEqual(expected, actual);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** In-memory sessions: they don't survive a restart, and that's just fine. */
|
|
41
|
+
export class SessionStore {
|
|
42
|
+
tokens = new Set();
|
|
43
|
+
issue() {
|
|
44
|
+
const token = randomBytes(32).toString("hex");
|
|
45
|
+
this.tokens.add(token);
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
valid(token) {
|
|
49
|
+
return token !== undefined && this.tokens.has(token);
|
|
50
|
+
}
|
|
51
|
+
revoke(token) {
|
|
52
|
+
this.tokens.delete(token);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const SESSION_COOKIE = "laneyard_session";
|
|
56
|
+
/**
|
|
57
|
+
* Slows down repeated login attempts.
|
|
58
|
+
*
|
|
59
|
+
* Without this, a network neighbour could try passwords as fast as the
|
|
60
|
+
* server responds. The delay grows with failures and resets to zero on a
|
|
61
|
+
* success: the legitimate user who mistypes once doesn't feel it.
|
|
62
|
+
*/
|
|
63
|
+
export class LoginThrottle {
|
|
64
|
+
failures = 0;
|
|
65
|
+
until = 0;
|
|
66
|
+
/** Milliseconds left to wait, 0 if the way is clear. */
|
|
67
|
+
retryAfterMs(now = Date.now()) {
|
|
68
|
+
return Math.max(0, this.until - now);
|
|
69
|
+
}
|
|
70
|
+
recordFailure(now = Date.now()) {
|
|
71
|
+
this.failures += 1;
|
|
72
|
+
// 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
|
|
73
|
+
if (this.failures > 3) {
|
|
74
|
+
this.until = now + Math.min(60_000, 2 ** (this.failures - 4) * 1000);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
recordSuccess() {
|
|
78
|
+
this.failures = 0;
|
|
79
|
+
this.until = 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export async function registerProjectRoutes(app, ctx) {
|
|
2
|
+
app.get("/api/projects", async () => ctx.config.projects().map((p) => {
|
|
3
|
+
const last = ctx.runs.listByProject(p.slug, 1)[0] ?? null;
|
|
4
|
+
return {
|
|
5
|
+
slug: p.slug,
|
|
6
|
+
name: p.name,
|
|
7
|
+
color: p.color,
|
|
8
|
+
lastRun: last && { id: last.id, status: last.status, lane: last.lane, finishedAt: last.finishedAt },
|
|
9
|
+
};
|
|
10
|
+
}));
|
|
11
|
+
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
12
|
+
const { slug } = req.params;
|
|
13
|
+
const entry = ctx.config.project(slug);
|
|
14
|
+
if (!entry)
|
|
15
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
16
|
+
try {
|
|
17
|
+
// Lanes live in the repository: with no clone, there's nothing to read.
|
|
18
|
+
// A freshly declared project must be usable without launching a run blind.
|
|
19
|
+
await ctx.ensureWorkspace(slug);
|
|
20
|
+
const resolved = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
21
|
+
return await ctx.lanes(slug, ctx.workspacePath(slug), resolved.settings.fastlane_dir);
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
// Workspace not cloned yet, broken Fastfile, sidecar failure: the
|
|
25
|
+
// interface must be able to tell the user, rather than show an empty list.
|
|
26
|
+
return reply.code(503).send({ error: cause.message });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
app.get("/api/projects/:slug/runs", async (req, reply) => {
|
|
30
|
+
const { slug } = req.params;
|
|
31
|
+
if (!ctx.config.project(slug))
|
|
32
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
33
|
+
return ctx.runs.listByProject(slug);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { executeRun } from "../../runner/orchestrate.js";
|
|
3
|
+
export async function registerRunRoutes(app, ctx) {
|
|
4
|
+
app.post("/api/projects/:slug/runs", async (req, reply) => {
|
|
5
|
+
const { slug } = req.params;
|
|
6
|
+
const body = req.body;
|
|
7
|
+
const entry = ctx.config.project(slug);
|
|
8
|
+
if (!entry)
|
|
9
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
10
|
+
if (!body.lane)
|
|
11
|
+
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
|
+
// We check that the lane genuinely exists before creating a run doomed to fail.
|
|
24
|
+
try {
|
|
25
|
+
await ctx.ensureWorkspace(slug);
|
|
26
|
+
const resolved = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
27
|
+
const lanes = await ctx.lanes(slug, ctx.workspacePath(slug), resolved.settings.fastlane_dir);
|
|
28
|
+
if (!lanes.some((l) => l.name === body.lane)) {
|
|
29
|
+
return reply.code(400).send({ error: `Unknown lane: ${body.lane}` });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Unreadable lanes: we let it through, the run will fail with a clear message.
|
|
34
|
+
}
|
|
35
|
+
const id = ctx.runs.create({
|
|
36
|
+
projectSlug: slug,
|
|
37
|
+
lane: body.lane,
|
|
38
|
+
platform: body.platform ?? null,
|
|
39
|
+
params: body.params ?? {},
|
|
40
|
+
});
|
|
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 });
|
|
73
|
+
});
|
|
74
|
+
app.get("/api/runs/:id", async (req, reply) => {
|
|
75
|
+
const id = Number(req.params.id);
|
|
76
|
+
const run = ctx.runs.get(id);
|
|
77
|
+
if (!run)
|
|
78
|
+
return reply.code(404).send({ error: "Unknown run" });
|
|
79
|
+
return { ...run, steps: ctx.runs.steps(id), artifacts: ctx.runs.artifacts(id) };
|
|
80
|
+
});
|
|
81
|
+
app.get("/api/runs/:id/log", async (req, reply) => {
|
|
82
|
+
const id = Number(req.params.id);
|
|
83
|
+
const from = Number(req.query.from ?? 0);
|
|
84
|
+
if (!ctx.runs.get(id))
|
|
85
|
+
return reply.code(404).send({ error: "Unknown run" });
|
|
86
|
+
return reply.type("text/plain; charset=utf-8").send(await ctx.logs.read(id, from));
|
|
87
|
+
});
|
|
88
|
+
app.get("/api/runs/:id/artifacts/:artifactId", async (req, reply) => {
|
|
89
|
+
const { id, artifactId } = req.params;
|
|
90
|
+
const artifact = ctx.runs.artifacts(Number(id)).find((a) => a.id === Number(artifactId));
|
|
91
|
+
if (!artifact)
|
|
92
|
+
return reply.code(404).send({ error: "Unknown artifact" });
|
|
93
|
+
return reply
|
|
94
|
+
// A file from the repository can carry a quote in its name: without
|
|
95
|
+
// escaping it would break the header.
|
|
96
|
+
.header("Content-Disposition", `attachment; filename="${artifact.filename.replace(/["\\]/g, "_")}"`)
|
|
97
|
+
.type("application/octet-stream")
|
|
98
|
+
.send(createReadStream(artifact.path));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import websocket from "@fastify/websocket";
|
|
2
|
+
import { SESSION_COOKIE } from "./auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Broadcasts output fragments to browsers watching a run.
|
|
5
|
+
*
|
|
6
|
+
* Every message carries its byte offset: a client that reconnects requests
|
|
7
|
+
* the log from its last known offset and loses nothing.
|
|
8
|
+
*/
|
|
9
|
+
export class RunSockets {
|
|
10
|
+
byRun = new Map();
|
|
11
|
+
subscribe(runId, sink) {
|
|
12
|
+
const set = this.byRun.get(runId) ?? new Set();
|
|
13
|
+
set.add(sink);
|
|
14
|
+
this.byRun.set(runId, set);
|
|
15
|
+
}
|
|
16
|
+
unsubscribe(runId, sink) {
|
|
17
|
+
const set = this.byRun.get(runId);
|
|
18
|
+
if (!set)
|
|
19
|
+
return;
|
|
20
|
+
set.delete(sink);
|
|
21
|
+
if (set.size === 0)
|
|
22
|
+
this.byRun.delete(runId);
|
|
23
|
+
}
|
|
24
|
+
emit(runId, payload) {
|
|
25
|
+
const set = this.byRun.get(runId);
|
|
26
|
+
if (!set)
|
|
27
|
+
return;
|
|
28
|
+
const data = JSON.stringify(payload);
|
|
29
|
+
for (const sink of set) {
|
|
30
|
+
try {
|
|
31
|
+
sink.send(data);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A dead client must never interrupt the broadcast to the others.
|
|
35
|
+
set.delete(sink);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
broadcast(runId, chunk, offset) {
|
|
40
|
+
this.emit(runId, { type: "chunk", offset, data: chunk });
|
|
41
|
+
}
|
|
42
|
+
finish(runId, status) {
|
|
43
|
+
this.emit(runId, { type: "finished", status });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function registerWebSocket(app, ctx) {
|
|
47
|
+
const hub = new RunSockets();
|
|
48
|
+
await app.register(websocket);
|
|
49
|
+
app.get("/api/runs/:id/stream", { websocket: true }, (socket, req) => {
|
|
50
|
+
// Deliberate redundancy: `app.ts`'s global hook already refuses every
|
|
51
|
+
// `/api` route without a session, and does so right at the handshake —
|
|
52
|
+
// an unauthenticated client gets a 401 HTTP response and never reaches
|
|
53
|
+
// here. This guard costs nothing and prevents a future exemption of
|
|
54
|
+
// that hook from silently opening the stream.
|
|
55
|
+
if (!ctx.sessions.valid(req.cookies[SESSION_COOKIE])) {
|
|
56
|
+
socket.close(4001, "Session required");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const runId = Number(req.params.id);
|
|
60
|
+
const sink = { send: (d) => socket.send(d) };
|
|
61
|
+
hub.subscribe(runId, sink);
|
|
62
|
+
socket.on("close", () => hub.unsubscribe(runId, sink));
|
|
63
|
+
});
|
|
64
|
+
app.decorate("broadcastRunChunk", (runId, chunk, offset) => hub.broadcast(runId, chunk, offset));
|
|
65
|
+
return hub;
|
|
66
|
+
}
|