laneyard 0.3.0 → 0.4.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 +186 -18
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +10 -1
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
package/dist/src/server/app.js
CHANGED
|
@@ -6,14 +6,18 @@ import { dirname, join } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
8
8
|
import { RunStore } from "../db/runs.js";
|
|
9
|
+
import { SessionRecords } from "../db/sessions.js";
|
|
9
10
|
import { Workspace } from "../git/workspace.js";
|
|
10
11
|
import { LogStore } from "../logs/store.js";
|
|
12
|
+
import { materialiseCredentials } from "../runner/materialise.js";
|
|
11
13
|
import { executeRun } from "../runner/orchestrate.js";
|
|
12
14
|
import { RunQueue } from "../runner/queue.js";
|
|
13
|
-
import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
15
|
+
import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
14
16
|
import { requiresAdmin } from "./permissions.js";
|
|
17
|
+
import { registerCredentialRoutes } from "./routes/credentials.js";
|
|
15
18
|
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
16
19
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
20
|
+
import { registerAccountRoutes } from "./routes/account.js";
|
|
17
21
|
import { registerReadinessRoutes } from "./routes/readiness.js";
|
|
18
22
|
import { registerRunRoutes } from "./routes/runs.js";
|
|
19
23
|
import { registerSecretRoutes } from "./routes/secrets.js";
|
|
@@ -30,9 +34,10 @@ export async function buildApp(deps) {
|
|
|
30
34
|
...deps,
|
|
31
35
|
runs: new RunStore(deps.db),
|
|
32
36
|
logs: new LogStore(join(deps.root, "logs")),
|
|
33
|
-
sessions: new SessionStore(),
|
|
37
|
+
sessions: new SessionStore(new SessionRecords(deps.db)),
|
|
34
38
|
workspacePath,
|
|
35
39
|
artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
|
|
40
|
+
runSecretsDir: (runId) => join(deps.root, "runs", String(runId), "secrets"),
|
|
36
41
|
ensureWorkspace: async (slug) => {
|
|
37
42
|
const entry = deps.config.project(slug);
|
|
38
43
|
if (!entry)
|
|
@@ -61,6 +66,23 @@ export async function buildApp(deps) {
|
|
|
61
66
|
});
|
|
62
67
|
return;
|
|
63
68
|
}
|
|
69
|
+
// Before the run, and outside it: `executeRun` is handed plaintext and
|
|
70
|
+
// never the vault, a boundary worth more than the convenience of moving
|
|
71
|
+
// this one call inside. A block that will not decrypt stops the run here,
|
|
72
|
+
// with the reason, rather than producing an artifact signed by nothing.
|
|
73
|
+
let credentials;
|
|
74
|
+
try {
|
|
75
|
+
credentials = await materialiseCredentials(ctx.vault, slug, ctx.runSecretsDir(runId));
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
ctx.runs.finish(runId, {
|
|
79
|
+
status: "failed",
|
|
80
|
+
exitCode: null,
|
|
81
|
+
errorSummary: ctx.vault.scrub(slug, cause.message),
|
|
82
|
+
});
|
|
83
|
+
ctx.sockets?.finish(runId, "failed");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
64
86
|
await executeRun({
|
|
65
87
|
runId,
|
|
66
88
|
runs: ctx.runs,
|
|
@@ -77,6 +99,9 @@ export async function buildApp(deps) {
|
|
|
77
99
|
},
|
|
78
100
|
env: process.env,
|
|
79
101
|
secrets: ctx.vault.resolve(slug),
|
|
102
|
+
credentialEnv: credentials.env,
|
|
103
|
+
androidKeystore: credentials.keystore,
|
|
104
|
+
cleanup: credentials.cleanup,
|
|
80
105
|
maskedValues: ctx.vault.maskedValues(slug),
|
|
81
106
|
signal,
|
|
82
107
|
onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
|
|
@@ -130,7 +155,7 @@ export async function buildApp(deps) {
|
|
|
130
155
|
throttle.recordSuccess(account);
|
|
131
156
|
const token = ctx.sessions.issue(identity);
|
|
132
157
|
return reply
|
|
133
|
-
.setCookie(SESSION_COOKIE, token,
|
|
158
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
134
159
|
.send({ ok: true, name: identity.name, role: identity.role });
|
|
135
160
|
});
|
|
136
161
|
// Every /api route except /api/login requires a session, and the routes on
|
|
@@ -180,9 +205,11 @@ export async function buildApp(deps) {
|
|
|
180
205
|
await registerProjectRoutes(app, ctx);
|
|
181
206
|
await registerRunRoutes(app, ctx);
|
|
182
207
|
await registerSecretRoutes(app, ctx);
|
|
208
|
+
await registerCredentialRoutes(app, ctx);
|
|
183
209
|
await registerReadinessRoutes(app, ctx);
|
|
184
210
|
await registerFastfileRoutes(app, ctx);
|
|
185
211
|
await registerUserRoutes(app, ctx);
|
|
212
|
+
await registerAccountRoutes(app, ctx);
|
|
186
213
|
// Resolved from the module's location, not from the data folder:
|
|
187
214
|
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
188
215
|
// relative positions, depending on whether we're running on the sources
|
package/dist/src/server/auth.js
CHANGED
|
@@ -65,23 +65,47 @@ export async function authenticate(users, name, password) {
|
|
|
65
65
|
return null;
|
|
66
66
|
return { name: user.name, role: user.role };
|
|
67
67
|
}
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* How long a session lasts, and how long the cookie is kept for.
|
|
70
|
+
*
|
|
71
|
+
* Thirty days, not forever: a browser left on a desk is the reason the password
|
|
72
|
+
* change asks for the current password, and a session that never ended would
|
|
73
|
+
* make that precaution pointless. The cookie carries the same figure, so the
|
|
74
|
+
* browser stops presenting a token the server would refuse anyway.
|
|
75
|
+
*/
|
|
76
|
+
export const SESSION_TTL_DAYS = 30;
|
|
77
|
+
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
78
|
+
/**
|
|
79
|
+
* Sessions, kept in the database so they outlive a restart.
|
|
80
|
+
*
|
|
81
|
+
* They used to be a Map, with a comment saying they did not survive a restart
|
|
82
|
+
* "and that's just fine". It was not: restarting the server to pick up an edit
|
|
83
|
+
* to config.yml signed everybody out, which on a machine still being set up is
|
|
84
|
+
* several times an hour. The API is unchanged — every caller still issues, gets
|
|
85
|
+
* and revokes — only the shelf underneath is different.
|
|
86
|
+
*/
|
|
69
87
|
export class SessionStore {
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
records;
|
|
89
|
+
constructor(records) {
|
|
90
|
+
this.records = records;
|
|
91
|
+
}
|
|
92
|
+
issue(identity, now = new Date()) {
|
|
72
93
|
const token = randomBytes(32).toString("hex");
|
|
73
|
-
this.
|
|
94
|
+
this.records.insert(token, identity, new Date(now.getTime() + SESSION_TTL_MS));
|
|
74
95
|
return token;
|
|
75
96
|
}
|
|
76
97
|
/** Who the token belongs to, or undefined if it belongs to nobody. */
|
|
77
98
|
get(token) {
|
|
78
|
-
|
|
99
|
+
if (token === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const owner = this.records.find(token);
|
|
102
|
+
return owner ? { name: owner.name, role: owner.role } : undefined;
|
|
79
103
|
}
|
|
80
104
|
valid(token) {
|
|
81
105
|
return this.get(token) !== undefined;
|
|
82
106
|
}
|
|
83
107
|
revoke(token) {
|
|
84
|
-
this.
|
|
108
|
+
this.records.remove(token);
|
|
85
109
|
}
|
|
86
110
|
/**
|
|
87
111
|
* Drops every session belonging to a name.
|
|
@@ -93,13 +117,29 @@ export class SessionStore {
|
|
|
93
117
|
* every interface that offers the first implies the second.
|
|
94
118
|
*/
|
|
95
119
|
revokeAllFor(name) {
|
|
96
|
-
|
|
97
|
-
if (identity.name === name)
|
|
98
|
-
this.tokens.delete(token);
|
|
99
|
-
}
|
|
120
|
+
this.records.removeAllFor(name);
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
export const SESSION_COOKIE = "laneyard_session";
|
|
124
|
+
/**
|
|
125
|
+
* How the session cookie is written, in one place so the two routes that issue
|
|
126
|
+
* one cannot disagree.
|
|
127
|
+
*
|
|
128
|
+
* `maxAge` is the half that was missing: without it the browser treats this as
|
|
129
|
+
* a session cookie and forgets it the moment the window closes, so signing in
|
|
130
|
+
* again was the price of quitting Chrome. It matches the server's own TTL, so
|
|
131
|
+
* the browser stops presenting a token the server would refuse anyway.
|
|
132
|
+
*
|
|
133
|
+
* Not `secure`: Laneyard is commonly reached over plain HTTP on a local
|
|
134
|
+
* network, and a cookie the browser refuses to send is a login page that never
|
|
135
|
+
* goes away.
|
|
136
|
+
*/
|
|
137
|
+
export const COOKIE_OPTIONS = {
|
|
138
|
+
path: "/",
|
|
139
|
+
httpOnly: true,
|
|
140
|
+
sameSite: "lax",
|
|
141
|
+
maxAge: SESSION_TTL_MS / 1000,
|
|
142
|
+
};
|
|
103
143
|
/** Past this many tracked names, the entries that no longer delay anyone go. */
|
|
104
144
|
const THROTTLE_PRUNE_ABOVE = 1_000;
|
|
105
145
|
/**
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
export const REQUIRES_ADMIN = [
|
|
21
21
|
{ method: "*", path: "/api/secrets" },
|
|
22
22
|
{ method: "*", path: "/api/projects/:slug/secrets" },
|
|
23
|
+
{ method: "*", path: "/api/credentials" },
|
|
24
|
+
{ method: "*", path: "/api/projects/:slug/credentials" },
|
|
23
25
|
{ method: "PUT", path: "/api/projects/:slug/fastfile" },
|
|
24
26
|
{ method: "POST", path: "/api/projects/:slug/commit" },
|
|
25
27
|
{ method: "POST", path: "/api/projects/:slug/push" },
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseEnvExample } from "../heuristics/env-example.js";
|
|
4
|
+
export async function requiredSecrets(input) {
|
|
5
|
+
const fromExample = await envExampleNames(input.workspacePath, input.fastlaneDir);
|
|
6
|
+
const required = [
|
|
7
|
+
...new Set([...input.lanes.flatMap((l) => l.env ?? []), ...input.declared, ...fromExample]),
|
|
8
|
+
].sort();
|
|
9
|
+
const supplied = new Set([...input.vaultKeys, ...input.blockNames, ...input.serverEnv]);
|
|
10
|
+
return {
|
|
11
|
+
required,
|
|
12
|
+
missing: required.filter((name) => !supplied.has(name)),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The names a committed `.env.example` advertises, if there is one.
|
|
17
|
+
*
|
|
18
|
+
* Two conventional filenames and no more: a convenience for the common case,
|
|
19
|
+
* not a search. Never throws — an absent file is the ordinary situation and
|
|
20
|
+
* means "nothing declared", not a failure.
|
|
21
|
+
*/
|
|
22
|
+
export async function envExampleNames(workspacePath, fastlaneDir) {
|
|
23
|
+
const names = [];
|
|
24
|
+
for (const file of [".env.example", ".env.sample"]) {
|
|
25
|
+
const text = await readFile(join(workspacePath, fastlaneDir, file), "utf8").catch(() => null);
|
|
26
|
+
if (text !== null)
|
|
27
|
+
names.push(...parseEnvExample(text));
|
|
28
|
+
}
|
|
29
|
+
return names;
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
|
|
2
|
+
import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* What someone may do to their own account, whatever their role.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately not under `/api/users`: that prefix is on the admin list in
|
|
7
|
+
* `permissions.ts`, and it means "the accounts on this machine". This is the
|
|
8
|
+
* other thing entirely — one person, their own password — and a builder must be
|
|
9
|
+
* able to reach it. Changing the password of somebody *else* stays admin-only,
|
|
10
|
+
* and stays over there.
|
|
11
|
+
*
|
|
12
|
+
* The current password is required even though the session already proves who
|
|
13
|
+
* this is. A session is a cookie in a browser that may have been left open on a
|
|
14
|
+
* desk; a password is the one thing that says the person at the keyboard is
|
|
15
|
+
* still the person who signed in.
|
|
16
|
+
*/
|
|
17
|
+
export async function registerAccountRoutes(app, ctx) {
|
|
18
|
+
app.post("/api/account/password", async (req, reply) => {
|
|
19
|
+
// Non-null: the hook in `app.ts` rejected every request without a session
|
|
20
|
+
// before this handler could run.
|
|
21
|
+
const { name, role } = req.identity;
|
|
22
|
+
const { current, next } = (req.body ?? {});
|
|
23
|
+
if (typeof next !== "string" || next.length < MIN_PASSWORD_LENGTH) {
|
|
24
|
+
return reply
|
|
25
|
+
.code(400)
|
|
26
|
+
.send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
|
|
27
|
+
}
|
|
28
|
+
const users = ctx.config.server()?.users ?? [];
|
|
29
|
+
if (typeof current !== "string" || !(await authenticate(users, name, current))) {
|
|
30
|
+
// No throttle here, unlike `/api/login`: reaching this route already costs
|
|
31
|
+
// a valid session, so there is nobody to slow down who is not already in.
|
|
32
|
+
return reply.code(401).send({ error: "That is not your current password." });
|
|
33
|
+
}
|
|
34
|
+
if (next === current) {
|
|
35
|
+
return reply.code(400).send({ error: "That is the password you already have." });
|
|
36
|
+
}
|
|
37
|
+
// The role is carried over rather than read from the body: this route
|
|
38
|
+
// changes a password and nothing else, and an account that could hand
|
|
39
|
+
// itself `admin` in a field named `role` would make the whole permission
|
|
40
|
+
// table decorative.
|
|
41
|
+
await upsertUserInConfig(ctx.config.configPath(), { name, role, password: next });
|
|
42
|
+
// Every session this account has, gone — including this one. That is the
|
|
43
|
+
// point: changing a password is what someone does when they think another
|
|
44
|
+
// browser somewhere should stop being signed in. A fresh session is issued
|
|
45
|
+
// immediately afterwards, so the person who just did it is not signed out
|
|
46
|
+
// of the page they did it on.
|
|
47
|
+
ctx.sessions.revokeAllFor(name);
|
|
48
|
+
const token = ctx.sessions.issue({ name, role });
|
|
49
|
+
// The file is watched on a debounce; loading here is what makes the very
|
|
50
|
+
// next request see the new hash instead of the old one.
|
|
51
|
+
await ctx.config.load();
|
|
52
|
+
return reply
|
|
53
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
54
|
+
.code(204)
|
|
55
|
+
.send();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/kinds.js";
|
|
2
|
+
/**
|
|
3
|
+
* Signing blocks over HTTP: the file and the fields that make it usable.
|
|
4
|
+
*
|
|
5
|
+
* The file arrives base64 inside a JSON body rather than as a multipart upload.
|
|
6
|
+
* No multipart plugin is registered — see `app.ts` — and a `.p8` is two
|
|
7
|
+
* kilobytes, so adding a dependency to carry it would be paying in supply chain
|
|
8
|
+
* for a convenience the browser can provide in one `FileReader` call.
|
|
9
|
+
*
|
|
10
|
+
* A block is taken or refused whole. That is what makes it a block rather than
|
|
11
|
+
* three loose rows: a keystore stored without its alias is not a partial
|
|
12
|
+
* success, it is a build that fails in a month with an unusable artifact.
|
|
13
|
+
*/
|
|
14
|
+
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
15
|
+
const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
export async function registerCredentialRoutes(app, ctx) {
|
|
17
|
+
app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
|
|
18
|
+
app.get("/api/projects/:slug/credentials", async (req, reply) => {
|
|
19
|
+
const { slug } = req.params;
|
|
20
|
+
if (!ctx.config.project(slug))
|
|
21
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
22
|
+
return ctx.vault.listCredentials(slug);
|
|
23
|
+
});
|
|
24
|
+
const put = async (slug, kind, body, reply) => {
|
|
25
|
+
const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
|
|
26
|
+
if (!spec) {
|
|
27
|
+
return reply.code(400).send({
|
|
28
|
+
error: `"${kind}" is not a kind of credential Laneyard knows: ${CREDENTIAL_KINDS.map((k) => k.kind).join(", ")}.`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const { fileName, fileBase64, fields, varNames } = (body ?? {});
|
|
32
|
+
if (typeof fileName !== "string" || fileName === "") {
|
|
33
|
+
return reply.code(400).send({ error: "A file name is required" });
|
|
34
|
+
}
|
|
35
|
+
if (typeof fileBase64 !== "string" || fileBase64 === "") {
|
|
36
|
+
return reply.code(400).send({ error: `The ${spec.what} file is required, base64-encoded.` });
|
|
37
|
+
}
|
|
38
|
+
const given = (fields ?? {});
|
|
39
|
+
if (typeof given !== "object" || Array.isArray(given)) {
|
|
40
|
+
return reply.code(400).send({ error: "`fields` is an object of name to value." });
|
|
41
|
+
}
|
|
42
|
+
// Optional fields are the settings a block may leave unanswered — where a
|
|
43
|
+
// gradle properties file goes, what it is read under. Laneyard is allowed to
|
|
44
|
+
// ask; refusing the whole block over an unanswered one would be requiring.
|
|
45
|
+
const missing = fieldsOf(spec.kind)
|
|
46
|
+
.filter((f) => !f.optional && (typeof given[f.name] !== "string" || given[f.name] === ""))
|
|
47
|
+
.map((f) => f.name);
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
return reply.code(400).send({
|
|
50
|
+
error: `A ${spec.what} needs ${missing.join(", ")}. Without it the file alone signs nothing.`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Only the fields the kind declares are kept. An extra one would be stored,
|
|
54
|
+
// never read, and quietly disagree with what the block claims to be.
|
|
55
|
+
const kept = {};
|
|
56
|
+
for (const field of fieldsOf(spec.kind)) {
|
|
57
|
+
const value = given[field.name];
|
|
58
|
+
// An unanswered optional field is absent from the block rather than
|
|
59
|
+
// stored empty: the reader then falls back to what it would have used
|
|
60
|
+
// anyway, instead of taking "" for an answer someone gave.
|
|
61
|
+
if (typeof value === "string" && value !== "")
|
|
62
|
+
kept[field.name] = value;
|
|
63
|
+
}
|
|
64
|
+
const names = { ...defaultVarNames(spec.kind), ...(varNames ?? {}) };
|
|
65
|
+
for (const [slot, name] of Object.entries(names)) {
|
|
66
|
+
if (!(slot in defaultVarNames(spec.kind))) {
|
|
67
|
+
return reply.code(400).send({ error: `A ${spec.what} exports nothing called "${slot}".` });
|
|
68
|
+
}
|
|
69
|
+
if (typeof name !== "string" || !VALID_VAR.test(name)) {
|
|
70
|
+
return reply.code(400).send({
|
|
71
|
+
error: `"${name}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Node's base64 decoder ignores what it cannot read rather than refusing, so
|
|
76
|
+
// a truncated or mistyped upload would be stored as a shorter file and only
|
|
77
|
+
// surface as an unreadable key at signing time.
|
|
78
|
+
const fileBytes = Buffer.from(fileBase64, "base64");
|
|
79
|
+
if (fileBytes.length === 0 || fileBytes.toString("base64").replace(/=+$/, "") !== fileBase64.replace(/=+$/, "")) {
|
|
80
|
+
return reply.code(400).send({ error: "The file is not valid base64." });
|
|
81
|
+
}
|
|
82
|
+
await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
|
|
83
|
+
return reply.code(204).send();
|
|
84
|
+
};
|
|
85
|
+
app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
|
|
86
|
+
app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
87
|
+
const { slug, kind } = req.params;
|
|
88
|
+
if (!ctx.config.project(slug))
|
|
89
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
90
|
+
return put(slug, kind, req.body, reply);
|
|
91
|
+
});
|
|
92
|
+
app.delete("/api/credentials/:kind", async (req, reply) => {
|
|
93
|
+
const { kind } = req.params;
|
|
94
|
+
const removed = ctx.vault.removeCredential(null, kind);
|
|
95
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Removes this project's own block, and only that one. A global block that
|
|
99
|
+
* was shadowed comes back into view, which is the deletion someone asked for:
|
|
100
|
+
* they are undoing an override, not deleting everyone's key from inside one
|
|
101
|
+
* project.
|
|
102
|
+
*/
|
|
103
|
+
app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
104
|
+
const { slug, kind } = req.params;
|
|
105
|
+
const removed = ctx.vault.removeCredential(slug, kind);
|
|
106
|
+
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
2
|
+
import { access, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
6
6
|
import { Workspace } from "../../git/workspace.js";
|
|
7
|
-
import {
|
|
7
|
+
import { NO_APPFILE, parseAppfile } from "../../heuristics/appfile.js";
|
|
8
|
+
import { findAndroidBuild } from "../../heuristics/android-root.js";
|
|
9
|
+
import { exportedVarNames } from "../../credentials/kinds.js";
|
|
10
|
+
import { envExampleNames } from "../required-secrets.js";
|
|
11
|
+
import { appRootOf, resolvePlatforms, searchDir } from "../../heuristics/platforms.js";
|
|
8
12
|
import { runChecklist } from "../../heuristics/readiness.js";
|
|
13
|
+
import { LANEYARD_MARKER, propertyNames } from "../../runner/gradle-properties.js";
|
|
9
14
|
const exec = promisify(execFile);
|
|
10
15
|
/**
|
|
11
16
|
* `bundle check` in the workspace, rejecting with what bundler said.
|
|
@@ -35,9 +40,100 @@ async function findFastlane() {
|
|
|
35
40
|
return null;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
/** The globbing `heuristics/platforms.ts` asks for, bound to
|
|
43
|
+
/** The globbing `heuristics/platforms.ts` asks for, bound to a directory. */
|
|
39
44
|
const findIn = (dir) => (globs, { onlyDirectories }) => glob(globs, onlyDirectories ? { cwd: dir, onlyDirectories: true } : { cwd: dir, onlyFiles: true });
|
|
40
45
|
const exists = async (path) => access(path).then(() => true, () => false);
|
|
46
|
+
/**
|
|
47
|
+
* Is this the user's own properties file — as opposed to one Laneyard wrote
|
|
48
|
+
* and failed to clean up?
|
|
49
|
+
*
|
|
50
|
+
* `gradle-properties.ts` marks every file it writes with `LANEYARD_MARKER` as
|
|
51
|
+
* its first line, precisely so this check can tell the two apart. A run killed
|
|
52
|
+
* between writing the file and reaching its `finally` leaves a marked one
|
|
53
|
+
* behind in the persistent clone; counting it as the user's own signing
|
|
54
|
+
* configuration would flip the checklist from "the release build will use the
|
|
55
|
+
* debug key" to "the release key is used" — a green verdict Laneyard
|
|
56
|
+
* manufactured for itself out of a cleanup it failed to run.
|
|
57
|
+
*
|
|
58
|
+
* Reading fails the same way `exists` does: a missing file, a permission
|
|
59
|
+
* error, or one this process cannot open are all "not the user's file",
|
|
60
|
+
* because none of them is evidence of a real signing configuration.
|
|
61
|
+
*/
|
|
62
|
+
async function isUsersOwn(path) {
|
|
63
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
64
|
+
if (text === null)
|
|
65
|
+
return false;
|
|
66
|
+
return (text.split("\n")[0] ?? "").trimEnd() !== LANEYARD_MARKER;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Is the properties file where the build script would look for it — and is it
|
|
70
|
+
* the user's, not Laneyard's own leftover?
|
|
71
|
+
*
|
|
72
|
+
* The module directory is the one holding the build script, and the Gradle root
|
|
73
|
+
* is its parent — `android/` for an `android/app/build.gradle`. Which of the two
|
|
74
|
+
* the name is relative to is the script's decision, and the parser reports which
|
|
75
|
+
* one it made. When it could not tell, both are looked in: answering "not in the
|
|
76
|
+
* clone" because the wrong directory was searched would have the checklist
|
|
77
|
+
* inventing the very failure it exists to catch. Both places apply the same
|
|
78
|
+
* marker rule — a leftover in either one is still Laneyard's, not the user's.
|
|
79
|
+
*/
|
|
80
|
+
async function isPresent(build, file) {
|
|
81
|
+
const { moduleDir, gradleRoot } = build;
|
|
82
|
+
const places = file.scope === "root"
|
|
83
|
+
? [gradleRoot]
|
|
84
|
+
: file.scope === "module"
|
|
85
|
+
? [moduleDir]
|
|
86
|
+
: [gradleRoot, moduleDir];
|
|
87
|
+
const found = await Promise.all(places.map((dir) => isUsersOwn(join(dir, file.name))));
|
|
88
|
+
return found.some(Boolean);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What the android build script says about release signing, and whether the
|
|
92
|
+
* file it depends on is in the clone.
|
|
93
|
+
*
|
|
94
|
+
* The listing is the caller's half of the answer, as everywhere else here: the
|
|
95
|
+
* check reads text and reaches for nothing. Which script speaks for the android
|
|
96
|
+
* side is `heuristics/android-root.ts`'s decision rather than this file's,
|
|
97
|
+
* because the runner writes the properties file against that same decision — see
|
|
98
|
+
* that module for why the two must not be able to disagree.
|
|
99
|
+
*/
|
|
100
|
+
async function androidSigning(workspacePath, appRoot, unreachable) {
|
|
101
|
+
if (unreachable !== null) {
|
|
102
|
+
return { androidSigning: { ok: false, reason: unreachable }, signingFilePresent: false };
|
|
103
|
+
}
|
|
104
|
+
const build = await findAndroidBuild(join(workspacePath, appRoot));
|
|
105
|
+
if (build === null) {
|
|
106
|
+
return {
|
|
107
|
+
androidSigning: { ok: false, reason: "no android build.gradle found in the clone" },
|
|
108
|
+
signingFilePresent: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const present = build.facts.conditionalOn === null ? false : await isPresent(build, build.facts.conditionalOn);
|
|
112
|
+
return { androidSigning: { ok: true, value: build.facts }, signingFilePresent: present };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* What the keystore block says about the properties file, and nothing else.
|
|
116
|
+
*
|
|
117
|
+
* The block has to be decrypted to be asked — `property_names` and
|
|
118
|
+
* `properties_path` are stored with the passphrases — so the narrowing happens
|
|
119
|
+
* here, at the last point that touches plaintext. What crosses into the
|
|
120
|
+
* checklist is two settings a browser is already shown on the block's own form.
|
|
121
|
+
*
|
|
122
|
+
* A block that will not decrypt is not an error page: it is a keystore the
|
|
123
|
+
* checklist cannot speak for, and `credentials` already reports that separately.
|
|
124
|
+
*/
|
|
125
|
+
function keystoreSetting(vault, slug) {
|
|
126
|
+
try {
|
|
127
|
+
const block = vault.resolveCredential(slug, "android_keystore");
|
|
128
|
+
if (!block)
|
|
129
|
+
return null;
|
|
130
|
+
const path = (block.fields["properties_path"] ?? "").trim();
|
|
131
|
+
return { propertyNames: propertyNames(block.fields), propertiesPath: path === "" ? null : path };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
41
137
|
export async function registerReadinessRoutes(app, ctx) {
|
|
42
138
|
/**
|
|
43
139
|
* Computed only when asked for.
|
|
@@ -67,14 +163,30 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
67
163
|
}
|
|
68
164
|
const resolved = await ctx.config.resolve(slug, workspacePath);
|
|
69
165
|
const fastlaneDir = resolved?.settings.fastlane_dir ?? "fastlane";
|
|
70
|
-
const
|
|
166
|
+
const read = unreachable !== null
|
|
71
167
|
? { ok: false, reason: unreachable }
|
|
72
168
|
: await ctx
|
|
73
169
|
.uses(slug, workspacePath, fastlaneDir)
|
|
74
|
-
.then((
|
|
170
|
+
.then((value) => ({ ok: true, value }))
|
|
75
171
|
// Broken Fastfile, no Ruby, no fastlane: all of them are "could not
|
|
76
172
|
// tell", none of them is a 500.
|
|
77
173
|
.catch((cause) => ({ ok: false, reason: cause.message }));
|
|
174
|
+
const uses = read.ok
|
|
175
|
+
? { ok: true, value: read.value.lanes }
|
|
176
|
+
: { ok: false, reason: read.reason };
|
|
177
|
+
// What reading the Fastfile could not account for. `fastlane/actions` is the
|
|
178
|
+
// caller's half of the answer — a directory listing, not something the Ruby
|
|
179
|
+
// parser could know — and the sidecar supplies the other, `import`. A check
|
|
180
|
+
// that would conclude something from finding nothing consults this first.
|
|
181
|
+
const unread = read.ok
|
|
182
|
+
? {
|
|
183
|
+
ok: true,
|
|
184
|
+
value: {
|
|
185
|
+
imports: read.value.imports,
|
|
186
|
+
customActions: await exists(join(workspacePath, fastlaneDir, "actions")),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: { ok: false, reason: read.reason };
|
|
78
190
|
// What the project builds for decides which half of the checklist applies.
|
|
79
191
|
// `laneyard.yml` answers on its own; without it the workspace is looked at,
|
|
80
192
|
// and an unreachable workspace is a "could not tell" rather than a claim
|
|
@@ -82,7 +194,29 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
82
194
|
const configured = resolved?.settings.platforms;
|
|
83
195
|
const platforms = unreachable !== null && (configured === undefined || configured.length === 0)
|
|
84
196
|
? { ok: false, reason: unreachable }
|
|
85
|
-
: {
|
|
197
|
+
: {
|
|
198
|
+
ok: true,
|
|
199
|
+
// Beside the Fastfile rather than at the repository root: in a
|
|
200
|
+
// monorepo the app is one directory down, and so are its platform
|
|
201
|
+
// folders.
|
|
202
|
+
value: await resolvePlatforms(configured, findIn(searchDir(workspacePath, appRootOf(fastlaneDir)))),
|
|
203
|
+
};
|
|
204
|
+
// The Appfile is fastlane's own file, beside the Fastfile, and it is where a
|
|
205
|
+
// project configured long before it met Laneyard keeps its Play Store
|
|
206
|
+
// service account. An absent one is a fact — `NO_APPFILE` — not a failure;
|
|
207
|
+
// an unreachable workspace is the only reason this cannot be answered.
|
|
208
|
+
const appfile = unreachable !== null
|
|
209
|
+
? { ok: false, reason: unreachable }
|
|
210
|
+
: await readFile(join(workspacePath, fastlaneDir, "Appfile"), "utf8").then((text) => ({ ok: true, value: parseAppfile(text) }),
|
|
211
|
+
// Missing, unreadable, a directory: all of them mean the same to a
|
|
212
|
+
// check, which is that the Appfile says nothing.
|
|
213
|
+
() => ({ ok: true, value: NO_APPFILE }));
|
|
214
|
+
// Listed the same way platforms are, from the clone rather than from any
|
|
215
|
+
// path a Fastfile mentions: what is asked is "does the repository carry a
|
|
216
|
+
// key", which is a question about the repository.
|
|
217
|
+
const keyFilesInRepo = unreachable !== null
|
|
218
|
+
? { ok: false, reason: unreachable }
|
|
219
|
+
: await glob(["**/*.p8"], { cwd: workspacePath, onlyFiles: true, dot: true }).then((found) => ({ ok: true, value: found.sort() }), (cause) => ({ ok: false, reason: cause.message }));
|
|
86
220
|
const sections = await runChecklist({
|
|
87
221
|
probeRepository: () => workspace.probeRemote(),
|
|
88
222
|
dependencies: {
|
|
@@ -94,8 +228,30 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
94
228
|
},
|
|
95
229
|
// Names only: the vault never hands a value to anything but a run.
|
|
96
230
|
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
231
|
+
// Which blocks apply, resolved the way a run resolves them — a project's
|
|
232
|
+
// own shadowing a global one — so the checklist and the run cannot
|
|
233
|
+
// disagree about whether a credential exists.
|
|
234
|
+
blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
|
|
235
|
+
// And the names those blocks will export, which the environment check
|
|
236
|
+
// counts as supplied — Laneyard writes the file and sets the variable
|
|
237
|
+
// itself, so a lane reading it is not a lane short of anything. The same
|
|
238
|
+
// list the secrets screen is given, from the same call.
|
|
239
|
+
blockNames: exportedVarNames(ctx.vault.listCredentials(slug)),
|
|
240
|
+
keystore: keystoreSetting(ctx.vault, slug),
|
|
97
241
|
uses,
|
|
98
242
|
platforms,
|
|
243
|
+
appfile,
|
|
244
|
+
keyFilesInRepo,
|
|
245
|
+
unread,
|
|
246
|
+
// A run inherits the server's environment, so a variable exported where
|
|
247
|
+
// Laneyard was started really is available to a lane. Names only: a
|
|
248
|
+
// checklist has no business reading a value, here least of all.
|
|
249
|
+
serverEnv: Object.keys(process.env),
|
|
250
|
+
...(await androidSigning(workspacePath, appRootOf(fastlaneDir), unreachable)),
|
|
251
|
+
declaredSecrets: [
|
|
252
|
+
...(resolved?.settings.required_secrets ?? []),
|
|
253
|
+
...(await envExampleNames(workspacePath, fastlaneDir)),
|
|
254
|
+
],
|
|
99
255
|
});
|
|
100
256
|
return { checkedAt: new Date().toISOString(), sections };
|
|
101
257
|
});
|