@agent-flow-board/bridge 0.2.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 +16 -0
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +3 -2
- package/dist/checks/bind.js +3 -2
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +137 -32
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/command.js +75 -88
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connections.js +4 -3
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +50 -1
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +28 -4
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +24 -0
- package/dist/checks/preview.js +418 -191
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +1 -1
- package/dist/checks/recipe.js +19 -5
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +243 -53
- package/dist/checks/sandbox.js +32 -10
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +119 -30
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +7 -1
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +190 -63
- package/dist/checks/wire.js +87 -0
- package/package.json +1 -1
package/dist/checks/preview.js
CHANGED
|
@@ -1,119 +1,47 @@
|
|
|
1
1
|
// THE PREVIEW OF ONE RUN: A PRIVATE COPY OF THE REPO, BUILT AND SERVED FROM ITS OWN FOLDER ON A FREE PORT, WITH AN ENVIRONMENT THE TOOL BUILT (envPolicy.ts).
|
|
2
|
-
// A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy
|
|
3
|
+
// is a local clone of the git root at HEAD plus the working-tree changes (copy.ts); env files are not copied, they are read from the source repo.
|
|
4
|
+
// With an app folder the copy is still made from the git root, but install, build and start run in the app folder of the copy. A live site (cfg.url)
|
|
5
|
+
// builds and starts nothing: the run is unverified. A dependency cache is not made here: a fresh install per run cannot serve a stale module.
|
|
5
6
|
// -- DEP-MODULES -- //
|
|
6
|
-
import {
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
7
8
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { closeSync,
|
|
9
|
-
import {
|
|
9
|
+
import { closeSync, lstatSync, openSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
10
11
|
// -- MODULES -- //
|
|
11
|
-
import {
|
|
12
|
+
import { appDirProblem, sandboxAppRoot } from './appdir.js';
|
|
13
|
+
import { git, listFiles, makePrivateCopy } from './copy.js';
|
|
14
|
+
import { buildAdoptedEnv, buildPreviewEnv } from './envPolicy.js';
|
|
15
|
+
import { describeFence, fenceProblem, readFence, withMailGuard } from './mailguard.js';
|
|
16
|
+
import { omitKeys, resolveSealedEnv } from './secrets.js';
|
|
12
17
|
import { FORBIDDEN_PORTS } from './guardrails.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
18
|
+
import { KEEP_BUILDS, KEEP_DEPS, buildKey, depsKey, evict, hasEntry, publishBuildAsync, publishDepsAsync, restoreBuildAsync, restoreDepsAsync, restoreOrNull, snapshotAsync, treeId } from './cache.js';
|
|
19
|
+
import { installCacheable, installCommand, installDirFor, lockHash, packageManagerOf, packageManagerVersion, pathOf, programArgv } from './install.js';
|
|
20
|
+
import { cacheDirFor, copiesDir, ensureDir, logDirFor, runsDir, under } from './paths.js';
|
|
21
|
+
import { pinFiles } from './recipe.js';
|
|
22
|
+
import { redact as redactText } from './redact.js';
|
|
23
|
+
import { InfraError, singleFlight } from './singleflight.js';
|
|
24
|
+
import { provisionDatabase } from './dbpool.js';
|
|
25
|
+
import { freePort } from './provision.js';
|
|
15
26
|
import { killTree, removeTree } from './scope.js';
|
|
16
27
|
// -- CONSTANTS -- //
|
|
17
|
-
/** A file bigger than this is not copied (a build output, a dump, a video). */
|
|
18
|
-
const MAX_COPY_BYTES = 5 * 1024 * 1024;
|
|
19
28
|
const BUILD_TIMEOUT_MS = 10 * 60_000;
|
|
20
29
|
const READY_TIMEOUT_MS = 120_000;
|
|
21
|
-
/** Env files are
|
|
30
|
+
/** Env files are never part of what is hashed. */
|
|
22
31
|
const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/i;
|
|
23
|
-
|
|
32
|
+
/** Host names that mean this machine. */
|
|
33
|
+
const LOOPBACK = new Set([
|
|
34
|
+
'localhost',
|
|
35
|
+
'127.0.0.1',
|
|
36
|
+
'::1',
|
|
37
|
+
'[::1]',
|
|
38
|
+
'0.0.0.0',
|
|
39
|
+
'host.docker.internal'
|
|
40
|
+
]);
|
|
24
41
|
// -- FUNCTIONS -- //
|
|
25
|
-
/** Runs git with an argument list and no shell. Resolves with the exit status; never throws. */
|
|
26
|
-
function git(cwd, args) {
|
|
27
|
-
return new Promise((resolve) => {
|
|
28
|
-
execFile('git', args, {
|
|
29
|
-
cwd,
|
|
30
|
-
windowsHide: true,
|
|
31
|
-
encoding: 'buffer',
|
|
32
|
-
maxBuffer: 256 * 1024 * 1024,
|
|
33
|
-
timeout: 120_000
|
|
34
|
-
}, (err, stdout) => {
|
|
35
|
-
const code = err
|
|
36
|
-
? typeof err.code === 'number'
|
|
37
|
-
? err.code
|
|
38
|
-
: -1
|
|
39
|
-
: 0;
|
|
40
|
-
resolve({ status: code, stdout: stdout });
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
/** The tracked and the untracked-not-ignored files of a repo, as paths relative to it. Null when the folder is not a git repo. */
|
|
45
|
-
async function listFiles(repo) {
|
|
46
|
-
const r = await git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
|
47
|
-
if (r.status !== 0)
|
|
48
|
-
return null;
|
|
49
|
-
return [...new Set(r.stdout.toString('utf8').split('\0').filter(Boolean))];
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Copies the listed files of `repo` into `dest`. A link anywhere in the repo's files (the file itself or a folder above it) is refused by name,
|
|
53
|
-
* because a copy that follows a link could carry files from outside the repo into the build.
|
|
54
|
-
*/
|
|
55
|
-
export async function makePrivateCopy(repo, dest, log = () => { }) {
|
|
56
|
-
const files = await listFiles(repo);
|
|
57
|
-
if (!files)
|
|
58
|
-
throw new Error('The project folder is not a git repository, so the tool cannot tell which files belong to it.');
|
|
59
|
-
const checkedDirs = new Set();
|
|
60
|
-
let copied = 0;
|
|
61
|
-
let skipped = 0;
|
|
62
|
-
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
63
|
-
for (const rel of files) {
|
|
64
|
-
const parts = rel.split('/');
|
|
65
|
-
if (parts[0] === '.git' || parts.includes('..'))
|
|
66
|
-
throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
|
|
67
|
-
// Every folder above the file must be a real folder
|
|
68
|
-
for (let i = 1; i < parts.length; i++) {
|
|
69
|
-
const folder = parts.slice(0, i).join('/');
|
|
70
|
-
if (checkedDirs.has(folder))
|
|
71
|
-
continue;
|
|
72
|
-
let st;
|
|
73
|
-
try {
|
|
74
|
-
st = lstatSync(join(repo, folder));
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
if (st.isSymbolicLink())
|
|
80
|
-
throw new Error(`"${folder.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
81
|
-
checkedDirs.add(folder);
|
|
82
|
-
}
|
|
83
|
-
const from = join(repo, ...parts);
|
|
84
|
-
let st;
|
|
85
|
-
try {
|
|
86
|
-
st = lstatSync(from);
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
continue; // Listed by git but deleted in the working tree
|
|
90
|
-
}
|
|
91
|
-
if (st.isSymbolicLink())
|
|
92
|
-
throw new Error(`"${rel.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
93
|
-
if (!st.isFile())
|
|
94
|
-
continue;
|
|
95
|
-
if ((ENV_FILE.test(rel) && !ENV_EXAMPLE.test(rel)) || st.size > MAX_COPY_BYTES) {
|
|
96
|
-
skipped++;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const to = under(dest, ...parts);
|
|
100
|
-
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
|
101
|
-
copyFileSync(from, to);
|
|
102
|
-
copied++;
|
|
103
|
-
}
|
|
104
|
-
if (skipped)
|
|
105
|
-
log(`${skipped} file(s) were not copied (env files and files over 5 MB).`);
|
|
106
|
-
return { files: copied, skipped };
|
|
107
|
-
}
|
|
108
|
-
/** The commit of HEAD, or null when the folder has no commit. */
|
|
109
|
-
async function headCommit(repo) {
|
|
110
|
-
const r = await git(repo, ['rev-parse', 'HEAD']);
|
|
111
|
-
return r.status === 0 ? r.stdout.toString('utf8').trim() || null : null;
|
|
112
|
-
}
|
|
113
42
|
/** A hash over the uncommitted changes (`git diff HEAD`) and the contents of untracked files. Null when the folder is not a git repo. */
|
|
114
43
|
async function hashChanges(repo) {
|
|
115
|
-
|
|
116
|
-
if (!files)
|
|
44
|
+
if (!(await listFiles(repo)))
|
|
117
45
|
return null;
|
|
118
46
|
const h = createHash('sha256');
|
|
119
47
|
const diff = await git(repo, ['diff', 'HEAD']);
|
|
@@ -136,63 +64,92 @@ async function hashChanges(repo) {
|
|
|
136
64
|
}
|
|
137
65
|
/**
|
|
138
66
|
* Starts a program from an argument list with no shell. On Windows the package managers are .cmd shims, which a plain spawn cannot find (ENOENT) and
|
|
139
|
-
* Node refuses to start directly;
|
|
140
|
-
* (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
|
|
67
|
+
* Node refuses to start directly; those go through `cmd.exe /d /s /c` (install.ts programArgv). The arguments came from a command that was already
|
|
68
|
+
* checked (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
|
|
141
69
|
*/
|
|
142
70
|
export function launch(argv, opts) {
|
|
143
|
-
const [file, ...args] = argv;
|
|
71
|
+
const [file, ...args] = programArgv(argv, { pathEnv: pathOf(opts.env) });
|
|
144
72
|
if (!file)
|
|
145
73
|
return Promise.reject(new Error('The command is empty.'));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const child = spawn(f, a, base);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(file, args, {
|
|
76
|
+
cwd: opts.cwd,
|
|
77
|
+
env: opts.env,
|
|
78
|
+
stdio: ['ignore', opts.out, opts.out],
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
shell: false,
|
|
81
|
+
detached: opts.detached
|
|
82
|
+
});
|
|
156
83
|
child.once('error', reject);
|
|
157
84
|
child.once('spawn', () => {
|
|
158
85
|
child.off('error', reject);
|
|
159
86
|
resolve(child);
|
|
160
87
|
});
|
|
161
88
|
});
|
|
162
|
-
return attempt(file, args).catch((e) => {
|
|
163
|
-
if (e.code === 'ENOENT' && process.platform === 'win32' && /^(yarn|npm|pnpm|npx)$/.test(file))
|
|
164
|
-
return attempt('cmd.exe', ['/d', '/s', '/c', `${file}.cmd`, ...args]);
|
|
165
|
-
throw e;
|
|
166
|
-
});
|
|
167
89
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
90
|
+
/** The message of a stopped run. */
|
|
91
|
+
const STOPPED = 'The run was stopped.';
|
|
92
|
+
/**
|
|
93
|
+
* The error of an install or build command that ended without success. No exit code at all (-1: it timed out, was killed or was stopped) is the machine's trouble and
|
|
94
|
+
* not the code's, so it is an InfraError: a run that waited for this one takes over instead of being handed the same failure. A real exit code is the code's failure
|
|
95
|
+
* and is shared with every waiter, with the last lines of the log.
|
|
96
|
+
*/
|
|
97
|
+
export function stageError(what, code, aborted, tail) {
|
|
98
|
+
if (code === -1)
|
|
99
|
+
return new InfraError(aborted ? STOPPED : `The ${what} timed out or was stopped.`);
|
|
100
|
+
return new Error(`The ${what} failed (exit ${code}). Last lines:\n${tail()}`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Runs a command to its end and resolves with its exit code (or -1 when it timed out or was killed). The output goes to the open file `out`.
|
|
104
|
+
* The process is registered on the scope while it runs, so a stop signal or a crash of the run cannot leave it (or the cmd.exe and node under it) behind;
|
|
105
|
+
* an abort kills its whole tree at once. A command that cannot be started at all (no such program, no disk, no handles) is an InfraError.
|
|
106
|
+
*/
|
|
107
|
+
export async function runToEnd(argv, opts) {
|
|
108
|
+
if (opts.signal?.aborted)
|
|
109
|
+
throw new InfraError(STOPPED);
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = await launch(argv, { ...opts, detached: false });
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new InfraError(`The command could not be started: ${e.code ?? 'an error'}.`, { cause: e });
|
|
116
|
+
}
|
|
117
|
+
let gone = false;
|
|
118
|
+
const pid = child.pid;
|
|
119
|
+
if (pid)
|
|
120
|
+
opts.scope?.add(opts.name ?? 'build process', () => (gone ? undefined : killTree(pid)));
|
|
171
121
|
return new Promise((resolve) => {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
void killTree(child.pid).then(() => resolve(-1));
|
|
175
|
-
else
|
|
176
|
-
resolve(-1);
|
|
177
|
-
}, opts.timeoutMs);
|
|
178
|
-
child.once('exit', (code) => {
|
|
122
|
+
const finish = (code) => {
|
|
123
|
+
gone = true;
|
|
179
124
|
clearTimeout(timer);
|
|
180
|
-
|
|
181
|
-
|
|
125
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
126
|
+
resolve(code);
|
|
127
|
+
};
|
|
128
|
+
// A process the tool killed (a timeout, an abort) is -1 whatever code the kill made it exit with (taskkill ends it with 1): the exit event
|
|
129
|
+
// can come before the kill call returns
|
|
130
|
+
let killed = false;
|
|
131
|
+
const kill = () => {
|
|
132
|
+
killed = true;
|
|
133
|
+
if (pid && !gone)
|
|
134
|
+
void killTree(pid).then(() => finish(-1));
|
|
135
|
+
else
|
|
136
|
+
finish(-1);
|
|
137
|
+
};
|
|
138
|
+
const onAbort = () => kill();
|
|
139
|
+
const timer = setTimeout(kill, opts.timeoutMs);
|
|
140
|
+
child.once('exit', (code) => finish(killed ? -1 : (code ?? -1)));
|
|
141
|
+
if (opts.signal) {
|
|
142
|
+
if (opts.signal.aborted)
|
|
143
|
+
kill();
|
|
144
|
+
else
|
|
145
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
146
|
+
}
|
|
182
147
|
});
|
|
183
148
|
}
|
|
184
149
|
const words = (command) => command.split(' ').filter(Boolean);
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return null;
|
|
189
|
-
if (existsSync(join(dir, 'yarn.lock')))
|
|
190
|
-
return ['yarn', 'install', '--immutable'];
|
|
191
|
-
if (existsSync(join(dir, 'pnpm-lock.yaml')))
|
|
192
|
-
return ['pnpm', 'install', '--frozen-lockfile'];
|
|
193
|
-
if (existsSync(join(dir, 'package-lock.json')))
|
|
194
|
-
return ['npm', 'ci'];
|
|
195
|
-
return null;
|
|
150
|
+
/** Logs how long one stage of a run took, as `Stage <name>: <seconds>s` (the benchmark reads these lines). */
|
|
151
|
+
function stageDone(log, name, since) {
|
|
152
|
+
log(`Stage ${name}: ${((Date.now() - since) / 1000).toFixed(1)}s`);
|
|
196
153
|
}
|
|
197
154
|
/** The last lines of a log file with every value of the preview environment hidden. */
|
|
198
155
|
export function tailOf(file, secrets, lines = 20) {
|
|
@@ -207,12 +164,15 @@ export function tailOf(file, secrets, lines = 20) {
|
|
|
207
164
|
for (const s of [...secrets].sort((a, b) => b.length - a.length))
|
|
208
165
|
if (s.length >= 4)
|
|
209
166
|
out = out.split(s).join('[hidden]');
|
|
210
|
-
|
|
167
|
+
// The raw value is not the only way a secret shows in a log: URL encoded, base64 and the shape of a token are removed as well
|
|
168
|
+
return redactText(out, secrets);
|
|
211
169
|
}
|
|
212
|
-
/** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`,
|
|
170
|
+
/** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`, at once when `exited` says the process is gone, or when the signal aborts. */
|
|
213
171
|
export async function waitReady(url, opts) {
|
|
214
172
|
const until = Date.now() + opts.timeoutMs;
|
|
215
173
|
while (Date.now() < until) {
|
|
174
|
+
if (opts.signal?.aborted)
|
|
175
|
+
return 'aborted';
|
|
216
176
|
if (opts.exited())
|
|
217
177
|
return 'exited';
|
|
218
178
|
try {
|
|
@@ -226,6 +186,8 @@ export async function waitReady(url, opts) {
|
|
|
226
186
|
}
|
|
227
187
|
await new Promise((r) => setTimeout(r, opts.intervalMs ?? 500));
|
|
228
188
|
}
|
|
189
|
+
if (opts.signal?.aborted)
|
|
190
|
+
return 'aborted';
|
|
229
191
|
return opts.exited() ? 'exited' : 'timeout';
|
|
230
192
|
}
|
|
231
193
|
/** Picks a free port on 127.0.0.1 and refuses a forbidden one. */
|
|
@@ -235,22 +197,92 @@ export async function pickPort() {
|
|
|
235
197
|
throw new Error(`Port ${port} is not one a check may use.`);
|
|
236
198
|
return port;
|
|
237
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The preview of a live site: nothing is copied, built or started. A loopback address on a port a check never uses is refused. The run is unverified:
|
|
202
|
+
* there is no commit and no diff hash, and the note says so.
|
|
203
|
+
*/
|
|
204
|
+
export function liveSitePreview(cfg) {
|
|
205
|
+
let u;
|
|
206
|
+
try {
|
|
207
|
+
u = new URL(String(cfg.url));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new Error('The live site address is not a web address.');
|
|
211
|
+
}
|
|
212
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
213
|
+
throw new Error('The live site address must start with http:// or https://.');
|
|
214
|
+
if (LOOPBACK.has(u.hostname.toLowerCase())) {
|
|
215
|
+
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80;
|
|
216
|
+
if (FORBIDDEN_PORTS.has(port))
|
|
217
|
+
throw new Error(`Port ${port} is not one a check may use.`);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
baseUrl: cfg.url,
|
|
221
|
+
commit: null,
|
|
222
|
+
diffHash: null,
|
|
223
|
+
testUser: cfg.testUser ?? null,
|
|
224
|
+
secrets: cfg.testUser ? [cfg.testUser.password] : [],
|
|
225
|
+
note: 'This is a live site: nothing was built or started, so the run is unverified (no commit is recorded).'
|
|
226
|
+
};
|
|
227
|
+
}
|
|
238
228
|
export async function startPreview(cfg, opts) {
|
|
239
|
-
const { runId, scope, log } = opts;
|
|
229
|
+
const { runId, scope, log, signal } = opts;
|
|
230
|
+
const stopIfAborted = () => {
|
|
231
|
+
if (signal?.aborted)
|
|
232
|
+
throw new Error(STOPPED);
|
|
233
|
+
};
|
|
234
|
+
// -- 0. A LIVE SITE: NOTHING TO MAKE -- //
|
|
235
|
+
if (cfg.url)
|
|
236
|
+
return liveSitePreview(cfg);
|
|
237
|
+
// The app folder is checked in the live repo first, so a bad one stops before anything is copied
|
|
238
|
+
if (cfg.appDir) {
|
|
239
|
+
const problem = appDirProblem(cfg.repo, cfg.appDir);
|
|
240
|
+
if (problem)
|
|
241
|
+
throw new Error(problem);
|
|
242
|
+
}
|
|
240
243
|
// -- 1. THE MARKER COMES FIRST -- //
|
|
241
244
|
scope.start();
|
|
242
|
-
|
|
245
|
+
// The run folder is the child's to write; the logs hold what the app printed and live where the child cannot read (paths.ts logsDir)
|
|
246
|
+
ensureDir(under(runsDir(), runId));
|
|
247
|
+
const logDir = ensureDir(logDirFor(runId));
|
|
243
248
|
const notes = [];
|
|
244
249
|
const secrets = [];
|
|
250
|
+
const known = opts.needles ?? [];
|
|
251
|
+
const learn = (...values) => {
|
|
252
|
+
for (const v of values)
|
|
253
|
+
if (typeof v === 'string' && v.length >= 6 && !known.includes(v))
|
|
254
|
+
known.push(v);
|
|
255
|
+
};
|
|
245
256
|
// -- 2. A PRIVATE COPY -- //
|
|
246
257
|
const copyDir = under(copiesDir(), runId);
|
|
247
258
|
scope.setInfo({ copyDir });
|
|
248
259
|
scope.add('private copy', () => removeTree(copyDir));
|
|
249
|
-
const
|
|
250
|
-
|
|
260
|
+
const copyStart = Date.now();
|
|
261
|
+
// The tree is hashed before the copy and again after it: a file saved while the copy was made would otherwise be tested but described by another hash
|
|
262
|
+
let hashBefore = await hashChanges(cfg.repo);
|
|
263
|
+
let calls = 0;
|
|
264
|
+
const copyOnce = () => makePrivateCopy(cfg.repo, copyDir, {
|
|
265
|
+
log,
|
|
266
|
+
afterCopy: opts.afterCopy ? () => opts.afterCopy?.(++calls) : undefined
|
|
267
|
+
});
|
|
268
|
+
let made = await copyOnce();
|
|
269
|
+
let diffHash = await hashChanges(cfg.repo);
|
|
270
|
+
if (diffHash !== hashBefore) {
|
|
271
|
+
log('The repo changed while it was being copied; copying again.');
|
|
272
|
+
removeTree(copyDir);
|
|
273
|
+
hashBefore = diffHash;
|
|
274
|
+
made = await copyOnce();
|
|
275
|
+
diffHash = await hashChanges(cfg.repo);
|
|
276
|
+
if (diffHash !== hashBefore)
|
|
277
|
+
notes.push('The repo kept changing while it was copied, so the diff hash may not describe exactly the files that were tested.');
|
|
278
|
+
}
|
|
279
|
+
stopIfAborted();
|
|
280
|
+
log(`Made a private copy of ${made.files} changed file(s) at ${made.head ? made.head.slice(0, 8) : 'no commit'}.`);
|
|
281
|
+
stageDone(log, 'copy', copyStart);
|
|
282
|
+
// The app folder is walked again inside the copy: a link or a missing package.json there stops the run
|
|
283
|
+
const appCwd = sandboxAppRoot(made.repoInCopy, cfg.appDir);
|
|
251
284
|
// -- 3. WHAT WAS TESTED -- //
|
|
252
|
-
const commit =
|
|
253
|
-
const diffHash = await hashChanges(cfg.repo);
|
|
285
|
+
const commit = made.head;
|
|
254
286
|
// -- 4. A FREE PORT -- //
|
|
255
287
|
const port = await pickPort();
|
|
256
288
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
@@ -258,62 +290,240 @@ export async function startPreview(cfg, opts) {
|
|
|
258
290
|
let databaseUrl;
|
|
259
291
|
let ownerUrl;
|
|
260
292
|
if (cfg.needsDatabase) {
|
|
261
|
-
const
|
|
293
|
+
const databaseStart = Date.now();
|
|
294
|
+
// One pool container per machine and a clone per run. The app migrates and seeds itself at start (AFB_AUTO_MIGRATE), so the template
|
|
295
|
+
// is an empty database that every run clones; it falls back to a container per run when the pool is not available
|
|
296
|
+
const db = await provisionDatabase(runId, scope, log, {
|
|
297
|
+
runtimeRole: cfg.runtimeRole,
|
|
298
|
+
template: {
|
|
299
|
+
recipeHash: createHash('sha256')
|
|
300
|
+
.update(JSON.stringify([cfg.build, cfg.start, cfg.runtimeRole ?? '']))
|
|
301
|
+
.digest('hex')
|
|
302
|
+
.slice(0, 16),
|
|
303
|
+
migrationHash: 'self-migrating',
|
|
304
|
+
warm: async () => undefined
|
|
305
|
+
}
|
|
306
|
+
});
|
|
262
307
|
databaseUrl = db.databaseUrl;
|
|
263
308
|
ownerUrl = db.ownerUrl;
|
|
264
309
|
secrets.push(db.databaseUrl, db.ownerUrl);
|
|
310
|
+
learn(db.databaseUrl, db.ownerUrl);
|
|
265
311
|
notes.push('A throwaway database was made for this run.');
|
|
312
|
+
stageDone(log, 'database', databaseStart);
|
|
266
313
|
}
|
|
267
314
|
// -- ENVIRONMENT -- //
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
315
|
+
// Sealed values ({secret:NAME}) are opened from this registration's own store only; service and mail keys are for the start alone, the build never sees them
|
|
316
|
+
let recipeEnv = cfg.env;
|
|
317
|
+
let startOnly = [];
|
|
318
|
+
let sealedHosts = [];
|
|
319
|
+
let sealedReds = [];
|
|
320
|
+
let sealedNames = [];
|
|
321
|
+
let mailInbox = null;
|
|
322
|
+
if (cfg.registration && Object.values(cfg.env).some((v) => v.includes('{secret:'))) {
|
|
323
|
+
const opened = resolveSealedEnv(cfg.registration.org, cfg.registration.name, cfg.env);
|
|
324
|
+
recipeEnv = opened.env;
|
|
325
|
+
startOnly = opened.startOnly;
|
|
326
|
+
sealedHosts = opened.hosts;
|
|
327
|
+
sealedReds = opened.redactions;
|
|
328
|
+
sealedNames = [...opened.filled];
|
|
329
|
+
mailInbox = opened.mailInbox;
|
|
330
|
+
secrets.push(...opened.redactions);
|
|
331
|
+
learn(...opened.redactions);
|
|
332
|
+
notes.push(`Opened ${opened.filled.length} sealed value(s) (names only: ${opened.filled.join(', ')}).`);
|
|
333
|
+
}
|
|
334
|
+
// A registration whose owner approved it adopts the whole repo .env (database values are refused, not stripped); any other keeps the stricter rule
|
|
335
|
+
let startEnv;
|
|
336
|
+
let buildEnv;
|
|
337
|
+
let adoptedReds = [];
|
|
338
|
+
if (cfg.adoptEnv) {
|
|
339
|
+
const adopted = buildAdoptedEnv({
|
|
340
|
+
repo: cfg.repo,
|
|
341
|
+
appDir: cfg.appDir,
|
|
342
|
+
recipeEnv,
|
|
343
|
+
port,
|
|
344
|
+
baseUrl,
|
|
345
|
+
databaseUrl,
|
|
346
|
+
databaseOwnerUrl: ownerUrl,
|
|
347
|
+
sealedNames,
|
|
348
|
+
testHost: sealedHosts[0]
|
|
349
|
+
});
|
|
350
|
+
if (adopted.problems.length)
|
|
351
|
+
throw new Error(adopted.problems.join(' '));
|
|
352
|
+
startEnv = adopted.env;
|
|
353
|
+
buildEnv = adopted.buildEnv;
|
|
354
|
+
// The address of the preview itself is not a secret: keeping it out of the redactions keeps the run log readable
|
|
355
|
+
adoptedReds = adopted.redactions.filter((v) => !v.includes(baseUrl));
|
|
356
|
+
notes.push(...adopted.notes);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const built = buildPreviewEnv({
|
|
360
|
+
repo: cfg.repo,
|
|
361
|
+
recipeEnv,
|
|
362
|
+
port,
|
|
363
|
+
baseUrl,
|
|
364
|
+
databaseUrl,
|
|
365
|
+
databaseOwnerUrl: ownerUrl
|
|
366
|
+
});
|
|
367
|
+
startEnv = built.env;
|
|
368
|
+
buildEnv = built.env;
|
|
369
|
+
notes.push(...built.notes);
|
|
370
|
+
}
|
|
371
|
+
buildEnv = omitKeys(buildEnv, startOnly);
|
|
372
|
+
const mailLog = join(logDir, 'mail-guard.jsonl');
|
|
373
|
+
if (mailInbox)
|
|
374
|
+
startEnv = withMailGuard(startEnv, { inbox: mailInbox, log: mailLog });
|
|
375
|
+
const redact = [
|
|
376
|
+
...Object.values(startEnv).filter((v) => v.length >= 6),
|
|
377
|
+
...Object.values(buildEnv).filter((v) => v.length >= 6),
|
|
378
|
+
...secrets,
|
|
379
|
+
...sealedReds,
|
|
380
|
+
...adoptedReds
|
|
381
|
+
];
|
|
382
|
+
secrets.push(...adoptedReds);
|
|
383
|
+
if (cfg.testUser) {
|
|
279
384
|
secrets.push(cfg.testUser.password);
|
|
385
|
+
redact.push(cfg.testUser.password);
|
|
386
|
+
}
|
|
387
|
+
learn(...redact);
|
|
388
|
+
stopIfAborted();
|
|
280
389
|
// -- 6. INSTALL AND BUILD -- //
|
|
281
|
-
const
|
|
390
|
+
const buildLogFile = join(logDir, 'build.log');
|
|
391
|
+
const buildLog = openSync(buildLogFile, 'a');
|
|
392
|
+
// The command of a stage ended without an exit code: it timed out, was killed or was stopped. That is the machine's trouble, not the code's, so a run that waited for it may try again
|
|
393
|
+
const stageFailed = (what, code) => stageError(what, code, Boolean(signal?.aborted), () => tailOf(buildLogFile, redact));
|
|
282
394
|
try {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
395
|
+
const installStart = Date.now();
|
|
396
|
+
// A monorepo keeps its lock file above the app folder: the install runs where the lock file is
|
|
397
|
+
const installDir = installDirFor(appCwd, copyDir);
|
|
398
|
+
const install = installDir ? installCommand(installDir) : null;
|
|
399
|
+
if (!install || !installDir)
|
|
400
|
+
log('No lockfile, install skipped.');
|
|
401
|
+
if (install && installDir) {
|
|
402
|
+
const doInstall = async () => {
|
|
403
|
+
stopIfAborted();
|
|
404
|
+
log('Installing dependencies in the private copy.');
|
|
405
|
+
const code = await runToEnd(install, {
|
|
406
|
+
cwd: installDir,
|
|
407
|
+
env: buildEnv,
|
|
408
|
+
out: buildLog,
|
|
409
|
+
timeoutMs: BUILD_TIMEOUT_MS,
|
|
410
|
+
scope,
|
|
411
|
+
name: 'install process',
|
|
412
|
+
signal
|
|
413
|
+
});
|
|
414
|
+
if (code !== 0)
|
|
415
|
+
throw stageFailed('install', code);
|
|
416
|
+
};
|
|
417
|
+
// Dependencies are kept per registration by lockfile hash, node, platform, architecture and package manager. Runs that need the same set
|
|
418
|
+
// install once (single flight) and the others restore it; a pnpm install is never kept (its modules name the store location)
|
|
419
|
+
const pm = packageManagerOf(installDir);
|
|
420
|
+
const depsCacheRoot = cfg.registration
|
|
421
|
+
? cacheDirFor(cfg.registration.org, cfg.registration.name)
|
|
422
|
+
: null;
|
|
423
|
+
const dkey = depsCacheRoot && pm && installCacheable(installDir)
|
|
424
|
+
? depsKey({
|
|
425
|
+
lockHash: lockHash(installDir),
|
|
426
|
+
node: process.version,
|
|
427
|
+
platform: process.platform,
|
|
428
|
+
arch: process.arch,
|
|
429
|
+
pm,
|
|
430
|
+
pmVersion: await packageManagerVersion(pm, installDir, buildEnv)
|
|
431
|
+
})
|
|
432
|
+
: null;
|
|
433
|
+
if (depsCacheRoot && dkey) {
|
|
434
|
+
const flight = await singleFlight(`deps-${dkey.slice(0, 32)}`, async () => {
|
|
435
|
+
if (hasEntry(depsCacheRoot, 'deps', dkey))
|
|
436
|
+
return { hit: true };
|
|
437
|
+
await doInstall();
|
|
438
|
+
await publishDepsAsync(depsCacheRoot, dkey, installDir);
|
|
439
|
+
return { hit: false };
|
|
440
|
+
}, { signal });
|
|
441
|
+
const installedHere = flight.role === 'builder' && !flight.result.hit;
|
|
442
|
+
if (!installedHere) {
|
|
443
|
+
const restored = await restoreOrNull('dependencies', () => restoreDepsAsync(depsCacheRoot, dkey, installDir, { link: 'auto' }), log);
|
|
444
|
+
if (restored)
|
|
445
|
+
log(`Restored the kept dependencies (${restored.files} files).`);
|
|
446
|
+
else
|
|
447
|
+
await doInstall();
|
|
448
|
+
}
|
|
449
|
+
evict(depsCacheRoot, { builds: KEEP_BUILDS, deps: KEEP_DEPS, maxAgeDays: 14 });
|
|
450
|
+
}
|
|
451
|
+
else
|
|
452
|
+
await doInstall();
|
|
294
453
|
}
|
|
454
|
+
stageDone(log, 'install', installStart);
|
|
455
|
+
stopIfAborted();
|
|
456
|
+
const buildStart = Date.now();
|
|
295
457
|
if (cfg.build) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
458
|
+
const doBuild = async () => {
|
|
459
|
+
stopIfAborted();
|
|
460
|
+
log('Building the project.');
|
|
461
|
+
const code = await runToEnd(words(cfg.build), {
|
|
462
|
+
cwd: appCwd,
|
|
463
|
+
env: buildEnv,
|
|
464
|
+
out: buildLog,
|
|
465
|
+
timeoutMs: BUILD_TIMEOUT_MS,
|
|
466
|
+
scope,
|
|
467
|
+
name: 'build process',
|
|
468
|
+
signal
|
|
469
|
+
});
|
|
470
|
+
if (code !== 0)
|
|
471
|
+
throw stageFailed('build', code);
|
|
472
|
+
};
|
|
473
|
+
// A finished build is kept per registration when nothing that decides it is per run (no port, base url, database url or secret in the
|
|
474
|
+
// command or the environment, adopted values included). Runs that need the same build build it once and the others restore it
|
|
475
|
+
const buildRoot = cfg.registration
|
|
476
|
+
? cacheDirFor(cfg.registration.org, cfg.registration.name)
|
|
477
|
+
: null;
|
|
478
|
+
const tree = treeId({ commit, diffHash });
|
|
479
|
+
const bkey = buildRoot && tree
|
|
480
|
+
? buildKey({
|
|
481
|
+
org: cfg.registration?.org ?? '',
|
|
482
|
+
name: cfg.registration?.name ?? '',
|
|
483
|
+
treeId: tree,
|
|
484
|
+
surface: pinFiles(cfg.repo),
|
|
485
|
+
build: cfg.build,
|
|
486
|
+
env: buildEnv,
|
|
487
|
+
secretValues: secrets,
|
|
488
|
+
appDir: cfg.appDir ?? ''
|
|
489
|
+
})
|
|
490
|
+
: null;
|
|
491
|
+
if (buildRoot && bkey) {
|
|
492
|
+
const before = await snapshotAsync(appCwd);
|
|
493
|
+
const flight = await singleFlight(`build-${bkey.slice(0, 32)}`, async () => {
|
|
494
|
+
if (hasEntry(buildRoot, 'builds', bkey))
|
|
495
|
+
return { hit: true };
|
|
496
|
+
await doBuild();
|
|
497
|
+
await publishBuildAsync(buildRoot, bkey, appCwd, before);
|
|
498
|
+
return { hit: false };
|
|
499
|
+
}, { signal });
|
|
500
|
+
const builtHere = flight.role === 'builder' && !flight.result.hit;
|
|
501
|
+
if (!builtHere) {
|
|
502
|
+
const restored = await restoreOrNull('build', () => restoreBuildAsync(buildRoot, bkey, appCwd, { link: 'auto' }), log);
|
|
503
|
+
if (restored)
|
|
504
|
+
log(`Restored the kept build (${restored.files} files).`);
|
|
505
|
+
else
|
|
506
|
+
await doBuild();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
else
|
|
510
|
+
await doBuild();
|
|
305
511
|
}
|
|
512
|
+
stageDone(log, 'build', buildStart);
|
|
306
513
|
}
|
|
307
514
|
finally {
|
|
308
515
|
closeSync(buildLog);
|
|
309
516
|
}
|
|
517
|
+
stopIfAborted();
|
|
310
518
|
// -- 7. START -- //
|
|
311
|
-
const
|
|
519
|
+
const startStageStart = Date.now();
|
|
520
|
+
const previewLogFile = join(logDir, 'preview.log');
|
|
521
|
+
const startLog = openSync(previewLogFile, 'a');
|
|
312
522
|
let child;
|
|
313
523
|
try {
|
|
314
524
|
child = await launch(words(cfg.start.split('{port}').join(String(port))), {
|
|
315
|
-
cwd:
|
|
316
|
-
env:
|
|
525
|
+
cwd: appCwd,
|
|
526
|
+
env: startEnv,
|
|
317
527
|
out: startLog,
|
|
318
528
|
detached: process.platform !== 'win32'
|
|
319
529
|
});
|
|
@@ -330,14 +540,29 @@ export async function startPreview(cfg, opts) {
|
|
|
330
540
|
}
|
|
331
541
|
// -- 8. WAIT UNTIL IT ANSWERS -- //
|
|
332
542
|
const readyUrl = baseUrl + (cfg.ready ?? '/');
|
|
333
|
-
const state = await waitReady(readyUrl, {
|
|
543
|
+
const state = await waitReady(readyUrl, {
|
|
544
|
+
timeoutMs: READY_TIMEOUT_MS,
|
|
545
|
+
exited: () => exited,
|
|
546
|
+
signal
|
|
547
|
+
});
|
|
548
|
+
if (state === 'aborted')
|
|
549
|
+
throw new Error(STOPPED);
|
|
334
550
|
if (state !== 'ready') {
|
|
335
551
|
const why = state === 'exited'
|
|
336
552
|
? 'The preview stopped before it answered.'
|
|
337
553
|
: 'The preview did not answer within 120 seconds.';
|
|
338
|
-
throw new Error(`${why} Last lines:\n${tailOf(
|
|
554
|
+
throw new Error(`${why} Last lines:\n${tailOf(previewLogFile, redact)}`);
|
|
339
555
|
}
|
|
340
556
|
log('The preview answers.');
|
|
557
|
+
stageDone(log, 'start-and-ready', startStageStart);
|
|
558
|
+
// The mail guard must have reported listening on the preview port, or mail may not have been fenced and the run does not count
|
|
559
|
+
if (mailInbox) {
|
|
560
|
+
const fence = readFence(mailLog, port);
|
|
561
|
+
const problem = fenceProblem(true, fence);
|
|
562
|
+
if (problem)
|
|
563
|
+
throw new Error(problem);
|
|
564
|
+
notes.push(describeFence(mailInbox, fence));
|
|
565
|
+
}
|
|
341
566
|
// -- 9. RESULT -- //
|
|
342
567
|
return {
|
|
343
568
|
baseUrl,
|
|
@@ -345,6 +570,8 @@ export async function startPreview(cfg, opts) {
|
|
|
345
570
|
diffHash,
|
|
346
571
|
testUser: cfg.testUser ?? null,
|
|
347
572
|
secrets,
|
|
348
|
-
note: notes.join(' ')
|
|
573
|
+
note: notes.join(' '),
|
|
574
|
+
extraHosts: sealedHosts,
|
|
575
|
+
...(mailInbox ? { mail: { inbox: mailInbox, log: mailLog, port } } : {})
|
|
349
576
|
};
|
|
350
577
|
}
|