redkite 0.1.6

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.
Files changed (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +819 -0
  3. package/bin/redkite.mjs +12 -0
  4. package/dist/build.d.ts +27 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +132 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/checks.d.ts +4 -0
  9. package/dist/checks.d.ts.map +1 -0
  10. package/dist/checks.js +49 -0
  11. package/dist/checks.js.map +1 -0
  12. package/dist/cli/agent.d.ts +2 -0
  13. package/dist/cli/agent.d.ts.map +1 -0
  14. package/dist/cli/agent.js +24 -0
  15. package/dist/cli/agent.js.map +1 -0
  16. package/dist/cli/config.d.ts +5 -0
  17. package/dist/cli/config.d.ts.map +1 -0
  18. package/dist/cli/config.js +217 -0
  19. package/dist/cli/config.js.map +1 -0
  20. package/dist/cli/index.d.ts +11 -0
  21. package/dist/cli/index.d.ts.map +1 -0
  22. package/dist/cli/index.js +438 -0
  23. package/dist/cli/index.js.map +1 -0
  24. package/dist/cli/log.d.ts +9 -0
  25. package/dist/cli/log.d.ts.map +1 -0
  26. package/dist/cli/log.js +128 -0
  27. package/dist/cli/log.js.map +1 -0
  28. package/dist/cli/screen.d.ts +34 -0
  29. package/dist/cli/screen.d.ts.map +1 -0
  30. package/dist/cli/screen.js +298 -0
  31. package/dist/cli/screen.js.map +1 -0
  32. package/dist/cli/viewer.d.ts +9 -0
  33. package/dist/cli/viewer.d.ts.map +1 -0
  34. package/dist/cli/viewer.js +139 -0
  35. package/dist/cli/viewer.js.map +1 -0
  36. package/dist/config.d.ts +7 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +79 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/deploy.d.ts +19 -0
  41. package/dist/deploy.d.ts.map +1 -0
  42. package/dist/deploy.js +275 -0
  43. package/dist/deploy.js.map +1 -0
  44. package/dist/docker.d.ts +96 -0
  45. package/dist/docker.d.ts.map +1 -0
  46. package/dist/docker.js +325 -0
  47. package/dist/docker.js.map +1 -0
  48. package/dist/dockerfile.d.ts +14 -0
  49. package/dist/dockerfile.d.ts.map +1 -0
  50. package/dist/dockerfile.js +160 -0
  51. package/dist/dockerfile.js.map +1 -0
  52. package/dist/health.d.ts +13 -0
  53. package/dist/health.d.ts.map +1 -0
  54. package/dist/health.js +54 -0
  55. package/dist/health.js.map +1 -0
  56. package/dist/host.d.ts +21 -0
  57. package/dist/host.d.ts.map +1 -0
  58. package/dist/host.js +27 -0
  59. package/dist/host.js.map +1 -0
  60. package/dist/index.d.ts +26 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +26 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/layout.d.ts +5 -0
  65. package/dist/layout.d.ts.map +1 -0
  66. package/dist/layout.js +45 -0
  67. package/dist/layout.js.map +1 -0
  68. package/dist/localHost.d.ts +7 -0
  69. package/dist/localHost.d.ts.map +1 -0
  70. package/dist/localHost.js +32 -0
  71. package/dist/localHost.js.map +1 -0
  72. package/dist/log.d.ts +14 -0
  73. package/dist/log.d.ts.map +1 -0
  74. package/dist/log.js +15 -0
  75. package/dist/log.js.map +1 -0
  76. package/dist/nginx.d.ts +4 -0
  77. package/dist/nginx.d.ts.map +1 -0
  78. package/dist/nginx.js +53 -0
  79. package/dist/nginx.js.map +1 -0
  80. package/dist/pipeline.d.ts +103 -0
  81. package/dist/pipeline.d.ts.map +1 -0
  82. package/dist/pipeline.js +142 -0
  83. package/dist/pipeline.js.map +1 -0
  84. package/dist/presets/index.d.ts +3 -0
  85. package/dist/presets/index.d.ts.map +1 -0
  86. package/dist/presets/index.js +3 -0
  87. package/dist/presets/index.js.map +1 -0
  88. package/dist/presets/nextApp.d.ts +15 -0
  89. package/dist/presets/nextApp.d.ts.map +1 -0
  90. package/dist/presets/nextApp.js +53 -0
  91. package/dist/presets/nextApp.js.map +1 -0
  92. package/dist/presets/nodeApp.d.ts +19 -0
  93. package/dist/presets/nodeApp.d.ts.map +1 -0
  94. package/dist/presets/nodeApp.js +29 -0
  95. package/dist/presets/nodeApp.js.map +1 -0
  96. package/dist/secrets/bitwarden.d.ts +3 -0
  97. package/dist/secrets/bitwarden.d.ts.map +1 -0
  98. package/dist/secrets/bitwarden.js +6 -0
  99. package/dist/secrets/bitwarden.js.map +1 -0
  100. package/dist/secrets/refs.d.ts +9 -0
  101. package/dist/secrets/refs.d.ts.map +1 -0
  102. package/dist/secrets/refs.js +20 -0
  103. package/dist/secrets/refs.js.map +1 -0
  104. package/dist/secrets/store.d.ts +9 -0
  105. package/dist/secrets/store.d.ts.map +1 -0
  106. package/dist/secrets/store.js +104 -0
  107. package/dist/secrets/store.js.map +1 -0
  108. package/dist/services/ensure.d.ts +18 -0
  109. package/dist/services/ensure.d.ts.map +1 -0
  110. package/dist/services/ensure.js +93 -0
  111. package/dist/services/ensure.js.map +1 -0
  112. package/dist/services/index.d.ts +19 -0
  113. package/dist/services/index.d.ts.map +1 -0
  114. package/dist/services/index.js +27 -0
  115. package/dist/services/index.js.map +1 -0
  116. package/dist/services/planned.d.ts +20 -0
  117. package/dist/services/planned.d.ts.map +1 -0
  118. package/dist/services/planned.js +77 -0
  119. package/dist/services/planned.js.map +1 -0
  120. package/dist/shell.d.ts +11 -0
  121. package/dist/shell.d.ts.map +1 -0
  122. package/dist/shell.js +65 -0
  123. package/dist/shell.js.map +1 -0
  124. package/dist/source.d.ts +16 -0
  125. package/dist/source.d.ts.map +1 -0
  126. package/dist/source.js +108 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/sshHost.d.ts +15 -0
  129. package/dist/sshHost.d.ts.map +1 -0
  130. package/dist/sshHost.js +91 -0
  131. package/dist/sshHost.js.map +1 -0
  132. package/dist/steps.d.ts +27 -0
  133. package/dist/steps.d.ts.map +1 -0
  134. package/dist/steps.js +69 -0
  135. package/dist/steps.js.map +1 -0
  136. package/dist/topology.d.ts +40 -0
  137. package/dist/topology.d.ts.map +1 -0
  138. package/dist/topology.js +113 -0
  139. package/dist/topology.js.map +1 -0
  140. package/dist/types.d.ts +100 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +2 -0
  143. package/dist/types.js.map +1 -0
  144. package/package.json +64 -0
  145. package/src/build.ts +236 -0
  146. package/src/checks.ts +65 -0
  147. package/src/cli/agent.ts +28 -0
  148. package/src/cli/config.ts +275 -0
  149. package/src/cli/index.ts +559 -0
  150. package/src/cli/log.ts +168 -0
  151. package/src/cli/screen.ts +409 -0
  152. package/src/cli/viewer.ts +192 -0
  153. package/src/config.ts +90 -0
  154. package/src/deploy.ts +409 -0
  155. package/src/docker.ts +433 -0
  156. package/src/dockerfile.ts +228 -0
  157. package/src/health.ts +83 -0
  158. package/src/host.ts +54 -0
  159. package/src/index.ts +25 -0
  160. package/src/layout.ts +41 -0
  161. package/src/localHost.ts +50 -0
  162. package/src/log.ts +38 -0
  163. package/src/nginx.ts +63 -0
  164. package/src/pipeline.ts +335 -0
  165. package/src/presets/index.ts +2 -0
  166. package/src/presets/nextApp.ts +70 -0
  167. package/src/presets/nodeApp.ts +46 -0
  168. package/src/secrets/bitwarden.ts +7 -0
  169. package/src/secrets/refs.ts +32 -0
  170. package/src/secrets/store.ts +147 -0
  171. package/src/services/ensure.ts +160 -0
  172. package/src/services/index.ts +48 -0
  173. package/src/services/planned.ts +119 -0
  174. package/src/shell.ts +95 -0
  175. package/src/source.ts +171 -0
  176. package/src/sshHost.ts +145 -0
  177. package/src/steps.ts +101 -0
  178. package/src/topology.ts +199 -0
  179. package/src/types.ts +243 -0
package/src/shell.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ import { lineReader, tail, type OnLine, type Result } from "./host.js";
4
+
5
+ // One child process, its output collected and streamed. Both hosts ran their
6
+ // own copy of this, and shipping an image needs a third caller.
7
+
8
+ export type SpawnOptions = {
9
+ stdin?: string;
10
+ onLine?: OnLine;
11
+ // Only stops a command being started. What is already running is stopped by
12
+ // signalling it, which is the host's to do: over ssh the process to signal is
13
+ // on the other machine, and killing the client here would only lose the reach
14
+ signal?: AbortSignal;
15
+ };
16
+
17
+ // Every child still running. A detached child outlives this process, so a stop
18
+ // that does not go through here leaves a build running
19
+ const live = new Set<ChildProcess>();
20
+
21
+ // Signals every group and answers with how many are still there. The count is
22
+ // the whole point: nothing may exit while it is above zero
23
+ export function signalEverything(name: NodeJS.Signals) {
24
+ for (const child of live) {
25
+ try {
26
+ if (child.pid) process.kill(-child.pid, name);
27
+ } catch {
28
+ // Already gone, which is the outcome being asked for
29
+ }
30
+ }
31
+
32
+ return live.size;
33
+ }
34
+
35
+ export function stillRunning() {
36
+ return live.size;
37
+ }
38
+
39
+ export function spawnCollect(
40
+ command: string,
41
+ args: string[],
42
+ options: SpawnOptions = {},
43
+ ): Promise<Result> {
44
+ return new Promise((resolve, reject) => {
45
+ if (options.signal?.aborted) return reject(new Error("Stopped"));
46
+
47
+ // Its own process group, so one signal reaches everything it started. A
48
+ // shell that ran docker dies on its own otherwise, and docker keeps going
49
+ const child = spawn(command, args, {
50
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
51
+ detached: true,
52
+ });
53
+
54
+ live.add(child);
55
+
56
+ const out: string[] = [];
57
+ const err: string[] = [];
58
+
59
+ const collect = (into: string[]) =>
60
+ lineReader((line) => {
61
+ into.push(line);
62
+ options.onLine?.(line);
63
+ });
64
+
65
+ const stdout = collect(out);
66
+ const stderr = collect(err);
67
+
68
+ // Only a streamed command is truncated. The host snapshot is one command
69
+ // listing every container and image, and a busy host overruns any tail
70
+ const keep = (lines: string[]) => (options.onLine ? tail(lines) : lines.join("\n"));
71
+
72
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk.toString()));
73
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk.toString()));
74
+
75
+ child.on("error", reject);
76
+
77
+ // The only thing that settles this. A signalled command answers here once
78
+ // it has actually gone, which is what lets a caller wait for exactly that
79
+ child.on("close", (code) => {
80
+ live.delete(child);
81
+
82
+ stdout.flush();
83
+ stderr.flush();
84
+ resolve({ code: code ?? 1, stdout: keep(out), stderr: keep(err) });
85
+ });
86
+
87
+ if (options.stdin !== undefined) child.stdin?.end(options.stdin);
88
+ });
89
+ }
90
+
91
+ // Single quotes are what a shell strips, so a value containing double quotes
92
+ // survives being handed to one
93
+ export function quote(value: string) {
94
+ return `'${value.replaceAll("'", `'\\''`)}'`;
95
+ }
package/src/source.ts ADDED
@@ -0,0 +1,171 @@
1
+ import type { Host } from "./host.js";
2
+
3
+ // Getting the source onto the machine that builds it. A repository the host
4
+ // clones for itself over the forwarded agent, so no tree crosses the wire: a
5
+ // commit that is already mirrored costs a fetch of whatever is new since the
6
+ // last deploy. A directory is already there and is built as it stands.
7
+
8
+ export type Source = {
9
+ // What the image is tagged by: the commit for a repository, and the content
10
+ // of the working tree for a directory
11
+ release: string;
12
+ // What the build reads, on the machine that builds it
13
+ tree: string;
14
+ };
15
+
16
+ export type SourceRequest = {
17
+ // Names the mirror and the checkout. Keyed per app and environment rather
18
+ // than per repository, so two deploys never fetch into one directory at once
19
+ name: string;
20
+ // Exactly one of these, which is what the config is checked for
21
+ repo?: string;
22
+ path?: string;
23
+ // What of a path goes into the build. Absent leaves it to the work tree's
24
+ // own .gitignore, which is the only other thing that can say
25
+ include?: string[];
26
+ branch: string;
27
+ submodules: boolean;
28
+ detail?: (message: string) => void;
29
+ };
30
+
31
+ // accept-new rather than no: a host key that changes is still worth refusing
32
+ const GIT_SSH = "ssh -o StrictHostKeyChecking=accept-new";
33
+
34
+ export async function prepareSource(
35
+ host: Host,
36
+ request: SourceRequest,
37
+ ): Promise<Source> {
38
+ if (request.path) return await localSource(host, request.path, request);
39
+ if (request.repo) return await clonedSource(host, request.repo, request);
40
+
41
+ throw new Error(`${request.name} names neither a repo nor a path to build from`);
42
+ }
43
+
44
+ // Built where it sits. Nothing is cloned, checked out or cleaned: what the
45
+ // build reads is the tree as the person running this left it
46
+ async function localSource(
47
+ host: Host,
48
+ path: string,
49
+ request: SourceRequest,
50
+ ): Promise<Source> {
51
+ const detail = request.detail ?? (() => {});
52
+ detail(`reading ${path}`);
53
+
54
+ await assertKnowable(host, path, request.include);
55
+
56
+ const release = await treeOf(host, path, request.include);
57
+ detail(`built from ${release.slice(0, 7)}`);
58
+
59
+ return { release, tree: path };
60
+ }
61
+
62
+ // Something has to say what belongs in the build. A work tree's .gitignore
63
+ // does; anywhere else the deployment has to say it, or the release would cover
64
+ // whatever happened to be lying in the directory
65
+ async function assertKnowable(host: Host, path: string, include?: string[]) {
66
+ if (include) return;
67
+
68
+ const inside = await host.sh(`git -C '${path}' rev-parse --is-inside-work-tree`);
69
+ if (inside.code === 0 && inside.stdout.trim() === "true") return;
70
+
71
+ throw new Error(
72
+ `${path} is not a git work tree, so nothing there says what belongs in the ` +
73
+ 'build. Say it: include: ["src", "package.json"] on the app names what ' +
74
+ "ships, and git init there would let .gitignore name it instead",
75
+ );
76
+ }
77
+
78
+ // git's own content addressing, over a scratch repository so nothing about the
79
+ // source changes and no object lands in it. The tree covers what is committed,
80
+ // what is modified and what is untracked, so an edit that is never committed is
81
+ // a new release. Without an include it honours .gitignore, which is the same
82
+ // set the build reads
83
+ async function treeOf(host: Host, path: string, include?: string[]) {
84
+ // Everything the work tree does not ignore, or exactly what was named. The
85
+ // -- is only for the second: it would make -A a path rather than a flag
86
+ const added = include ? `-- ${include.map((item) => `'${item}'`).join(" ")}` : "-A";
87
+
88
+ const written = await host.sh(
89
+ [
90
+ "scratch=$(mktemp -d)",
91
+ 'git init -q --bare "$scratch/git"',
92
+ `export GIT_DIR="$scratch/git" GIT_WORK_TREE='${path}' GIT_INDEX_FILE="$scratch/index"`,
93
+ `git add ${added}`,
94
+ "git write-tree",
95
+ 'rm -rf "$scratch"',
96
+ ].join("\n"),
97
+ );
98
+
99
+ const release = written.stdout.trim().split("\n").at(-1) ?? "";
100
+ if (/^[0-9a-f]{40}$/.test(release)) return release;
101
+
102
+ throw new Error(`Could not read the state of ${path}: ${written.stderr || written.stdout}`);
103
+ }
104
+
105
+ async function clonedSource(
106
+ host: Host,
107
+ repo: string,
108
+ request: SourceRequest,
109
+ ): Promise<Source> {
110
+ const detail = request.detail ?? (() => {});
111
+ const mirror = `${host.cache}/mirrors/${request.name}.git`;
112
+ const path = `${host.cache}/source/${request.name}`;
113
+
114
+ detail(`fetching ${request.branch}`);
115
+ await run(host, `updating the mirror of ${repo}`, [
116
+ `if [ -d '${mirror}' ]; then`,
117
+ ` git -C '${mirror}' remote set-url origin '${repo}'`,
118
+ ` git -C '${mirror}' remote update --prune`,
119
+ "else",
120
+ ` mkdir -p '${host.cache}/mirrors'`,
121
+ ` git clone --mirror '${repo}' '${mirror}'`,
122
+ "fi",
123
+ ]);
124
+
125
+ const resolved = await run(host, `resolving ${request.branch}`, [
126
+ `git -C '${mirror}' rev-parse 'refs/heads/${request.branch}'`,
127
+ ]);
128
+
129
+ const release = resolved.stdout.trim();
130
+ if (!/^[0-9a-f]{40}$/.test(release)) {
131
+ throw new Error(`${repo} has no branch ${request.branch}`);
132
+ }
133
+
134
+ detail(`checking out ${release.slice(0, 7)}`);
135
+ await run(host, `checking out ${release.slice(0, 7)}`, [
136
+ // The checkout shares the mirror's object store, so it costs the working
137
+ // tree and nothing else. Reused between deploys, hence the reset and clean
138
+ `if [ ! -d '${path}/.git' ]; then`,
139
+ ` rm -rf '${path}'`,
140
+ ` mkdir -p '${host.cache}/source'`,
141
+ ` git clone --shared --no-checkout '${mirror}' '${path}'`,
142
+ "fi",
143
+ `git -C '${path}' fetch --prune origin`,
144
+ `git -C '${path}' checkout --detach --force '${release}'`,
145
+ // Leaves the submodules alone: they are tracked, and clean only removes
146
+ // what is not
147
+ `git -C '${path}' clean -ffdx`,
148
+ ]);
149
+
150
+ if (request.submodules) {
151
+ // --remote follows the branch named in .gitmodules rather than the commit
152
+ // the parent recorded, which is what the pipeline has always done
153
+ detail("updating submodules");
154
+ await run(host, "updating submodules", [
155
+ `git -C '${path}' submodule sync --recursive`,
156
+ `git -C '${path}' submodule update --init --remote --recursive`,
157
+ ]);
158
+ }
159
+
160
+ return { release, tree: path };
161
+ }
162
+
163
+ async function run(host: Host, what: string, script: string[]) {
164
+ const result = await host.sh(
165
+ [`export GIT_SSH_COMMAND='${GIT_SSH}'`, "set -e", ...script].join("\n"),
166
+ );
167
+
168
+ if (result.code === 0) return result;
169
+
170
+ throw new Error(`Failed ${what}: ${result.stderr || result.stdout}`);
171
+ }
package/src/sshHost.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { dirname } from "node:path";
3
+
4
+ import type { Host, OnLine, Result } from "./host.js";
5
+ import { quote, spawnCollect } from "./shell.js";
6
+
7
+ // The deploy host, reached over ssh. The agent is forwarded rather than a key,
8
+ // there is nothing to forward, and the agent travels with the connection so the
9
+ // host can clone the repositories itself.
10
+
11
+ // Injected so the argv this builds is asserted on rather than trusted
12
+ export type Ssh = (
13
+ args: string[],
14
+ options?: { stdin?: string; onLine?: OnLine },
15
+ ) => Promise<Result>;
16
+
17
+ // The local shell a piped stream is fed through, injected for the same reason
18
+ // run is: what this builds is asserted on rather than trusted
19
+ export type Shell = (command: string) => Promise<Result>;
20
+
21
+ export type SshOptions = {
22
+ run?: Ssh;
23
+ shell?: Shell;
24
+ directory?: string;
25
+ cache?: string;
26
+ // Aborting kills the ssh client. The command it was carrying keeps running on
27
+ // the other machine, which is what stop is for
28
+ signal?: AbortSignal;
29
+ };
30
+
31
+ // One TCP connection and one authentication for the whole deploy. Without it
32
+ // every command pays a handshake, which is most of what a command costs
33
+ const MULTIPLEX = [
34
+ "-o",
35
+ "ControlMaster=auto",
36
+ "-o",
37
+ "ControlPersist=60s",
38
+ "-o",
39
+ "StrictHostKeyChecking=no",
40
+ // The host clones from GitHub on our behalf rather than us shipping a tree
41
+ "-A",
42
+ ];
43
+
44
+ export async function sshHost(bastion: string, options: SshOptions = {}): Promise<Host> {
45
+ const control = `/tmp/redkite-${randomUUID().slice(0, 8)}.control`;
46
+ const directory = options.directory ?? `/tmp/redkite-${randomUUID().slice(0, 8)}`;
47
+ const signal = options.signal;
48
+
49
+ const run =
50
+ options.run ?? ((args, extra) => spawnCollect("ssh", args, { ...extra, signal }));
51
+
52
+ // Cleanup has to survive the abort that made it necessary, so it is the one
53
+ // thing here that is not killed along with everything else
54
+ const final = options.run ?? ((args, extra) => spawnCollect("ssh", args, extra));
55
+
56
+ const shell =
57
+ options.shell ??
58
+ ((command: string) => spawnCollect("sh", ["-c", command], { signal }));
59
+
60
+ const argv = (command: string) => [
61
+ ...MULTIPLEX,
62
+ "-o",
63
+ `ControlPath=${control}`,
64
+ bastion,
65
+ command,
66
+ ];
67
+
68
+ const ssh = (command: string, extra?: { stdin?: string; onLine?: OnLine }) =>
69
+ run(argv(command), extra);
70
+
71
+ // Each command records the process group job control gave it, because killing
72
+ // the ssh client here leaves what it was carrying running there
73
+ let issued = 0;
74
+
75
+ // One round trip for all of it, including the home directory the cache hangs
76
+ // off. A literal ~ would only survive as long as every caller used a shell
77
+ const opened = await ssh(
78
+ `mkdir -p -m 700 '${directory}' && mkdir -p "$HOME/.cache/redkite" && printf %s "$HOME"`,
79
+ );
80
+
81
+ if (opened.code !== 0) {
82
+ throw new Error(`Could not reach ${bastion}: ${opened.stderr || opened.stdout}`);
83
+ }
84
+
85
+ const cache = options.cache ?? `${opened.stdout.trim()}/.cache/redkite`;
86
+
87
+ return {
88
+ directory,
89
+ cache,
90
+
91
+ sh: async (command, onLine) => {
92
+ issued += 1;
93
+ return await ssh(supervised(command, `${directory}/run.${issued}.pid`), { onLine });
94
+ },
95
+
96
+ write: async (name, contents) => {
97
+ const path = `${directory}/${name}`;
98
+ const result = await ssh(`mkdir -p '${dirname(path)}' && cat > '${path}'`, {
99
+ stdin: contents,
100
+ });
101
+
102
+ if (result.code !== 0) {
103
+ throw new Error(`Could not write ${name}: ${result.stderr}`);
104
+ }
105
+
106
+ return path;
107
+ },
108
+
109
+ // Through the connection that is already open, so an image is streamed
110
+ // rather than written to a disk at each end and copied between them
111
+ pipe: async (local, remote) =>
112
+ await shell(`${local} | ssh ${argv(remote).map(quote).join(" ")}`),
113
+
114
+ // Signalled where they are running, not here. Killing the client would
115
+ // leave the build going with nothing left able to reach it
116
+ stop: async (name) => {
117
+ const result = await final(argv(sweep(directory, name)));
118
+ return Number(result.stdout.trim()) || 0;
119
+ },
120
+
121
+ close: async () => {
122
+ await final(argv(`rm -rf '${directory}'`));
123
+ await final(["-o", `ControlPath=${control}`, "-O", "exit", bastion]);
124
+ },
125
+ };
126
+ }
127
+
128
+ // Job control puts a background job in a process group of its own, which is the
129
+ // only handle a second connection has on what the first one started. Nothing is
130
+ // written to stdout by this: an announcement would corrupt the host snapshot
131
+ function supervised(command: string, pidfile: string) {
132
+ return `set -m; { ${command}; } & __rk=$!; printf %s "$__rk" > '${pidfile}'; wait "$__rk"`;
133
+ }
134
+
135
+ // Signals every group this deploy started there, then counts the ones still
136
+ // answering. The count is what the caller waits on: a build that ignores the
137
+ // first signal keeps it above zero until a harder one is sent
138
+ function sweep(directory: string, name: string) {
139
+ return (
140
+ `left=0; for f in '${directory}'/*.pid; do [ -f "$f" ] || continue; ` +
141
+ `p=$(cat "$f" 2>/dev/null) || continue; ` +
142
+ `kill -${name} -"$p" 2>/dev/null; ` +
143
+ `kill -0 -"$p" 2>/dev/null && left=$((left+1)); done; printf %s "$left"`
144
+ );
145
+ }
package/src/steps.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { environmentOf } from "./config.js";
2
+ import type { Built, Plan, Step } from "./pipeline.js";
3
+ import type { Topology } from "./topology.js";
4
+ import type { AppSpec, StepNetwork } from "./types.js";
5
+
6
+ // The flags that attach one. The deployment network carries the aliases too,
7
+ // because a service answers to its alias rather than its container name, and a
8
+ // step reaching postgres:5432 is the whole reason to be on that network
9
+ export function attachment(network: StepNetwork, topology: Topology): string[] {
10
+ if (network === "host") return ["--network host"];
11
+ if (network === "none") return ["--network none"];
12
+ if (network !== "deployment") return [`--network ${network.named}`];
13
+
14
+ return [
15
+ // No address is asked for. Docker allocates from the bottom of the subnet
16
+ // and every derived address starts at .20, so an ephemeral container never
17
+ // takes one an app is about to be created on
18
+ `--network ${topology.network}`,
19
+ ...Object.entries(topology.extraHosts).map(([name, ip]) => `--add-host ${name}:${ip}`),
20
+ ];
21
+ }
22
+
23
+ type MigrateOptions = {
24
+ // The app whose builder image the command runs in. Every app keeps one, so
25
+ // there is nothing else a config has to set for this to work
26
+ app: string;
27
+ command: string;
28
+ // Defaults to the deploy host's own stack, which is the machine that can
29
+ // already reach whatever the app's environment file points at. A database
30
+ // this deployment runs as a service is on "deployment" instead
31
+ network?: StepNetwork;
32
+ // Written when the step reached its database through a forward. It runs on
33
+ // the deploy host now, which is the machine that bastion named, so nothing
34
+ // is forwarded and the deploy refuses a bastion that is anything else
35
+ tunnel?: { bastion: string; from: string; alias?: string; port?: number };
36
+ };
37
+
38
+ // An ordinary step at an ordinary point. Hung before the swap, it runs while
39
+ // the old containers still serve, and it throws, so nothing retires
40
+ export function migrate(options: MigrateOptions): Step<`swap:before:${string}`> {
41
+ const command = options.command.split(" ");
42
+
43
+ return {
44
+ point: `swap:before:migrate-${options.app}`,
45
+ check: (plan) => assertReachable(plan, options),
46
+
47
+ run: async (input, context) => {
48
+ const image = builderOf(input, options.app);
49
+ context.task.detail(`${options.app}: ${options.command}`);
50
+
51
+ await context.docker.runOrThrow(
52
+ [
53
+ "run --rm",
54
+ ...attachment(options.network ?? "host", context.topology),
55
+ "--workdir /app",
56
+ image,
57
+ ...command,
58
+ ].join(" "),
59
+ `${options.app} failed to migrate`,
60
+ );
61
+
62
+ return input;
63
+ },
64
+ };
65
+ }
66
+
67
+ // Checked before the run starts, so a config naming an app that never kept a
68
+ // builder fails without having touched the host
69
+ function assertReachable(plan: Plan, options: MigrateOptions) {
70
+ const app = plan.config.apps.find((item) => item.name === options.app);
71
+ if (!app) throw new Error(`${options.app} names no app in this deployment`);
72
+
73
+ const bastion = environmentOf(plan.config, plan.environment)?.host?.bastion;
74
+ const tunnel = options.tunnel;
75
+ if (!tunnel || tunnel.bastion === bastion) return;
76
+
77
+ throw new Error(
78
+ `${options.app} tunnels its migration through ${tunnel.bastion}, ` +
79
+ `but ${plan.environment} deploys to ${bastion ?? "this machine"}. ` +
80
+ "The step runs on the deploy host, so they have to be the same",
81
+ );
82
+ }
83
+
84
+ // A deployment may replace the build step, and a command hung on the pipeline
85
+ // has to run in an image something actually produced
86
+ export function builderOf(input: Built, name: string) {
87
+ const app = input.apps.find((item) => item.name === name);
88
+ if (app?.builderTag) return app.builderTag;
89
+
90
+ throw new Error(`${name} has no builder image, so nothing can be run in it`);
91
+ }
92
+
93
+ type SentryOptions = { stripFromImage: string };
94
+
95
+ export function sentry(options: SentryOptions) {
96
+ return { provider: "sentry" as const, stripFromImage: options.stripFromImage };
97
+ }
98
+
99
+ export function routeOf(app: AppSpec) {
100
+ return app.route;
101
+ }