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
package/dist/src/server/auth.js
CHANGED
|
@@ -37,45 +37,113 @@ export async function verifyPassword(password, stored) {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A hash no password matches, used to spend the same work on an unknown name.
|
|
42
|
+
*
|
|
43
|
+
* Built from random bytes rather than by hashing something: `hashPassword` is
|
|
44
|
+
* synchronous and would freeze the server for its duration. The shape is what
|
|
45
|
+
* matters — `verifyPassword` derives a 32-byte key with the same parameters and
|
|
46
|
+
* then fails the comparison, which is exactly what a wrong password costs.
|
|
47
|
+
*/
|
|
48
|
+
const DECOY_HASH = `scrypt$${randomBytes(16).toString("hex")}$${randomBytes(32).toString("hex")}`;
|
|
49
|
+
/**
|
|
50
|
+
* Turns a name and a password into who that is, or into nothing.
|
|
51
|
+
*
|
|
52
|
+
* An unknown name is verified against a decoy hash instead of returning early.
|
|
53
|
+
* Without that, a refusal for a name that does not exist comes back in
|
|
54
|
+
* microseconds while a wrong password takes the thirty milliseconds scrypt
|
|
55
|
+
* costs — and the login form becomes a way to enumerate accounts before trying
|
|
56
|
+
* a single password against them.
|
|
57
|
+
*/
|
|
58
|
+
export async function authenticate(users, name, password) {
|
|
59
|
+
const user = users.find((u) => u.name === name);
|
|
60
|
+
if (!user) {
|
|
61
|
+
await verifyPassword(password, DECOY_HASH);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!(await verifyPassword(password, user.password_hash)))
|
|
65
|
+
return null;
|
|
66
|
+
return { name: user.name, role: user.role };
|
|
67
|
+
}
|
|
40
68
|
/** In-memory sessions: they don't survive a restart, and that's just fine. */
|
|
41
69
|
export class SessionStore {
|
|
42
|
-
tokens = new
|
|
43
|
-
issue() {
|
|
70
|
+
tokens = new Map();
|
|
71
|
+
issue(identity) {
|
|
44
72
|
const token = randomBytes(32).toString("hex");
|
|
45
|
-
this.tokens.
|
|
73
|
+
this.tokens.set(token, identity);
|
|
46
74
|
return token;
|
|
47
75
|
}
|
|
76
|
+
/** Who the token belongs to, or undefined if it belongs to nobody. */
|
|
77
|
+
get(token) {
|
|
78
|
+
return token === undefined ? undefined : this.tokens.get(token);
|
|
79
|
+
}
|
|
48
80
|
valid(token) {
|
|
49
|
-
return token !== undefined
|
|
81
|
+
return this.get(token) !== undefined;
|
|
50
82
|
}
|
|
51
83
|
revoke(token) {
|
|
52
84
|
this.tokens.delete(token);
|
|
53
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Drops every session belonging to a name.
|
|
88
|
+
*
|
|
89
|
+
* The identity is snapshotted when the session is issued, which is what makes
|
|
90
|
+
* a request cheap — but it also means removing an account or changing its role
|
|
91
|
+
* leaves the old answer live in whatever browsers already had it. Without this,
|
|
92
|
+
* "remove the account" and "revoke access" are two different things, while
|
|
93
|
+
* every interface that offers the first implies the second.
|
|
94
|
+
*/
|
|
95
|
+
revokeAllFor(name) {
|
|
96
|
+
for (const [token, identity] of this.tokens) {
|
|
97
|
+
if (identity.name === name)
|
|
98
|
+
this.tokens.delete(token);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
54
101
|
}
|
|
55
102
|
export const SESSION_COOKIE = "laneyard_session";
|
|
103
|
+
/** Past this many tracked names, the entries that no longer delay anyone go. */
|
|
104
|
+
const THROTTLE_PRUNE_ABOVE = 1_000;
|
|
56
105
|
/**
|
|
57
|
-
* Slows down repeated login attempts.
|
|
106
|
+
* Slows down repeated login attempts, one account at a time.
|
|
58
107
|
*
|
|
59
108
|
* Without this, a network neighbour could try passwords as fast as the
|
|
60
109
|
* server responds. The delay grows with failures and resets to zero on a
|
|
61
110
|
* success: the legitimate user who mistypes once doesn't feel it.
|
|
111
|
+
*
|
|
112
|
+
* Counted per name rather than globally: a single counter means anyone able to
|
|
113
|
+
* reach the login form can lock out every account on the server by hammering
|
|
114
|
+
* one name — a denial of service disguised as a security measure.
|
|
62
115
|
*/
|
|
63
116
|
export class LoginThrottle {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return Math.max(0, this.until - now);
|
|
117
|
+
perName = new Map();
|
|
118
|
+
/** Milliseconds left to wait for that name, 0 if the way is clear. */
|
|
119
|
+
retryAfterMs(name, now = Date.now()) {
|
|
120
|
+
return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
|
|
69
121
|
}
|
|
70
|
-
recordFailure(now = Date.now()) {
|
|
71
|
-
this.failures
|
|
122
|
+
recordFailure(name, now = Date.now()) {
|
|
123
|
+
const state = this.perName.get(name) ?? { failures: 0, until: 0 };
|
|
124
|
+
state.failures += 1;
|
|
72
125
|
// 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
|
|
73
|
-
if (
|
|
74
|
-
|
|
126
|
+
if (state.failures > 3) {
|
|
127
|
+
state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
|
|
75
128
|
}
|
|
129
|
+
this.perName.set(name, state);
|
|
130
|
+
// The key is whatever the caller sent, so an attacker chooses how many
|
|
131
|
+
// entries exist. Dropping those whose delay has run out bounds the map by
|
|
132
|
+
// how fast someone can be delayed, not by how many names they can invent.
|
|
133
|
+
if (this.perName.size > THROTTLE_PRUNE_ABOVE)
|
|
134
|
+
this.prune(now);
|
|
76
135
|
}
|
|
77
|
-
recordSuccess() {
|
|
78
|
-
this.
|
|
79
|
-
|
|
136
|
+
recordSuccess(name) {
|
|
137
|
+
this.perName.delete(name);
|
|
138
|
+
}
|
|
139
|
+
/** Number of names currently tracked. Exposed so the pruning can be tested. */
|
|
140
|
+
size() {
|
|
141
|
+
return this.perName.size;
|
|
142
|
+
}
|
|
143
|
+
prune(now) {
|
|
144
|
+
for (const [name, state] of this.perName) {
|
|
145
|
+
if (state.until <= now)
|
|
146
|
+
this.perName.delete(name);
|
|
147
|
+
}
|
|
80
148
|
}
|
|
81
149
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can do what — the whole answer, in one file.
|
|
3
|
+
*
|
|
4
|
+
* A permission expressed as an `if` inside a handler is a permission nobody
|
|
5
|
+
* finds when it matters: during an audit, or after an incident. So there are no
|
|
6
|
+
* such `if`s. Every route that needs more than a session is named below, and the
|
|
7
|
+
* single hook in `app.ts` is the only thing that reads this.
|
|
8
|
+
*
|
|
9
|
+
* Two roles, and only two. `admin` may do everything; `builder` may start a
|
|
10
|
+
* build, watch it, cancel it and download what it produced — nothing that
|
|
11
|
+
* changes what a build does, and nothing that reveals a credential.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* What each route needs. Everything absent from this list needs only a session.
|
|
15
|
+
*
|
|
16
|
+
* Reading the Fastfile is deliberately not here: a builder who can start a lane
|
|
17
|
+
* benefits from seeing what it does, and it contains no credential — anything
|
|
18
|
+
* that does is in the vault, which is.
|
|
19
|
+
*/
|
|
20
|
+
export const REQUIRES_ADMIN = [
|
|
21
|
+
{ method: "*", path: "/api/secrets" },
|
|
22
|
+
{ method: "*", path: "/api/projects/:slug/secrets" },
|
|
23
|
+
{ method: "PUT", path: "/api/projects/:slug/fastfile" },
|
|
24
|
+
{ method: "POST", path: "/api/projects/:slug/commit" },
|
|
25
|
+
{ method: "POST", path: "/api/projects/:slug/push" },
|
|
26
|
+
{ method: "DELETE", path: "/api/projects/:slug" },
|
|
27
|
+
{ method: "*", path: "/api/users" },
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
|
|
31
|
+
*
|
|
32
|
+
* Each segment is percent-decoded, because the router decodes before it matches
|
|
33
|
+
* and this must see the same path the router did. Comparing the raw text sent
|
|
34
|
+
* `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
|
|
35
|
+
* routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
|
|
36
|
+
*/
|
|
37
|
+
function segments(url) {
|
|
38
|
+
return (url.split("?")[0] ?? "")
|
|
39
|
+
.split("/")
|
|
40
|
+
.filter((s) => s !== "")
|
|
41
|
+
.map((s) => {
|
|
42
|
+
try {
|
|
43
|
+
return decodeURIComponent(s);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Malformed escapes reach no route either; left as-is rather than
|
|
47
|
+
// swallowed, so a segment is never silently emptied into a match.
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Does this request need an admin?
|
|
54
|
+
*
|
|
55
|
+
* A pattern matches a request whose path *starts with* it, segment by segment,
|
|
56
|
+
* with `:name` standing for any one segment. The prefix is deliberate:
|
|
57
|
+
* `/api/secrets` means the vault, not one URL, and a table naming every key's
|
|
58
|
+
* route separately would be a table someone forgets to extend the next time a
|
|
59
|
+
* route is added under it. It only ever errs towards refusing, since every
|
|
60
|
+
* entry here is a restriction.
|
|
61
|
+
*/
|
|
62
|
+
export function requiresAdmin(method, url) {
|
|
63
|
+
const parts = segments(url);
|
|
64
|
+
const verb = method.toUpperCase();
|
|
65
|
+
return REQUIRES_ADMIN.some((pattern) => {
|
|
66
|
+
if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
|
|
67
|
+
return false;
|
|
68
|
+
const expected = segments(pattern.path);
|
|
69
|
+
if (parts.length < expected.length)
|
|
70
|
+
return false;
|
|
71
|
+
return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { removeProjectFromConfig } from "../../cli/setup.js";
|
|
1
3
|
export async function registerProjectRoutes(app, ctx) {
|
|
2
4
|
app.get("/api/projects", async () => ctx.config.projects().map((p) => {
|
|
3
5
|
const last = ctx.runs.listByProject(p.slug, 1)[0] ?? null;
|
|
@@ -8,6 +10,46 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
8
10
|
lastRun: last && { id: last.id, status: last.status, lane: last.lane, finishedAt: last.finishedAt },
|
|
9
11
|
};
|
|
10
12
|
}));
|
|
13
|
+
/**
|
|
14
|
+
* Stops showing a project. It is the one destructive route in the product,
|
|
15
|
+
* and what it does not destroy is most of the point:
|
|
16
|
+
*
|
|
17
|
+
* - the project's block leaves config.yml, through the YAML document so the
|
|
18
|
+
* rest of a hand-written file is untouched;
|
|
19
|
+
* - its runs stay in the database, still reachable at their own URL — the
|
|
20
|
+
* history of what this machine built is not the project's to take away;
|
|
21
|
+
* - the clone and the artifacts stay on disk, named in the answer so they
|
|
22
|
+
* can be removed by hand. Deleting files someone may still want, from a
|
|
23
|
+
* web page, on one click, is not a thing to do.
|
|
24
|
+
*/
|
|
25
|
+
app.delete("/api/projects/:slug", async (req, reply) => {
|
|
26
|
+
const { slug } = req.params;
|
|
27
|
+
const entry = ctx.config.project(slug);
|
|
28
|
+
if (!entry)
|
|
29
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
30
|
+
// A run that has begun is reading the workspace this project points at.
|
|
31
|
+
// Queued runs are not: the queue already fails a run whose project went
|
|
32
|
+
// away, so waiting on them would only mean refusing for longer.
|
|
33
|
+
if (ctx.runs.hasActiveRun(slug)) {
|
|
34
|
+
return reply.code(409).send({
|
|
35
|
+
error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const removed = await removeProjectFromConfig(ctx.config.configPath(), slug);
|
|
39
|
+
if (!removed)
|
|
40
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
41
|
+
// The file is watched, but on a debounce: reloading here is what makes the
|
|
42
|
+
// very next request — the listing this page is about to ask for — truthful.
|
|
43
|
+
await ctx.config.load();
|
|
44
|
+
// -1 is SQLite's "no limit": the answer names every artifact folder left
|
|
45
|
+
// behind, and a project with sixty runs must not be told about fifty of them.
|
|
46
|
+
const runs = ctx.runs.listByProject(slug, -1);
|
|
47
|
+
const leftOnDisk = [
|
|
48
|
+
ctx.workspacePath(slug),
|
|
49
|
+
...runs.map((run) => ctx.artifactsDir(run.id)),
|
|
50
|
+
].filter((path) => existsSync(path));
|
|
51
|
+
return reply.send({ slug, name: entry.name, runsKept: runs.length, leftOnDisk });
|
|
52
|
+
});
|
|
11
53
|
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
12
54
|
const { slug } = req.params;
|
|
13
55
|
const entry = ctx.config.project(slug);
|
|
@@ -30,6 +72,13 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
30
72
|
const { slug } = req.params;
|
|
31
73
|
if (!ctx.config.project(slug))
|
|
32
74
|
return reply.code(404).send({ error: "Unknown project" });
|
|
33
|
-
|
|
75
|
+
// The whole line is read once and the positions are looked up in it, rather
|
|
76
|
+
// than asking the database where each of fifty runs stands. The position is
|
|
77
|
+
// the global one: the queue is shared by every project.
|
|
78
|
+
const line = ctx.runs.queued().map((r) => r.id);
|
|
79
|
+
return ctx.runs.listByProject(slug).map((run) => {
|
|
80
|
+
const at = line.indexOf(run.id);
|
|
81
|
+
return { ...run, queuePosition: at === -1 ? null : at + 1 };
|
|
82
|
+
});
|
|
34
83
|
});
|
|
35
84
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { glob } from "tinyglobby";
|
|
6
|
+
import { Workspace } from "../../git/workspace.js";
|
|
7
|
+
import { resolvePlatforms } from "../../heuristics/platforms.js";
|
|
8
|
+
import { runChecklist } from "../../heuristics/readiness.js";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* `bundle check` in the workspace, rejecting with what bundler said.
|
|
12
|
+
*
|
|
13
|
+
* Installs nothing: the checklist reports, it does not act. The timeout is
|
|
14
|
+
* generous because bundler resolves the whole Gemfile.lock, and short enough
|
|
15
|
+
* that a wedged bundler does not hold the request open.
|
|
16
|
+
*/
|
|
17
|
+
async function bundleCheck(cwd) {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await exec("bundle", ["check"], { cwd, timeout: 60_000 });
|
|
20
|
+
return stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
const err = cause;
|
|
24
|
+
throw new Error((err.stdout || err.stderr || err.message).trim());
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The path of a `fastlane` a run would find, or null. */
|
|
28
|
+
async function findFastlane() {
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await exec("which", ["fastlane"], { timeout: 5_000 });
|
|
31
|
+
return stdout.trim() || null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A non-zero exit from `which` is the answer "no", not a failure.
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The globbing `heuristics/platforms.ts` asks for, bound to the workspace. */
|
|
39
|
+
const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
|
|
40
|
+
const exists = async (path) => access(path).then(() => true, () => false);
|
|
41
|
+
export async function registerReadinessRoutes(app, ctx) {
|
|
42
|
+
/**
|
|
43
|
+
* Computed only when asked for.
|
|
44
|
+
*
|
|
45
|
+
* These checks shell out to git and to bundler, so nothing else in the
|
|
46
|
+
* interface may trigger them: the tab asks when it is opened, and when the
|
|
47
|
+
* user presses refresh. Nothing here is cached either — a stale green tick is
|
|
48
|
+
* worse than a red cross, which is why the answer carries the time it was
|
|
49
|
+
* produced.
|
|
50
|
+
*/
|
|
51
|
+
app.get("/api/projects/:slug/readiness", async (req, reply) => {
|
|
52
|
+
const { slug } = req.params;
|
|
53
|
+
const entry = ctx.config.project(slug);
|
|
54
|
+
if (!entry)
|
|
55
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
56
|
+
const workspacePath = ctx.workspacePath(slug);
|
|
57
|
+
const workspace = new Workspace(workspacePath, entry.git_url, entry.git_auth);
|
|
58
|
+
// What each lane calls lives in the repository, so the clone has to exist.
|
|
59
|
+
// A clone that fails is not an error page: it is the reason two of the five
|
|
60
|
+
// checks cannot answer, and the other three still can.
|
|
61
|
+
let unreachable = null;
|
|
62
|
+
try {
|
|
63
|
+
await ctx.ensureWorkspace(slug);
|
|
64
|
+
}
|
|
65
|
+
catch (cause) {
|
|
66
|
+
unreachable = cause.message;
|
|
67
|
+
}
|
|
68
|
+
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
69
|
+
const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
|
|
70
|
+
const uses = unreachable !== null
|
|
71
|
+
? { ok: false, reason: unreachable }
|
|
72
|
+
: await ctx
|
|
73
|
+
.uses(slug, workspacePath, fastlaneDir)
|
|
74
|
+
.then((lanes) => ({ ok: true, value: lanes }))
|
|
75
|
+
// Broken Fastfile, no Ruby, no fastlane: all of them are "could not
|
|
76
|
+
// tell", none of them is a 500.
|
|
77
|
+
.catch((cause) => ({ ok: false, reason: cause.message }));
|
|
78
|
+
// What the project builds for decides which half of the checklist applies.
|
|
79
|
+
// `laneyard.yml` answers on its own; without it the workspace is looked at,
|
|
80
|
+
// and an unreachable workspace is a "could not tell" rather than a claim
|
|
81
|
+
// that the repository holds neither an Xcode project nor a Gradle build.
|
|
82
|
+
const configured = resolved?.settings.platforms;
|
|
83
|
+
const platforms = unreachable !== null && (configured === undefined || configured.length === 0)
|
|
84
|
+
? { ok: false, reason: unreachable }
|
|
85
|
+
: { ok: true, value: await resolvePlatforms(configured, findIn(workspacePath)) };
|
|
86
|
+
const sections = await runChecklist({
|
|
87
|
+
probeRepository: () => workspace.probeRemote(),
|
|
88
|
+
dependencies: {
|
|
89
|
+
workspace: unreachable !== null
|
|
90
|
+
? { ok: false, reason: unreachable }
|
|
91
|
+
: { ok: true, value: { hasGemfile: await exists(join(workspacePath, "Gemfile")) } },
|
|
92
|
+
bundleCheck: () => bundleCheck(workspacePath),
|
|
93
|
+
findFastlane,
|
|
94
|
+
},
|
|
95
|
+
// Names only: the vault never hands a value to anything but a run.
|
|
96
|
+
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
97
|
+
uses,
|
|
98
|
+
platforms,
|
|
99
|
+
});
|
|
100
|
+
return { checkedAt: new Date().toISOString(), sections };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
|
-
|
|
2
|
+
/** Statuses a run can still be cancelled from. */
|
|
3
|
+
const CANCELLABLE = ["queued", "preparing", "running"];
|
|
3
4
|
export async function registerRunRoutes(app, ctx) {
|
|
4
5
|
app.post("/api/projects/:slug/runs", async (req, reply) => {
|
|
5
6
|
const { slug } = req.params;
|
|
@@ -9,17 +10,6 @@ export async function registerRunRoutes(app, ctx) {
|
|
|
9
10
|
return reply.code(404).send({ error: "Unknown project" });
|
|
10
11
|
if (!body.lane)
|
|
11
12
|
return reply.code(400).send({ error: "Missing lane" });
|
|
12
|
-
// Only one run at a time per project: they share the same git workspace.
|
|
13
|
-
// Two concurrent runs would silently trip over each other — one would
|
|
14
|
-
// change the commit out from under the other, carry off its artifacts
|
|
15
|
-
// and delete its report. The real queue comes at the next milestone;
|
|
16
|
-
// this refusal, for its part, already prevents false results.
|
|
17
|
-
const last = ctx.runs.listByProject(slug, 1)[0];
|
|
18
|
-
if (last && ["queued", "preparing", "running"].includes(last.status)) {
|
|
19
|
-
return reply.code(409).send({
|
|
20
|
-
error: `Run #${last.id} is still in progress on this project. Wait for it to finish.`,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
13
|
// We check that the lane genuinely exists before creating a run doomed to fail.
|
|
24
14
|
try {
|
|
25
15
|
await ctx.ensureWorkspace(slug);
|
|
@@ -38,45 +28,37 @@ export async function registerRunRoutes(app, ctx) {
|
|
|
38
28
|
platform: body.platform ?? null,
|
|
39
29
|
params: body.params ?? {},
|
|
40
30
|
});
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
artifactsDir: ctx.artifactsDir(id),
|
|
48
|
-
gitUrl: entry.git_url,
|
|
49
|
-
gitAuth: entry.git_auth,
|
|
50
|
-
branch: entry.default_branch,
|
|
51
|
-
// Resolved after the clone, once the repository's laneyard.yml is finally readable.
|
|
52
|
-
resolveSettings: async () => {
|
|
53
|
-
const r = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
54
|
-
return r.settings;
|
|
55
|
-
},
|
|
56
|
-
env: process.env,
|
|
57
|
-
onChunk: (chunk, offset) => app.broadcastRunChunk?.(id, chunk, offset),
|
|
58
|
-
})
|
|
59
|
-
.then((r) => ctx.sockets?.finish(id, r.status))
|
|
60
|
-
.catch((cause) => {
|
|
61
|
-
// Last safety net. `executeRun` commits to never throwing, but a
|
|
62
|
-
// rejected promise with no handler brings down the whole Node
|
|
63
|
-
// process — and with it, the other runs in progress. The cost of
|
|
64
|
-
// forgetting this would be disproportionate.
|
|
65
|
-
ctx.runs.finish(id, {
|
|
66
|
-
status: "failed",
|
|
67
|
-
exitCode: null,
|
|
68
|
-
errorSummary: `Unexpected failure: ${cause.message}`,
|
|
69
|
-
});
|
|
70
|
-
ctx.sockets?.finish(id, "failed");
|
|
71
|
-
});
|
|
72
|
-
return reply.code(201).send({ id });
|
|
31
|
+
// Read before the queue is woken, so the answer describes the line the
|
|
32
|
+
// caller just joined rather than one the worker has already moved on from.
|
|
33
|
+
const queuePosition = ctx.runs.queuePosition(id);
|
|
34
|
+
// The route creates a row and rings the bell; the worker does the rest.
|
|
35
|
+
app.queue.wake();
|
|
36
|
+
return reply.code(201).send({ id, queuePosition });
|
|
73
37
|
});
|
|
74
38
|
app.get("/api/runs/:id", async (req, reply) => {
|
|
75
39
|
const id = Number(req.params.id);
|
|
76
40
|
const run = ctx.runs.get(id);
|
|
77
41
|
if (!run)
|
|
78
42
|
return reply.code(404).send({ error: "Unknown run" });
|
|
79
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
...run,
|
|
45
|
+
queuePosition: ctx.runs.queuePosition(id),
|
|
46
|
+
steps: ctx.runs.steps(id),
|
|
47
|
+
artifacts: ctx.runs.artifacts(id),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
app.post("/api/runs/:id/cancel", async (req, reply) => {
|
|
51
|
+
const id = Number(req.params.id);
|
|
52
|
+
const run = ctx.runs.get(id);
|
|
53
|
+
if (!run)
|
|
54
|
+
return reply.code(404).send({ error: "Unknown run" });
|
|
55
|
+
if (!CANCELLABLE.includes(run.status)) {
|
|
56
|
+
return reply.code(409).send({ error: `Run #${id} has already finished` });
|
|
57
|
+
}
|
|
58
|
+
// A queued run is finished on the spot; a running one is signalled and ends
|
|
59
|
+
// a few moments later. Either way the caller has nothing left to wait for.
|
|
60
|
+
app.queue.cancel(id);
|
|
61
|
+
return reply.code(204).send();
|
|
80
62
|
});
|
|
81
63
|
app.get("/api/runs/:id/log", async (req, reply) => {
|
|
82
64
|
const id = Number(req.params.id);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { MIN_LENGTH as MIN_REDACTABLE } from "../../logs/redact.js";
|
|
2
|
+
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
3
|
+
const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4
|
+
export async function registerSecretRoutes(app, ctx) {
|
|
5
|
+
const listRoute = (slug) => slug === null ? ctx.vault.listGlobal() : ctx.vault.list(slug);
|
|
6
|
+
app.get("/api/secrets", async () => listRoute(null));
|
|
7
|
+
app.get("/api/projects/:slug/secrets", async (req, reply) => {
|
|
8
|
+
const { slug } = req.params;
|
|
9
|
+
if (!ctx.config.project(slug))
|
|
10
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
11
|
+
return listRoute(slug);
|
|
12
|
+
});
|
|
13
|
+
const put = async (slug, key, body, reply) => {
|
|
14
|
+
const { value, masked } = (body ?? {});
|
|
15
|
+
if (!VALID_KEY.test(key)) {
|
|
16
|
+
return reply.code(400).send({
|
|
17
|
+
error: `"${key}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (typeof value !== "string" || value === "") {
|
|
21
|
+
return reply.code(400).send({ error: "A value is required" });
|
|
22
|
+
}
|
|
23
|
+
// Accepting the tick box and quietly not honouring it would leave someone
|
|
24
|
+
// believing they are protected. Refusing is the honest answer.
|
|
25
|
+
if (masked !== false && value.length < MIN_REDACTABLE) {
|
|
26
|
+
return reply.code(400).send({
|
|
27
|
+
error: `A value kept out of the logs must be at least ${MIN_REDACTABLE} characters. ` +
|
|
28
|
+
"Shorter than that, removing it would shred the log without hiding anything. " +
|
|
29
|
+
"Store it unmasked if you accept it appearing in the output.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
await ctx.vault.set(slug, key, value, masked !== false);
|
|
33
|
+
return reply.code(204).send();
|
|
34
|
+
};
|
|
35
|
+
app.put("/api/secrets/:key", async (req, reply) => put(null, req.params.key, req.body, reply));
|
|
36
|
+
app.put("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
37
|
+
const { slug, key } = req.params;
|
|
38
|
+
if (!ctx.config.project(slug))
|
|
39
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
40
|
+
return put(slug, key, req.body, reply);
|
|
41
|
+
});
|
|
42
|
+
app.delete("/api/secrets/:key", async (req, reply) => {
|
|
43
|
+
const removed = ctx.vault.remove(null, req.params.key);
|
|
44
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
|
|
45
|
+
});
|
|
46
|
+
app.delete("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
47
|
+
const { slug, key } = req.params;
|
|
48
|
+
const removed = ctx.vault.remove(slug, key);
|
|
49
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
|
|
50
|
+
});
|
|
51
|
+
}
|