laneyard 0.7.0 → 0.8.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 +1 -1
- package/dist/src/cli/remove.js +2 -2
- package/dist/src/cli/reset.js +2 -2
- package/dist/src/cli/uninstall.js +5 -21
- package/dist/src/config/env-file-setting.js +57 -0
- package/dist/src/config/resolve.js +8 -5
- package/dist/src/config/schema.js +29 -0
- package/dist/src/db/credentials.js +18 -54
- package/dist/src/db/migrate-global-scope.js +75 -0
- package/dist/src/db/open.js +21 -0
- package/dist/src/db/schema.sql +10 -5
- package/dist/src/db/secrets.js +44 -65
- package/dist/src/main.js +31 -2
- package/dist/src/runner/env-file.js +144 -0
- package/dist/src/runner/orchestrate.js +14 -1
- package/dist/src/secrets/vault.js +27 -29
- package/dist/src/server/app.js +4 -0
- package/dist/src/server/routes/credentials.js +0 -13
- package/dist/src/server/routes/projects.js +0 -16
- package/dist/src/server/routes/readiness.js +2 -3
- package/dist/src/server/routes/secrets.js +96 -27
- package/dist/web/assets/{Editor-wZdJUk7D.js → Editor-BK-ZRKlI.js} +1 -1
- package/dist/web/assets/index-DnUdqjT4.js +62 -0
- package/dist/web/assets/{index-CPKV0yx1.css → index-r3IyRuO4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-Cq1Ze8dP.js +0 -62
package/dist/src/main.js
CHANGED
|
@@ -14,6 +14,7 @@ import { runUserCommand } from "./cli/user.js";
|
|
|
14
14
|
import { ConfigStore } from "./config/store.js";
|
|
15
15
|
import { CacheStore } from "./db/cache.js";
|
|
16
16
|
import { openDatabase } from "./db/open.js";
|
|
17
|
+
import { migrateGlobalScope } from "./db/migrate-global-scope.js";
|
|
17
18
|
import { RunStore } from "./db/runs.js";
|
|
18
19
|
import { CredentialStore } from "./db/credentials.js";
|
|
19
20
|
import { SecretStore } from "./db/secrets.js";
|
|
@@ -51,6 +52,10 @@ export async function createServerFromConfig(root) {
|
|
|
51
52
|
if (!loaded.ok)
|
|
52
53
|
throw new Error(`Unreadable configuration: ${loaded.error}`);
|
|
53
54
|
const db = openDatabase(join(root, "laneyard.db"));
|
|
55
|
+
// Before anything reads the vault: the two scopes are gone, and every query
|
|
56
|
+
// below names a project. Reported rather than returned quietly — see
|
|
57
|
+
// `migrate-global-scope.ts` for why a user has to be told.
|
|
58
|
+
const migration = migrateGlobalScope(db, config.projects().map((p) => p.slug));
|
|
54
59
|
// No run that had begun can survive the shutdown of the process that carried
|
|
55
60
|
// it. Queued runs never began, so they stay queued for the next start.
|
|
56
61
|
new RunStore(db).interruptInFlight();
|
|
@@ -76,12 +81,35 @@ export async function createServerFromConfig(root) {
|
|
|
76
81
|
// this, `wake()` would only ever be called from the trigger route, and three
|
|
77
82
|
// runs queued before a restart would wait for someone to trigger a fourth.
|
|
78
83
|
app.queue.wake();
|
|
79
|
-
return { app, db, config };
|
|
84
|
+
return { app, db, config, migration };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The upgrade notice for a vault that had a global scope.
|
|
88
|
+
*
|
|
89
|
+
* Empty for every start but the one that migrates, so it costs nothing to leave
|
|
90
|
+
* in. It matters on that one start: someone who stored a signing key once now
|
|
91
|
+
* has a copy per project, and rotating it means replacing each of them. Finding
|
|
92
|
+
* that out from a build that uploaded with a key they thought they had replaced
|
|
93
|
+
* would be far worse than a paragraph on a terminal.
|
|
94
|
+
*/
|
|
95
|
+
function migrationNotice(migration) {
|
|
96
|
+
const { copied, dropped } = migration;
|
|
97
|
+
if (copied.length === 0 && dropped.length === 0)
|
|
98
|
+
return "";
|
|
99
|
+
const lines = [
|
|
100
|
+
...copied.map(({ what, name, slugs }) => ` ${bold(name)} — ${what}, now in ${slugs.join(", ")}`),
|
|
101
|
+
...dropped.map(({ what, name }) => ` ${bold(name)} — ${what}, ${dim("removed: no project read it")}`),
|
|
102
|
+
];
|
|
103
|
+
return ("\n" +
|
|
104
|
+
dim(" A secret and a signing block now belong to one project. What was shared\n") +
|
|
105
|
+
dim(" has been copied into each project that read it — delete any you do not want.\n\n") +
|
|
106
|
+
lines.join("\n") +
|
|
107
|
+
"\n");
|
|
80
108
|
}
|
|
81
109
|
/** Real startup, outside tests. */
|
|
82
110
|
async function main() {
|
|
83
111
|
const root = process.env["LANEYARD_HOME"] ?? join(homedir(), ".laneyard");
|
|
84
|
-
const { app, config } = await createServerFromConfig(root);
|
|
112
|
+
const { app, config, migration } = await createServerFromConfig(root);
|
|
85
113
|
config.watch((ok) => {
|
|
86
114
|
if (!ok)
|
|
87
115
|
console.error(`Invalid configuration, the previous one stays active: ${config.lastError()}`);
|
|
@@ -105,6 +133,7 @@ async function main() {
|
|
|
105
133
|
dim(" No project yet. From a folder that already uses fastlane:\n") +
|
|
106
134
|
` ${bold("laneyard setup")}\n`
|
|
107
135
|
: "") +
|
|
136
|
+
migrationNotice(migration) +
|
|
108
137
|
"\n");
|
|
109
138
|
}
|
|
110
139
|
/**
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment file a build reads from disk.
|
|
3
|
+
*
|
|
4
|
+
* A project's own `.env` — the one its app reads, not the one fastlane reads —
|
|
5
|
+
* is gitignored, and Laneyard builds from a clone, so it is never there. The
|
|
6
|
+
* vault does not answer that on its own: a stored secret becomes an environment
|
|
7
|
+
* variable of the run, which is enough for fastlane and enough for a Fastfile
|
|
8
|
+
* that forwards values itself, and no use at all to anything that reads a
|
|
9
|
+
* *file*. `flutter_dotenv` bundles `.env` as an asset,
|
|
10
|
+
* `--dart-define-from-file=config.json` reads a path at compile time, an
|
|
11
|
+
* `.xcconfig` is a file by definition. None of them looks at the environment,
|
|
12
|
+
* and none of them fails loudly — they produce an app configured with nothing.
|
|
13
|
+
*
|
|
14
|
+
* So the variables a project ticks are rendered into a file at the path its
|
|
15
|
+
* `laneyard.yml` names, for the length of one run, and removed afterwards. The
|
|
16
|
+
* ticking decides membership of this file and nothing else: a ticked secret
|
|
17
|
+
* still reaches the run as an environment variable like every other one.
|
|
18
|
+
*/
|
|
19
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { LANEYARD_MARKER } from "./gradle-properties.js";
|
|
22
|
+
/**
|
|
23
|
+
* Whether a value can be written bare.
|
|
24
|
+
*
|
|
25
|
+
* Anything outside this set is quoted. The set is deliberately narrow — a bare
|
|
26
|
+
* line is a convenience, and being wrong about it costs a value silently read
|
|
27
|
+
* back short. `#` starts a comment, whitespace at either end is trimmed by most
|
|
28
|
+
* readers, and a quote or backslash is an escape somebody has to interpret.
|
|
29
|
+
*/
|
|
30
|
+
const BARE = /^[A-Za-z0-9_./:@+-]*$/;
|
|
31
|
+
/**
|
|
32
|
+
* Escapes a value for a double-quoted dotenv line.
|
|
33
|
+
*
|
|
34
|
+
* Backslash first, or every escape this function then writes would be escaped
|
|
35
|
+
* again by its own output. Newlines become `\n` rather than being written
|
|
36
|
+
* literally: a line break inside a value ends the line, and the parser reads a
|
|
37
|
+
* truncated value and a second variable that does not exist. A private key
|
|
38
|
+
* pasted into a variable is where that happens for real.
|
|
39
|
+
*/
|
|
40
|
+
function escape(value) {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/\\/g, "\\\\")
|
|
43
|
+
.replace(/"/g, '\\"')
|
|
44
|
+
.replace(/\r/g, "\\r")
|
|
45
|
+
.replace(/\n/g, "\\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Renders variables as dotenv text.
|
|
49
|
+
*
|
|
50
|
+
* Sorted by name, so a diff between two runs shows what changed rather than
|
|
51
|
+
* what moved. Pure, and tested against a parser rather than against itself:
|
|
52
|
+
* the property worth having is "a reader gets back what went in", and a test
|
|
53
|
+
* written against this function's own idea of the format would pass however
|
|
54
|
+
* wrong that idea was.
|
|
55
|
+
*/
|
|
56
|
+
export function renderDotenv(values) {
|
|
57
|
+
return Object.keys(values)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((key) => dotenvLine(key, values[key]))
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One `KEY=value` line, quoted if the value needs it.
|
|
64
|
+
*
|
|
65
|
+
* Exported for the preview the secrets screen shows, which renders a stand-in
|
|
66
|
+
* for every masked value rather than the value. It has to compose its own lines
|
|
67
|
+
* — a row of dots is not ASCII, so it would come back quoted, and the quotes
|
|
68
|
+
* would be a claim about the real value that nobody can check. It still goes
|
|
69
|
+
* through this function for everything it does show, so the preview and the
|
|
70
|
+
* file cannot drift apart in how they write a line.
|
|
71
|
+
*/
|
|
72
|
+
export function dotenvLine(key, value) {
|
|
73
|
+
return `${key}=${BARE.test(value) ? value : `"${escape(value)}"`}\n`;
|
|
74
|
+
}
|
|
75
|
+
/** The first line of a file, or null when there is no file to read. */
|
|
76
|
+
async function firstLine(path) {
|
|
77
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
78
|
+
return text === null ? null : (text.split("\n")[0] ?? "");
|
|
79
|
+
}
|
|
80
|
+
/** Whether this file is one Laneyard wrote, and may therefore replace or remove. */
|
|
81
|
+
async function ours(path) {
|
|
82
|
+
const line = await firstLine(path);
|
|
83
|
+
return line === null || line.trimEnd() === LANEYARD_MARKER;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Writes the environment file, and returns its path — or null, having written
|
|
87
|
+
* nothing.
|
|
88
|
+
*
|
|
89
|
+
* Null has two causes and neither is an error: the project names no `env_file`,
|
|
90
|
+
* or a file is already there that Laneyard did not write. That second one is
|
|
91
|
+
* the important guard. The path points into a clone the user can work in by
|
|
92
|
+
* hand, and a `.env` they put there themselves is their build's real
|
|
93
|
+
* configuration — overwriting it would replace something that works with
|
|
94
|
+
* something assembled from a checklist. It is left exactly as it is.
|
|
95
|
+
*
|
|
96
|
+
* The marker is the first line, so the file says what it is to anyone who opens
|
|
97
|
+
* it, and so this function can tell its own output from a person's on the next
|
|
98
|
+
* run. `#` is a comment in dotenv, which is the reason dotenv is the only
|
|
99
|
+
* format for now: JSON has nowhere to put a line like this.
|
|
100
|
+
*/
|
|
101
|
+
export async function writeEnvFile(appRoot, envFile, values) {
|
|
102
|
+
if (envFile === undefined)
|
|
103
|
+
return null;
|
|
104
|
+
const path = join(appRoot, envFile);
|
|
105
|
+
if (!(await ours(path)))
|
|
106
|
+
return null;
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
// The mode is set twice, as in `materialise.ts` and `gradle-properties.ts`:
|
|
109
|
+
// `writeFile`'s mode is masked by the process umask, and ignored outright for
|
|
110
|
+
// a file that already exists. This one holds whatever the vault held.
|
|
111
|
+
await writeFile(path, `${LANEYARD_MARKER}\n${renderDotenv(values)}`, { mode: 0o600 });
|
|
112
|
+
await chmod(path, 0o600);
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Removes a file this run wrote, and only such a file.
|
|
117
|
+
*
|
|
118
|
+
* Called from the run's `finally`, so the values do not outlive the build that
|
|
119
|
+
* needed them. Takes null so the caller can hand it whatever `writeEnvFile`
|
|
120
|
+
* returned without a branch of its own.
|
|
121
|
+
*/
|
|
122
|
+
export async function removeEnvFile(path) {
|
|
123
|
+
if (path === null)
|
|
124
|
+
return;
|
|
125
|
+
if ((await firstLine(path))?.trimEnd() !== LANEYARD_MARKER)
|
|
126
|
+
return;
|
|
127
|
+
await rm(path, { force: true }).catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Removes a marked file left where this run would write one.
|
|
131
|
+
*
|
|
132
|
+
* A run killed between writing the file and reaching its `finally` — an
|
|
133
|
+
* unplugged server, a `kill -9` — leaves values in a working tree that is kept
|
|
134
|
+
* between runs. Nothing at that moment can clean up after itself, so the next
|
|
135
|
+
* run does it first.
|
|
136
|
+
*
|
|
137
|
+
* Silent about everything: a workspace that was never cloned has nothing to
|
|
138
|
+
* sweep, and a sweep that failed must not be the reason a build did not start.
|
|
139
|
+
*/
|
|
140
|
+
export async function sweepEnvFile(appRoot, envFile) {
|
|
141
|
+
if (envFile === undefined)
|
|
142
|
+
return;
|
|
143
|
+
await removeEnvFile(join(appRoot, envFile)).catch(() => { });
|
|
144
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Redactor, scrub } from "../logs/redact.js";
|
|
|
7
7
|
import { assertFastlaneDir } from "../sidecar/fastlane-dir.js";
|
|
8
8
|
import { collectArtifacts } from "./artifacts.js";
|
|
9
9
|
import { removeGradleProperties, sweepGradleProperties, writeGradleProperties, } from "./gradle-properties.js";
|
|
10
|
+
import { removeEnvFile, sweepEnvFile, writeEnvFile } from "./env-file.js";
|
|
10
11
|
import { LiveStepTracker } from "./live-steps.js";
|
|
11
12
|
import { startPty } from "./pty.js";
|
|
12
13
|
import { readReport } from "./report.js";
|
|
@@ -31,13 +32,14 @@ import { readReport } from "./report.js";
|
|
|
31
32
|
* inside the run and has to be visible to a `finally` wrapped around all of it.
|
|
32
33
|
*/
|
|
33
34
|
export async function executeRun(opts) {
|
|
34
|
-
const wrote = { properties: null };
|
|
35
|
+
const wrote = { properties: null, envFile: null };
|
|
35
36
|
try {
|
|
36
37
|
return await execute(opts, wrote);
|
|
37
38
|
}
|
|
38
39
|
finally {
|
|
39
40
|
// Guarded by the marker, so a file the user replaced mid-run survives.
|
|
40
41
|
await removeGradleProperties(wrote.properties).catch(() => { });
|
|
42
|
+
await removeEnvFile(wrote.envFile).catch(() => { });
|
|
41
43
|
// Deliberately swallowed: by now the log writer is closed and the run's
|
|
42
44
|
// verdict is recorded, so there is nowhere left to report this without
|
|
43
45
|
// rewriting a finished run's outcome as a failure it did not have. A
|
|
@@ -139,6 +141,7 @@ async function execute(opts, wrote) {
|
|
|
139
141
|
return fail(cause.message);
|
|
140
142
|
}
|
|
141
143
|
await sweepGradleProperties(appRoot, opts.androidKeystore).catch(() => { });
|
|
144
|
+
await sweepEnvFile(appRoot, settings.env_file).catch(() => { });
|
|
142
145
|
try {
|
|
143
146
|
wrote.properties = await writeGradleProperties(appRoot, opts.androidKeystore);
|
|
144
147
|
}
|
|
@@ -148,6 +151,16 @@ async function execute(opts, wrote) {
|
|
|
148
151
|
// succeeds, signed with the debug key, rejected by the store days later.
|
|
149
152
|
return fail(`Could not write the signing properties file: ${cause.message}`);
|
|
150
153
|
}
|
|
154
|
+
try {
|
|
155
|
+
wrote.envFile = await writeEnvFile(appRoot, settings.env_file, opts.envFileValues ?? {});
|
|
156
|
+
}
|
|
157
|
+
catch (cause) {
|
|
158
|
+
// Failing for the same reason, and it is the same shape of failure. A build
|
|
159
|
+
// that carries on without the file it was configured to read does not stop:
|
|
160
|
+
// it produces an app pointed at nothing, which is discovered by a person
|
|
161
|
+
// opening it rather than by this run.
|
|
162
|
+
return fail(`Could not write the environment file: ${cause.message}`);
|
|
163
|
+
}
|
|
151
164
|
// Cancelled during preparation: fastlane never gets to start.
|
|
152
165
|
if (opts.signal?.aborted) {
|
|
153
166
|
return finishAs("cancelled", "Cancelled");
|
|
@@ -21,8 +21,8 @@ export class Vault {
|
|
|
21
21
|
static async open(home, store, credentials) {
|
|
22
22
|
return new Vault(await loadOrCreateKey(home), store, credentials);
|
|
23
23
|
}
|
|
24
|
-
async set(projectSlug, key, value, masked) {
|
|
25
|
-
this.store.set(projectSlug, key, encrypt(value, this.key), masked);
|
|
24
|
+
async set(projectSlug, key, value, masked, inEnvFile = false) {
|
|
25
|
+
this.store.set(projectSlug, key, encrypt(value, this.key), masked, inEnvFile);
|
|
26
26
|
}
|
|
27
27
|
remove(projectSlug, key) {
|
|
28
28
|
return this.store.remove(projectSlug, key);
|
|
@@ -66,24 +66,6 @@ export class Vault {
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
listGlobal() {
|
|
70
|
-
return this.store.listGlobal();
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* What the vault holds under one project's own name — secrets and signing
|
|
74
|
-
* blocks — and nothing global.
|
|
75
|
-
*
|
|
76
|
-
* Its own method rather than a filter over `list`, because the question is a
|
|
77
|
-
* different one: `list` is "what would a run of this project see", and this is
|
|
78
|
-
* "what would be left behind if the project went away". A global secret is in
|
|
79
|
-
* the first answer and must never be in the second.
|
|
80
|
-
*/
|
|
81
|
-
ownedBy(projectSlug) {
|
|
82
|
-
return {
|
|
83
|
-
secrets: this.store.listOwn(projectSlug),
|
|
84
|
-
credentials: this.credentials.listOwn(projectSlug),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
69
|
/**
|
|
88
70
|
* Forgets everything stored under one project's name.
|
|
89
71
|
*
|
|
@@ -92,13 +74,15 @@ export class Vault {
|
|
|
92
74
|
* covers all of it. What it forgets is Laneyard's own encrypted copy — the
|
|
93
75
|
* `.p8` and the keystore that went in are still wherever the user keeps them.
|
|
94
76
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
77
|
+
* There is nothing it has to leave behind. Everything a project sees is a row
|
|
78
|
+
* under its own slug, so "what would a run see" and "what would be left if the
|
|
79
|
+
* project went away" are now the same question — which is most of the point of
|
|
80
|
+
* having one scope.
|
|
97
81
|
*/
|
|
98
82
|
forget(projectSlug) {
|
|
99
83
|
return {
|
|
100
|
-
secrets: this.store.
|
|
101
|
-
credentials: this.credentials.
|
|
84
|
+
secrets: this.store.removeAll(projectSlug),
|
|
85
|
+
credentials: this.credentials.removeAll(projectSlug),
|
|
102
86
|
};
|
|
103
87
|
}
|
|
104
88
|
/**
|
|
@@ -111,7 +95,7 @@ export class Vault {
|
|
|
111
95
|
return scrub(text, this.maskedValues(projectSlug));
|
|
112
96
|
}
|
|
113
97
|
/**
|
|
114
|
-
* Every secret
|
|
98
|
+
* Every secret a project holds, ready to become environment variables.
|
|
115
99
|
*
|
|
116
100
|
* A row that will not decrypt is skipped rather than thrown: a key that was
|
|
117
101
|
* rotated or a corrupted row should cost one variable, not the whole build.
|
|
@@ -160,6 +144,23 @@ export class Vault {
|
|
|
160
144
|
setMasked(projectSlug, key, masked) {
|
|
161
145
|
return this.store.setMasked(projectSlug, key, masked);
|
|
162
146
|
}
|
|
147
|
+
/** Flips whether a value is written into the environment file. */
|
|
148
|
+
setInEnvFile(projectSlug, key, inEnvFile) {
|
|
149
|
+
return this.store.setInEnvFile(projectSlug, key, inEnvFile);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The variables that go in the environment file, in the clear, ready to be
|
|
153
|
+
* rendered.
|
|
154
|
+
*
|
|
155
|
+
* Decrypted here rather than in the writer, so plaintext stays inside this one
|
|
156
|
+
* file — the property the whole module exists to make checkable by reading.
|
|
157
|
+
* A row that will not decrypt is skipped, the same leniency `resolve` takes:
|
|
158
|
+
* the build then fails on its own terms rather than on a rotated key.
|
|
159
|
+
*/
|
|
160
|
+
envFileValues(projectSlug) {
|
|
161
|
+
const wanted = new Set(this.store.envFileKeys(projectSlug));
|
|
162
|
+
return Object.fromEntries(Object.entries(this.resolve(projectSlug)).filter(([key]) => wanted.has(key)));
|
|
163
|
+
}
|
|
163
164
|
/**
|
|
164
165
|
* Stores a signing block: the file, and the fields that make it usable.
|
|
165
166
|
*
|
|
@@ -177,7 +178,7 @@ export class Vault {
|
|
|
177
178
|
});
|
|
178
179
|
}
|
|
179
180
|
/**
|
|
180
|
-
* One
|
|
181
|
+
* One of a project's blocks, in the clear, or undefined if there is none.
|
|
181
182
|
*
|
|
182
183
|
* Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
|
|
183
184
|
* missing variable makes fastlane stop and say which one. A block that quietly
|
|
@@ -205,9 +206,6 @@ export class Vault {
|
|
|
205
206
|
listCredentials(projectSlug) {
|
|
206
207
|
return this.credentials.list(projectSlug);
|
|
207
208
|
}
|
|
208
|
-
listGlobalCredentials() {
|
|
209
|
-
return this.credentials.listGlobal();
|
|
210
|
-
}
|
|
211
209
|
removeCredential(projectSlug, kind) {
|
|
212
210
|
return this.credentials.remove(projectSlug, kind);
|
|
213
211
|
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -98,6 +98,10 @@ export async function buildApp(deps) {
|
|
|
98
98
|
},
|
|
99
99
|
env: process.env,
|
|
100
100
|
secrets: ctx.vault.resolve(slug),
|
|
101
|
+
// A subset of the line above, not a second source. The tick decides which
|
|
102
|
+
// variables are also written into the file the build reads from disk;
|
|
103
|
+
// every one of them still reaches the run through the environment.
|
|
104
|
+
envFileValues: ctx.vault.envFileValues(slug),
|
|
101
105
|
credentialEnv: credentials.env,
|
|
102
106
|
androidKeystore: credentials.keystore,
|
|
103
107
|
cleanup: credentials.cleanup,
|
|
@@ -14,7 +14,6 @@ import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/k
|
|
|
14
14
|
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
15
15
|
const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
16
|
export async function registerCredentialRoutes(app, ctx) {
|
|
17
|
-
app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
|
|
18
17
|
app.get("/api/projects/:slug/credentials", async (req, reply) => {
|
|
19
18
|
const { slug } = req.params;
|
|
20
19
|
if (!ctx.config.project(slug))
|
|
@@ -82,24 +81,12 @@ export async function registerCredentialRoutes(app, ctx) {
|
|
|
82
81
|
await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
|
|
83
82
|
return reply.code(204).send();
|
|
84
83
|
};
|
|
85
|
-
app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
|
|
86
84
|
app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
87
85
|
const { slug, kind } = req.params;
|
|
88
86
|
if (!ctx.config.project(slug))
|
|
89
87
|
return reply.code(404).send({ error: "Unknown project" });
|
|
90
88
|
return put(slug, kind, req.body, reply);
|
|
91
89
|
});
|
|
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
90
|
app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
104
91
|
const { slug, kind } = req.params;
|
|
105
92
|
const removed = ctx.vault.removeCredential(slug, kind);
|
|
@@ -42,9 +42,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
42
42
|
* `.p8` or a keystore; the file that went in is still in the password
|
|
43
43
|
* manager or the safe it came from. The answer says so, the way
|
|
44
44
|
* `uninstall` does, so nobody is left thinking their keystore is gone.
|
|
45
|
-
* - global secrets and global signing blocks. They are read by every project
|
|
46
|
-
* on the machine, not this one's to take — `vault.forget` touches only
|
|
47
|
-
* slug-scoped rows, and the answer counts the global ones it left alone.
|
|
48
45
|
*
|
|
49
46
|
* The confirmation is the project's own slug, sent back as `?confirm=`.
|
|
50
47
|
* Without a match nothing is removed: a bare DELETE is a refusal, not a
|
|
@@ -73,12 +70,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
73
70
|
error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
|
|
74
71
|
});
|
|
75
72
|
}
|
|
76
|
-
// The global counts are read now — the last moment anyone is looking — so
|
|
77
|
-
// the answer can say what it left alone. They are shared by every project
|
|
78
|
-
// and survive one of them going away, which is why the removal never takes
|
|
79
|
-
// them and the reply names them apart.
|
|
80
|
-
const globalSecrets = ctx.vault.listGlobal().length;
|
|
81
|
-
const globalSigningBlocks = ctx.vault.listGlobalCredentials().length;
|
|
82
73
|
// The removal itself lives in one place, shared with `laneyard remove`: the
|
|
83
74
|
// route confirms and shapes the reply, the core does the deleting.
|
|
84
75
|
const result = await removeProjectData({
|
|
@@ -109,13 +100,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
109
100
|
secrets: result.secrets,
|
|
110
101
|
signingBlocks: result.signingBlocks,
|
|
111
102
|
},
|
|
112
|
-
// Named, not removed. The git remote and the credential originals are the
|
|
113
|
-
// user's and are never touched here; the global rows are shared by every
|
|
114
|
-
// project and survive one of them going away.
|
|
115
|
-
untouched: {
|
|
116
|
-
globalSecrets,
|
|
117
|
-
globalSigningBlocks,
|
|
118
|
-
},
|
|
119
103
|
});
|
|
120
104
|
});
|
|
121
105
|
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
@@ -228,9 +228,8 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
228
228
|
},
|
|
229
229
|
// Names only: the vault never hands a value to anything but a run.
|
|
230
230
|
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
231
|
-
// Which blocks
|
|
232
|
-
//
|
|
233
|
-
// disagree about whether a credential exists.
|
|
231
|
+
// Which blocks this project holds, read the way a run reads them, so the
|
|
232
|
+
// checklist and the run cannot disagree about whether a credential exists.
|
|
234
233
|
blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
|
|
235
234
|
// And the names those blocks will export, which the environment check
|
|
236
235
|
// counts as supplied — Laneyard writes the file and sets the variable
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { MIN_LENGTH as MIN_REDACTABLE } from "../../logs/redact.js";
|
|
2
2
|
import { exportedVarNames } from "../../credentials/kinds.js";
|
|
3
3
|
import { requiredSecrets } from "../required-secrets.js";
|
|
4
|
+
import { dotenvLine } from "../../runner/env-file.js";
|
|
5
|
+
import { envFileProblem, setEnvFileSetting } from "../../config/env-file-setting.js";
|
|
4
6
|
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
5
7
|
const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6
8
|
export async function registerSecretRoutes(app, ctx) {
|
|
7
|
-
// A project's listing carries the values that were never declared secret; the
|
|
8
|
-
// global one carries names only. Not a judgement about global secrets — it is
|
|
9
|
-
// that `/api/secrets` answers for no project in particular, and a value is
|
|
10
|
-
// only ever decrypted in the context of the project that reads it.
|
|
11
|
-
const listRoute = (slug) => slug === null ? ctx.vault.listGlobal() : ctx.vault.listWithValues(slug);
|
|
12
|
-
app.get("/api/secrets", async () => listRoute(null));
|
|
13
9
|
app.get("/api/projects/:slug/secrets", async (req, reply) => {
|
|
14
10
|
const { slug } = req.params;
|
|
15
11
|
if (!ctx.config.project(slug))
|
|
16
12
|
return reply.code(404).send({ error: "Unknown project" });
|
|
17
|
-
return
|
|
13
|
+
return ctx.vault.listWithValues(slug);
|
|
18
14
|
});
|
|
19
15
|
const put = async (slug, key, body, reply) => {
|
|
20
|
-
const { value, masked } = (body ?? {});
|
|
16
|
+
const { value, masked, inEnvFile } = (body ?? {});
|
|
21
17
|
if (!VALID_KEY.test(key)) {
|
|
22
18
|
return reply.code(400).send({
|
|
23
19
|
error: `"${key}" is not a valid environment variable name: letters, digits and underscore, not starting with a digit.`,
|
|
@@ -35,10 +31,11 @@ export async function registerSecretRoutes(app, ctx) {
|
|
|
35
31
|
"Store it unmasked if you accept it appearing in the output.",
|
|
36
32
|
});
|
|
37
33
|
}
|
|
38
|
-
|
|
34
|
+
// Defaults to false: a variable goes in the file because someone said so,
|
|
35
|
+
// never because a field was left out of a request.
|
|
36
|
+
await ctx.vault.set(slug, key, value, masked !== false, inEnvFile === true);
|
|
39
37
|
return reply.code(204).send();
|
|
40
38
|
};
|
|
41
|
-
app.put("/api/secrets/:key", async (req, reply) => put(null, req.params.key, req.body, reply));
|
|
42
39
|
app.put("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
43
40
|
const { slug, key } = req.params;
|
|
44
41
|
if (!ctx.config.project(slug))
|
|
@@ -69,31 +66,37 @@ export async function registerSecretRoutes(app, ctx) {
|
|
|
69
66
|
return { key, value };
|
|
70
67
|
});
|
|
71
68
|
/**
|
|
72
|
-
* Turns
|
|
69
|
+
* Turns a flag on or off, leaving the value alone.
|
|
73
70
|
*
|
|
74
71
|
* Needed because of a circle: to read a value you must first declare it not
|
|
75
72
|
* secret, and declaring that by storing it again would mean typing the value
|
|
76
73
|
* you were trying to read.
|
|
74
|
+
*
|
|
75
|
+
* Two independent flags, and either may be sent alone: `masked` is about what
|
|
76
|
+
* a run prints, `inEnvFile` about whether the variable is also written into
|
|
77
|
+
* the file the build reads. Sending one must not disturb the other — they
|
|
78
|
+
* answer different questions about the same row.
|
|
77
79
|
*/
|
|
78
80
|
app.patch("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
79
81
|
const { slug, key } = req.params;
|
|
80
82
|
if (!ctx.config.project(slug))
|
|
81
83
|
return reply.code(404).send({ error: "Unknown project" });
|
|
82
|
-
const { masked } = (req.body ?? {});
|
|
83
|
-
if (typeof masked !== "boolean") {
|
|
84
|
+
const { masked, inEnvFile } = (req.body ?? {});
|
|
85
|
+
if (masked !== undefined && typeof masked !== "boolean") {
|
|
84
86
|
return reply.code(400).send({ error: "`masked` is true or false." });
|
|
85
87
|
}
|
|
88
|
+
if (inEnvFile !== undefined && typeof inEnvFile !== "boolean") {
|
|
89
|
+
return reply.code(400).send({ error: "`inEnvFile` is true or false." });
|
|
90
|
+
}
|
|
91
|
+
if (masked === undefined && inEnvFile === undefined) {
|
|
92
|
+
return reply.code(400).send({ error: "Send `masked`, `inEnvFile`, or both." });
|
|
93
|
+
}
|
|
86
94
|
const existing = ctx.vault.list(slug).find((s) => s.key === key);
|
|
87
95
|
if (!existing)
|
|
88
96
|
return reply.code(404).send({ error: "Unknown secret" });
|
|
89
|
-
if (existing.scope === "global") {
|
|
90
|
-
// The same rule the interface draws: a global secret belongs to every
|
|
91
|
-
// project, so changing it from inside one would hide that from the rest.
|
|
92
|
-
return reply.code(409).send({ error: "That is a global secret. Change it with `laneyard secret set`." });
|
|
93
|
-
}
|
|
94
97
|
// A value too short to redact cannot be masked, the same refusal as on the
|
|
95
98
|
// way in — accepting it would leave someone believing they are protected.
|
|
96
|
-
if (masked) {
|
|
99
|
+
if (masked === true) {
|
|
97
100
|
const value = ctx.vault.reveal(slug, key);
|
|
98
101
|
if (value !== null && value.length < MIN_REDACTABLE) {
|
|
99
102
|
return reply.code(400).send({
|
|
@@ -101,7 +104,80 @@ export async function registerSecretRoutes(app, ctx) {
|
|
|
101
104
|
});
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
|
-
|
|
107
|
+
if (masked !== undefined)
|
|
108
|
+
ctx.vault.setMasked(slug, key, masked);
|
|
109
|
+
if (inEnvFile !== undefined)
|
|
110
|
+
ctx.vault.setInEnvFile(slug, key, inEnvFile);
|
|
111
|
+
return reply.code(204).send();
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* The environment file this project will write, before it writes it.
|
|
115
|
+
*
|
|
116
|
+
* The whole reason the flag lives on the variable rather than in a picker: a
|
|
117
|
+
* list of tick boxes cannot tell you that one is missing, and this can. What
|
|
118
|
+
* comes back is the file, rendered exactly as the run will render it, with
|
|
119
|
+
* every masked value replaced by dots.
|
|
120
|
+
*
|
|
121
|
+
* **The masking happens before the render, never after.** Rendering the real
|
|
122
|
+
* values and then trying to blank them would put every secret this project
|
|
123
|
+
* holds into a browser and hope the second pass caught them all. The values
|
|
124
|
+
* that leave this process are dots and the names beside them.
|
|
125
|
+
*/
|
|
126
|
+
app.get("/api/projects/:slug/env-file", async (req, reply) => {
|
|
127
|
+
const { slug } = req.params;
|
|
128
|
+
if (!ctx.config.project(slug))
|
|
129
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
130
|
+
const resolved = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
131
|
+
const path = resolved?.settings.env_file ?? null;
|
|
132
|
+
const provenance = path === null ? null : (resolved?.provenance.env_file ?? null);
|
|
133
|
+
const masked = new Set(ctx.vault
|
|
134
|
+
.list(slug)
|
|
135
|
+
.filter((s) => s.masked)
|
|
136
|
+
.map((s) => s.key));
|
|
137
|
+
const values = ctx.vault.envFileValues(slug);
|
|
138
|
+
const body = Object.keys(values)
|
|
139
|
+
.sort()
|
|
140
|
+
.map((key) => (masked.has(key) ? `${key}=••••\n` : dotenvLine(key, values[key])))
|
|
141
|
+
.join("");
|
|
142
|
+
return { path, provenance, body };
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* Turns the environment file on, moves it, or turns it off.
|
|
146
|
+
*
|
|
147
|
+
* Writes `config.yml`, because that is where configuration lives — and it is
|
|
148
|
+
* written from here because a setting reachable only by editing YAML by hand
|
|
149
|
+
* is a feature nobody finds. The accounts screen already writes that file for
|
|
150
|
+
* the same reason.
|
|
151
|
+
*
|
|
152
|
+
* Refused where the repository's own `laneyard.yml` names the path: that file
|
|
153
|
+
* wins for every setting, and a screen that appeared to change it while the
|
|
154
|
+
* next run ignored what it wrote would be worse than one that says who decides.
|
|
155
|
+
*/
|
|
156
|
+
app.put("/api/projects/:slug/env-file", async (req, reply) => {
|
|
157
|
+
const { slug } = req.params;
|
|
158
|
+
if (!ctx.config.project(slug))
|
|
159
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
160
|
+
const { path } = (req.body ?? {});
|
|
161
|
+
if (path !== null && typeof path !== "string") {
|
|
162
|
+
return reply.code(400).send({ error: "`path` is a path, or null to write no file." });
|
|
163
|
+
}
|
|
164
|
+
const resolved = await ctx.config.resolve(slug, ctx.workspacePath(slug));
|
|
165
|
+
if (resolved?.provenance.env_file === "repo") {
|
|
166
|
+
return reply.code(409).send({
|
|
167
|
+
error: "The repository's laneyard.yml sets env_file. Change it there, and commit it.",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (typeof path === "string") {
|
|
171
|
+
const problem = envFileProblem(path.trim());
|
|
172
|
+
if (problem)
|
|
173
|
+
return reply.code(400).send({ error: problem });
|
|
174
|
+
}
|
|
175
|
+
const found = await setEnvFileSetting(ctx.config.configPath(), slug, typeof path === "string" ? path.trim() : null);
|
|
176
|
+
if (!found)
|
|
177
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
178
|
+
// The file is watched, but on a debounce: reloading here is what makes the
|
|
179
|
+
// very next request — the one this screen is about to send — truthful.
|
|
180
|
+
await ctx.config.load();
|
|
105
181
|
return reply.code(204).send();
|
|
106
182
|
});
|
|
107
183
|
/**
|
|
@@ -136,17 +212,10 @@ export async function registerSecretRoutes(app, ctx) {
|
|
|
136
212
|
workspacePath,
|
|
137
213
|
fastlaneDir,
|
|
138
214
|
vaultKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
139
|
-
// Resolved the way a run resolves them, a project's own block shadowing a
|
|
140
|
-
// global one, so the form and the checklist cannot disagree about what is
|
|
141
|
-
// already supplied.
|
|
142
215
|
blockNames: exportedVarNames(ctx.vault.listCredentials(slug)),
|
|
143
216
|
serverEnv: Object.keys(process.env),
|
|
144
217
|
});
|
|
145
218
|
});
|
|
146
|
-
app.delete("/api/secrets/:key", async (req, reply) => {
|
|
147
|
-
const removed = ctx.vault.remove(null, req.params.key);
|
|
148
|
-
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown secret" });
|
|
149
|
-
});
|
|
150
219
|
app.delete("/api/projects/:slug/secrets/:key", async (req, reply) => {
|
|
151
220
|
const { slug, key } = req.params;
|
|
152
221
|
const removed = ctx.vault.remove(slug, key);
|