redkite 0.1.11 → 0.1.13
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 +76 -1
- package/dist/build.d.ts +3 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +14 -7
- package/dist/build.js.map +1 -1
- package/dist/cli/agent.d.ts +5 -1
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +7 -7
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/crash.d.ts +46 -0
- package/dist/cli/crash.d.ts.map +1 -0
- package/dist/cli/crash.js +204 -0
- package/dist/cli/crash.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/log.d.ts +1 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +4 -3
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/screen.d.ts +3 -0
- package/dist/cli/screen.d.ts.map +1 -1
- package/dist/cli/screen.js +32 -6
- package/dist/cli/screen.js.map +1 -1
- package/dist/cli/viewer.d.ts.map +1 -1
- package/dist/cli/viewer.js +5 -5
- package/dist/cli/viewer.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +72 -6
- package/dist/deploy.js.map +1 -1
- package/dist/docker.d.ts +1 -0
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -0
- package/dist/docker.js.map +1 -1
- package/dist/health.d.ts +2 -2
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +17 -9
- package/dist/health.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +13 -0
- package/dist/log.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +20 -0
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +23 -11
- package/src/cli/agent.ts +8 -8
- package/src/cli/crash.ts +287 -0
- package/src/cli/index.ts +59 -17
- package/src/cli/log.ts +4 -3
- package/src/cli/screen.ts +45 -4
- package/src/cli/viewer.ts +5 -4
- package/src/deploy.ts +88 -6
- package/src/docker.ts +6 -0
- package/src/health.ts +23 -16
- package/src/log.ts +19 -1
- package/src/source.ts +24 -0
- package/src/types.ts +9 -0
package/src/deploy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { build, type BuildContext, type BuildResult } from "./build.js";
|
|
1
|
+
import { build, refOf, sourceOf, type BuildContext, type BuildResult } from "./build.js";
|
|
2
2
|
import { assertCheckable, runChecks } from "./checks.js";
|
|
3
3
|
import { environmentOf, withEnvironment } from "./config.js";
|
|
4
4
|
import { Docker } from "./docker.js";
|
|
@@ -26,6 +26,7 @@ import { pluginSteps } from "./plugin.js";
|
|
|
26
26
|
import { readEnv, readRef, type SecretStores } from "./secrets/refs.js";
|
|
27
27
|
import { ensureService } from "./services/ensure.js";
|
|
28
28
|
import { plannedServices } from "./services/planned.js";
|
|
29
|
+
import { describeRef, describeRepo, type Source } from "./source.js";
|
|
29
30
|
import { topologyFor, type AppTopology, type Topology } from "./topology.js";
|
|
30
31
|
import type { AppSpec, Deployment } from "./types.js";
|
|
31
32
|
|
|
@@ -215,8 +216,14 @@ async function release(
|
|
|
215
216
|
// Inside, because the swap is not over until this says so. A stop lands
|
|
216
217
|
// here more often than anywhere else: it is the longest part, and by now
|
|
217
218
|
// every address has already moved
|
|
218
|
-
|
|
219
|
+
const unhealthy = await checkAll(context, health);
|
|
220
|
+
|
|
221
|
+
if (unhealthy.size > 0) {
|
|
219
222
|
context.log.fail("Health checks failed, reverting");
|
|
223
|
+
|
|
224
|
+
// Before the revert, which gives the live name back to the container it
|
|
225
|
+
// retired. Asked after it, the logs would be the previous release's
|
|
226
|
+
await dumpLogs(context, apps, unhealthy);
|
|
220
227
|
await Promise.all(apps.map((app) => revert(docker, topology, app)));
|
|
221
228
|
|
|
222
229
|
return {
|
|
@@ -298,20 +305,58 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
|
|
|
298
305
|
const results = await Promise.all(
|
|
299
306
|
context.config.apps.map(async (app) => {
|
|
300
307
|
const target = appOf(topology, app.name);
|
|
308
|
+
|
|
309
|
+
// A step of its own for each app, rather than lines said beside the swap:
|
|
310
|
+
// every attempt lands on it, and a crash log gives it a file
|
|
301
311
|
const deps: HealthDeps = {
|
|
302
312
|
...health,
|
|
303
313
|
probe: async (container, url) => {
|
|
304
314
|
const result = await docker.run(`exec ${container} curl -s ${url}`);
|
|
305
315
|
return { code: result.code, output: result.stdout };
|
|
306
316
|
},
|
|
307
|
-
log,
|
|
317
|
+
task: log.step(`Health check of ${app.name}`),
|
|
308
318
|
};
|
|
309
319
|
|
|
310
|
-
|
|
320
|
+
const healthy = await healthcheck(target.container, target.port, app.health, deps);
|
|
321
|
+
return healthy ? undefined : target.container;
|
|
311
322
|
}),
|
|
312
323
|
);
|
|
313
324
|
|
|
314
|
-
|
|
325
|
+
// The containers that failed, since what gets written out for each depends on
|
|
326
|
+
// whether it was one of them
|
|
327
|
+
return new Set(results.filter((container): container is string => container !== undefined));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Enough to hold a stack trace and what led up to it, not a day of access logs
|
|
331
|
+
const LOG_TAIL = 200;
|
|
332
|
+
|
|
333
|
+
// Every new container's, not only the ones that failed: a backend that never came
|
|
334
|
+
// up is often explained by what the frontend says it could not reach. Each is a
|
|
335
|
+
// step of its own, so the crash log gives it a file, and the one that failed its
|
|
336
|
+
// check is marked failed, which is what puts its tail on the screen at the end
|
|
337
|
+
async function dumpLogs(context: Context, apps: AppTopology[], unhealthy: Set<string>) {
|
|
338
|
+
await Promise.all(
|
|
339
|
+
apps.map(async (app) => {
|
|
340
|
+
const task = context.log.step(`Logs of ${app.name}`);
|
|
341
|
+
task.detail(`the last ${LOG_TAIL} lines of ${app.container}`);
|
|
342
|
+
|
|
343
|
+
const result = await context.docker.container.logs(app.container, LOG_TAIL);
|
|
344
|
+
|
|
345
|
+
// Said, and left there. The revert still has to run, and logs that could
|
|
346
|
+
// not be read are no reason to leave a failed release serving
|
|
347
|
+
if (result.code !== 0) {
|
|
348
|
+
task.fail(`could not read the logs of ${app.container}: ${result.stdout || result.stderr}`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const lines = result.stdout.split("\n").filter((line) => line.length > 0);
|
|
353
|
+
for (const line of lines) task.line(line);
|
|
354
|
+
|
|
355
|
+
const said = `${lines.length} lines from ${app.container}`;
|
|
356
|
+
if (unhealthy.has(app.container)) task.fail(`${said}, which failed its health check`);
|
|
357
|
+
else task.done(said);
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
315
360
|
}
|
|
316
361
|
|
|
317
362
|
// The proxy is one of these, derived rather than listed. What each should be
|
|
@@ -347,6 +392,8 @@ async function buildAll(
|
|
|
347
392
|
|
|
348
393
|
return await Promise.all(
|
|
349
394
|
config.apps.map(async (app) => {
|
|
395
|
+
const placed = appOf(topology, app.name);
|
|
396
|
+
const source = await clone(app, placed, here ?? context.host, environment.branch, log);
|
|
350
397
|
const task = log.step(`Building ${app.name}`);
|
|
351
398
|
|
|
352
399
|
try {
|
|
@@ -374,7 +421,7 @@ async function buildAll(
|
|
|
374
421
|
output: task.line,
|
|
375
422
|
};
|
|
376
423
|
|
|
377
|
-
const result = await build(app,
|
|
424
|
+
const result = await build(app, placed, buildContext, source);
|
|
378
425
|
task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
|
|
379
426
|
|
|
380
427
|
return { app, result };
|
|
@@ -386,6 +433,41 @@ async function buildAll(
|
|
|
386
433
|
);
|
|
387
434
|
}
|
|
388
435
|
|
|
436
|
+
// A step of its own, ahead of the build that reads it. A wrong branch or an
|
|
437
|
+
// unreachable repository shows here, and folded into the build it was a detail
|
|
438
|
+
// that scrolled past without ever saying what it fetched
|
|
439
|
+
async function clone(
|
|
440
|
+
app: AppSpec,
|
|
441
|
+
placed: AppTopology,
|
|
442
|
+
host: Host,
|
|
443
|
+
branch: string,
|
|
444
|
+
log: Log,
|
|
445
|
+
): Promise<Source | undefined> {
|
|
446
|
+
// A directory is read where it is, not cloned, and the build still does that
|
|
447
|
+
if (!app.repo) return undefined;
|
|
448
|
+
|
|
449
|
+
const ref = refOf(app, branch);
|
|
450
|
+
const what = `${describeRepo(app.repo)} ${describeRef(ref)}`;
|
|
451
|
+
const task = log.step(`Cloning ${app.name}`);
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
task.detail(what);
|
|
455
|
+
const source = await sourceOf(app, placed, {
|
|
456
|
+
host,
|
|
457
|
+
branch,
|
|
458
|
+
detail: task.detail,
|
|
459
|
+
output: task.line,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Said again at the end, where it outlasts the details that replaced it
|
|
463
|
+
task.done(`${what} -> ${source.release.slice(0, 7)}`);
|
|
464
|
+
return source;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
task.fail(`${app.name} could not clone ${what}`);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
389
471
|
async function resolveFiles(app: AppSpec, stores: SecretStores) {
|
|
390
472
|
const entries = await Promise.all(
|
|
391
473
|
Object.entries(app.files ?? {}).map(
|
package/src/docker.ts
CHANGED
|
@@ -233,6 +233,12 @@ class DockerContainer {
|
|
|
233
233
|
return RUNNING.has(await this.status(name));
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Both streams, since a process that dies as it starts says why on stderr, and
|
|
237
|
+
// stamped so a line can be set against when the health check gave up
|
|
238
|
+
async logs(name: string, tail: number) {
|
|
239
|
+
return await this.docker.run(`container logs --tail ${tail} --timestamps ${name} 2>&1`);
|
|
240
|
+
}
|
|
241
|
+
|
|
236
242
|
async start(name: string) {
|
|
237
243
|
if (!(await this.exists(name))) {
|
|
238
244
|
throw new Error(`Cannot start ${name}, it does not exist`);
|
package/src/health.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { silent, type
|
|
1
|
+
import { silent, type Task } from "./log.js";
|
|
2
2
|
import type { HealthSpec } from "./types.js";
|
|
3
3
|
|
|
4
4
|
const RETRIES = 10;
|
|
@@ -16,7 +16,9 @@ export type Probe = (
|
|
|
16
16
|
export type HealthDeps = {
|
|
17
17
|
probe: Probe;
|
|
18
18
|
sleep: (ms: number) => Promise<void>;
|
|
19
|
-
|
|
19
|
+
// The step the check reports on. Every attempt is said on it, so a check that
|
|
20
|
+
// gave up shows what the container answered each time rather than only that
|
|
21
|
+
task?: Task;
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
// One loop for every app. The two copies it replaces had already drifted: the
|
|
@@ -30,22 +32,28 @@ export async function healthcheck(
|
|
|
30
32
|
): Promise<boolean> {
|
|
31
33
|
const retries = spec.retries ?? RETRIES;
|
|
32
34
|
const ceiling = spec.intervalMs ?? INTERVAL_MS;
|
|
33
|
-
const
|
|
35
|
+
const task = deps.task ?? silent.step("");
|
|
34
36
|
const delay = spec.delayMs ?? DELAY_MS;
|
|
37
|
+
const url = `localhost:${port}${spec.path}`;
|
|
35
38
|
|
|
39
|
+
task.detail(`probing ${container} at ${url}`);
|
|
36
40
|
if (delay > 0) await deps.sleep(delay);
|
|
37
41
|
|
|
38
42
|
let backoff = FIRST_BACKOFF_MS;
|
|
39
43
|
|
|
40
44
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
41
|
-
const url = `localhost:${port}${spec.path}`;
|
|
42
45
|
const { code, output } = await deps.probe(container, url);
|
|
46
|
+
const said = (message: string) => task.detail(`attempt ${attempt} of ${retries}: ${message}`);
|
|
43
47
|
|
|
44
|
-
if (code === 0 && passes(spec, output,
|
|
45
|
-
|
|
48
|
+
if (code === 0 && passes(spec, output, said)) {
|
|
49
|
+
task.done(`${container} healthy after ${attempt} ${attempt === 1 ? "attempt" : "attempts"}`);
|
|
46
50
|
return true;
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
// Nothing answered at all, which is a container still starting or one that
|
|
54
|
+
// is listening somewhere other than where the check asks
|
|
55
|
+
if (code !== 0) said(`no answer on ${url}`);
|
|
56
|
+
|
|
49
57
|
if (attempt === retries) break;
|
|
50
58
|
|
|
51
59
|
// Doubling from a quarter second means a container that starts quickly
|
|
@@ -54,30 +62,29 @@ export async function healthcheck(
|
|
|
54
62
|
backoff = Math.min(backoff * 2, ceiling);
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
|
|
65
|
+
task.fail(`${container} unhealthy after ${retries} attempts`);
|
|
58
66
|
return false;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
// A body that parses but fails the predicate is a retry, not a verdict. The
|
|
62
70
|
// container may still be warming up, and the caller has a retry budget for it
|
|
63
|
-
function passes(
|
|
64
|
-
spec: HealthSpec,
|
|
65
|
-
output: string,
|
|
66
|
-
log: Log,
|
|
67
|
-
container: string,
|
|
68
|
-
) {
|
|
71
|
+
function passes(spec: HealthSpec, output: string, said: (message: string) => void) {
|
|
69
72
|
let body: unknown;
|
|
70
73
|
|
|
71
74
|
try {
|
|
72
75
|
body = JSON.parse(output);
|
|
73
76
|
} catch {
|
|
74
|
-
|
|
77
|
+
said(`answered with something that is not JSON: ${output}`);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof body !== "object" || body === null) {
|
|
82
|
+
said(`answered with JSON that is not an object: ${output}`);
|
|
75
83
|
return false;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
if (typeof body !== "object" || body === null) return false;
|
|
79
86
|
if (spec.expect(body as Record<string, unknown>)) return true;
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
said(`not healthy yet: ${output}`);
|
|
82
89
|
return false;
|
|
83
90
|
}
|
package/src/log.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
// The shape progress is reported through. The library only knows this much, so
|
|
2
2
|
// a deploy driven from somewhere without a terminal supplies its own.
|
|
3
3
|
|
|
4
|
+
// A short label a terminal can draw as a tag, such as the host a repository
|
|
5
|
+
// lives on. Marked with private-use characters, which nothing a command prints
|
|
6
|
+
// uses, so a log finds one without guessing at brackets somebody typed
|
|
7
|
+
const TAG_OPEN = "\uE000";
|
|
8
|
+
const TAG_CLOSE = "\uE001";
|
|
9
|
+
|
|
10
|
+
export const TAGS = /\uE000([^\uE000\uE001]*)\uE001/g;
|
|
11
|
+
|
|
12
|
+
export function tag(label: string) {
|
|
13
|
+
return `${TAG_OPEN}${label}${TAG_CLOSE}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// For a log that has no way to draw one: a file, a pipe, a CI log
|
|
17
|
+
export function untagged(text: string) {
|
|
18
|
+
return text.replaceAll(TAGS, "[$1]");
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
// Work that is still running. Announced when it starts, because the thing worth
|
|
5
|
-
// knowing during a four minute build is which step is the four minutes
|
|
22
|
+
// knowing during a four minute build is which step is the four minutes. What it
|
|
23
|
+
// says may carry a tag, which a log that draws plain text passes through untagged
|
|
6
24
|
export type Task = {
|
|
7
25
|
// What the step is doing right now, replacing whatever it said before
|
|
8
26
|
detail(message: string): void;
|
package/src/source.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Host } from "./host.js";
|
|
2
|
+
import { tag } from "./log.js";
|
|
2
3
|
|
|
3
4
|
// Getting the source onto the machine that builds it. A repository the host
|
|
4
5
|
// clones for itself over the forwarded agent, so no tree crosses the wire: a
|
|
@@ -45,6 +46,29 @@ const REVISION: Record<RefKind, (name: string) => string> = {
|
|
|
45
46
|
commit: (name) => `${name}^{commit}`,
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
// Hosts common enough to be worth a tag in place of their name
|
|
50
|
+
const HOSTS: Record<string, string> = { "github.com": "GH" };
|
|
51
|
+
|
|
52
|
+
// user@host:path, or scheme://user@host/path, with the .git every clone URL ends
|
|
53
|
+
// in. Anything that is not one of those, a directory for instance, has no host
|
|
54
|
+
const REMOTE = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](.+?)(?:\.git)?\/?$/;
|
|
55
|
+
|
|
56
|
+
// How a repository is named on screen: the host as a tag where it is a known
|
|
57
|
+
// one, and the path without the .git that says nothing a person needs
|
|
58
|
+
export function describeRepo(repo: string) {
|
|
59
|
+
const match = REMOTE.exec(repo);
|
|
60
|
+
if (!match?.[1] || !match[2]) return repo.replace(/\.git$/, "");
|
|
61
|
+
|
|
62
|
+
const known = HOSTS[match[1]];
|
|
63
|
+
return known ? `${tag(known)} ${match[2]}` : `${match[1]}:${match[2]}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A branch is what is tracked nearly always, so it goes by its name alone. A tag
|
|
67
|
+
// or a commit is a pin, and says which kind it is
|
|
68
|
+
export function describeRef(ref: Ref) {
|
|
69
|
+
return ref.kind === "branch" ? ref.name : `${ref.kind} ${ref.name}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
// accept-new rather than no: a host key that changes is still worth refusing
|
|
49
73
|
const GIT_SSH = "ssh -o StrictHostKeyChecking=accept-new";
|
|
50
74
|
|
package/src/types.ts
CHANGED
|
@@ -282,4 +282,13 @@ export type Deployment = {
|
|
|
282
282
|
// refs, and whatever else brings steps of its own. Nothing a plugin carries
|
|
283
283
|
// happens until it is listed here, redkite's own vault included
|
|
284
284
|
plugins?: Plugin[];
|
|
285
|
+
// How redkite itself behaves, rather than anything it deploys
|
|
286
|
+
options?: DeploymentOptions;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export type DeploymentOptions = {
|
|
290
|
+
// A run that fails writes everything it said to
|
|
291
|
+
// /tmp/<project>/<environment>/crash-<time>/: the run's own log, and one file
|
|
292
|
+
// per step with its output in full. On unless this says false
|
|
293
|
+
crashLog?: boolean;
|
|
285
294
|
};
|