redkite 0.1.12 → 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 +12 -5
- 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.js +4 -2
- package/dist/cli/crash.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +18 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +3 -2
- 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 +6 -3
- package/dist/deploy.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/package.json +1 -1
- package/src/cli/agent.ts +8 -8
- package/src/cli/crash.ts +4 -3
- package/src/cli/index.ts +21 -9
- package/src/cli/log.ts +3 -2
- package/src/cli/screen.ts +45 -4
- package/src/cli/viewer.ts +5 -4
- package/src/deploy.ts +7 -4
- package/src/health.ts +23 -16
- package/src/log.ts +19 -1
- package/src/source.ts +24 -0
package/src/cli/viewer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
elapsed,
|
|
6
6
|
emptyModel,
|
|
7
7
|
keysOf,
|
|
8
|
+
paintTags,
|
|
8
9
|
render,
|
|
9
10
|
type Model,
|
|
10
11
|
type Step,
|
|
@@ -113,7 +114,7 @@ export function createViewer(
|
|
|
113
114
|
stream.write(ALTERNATE_OFF);
|
|
114
115
|
// The alternate screen takes every frame with it, so the run has to be
|
|
115
116
|
// written again on the screen the person keeps
|
|
116
|
-
for (const line of summary(model)) stream.write(`${line}\n`);
|
|
117
|
+
for (const line of summary(model, colour)) stream.write(`${line}\n`);
|
|
117
118
|
};
|
|
118
119
|
|
|
119
120
|
// What a second press means is not the view's to decide: the same key
|
|
@@ -191,18 +192,18 @@ export function createViewer(
|
|
|
191
192
|
|
|
192
193
|
// One line per step and whatever was said outside them, which is the record a
|
|
193
194
|
// person scrolls back to after the deploy is over
|
|
194
|
-
function summary(model: Model): string[] {
|
|
195
|
+
function summary(model: Model, colour: boolean): string[] {
|
|
195
196
|
const rows = model.steps.map((step) => {
|
|
196
197
|
const glyph = step.state === "done" ? "✔" : step.state === "failed" ? "✘" : "·";
|
|
197
198
|
const said = step.note ? `: ${step.note}` : "";
|
|
198
199
|
const took = elapsed(step.ended ?? model.now, step.started);
|
|
199
200
|
|
|
200
|
-
return `${glyph} ${step.label}${said} (${took})
|
|
201
|
+
return paintTags(`${glyph} ${step.label}${said} (${took})`, colour);
|
|
201
202
|
});
|
|
202
203
|
|
|
203
204
|
return [
|
|
204
205
|
...rows,
|
|
205
|
-
...model.messages.map((message) => message.text),
|
|
206
|
+
...model.messages.map((message) => paintTags(message.text, colour)),
|
|
206
207
|
...whatFailed(model),
|
|
207
208
|
];
|
|
208
209
|
}
|
package/src/deploy.ts
CHANGED
|
@@ -26,7 +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 type
|
|
29
|
+
import { describeRef, describeRepo, type Source } from "./source.js";
|
|
30
30
|
import { topologyFor, type AppTopology, type Topology } from "./topology.js";
|
|
31
31
|
import type { AppSpec, Deployment } from "./types.js";
|
|
32
32
|
|
|
@@ -305,13 +305,16 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
|
|
|
305
305
|
const results = await Promise.all(
|
|
306
306
|
context.config.apps.map(async (app) => {
|
|
307
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
|
|
308
311
|
const deps: HealthDeps = {
|
|
309
312
|
...health,
|
|
310
313
|
probe: async (container, url) => {
|
|
311
314
|
const result = await docker.run(`exec ${container} curl -s ${url}`);
|
|
312
315
|
return { code: result.code, output: result.stdout };
|
|
313
316
|
},
|
|
314
|
-
log,
|
|
317
|
+
task: log.step(`Health check of ${app.name}`),
|
|
315
318
|
};
|
|
316
319
|
|
|
317
320
|
const healthy = await healthcheck(target.container, target.port, app.health, deps);
|
|
@@ -444,7 +447,7 @@ async function clone(
|
|
|
444
447
|
if (!app.repo) return undefined;
|
|
445
448
|
|
|
446
449
|
const ref = refOf(app, branch);
|
|
447
|
-
const what = `${app.repo}
|
|
450
|
+
const what = `${describeRepo(app.repo)} ${describeRef(ref)}`;
|
|
448
451
|
const task = log.step(`Cloning ${app.name}`);
|
|
449
452
|
|
|
450
453
|
try {
|
|
@@ -457,7 +460,7 @@ async function clone(
|
|
|
457
460
|
});
|
|
458
461
|
|
|
459
462
|
// Said again at the end, where it outlasts the details that replaced it
|
|
460
|
-
task.done(`${what}
|
|
463
|
+
task.done(`${what} -> ${source.release.slice(0, 7)}`);
|
|
461
464
|
return source;
|
|
462
465
|
} catch (error) {
|
|
463
466
|
task.fail(`${app.name} could not clone ${what}`);
|
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
|
|