redkite 0.1.10 → 0.1.12

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/src/cli/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { environmentOf } from "../config.js";
1
+ import { environmentOf, withEnvironment } from "../config.js";
2
2
  import { deploy, verify } from "../deploy.js";
3
3
  import { Docker } from "../docker.js";
4
4
  import type { Host } from "../host.js";
@@ -17,6 +17,7 @@ import type { Deployment, DeployHost } from "../types.js";
17
17
  import { needsAgent, requireAgent } from "./agent.js";
18
18
  import { loadConfig, projectRoot } from "./config.js";
19
19
  import { loadDotenv } from "./dotenv.js";
20
+ import { dumpCrash, recording } from "./crash.js";
20
21
  import { createLog, describeFailure } from "./log.js";
21
22
 
22
23
  // One command that reads the config and does everything under it: the agent,
@@ -42,7 +43,7 @@ of the project, and everything below it is derived.
42
43
 
43
44
  // Wraps a host rather than living inside one, so both implementations are
44
45
  // measured the same way and neither knows it is being timed
45
- function measured(host: Host, log?: Log) {
46
+ function measured(host: Host, say?: (line: string) => void) {
46
47
  const totals = { commands: 0, commandMs: 0, files: 0 };
47
48
 
48
49
  const wrapped: Host = {
@@ -61,7 +62,7 @@ function measured(host: Host, log?: Log) {
61
62
 
62
63
  // The exit code matters: several of these are allowed to fail, and a
63
64
  // deploy reading verbose output is one where somebody wants to know which
64
- log?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
65
+ say?.(` $ ${command} ${Date.now() - started}ms exit ${result.code}`);
65
66
  return result;
66
67
  },
67
68
 
@@ -211,7 +212,7 @@ function asLog(say: (message: string) => void): Log {
211
212
  }
212
213
 
213
214
  async function plan(environment: string, configPath?: string, read: string[] = []) {
214
- const config = await loadConfig(configPath);
215
+ const config = withEnvironment(await loadConfig(configPath), environment);
215
216
  const topology = topologyFor(config, environment);
216
217
 
217
218
  const say = (message = "") => process.stdout.write(`${message}\n`);
@@ -549,7 +550,9 @@ async function run(
549
550
  configPath: string | undefined,
550
551
  options: RunOptions,
551
552
  ) {
552
- const config = await loadConfig(configPath);
553
+ // Folded in before anything opens, so an environment naming an app that is
554
+ // not there is refused before a connection or a vault
555
+ const config = withEnvironment(await loadConfig(configPath), environment);
553
556
 
554
557
  // Fail on a missing environment before opening anything for it
555
558
  const topology = topologyFor(config, environment);
@@ -566,10 +569,37 @@ async function run(
566
569
  // failure path, and the finally below removes the scratch directory
567
570
  const stopping = new AbortController();
568
571
 
569
- const log = createLog({
570
- ...options,
571
- onQuit: () => stop(),
572
- });
572
+ // Everything the run says still reaches the view. The recording is what a
573
+ // crash log is written from, since the view keeps only each step's tail
574
+ const recorder = recording(
575
+ createLog({
576
+ ...options,
577
+ onQuit: () => stop(),
578
+ }),
579
+ );
580
+
581
+ const log = recorder.log;
582
+
583
+ // Before the view closes, so where the log went is among what it writes out.
584
+ // A log that cannot be written must not replace the failure it was recording
585
+ const dumped = async (outcome: string, error?: unknown) => {
586
+ try {
587
+ const path = await dumpCrash(config, recorder.transcript, {
588
+ project: config.project,
589
+ environment,
590
+ command: kind,
591
+ version: await version(),
592
+ argv: process.argv.slice(2),
593
+ outcome,
594
+ at: Date.now(),
595
+ error,
596
+ });
597
+
598
+ if (path) log.warn(`Crash logs written to ${path}`);
599
+ } catch (failure) {
600
+ log.warn(`Could not write the crash log: ${describeFailure(failure).split("\n")[0]}`);
601
+ }
602
+ };
573
603
 
574
604
  // Read by the wait below, so a press during it hardens what that is sending
575
605
  let hardest: "TERM" | "KILL" = "TERM";
@@ -600,7 +630,7 @@ async function run(
600
630
  try {
601
631
  const meter = measured(
602
632
  await hostFor(deployHost, log, stopping.signal),
603
- options.verbose ? log : undefined,
633
+ options.verbose ? log : recorder.command,
604
634
  );
605
635
 
606
636
  host = meter.host;
@@ -631,15 +661,17 @@ async function run(
631
661
 
632
662
  log.fail(`Reverted ${result.reverted.join(", ")}`);
633
663
  process.exitCode = 1;
664
+ await dumped("reverted");
634
665
  } catch (error) {
635
666
  // Whatever the command in flight said about being killed is noise: what
636
- // happened is that somebody asked for it to stop
667
+ // happened is that somebody asked for it to stop, which is not a crash
637
668
  if (stopping.signal.aborted) {
638
669
  log.fail("Stopped");
639
670
  process.exitCode = 130;
640
671
  } else {
641
672
  log.fail(describeFailure(error));
642
673
  process.exitCode = 1;
674
+ await dumped("failed", error);
643
675
  }
644
676
  } finally {
645
677
  // Nothing leaves while the build is still running. The signal a press
package/src/cli/log.ts CHANGED
@@ -146,7 +146,7 @@ export function describeFailure(error: unknown) {
146
146
 
147
147
  const DEPTH = 8;
148
148
 
149
- function causes(error: Error) {
149
+ export function causes(error: Error) {
150
150
  const chain: Error[] = [];
151
151
  let current: unknown = error;
152
152
 
package/src/config.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { PROXY } from "./services/proxy.js";
2
2
  import { assertSteps } from "./pipeline.js";
3
3
  import { pluginSteps } from "./plugin.js";
4
+ import { listRefs } from "./secrets/refs.js";
5
+ import type { AnyStep } from "./pipeline.js";
4
6
  import type { Deployment, Environment } from "./types.js";
5
7
 
6
8
  // The one selected, from the one place worth looking. A deployment that carries
@@ -10,6 +12,72 @@ export function environmentOf(config: Deployment, name: string) {
10
12
  return config.environment ?? config.environments?.[name];
11
13
  }
12
14
 
15
+ // The selected environment's items folded into the apps they name, after each
16
+ // app's own. Refs merge in order and the later key wins, so the deployment holds
17
+ // what every environment shares and the environment holds what differs
18
+ export function withEnvironment(config: Deployment, name: string): Deployment {
19
+ const environment = environmentOf(config, name);
20
+ if (!environment?.secrets && !environment?.files && !environment?.steps) return config;
21
+
22
+ const { secrets = {}, files = {}, steps, ...rest } = environment;
23
+ assertNamesApps(config, name, [...Object.keys(secrets), ...Object.keys(files)]);
24
+
25
+ const apps = config.apps.map((app) => {
26
+ const refs = secrets[app.name];
27
+ const paths = files[app.name];
28
+ if (!refs && !paths) return app;
29
+
30
+ return {
31
+ ...app,
32
+ secrets: refs ? [...listRefs(app.secrets), ...listRefs(refs)] : app.secrets,
33
+ files: paths ? { ...app.files, ...paths } : app.files,
34
+ };
35
+ });
36
+
37
+ const folded = { ...config, apps, steps: steps ? stepsWith(config, steps) : config.steps };
38
+
39
+ // What was folded in leaves the environment, so folding it again adds nothing
40
+ // and a caller that already did it can hand the result on to one that will
41
+ if (config.environment) return { ...folded, environment: rest };
42
+ return { ...folded, environments: { ...config.environments, [name]: rest } };
43
+ }
44
+
45
+ // A step at a point the deployment already fills replaces it for this
46
+ // environment, where it stood. One only the environment has leads, the way a
47
+ // plugin's does, so a snapshot added here still runs above the shared migration
48
+ function stepsWith(config: Deployment, given: AnyStep[]) {
49
+ // Before the replacement map, which would otherwise keep the last of two and
50
+ // drop the other without a word
51
+ assertSteps(given);
52
+
53
+ const shared = config.steps ?? [];
54
+ const claimed = new Set(shared.map((step) => step.point));
55
+ const replacing = new Map(given.map((step) => [step.point, step]));
56
+
57
+ const steps = [
58
+ ...given.filter((step) => !claimed.has(step.point)),
59
+ ...shared.map((step) => replacing.get(step.point) ?? step),
60
+ ];
61
+
62
+ // A plugin fills points too, and one the environment lands on is the same
63
+ // collision defineDeployment refuses
64
+ assertSteps([...pluginSteps(config.plugins), ...steps]);
65
+ return steps;
66
+ }
67
+
68
+ // An environment file does not import the deployment, so an app name there
69
+ // cannot be checked when it compiles. A typo would otherwise read nothing
70
+ function assertNamesApps(config: Deployment, environment: string, names: string[]) {
71
+ const apps = new Set(config.apps.map((app) => app.name));
72
+ const unknown = names.filter((name) => !apps.has(name));
73
+ if (unknown.length === 0) return;
74
+
75
+ throw new Error(
76
+ `${environment} gives secrets to ${unknown.join(", ")}, which this deployment ` +
77
+ `has no app by that name for. Its apps are ${[...apps].join(", ")}`,
78
+ );
79
+ }
80
+
13
81
  // Identity, but it pins the type so a missing health predicate fails to
14
82
  // compile. Environments are excluded rather than merely unused: each lives in
15
83
  // a redkite.<name>.config.ts of its own, and a second place to put one is a
@@ -31,6 +99,7 @@ export function defineDeployment<const T extends Deployment & { environments?: n
31
99
  // Identity, for the environment a redkite.<name>.config.ts holds. It pins the
32
100
  // type the same way defineDeployment does, so a missing subnet fails to compile
33
101
  export function defineEnvironment<const T extends Environment>(environment: T): T {
102
+ if (environment.steps) assertSteps(environment.steps);
34
103
  return environment;
35
104
  }
36
105
 
package/src/deploy.ts CHANGED
@@ -1,6 +1,6 @@
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
- import { environmentOf } from "./config.js";
3
+ import { environmentOf, withEnvironment } from "./config.js";
4
4
  import { Docker } from "./docker.js";
5
5
  import { envFileFor } from "./environment.js";
6
6
  import { healthcheck, type HealthDeps } from "./health.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 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
 
@@ -68,11 +69,12 @@ export async function verify(options: DeployOptions): Promise<Finished> {
68
69
 
69
70
  async function start(run: Run, options: DeployOptions): Promise<Finished> {
70
71
  const log = options.log ?? silent;
72
+ const config = withEnvironment(options.config, options.environment);
71
73
 
72
74
  const setting: Omit<Context, "task"> = {
73
- config: options.config,
75
+ config,
74
76
  environment: options.environment,
75
- topology: topologyFor(options.config, options.environment),
77
+ topology: topologyFor(config, options.environment),
76
78
  host: options.host,
77
79
  docker: new Docker(options.host),
78
80
  secrets: options.secrets,
@@ -82,7 +84,7 @@ async function start(run: Run, options: DeployOptions): Promise<Finished> {
82
84
 
83
85
  // A plugin's steps lead, so a snapshot listed as a plugin runs above the
84
86
  // migration the deployment writes after it
85
- const added = [...pluginSteps(options.config.plugins), ...(options.config.steps ?? [])];
87
+ const added = [...pluginSteps(config.plugins), ...(config.steps ?? [])];
86
88
 
87
89
  return await runPipeline(run, merge(supplied(options), added), setting, options.signal);
88
90
  }
@@ -214,8 +216,14 @@ async function release(
214
216
  // Inside, because the swap is not over until this says so. A stop lands
215
217
  // here more often than anywhere else: it is the longest part, and by now
216
218
  // every address has already moved
217
- if (!(await checkAll(context, health))) {
219
+ const unhealthy = await checkAll(context, health);
220
+
221
+ if (unhealthy.size > 0) {
218
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);
219
227
  await Promise.all(apps.map((app) => revert(docker, topology, app)));
220
228
 
221
229
  return {
@@ -306,11 +314,46 @@ async function checkAll(context: Context, health: Omit<HealthDeps, "probe">) {
306
314
  log,
307
315
  };
308
316
 
309
- return await healthcheck(target.container, target.port, app.health, deps);
317
+ const healthy = await healthcheck(target.container, target.port, app.health, deps);
318
+ return healthy ? undefined : target.container;
310
319
  }),
311
320
  );
312
321
 
313
- return !results.includes(false);
322
+ // The containers that failed, since what gets written out for each depends on
323
+ // whether it was one of them
324
+ return new Set(results.filter((container): container is string => container !== undefined));
325
+ }
326
+
327
+ // Enough to hold a stack trace and what led up to it, not a day of access logs
328
+ const LOG_TAIL = 200;
329
+
330
+ // Every new container's, not only the ones that failed: a backend that never came
331
+ // up is often explained by what the frontend says it could not reach. Each is a
332
+ // step of its own, so the crash log gives it a file, and the one that failed its
333
+ // check is marked failed, which is what puts its tail on the screen at the end
334
+ async function dumpLogs(context: Context, apps: AppTopology[], unhealthy: Set<string>) {
335
+ await Promise.all(
336
+ apps.map(async (app) => {
337
+ const task = context.log.step(`Logs of ${app.name}`);
338
+ task.detail(`the last ${LOG_TAIL} lines of ${app.container}`);
339
+
340
+ const result = await context.docker.container.logs(app.container, LOG_TAIL);
341
+
342
+ // Said, and left there. The revert still has to run, and logs that could
343
+ // not be read are no reason to leave a failed release serving
344
+ if (result.code !== 0) {
345
+ task.fail(`could not read the logs of ${app.container}: ${result.stdout || result.stderr}`);
346
+ return;
347
+ }
348
+
349
+ const lines = result.stdout.split("\n").filter((line) => line.length > 0);
350
+ for (const line of lines) task.line(line);
351
+
352
+ const said = `${lines.length} lines from ${app.container}`;
353
+ if (unhealthy.has(app.container)) task.fail(`${said}, which failed its health check`);
354
+ else task.done(said);
355
+ }),
356
+ );
314
357
  }
315
358
 
316
359
  // The proxy is one of these, derived rather than listed. What each should be
@@ -346,6 +389,8 @@ async function buildAll(
346
389
 
347
390
  return await Promise.all(
348
391
  config.apps.map(async (app) => {
392
+ const placed = appOf(topology, app.name);
393
+ const source = await clone(app, placed, here ?? context.host, environment.branch, log);
349
394
  const task = log.step(`Building ${app.name}`);
350
395
 
351
396
  try {
@@ -373,7 +418,7 @@ async function buildAll(
373
418
  output: task.line,
374
419
  };
375
420
 
376
- const result = await build(app, appOf(topology, app.name), buildContext);
421
+ const result = await build(app, placed, buildContext, source);
377
422
  task.done(`${result.release.slice(0, 7)}${result.cached ? " (held)" : ""}`);
378
423
 
379
424
  return { app, result };
@@ -385,6 +430,41 @@ async function buildAll(
385
430
  );
386
431
  }
387
432
 
433
+ // A step of its own, ahead of the build that reads it. A wrong branch or an
434
+ // unreachable repository shows here, and folded into the build it was a detail
435
+ // that scrolled past without ever saying what it fetched
436
+ async function clone(
437
+ app: AppSpec,
438
+ placed: AppTopology,
439
+ host: Host,
440
+ branch: string,
441
+ log: Log,
442
+ ): Promise<Source | undefined> {
443
+ // A directory is read where it is, not cloned, and the build still does that
444
+ if (!app.repo) return undefined;
445
+
446
+ const ref = refOf(app, branch);
447
+ const what = `${app.repo}, ${ref.kind} ${ref.name}`;
448
+ const task = log.step(`Cloning ${app.name}`);
449
+
450
+ try {
451
+ task.detail(what);
452
+ const source = await sourceOf(app, placed, {
453
+ host,
454
+ branch,
455
+ detail: task.detail,
456
+ output: task.line,
457
+ });
458
+
459
+ // Said again at the end, where it outlasts the details that replaced it
460
+ task.done(`${what} at ${source.release.slice(0, 7)}`);
461
+ return source;
462
+ } catch (error) {
463
+ task.fail(`${app.name} could not clone ${what}`);
464
+ throw error;
465
+ }
466
+ }
467
+
388
468
  async function resolveFiles(app: AppSpec, stores: SecretStores) {
389
469
  const entries = await Promise.all(
390
470
  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/steps.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { environmentOf } from "./config.js";
2
1
  import { envFlags } from "./environment.js";
3
2
  import type { Built, Plan, Step } from "./pipeline.js";
4
3
  import type { Topology } from "./topology.js";
@@ -30,10 +29,6 @@ type MigrateOptions = {
30
29
  // already reach whatever the app's environment file points at. A database
31
30
  // this deployment runs as a service is on "deployment" instead
32
31
  network?: StepNetwork;
33
- // The machine this migration reaches its database through. The step runs on
34
- // the deploy host, so a deployment that moved to another one is refused
35
- // rather than pointed at a database it may not be able to see
36
- through?: string;
37
32
  };
38
33
 
39
34
  // An ordinary step at an ordinary point. Hung before the swap, it runs while
@@ -43,7 +38,7 @@ export function migrate(options: MigrateOptions): Step<`swap:before:${string}`>
43
38
 
44
39
  return {
45
40
  point: `swap:before:migrate-${options.app}`,
46
- check: (plan) => assertReachable(plan, options),
41
+ check: (plan) => assertApp(plan, options),
47
42
 
48
43
  run: async (input, context) => {
49
44
  const image = builderOf(input, options.app);
@@ -73,19 +68,9 @@ export function migrate(options: MigrateOptions): Step<`swap:before:${string}`>
73
68
 
74
69
  // Checked before the run starts, so a config naming an app that never kept a
75
70
  // builder fails without having touched the host
76
- function assertReachable(plan: Plan, options: MigrateOptions) {
77
- const app = plan.config.apps.find((item) => item.name === options.app);
78
- if (!app) throw new Error(`${options.app} names no app in this deployment`);
79
-
80
- const bastion = environmentOf(plan.config, plan.environment)?.host?.bastion;
81
- const expected = options.through;
82
- if (!expected || expected === bastion) return;
83
-
84
- throw new Error(
85
- `${options.app} migrates through ${expected}, ` +
86
- `but ${plan.environment} deploys to ${bastion ?? "this machine"}. ` +
87
- "The step runs on the deploy host, so they have to be the same",
88
- );
71
+ function assertApp(plan: Plan, options: MigrateOptions) {
72
+ if (plan.config.apps.some((item) => item.name === options.app)) return;
73
+ throw new Error(`${options.app} names no app in this deployment`);
89
74
  }
90
75
 
91
76
  // A deployment may replace the build step, and a command hung on the pipeline
package/src/types.ts CHANGED
@@ -26,6 +26,16 @@ export type Environment = {
26
26
  // derives. For a database or a legacy service that has no DNS the apps can
27
27
  // use, and whose address is what differs between environments
28
28
  extraHosts?: Record<string, string>;
29
+ // App name to the items this environment reads, after the app's own. Which
30
+ // item is usually what differs between staging and production, and the rest
31
+ // of what differs already lives here
32
+ secrets?: Record<string, SecretRefs>;
33
+ // App name to container path to item, laid over the app's own files
34
+ files?: Record<string, Record<string, SecretRef>>;
35
+ // Steps for this environment alone. One at a point the deployment already
36
+ // fills replaces it here, where it stood. One only this environment has runs
37
+ // ahead of the deployment's, so a snapshot lands above the migration
38
+ steps?: AnyStep[];
29
39
  };
30
40
 
31
41
  // Where a step's container is attached. "host" is the deploy host's own stack,
@@ -272,4 +282,13 @@ export type Deployment = {
272
282
  // refs, and whatever else brings steps of its own. Nothing a plugin carries
273
283
  // happens until it is listed here, redkite's own vault included
274
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;
275
294
  };