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/docker.ts ADDED
@@ -0,0 +1,433 @@
1
+ import type { Host, OnLine } from "./host.js";
2
+
3
+ // The docker CLI on the deploy host. Every guard here reads a single snapshot
4
+ // rather than inspecting one object at a time, because a round trip to another
5
+ // machine costs more than the command it carries.
6
+
7
+ export type BuildInvocation = {
8
+ // A directory on the host, which is where the checkout already is
9
+ context: string;
10
+ // Rendered per app and kept outside the context, so a repository's own
11
+ // Dockerfile and .dockerignore are neither read nor overwritten
12
+ dockerfile: string;
13
+ // The moving name the container refers to, and the versioned one the next
14
+ // deploy recognises
15
+ tags: string[];
16
+ // Secret id to a path on the host holding its contents
17
+ secrets?: Record<string, string>;
18
+ // Stops at the builder stage, for the image a step before the swap runs in
19
+ target?: string;
20
+ };
21
+
22
+ const RUNNING = new Set(["running", "restarting"]);
23
+
24
+ // What a container was created from, recorded on the container itself. Docker
25
+ // cannot change a label without recreating, which is exactly when it changes
26
+ export const SPEC_LABEL = "redkite.spec";
27
+
28
+ // One command in place of an inspect per object. Every guard in this file used
29
+ // to be its own round trip, and a round trip here is a container exec
30
+ const SNAPSHOT = [
31
+ `ps -a --format '{{.Names}}\t{{.State}}\t{{.Label "${SPEC_LABEL}"}}'`,
32
+ "docker image ls --format '{{.Repository}}:{{.Tag}}'",
33
+ "docker network ls --format '{{.Name}}'",
34
+ ].join("; echo --- ; ");
35
+
36
+ type ContainerState = { state: string; spec?: string };
37
+
38
+ type HostState = {
39
+ containers: Map<string, ContainerState>;
40
+ images: Set<string>;
41
+ networks: Set<string>;
42
+ };
43
+
44
+ export class Docker {
45
+ readonly network: DockerNetwork;
46
+ readonly image: DockerImage;
47
+ readonly container: DockerContainer;
48
+
49
+ // The promise, not the state. Two builds and the infrastructure step all ask
50
+ // for this at once, and caching the result alone lets every one of them miss
51
+ private state?: Promise<HostState>;
52
+
53
+ constructor(private readonly host: Host) {
54
+ this.network = new DockerNetwork(this);
55
+ this.image = new DockerImage(this);
56
+ this.container = new DockerContainer(this);
57
+ }
58
+
59
+ // Read once, then kept current by the mutations below. A deploy owns the
60
+ // host for its duration, so nothing else is moving underneath it
61
+ async snapshot() {
62
+ this.state ??= this.read();
63
+ return await this.state;
64
+ }
65
+
66
+ private async read(): Promise<HostState> {
67
+ const { stdout } = await this.run(SNAPSHOT);
68
+ const [containers = "", images = "", networks = ""] = stdout.split("---");
69
+
70
+ return {
71
+ containers: new Map(
72
+ lines(containers).map((line) => {
73
+ const [name = "", state = "unknown", spec = ""] = line.split("\t");
74
+ return [name, { state, spec: spec || undefined }];
75
+ }),
76
+ ),
77
+ // Both spellings, so a lookup by bare name and one by name:tag both hit
78
+ images: new Set(
79
+ lines(images).flatMap((line) => [line, line.replace(/:latest$/, "")]),
80
+ ),
81
+ networks: new Set(lines(networks)),
82
+ };
83
+ }
84
+
85
+ async run(command: string, onLine?: OnLine) {
86
+ return await this.host.sh(`docker ${command}`, onLine);
87
+ }
88
+
89
+ // Called by every mutation, so the snapshot never goes stale
90
+ async track(change: (state: HostState) => void) {
91
+ change(await this.snapshot());
92
+ }
93
+
94
+ async runOrThrow(command: string, message: string, onLine?: OnLine) {
95
+ const result = await this.run(command, onLine);
96
+ if (result.code === 0) return result;
97
+
98
+ throw new Error(`${message}: ${result.stderr || result.stdout}`);
99
+ }
100
+ }
101
+
102
+ class DockerNetwork {
103
+ constructor(private readonly docker: Docker) {}
104
+
105
+ async exists(name: string) {
106
+ return (await this.docker.snapshot()).networks.has(name);
107
+ }
108
+
109
+ async create(name: string, subnet?: string) {
110
+ if (await this.exists(name)) return false;
111
+
112
+ const suffix = subnet ? ` --subnet=${subnet}` : "";
113
+ await this.docker.runOrThrow(
114
+ `network create ${name}${suffix}`,
115
+ "Network creation failed",
116
+ );
117
+
118
+ await this.docker.track((state) => state.networks.add(name));
119
+ return true;
120
+ }
121
+
122
+ async remove(name: string) {
123
+ if (!(await this.exists(name))) return false;
124
+
125
+ await this.docker.runOrThrow(`network rm ${name}`, "Network removal failed");
126
+ await this.docker.track((state) => state.networks.delete(name));
127
+ return true;
128
+ }
129
+
130
+ // Disconnect can fail because the container was never attached, which is not
131
+ // an error. Connect failing is, the address is what nginx resolves to
132
+ async reconnect(network: string, container: string, ip: string) {
133
+ if (!(await this.docker.container.exists(container))) return false;
134
+
135
+ await this.docker.run(`network disconnect ${network} ${container}`);
136
+ await this.docker.runOrThrow(
137
+ `network connect --ip ${ip} ${network} ${container}`,
138
+ "Network connect failed",
139
+ );
140
+
141
+ return true;
142
+ }
143
+ }
144
+
145
+ class DockerImage {
146
+ constructor(private readonly docker: Docker) {}
147
+
148
+ async exists(name: string) {
149
+ return (await this.docker.snapshot()).images.has(name);
150
+ }
151
+
152
+ // Every version of one image the host is holding. A deploy tags what it built
153
+ // by release, and without this the previous ones are never reclaimed.
154
+ // :latest is not one of them: it is the moving name a container is created
155
+ // from, and reclaiming it leaves the host unable to start the app
156
+ async versionsOf(repository: string) {
157
+ const images = (await this.docker.snapshot()).images;
158
+
159
+ return [...images].filter(
160
+ (name) => name.startsWith(`${repository}:`) && name !== `${repository}:latest`,
161
+ );
162
+ }
163
+
164
+ // BuildKit is already inside the daemon that will run the container, so the
165
+ // image it produces never has to be serialised, transferred or loaded
166
+ async build(spec: BuildInvocation, onLine?: OnLine) {
167
+ const command = [
168
+ "build",
169
+ // Provenance attestations make the result a manifest list, which is a
170
+ // different thing to tag and nothing here consumes them
171
+ "--progress plain --provenance=false --pull",
172
+ `-f ${spec.dockerfile}`,
173
+ ...spec.tags.map((tag) => `-t ${tag}`),
174
+ ...Object.entries(spec.secrets ?? {}).map(
175
+ ([id, path]) => `--secret id=${id},src=${path}`,
176
+ ),
177
+ ...(spec.target ? [`--target ${spec.target}`] : []),
178
+ spec.context,
179
+ ].join(" ");
180
+
181
+ await this.docker.runOrThrow(command, "Image build failed", onLine);
182
+ await this.docker.track((state) => {
183
+ for (const tag of spec.tags) state.images.add(tag);
184
+ });
185
+
186
+ return true;
187
+ }
188
+
189
+ // A second name for an image that is already local. Costs nothing, and it is
190
+ // what lets the next deploy recognise a commit it already holds
191
+ async retag(from: string, to: string) {
192
+ await this.docker.runOrThrow(`tag ${from} ${to}`, "Image tagging failed");
193
+ await this.docker.track((state) => state.images.add(to));
194
+ return true;
195
+ }
196
+
197
+ async remove(name: string) {
198
+ if (!(await this.exists(name))) return false;
199
+
200
+ await this.docker.runOrThrow(
201
+ `image remove -f ${name}`,
202
+ "Image removal failed",
203
+ );
204
+
205
+ await this.docker.track((state) => state.images.delete(name));
206
+ return true;
207
+ }
208
+ }
209
+
210
+ class DockerContainer {
211
+ constructor(private readonly docker: Docker) {}
212
+
213
+ async exists(name: string) {
214
+ return (await this.docker.snapshot()).containers.has(name);
215
+ }
216
+
217
+ async status(name: string) {
218
+ return (await this.docker.snapshot()).containers.get(name)?.state ?? "none";
219
+ }
220
+
221
+ // What the running container was created from. Absent for one redkite did not
222
+ // create, or created before it started recording it
223
+ async specOf(name: string) {
224
+ return (await this.docker.snapshot()).containers.get(name)?.spec;
225
+ }
226
+
227
+ async isRunning(name: string) {
228
+ return RUNNING.has(await this.status(name));
229
+ }
230
+
231
+ async start(name: string) {
232
+ if (!(await this.exists(name))) {
233
+ throw new Error(`Cannot start ${name}, it does not exist`);
234
+ }
235
+
236
+ await this.docker.runOrThrow(
237
+ `container start ${name}`,
238
+ "Container start failed",
239
+ );
240
+
241
+ await this.docker.track((state) => set(state, name, "running"));
242
+ return true;
243
+ }
244
+
245
+ async stop(name: string) {
246
+ if (!(await this.exists(name))) return false;
247
+
248
+ await this.docker.runOrThrow(
249
+ `container stop ${name}`,
250
+ "Container stop failed",
251
+ );
252
+
253
+ await this.docker.track((state) => set(state, name, "exited"));
254
+ return true;
255
+ }
256
+
257
+ // Refuses rather than clobbers, a rename onto an existing name would lose
258
+ // whichever container is already there
259
+ async rename(from: string, to: string) {
260
+ if (!(await this.exists(from))) return false;
261
+ if (await this.exists(to)) return false;
262
+
263
+ await this.docker.runOrThrow(
264
+ `container rename ${from} ${to}`,
265
+ "Container rename failed",
266
+ );
267
+
268
+ await this.docker.track((state) => {
269
+ const held = state.containers.get(from) ?? { state: "unknown" };
270
+ state.containers.delete(from);
271
+ state.containers.set(to, held);
272
+ });
273
+
274
+ return true;
275
+ }
276
+
277
+ async remove(name: string) {
278
+ if (!(await this.exists(name))) return false;
279
+ if (await this.isRunning(name)) {
280
+ throw new Error(`Cannot remove ${name} while it is running`);
281
+ }
282
+
283
+ await this.docker.runOrThrow(
284
+ `container rm ${name}`,
285
+ "Container removal failed",
286
+ );
287
+
288
+ await this.docker.track((state) => state.containers.delete(name));
289
+ return true;
290
+ }
291
+
292
+ async create(builder: DockerBuilder) {
293
+ await this.docker.runOrThrow(builder.parse(), "Container create failed");
294
+ await this.docker.track((state) =>
295
+ state.containers.set(builder.named(), {
296
+ state: "created",
297
+ spec: builder.labelled(SPEC_LABEL),
298
+ }),
299
+ );
300
+ return true;
301
+ }
302
+
303
+ builder() {
304
+ return new DockerBuilder(this);
305
+ }
306
+ }
307
+
308
+ // A state change keeps whatever the container was created from: the label is
309
+ // on the container, and only a recreate can move it
310
+ function set(state: HostState, name: string, next: string) {
311
+ state.containers.set(name, { ...state.containers.get(name), state: next });
312
+ }
313
+
314
+ function lines(block: string) {
315
+ return block
316
+ .split("\n")
317
+ .map((line) => line.trim())
318
+ .filter(Boolean);
319
+ }
320
+
321
+ export class DockerBuilder {
322
+ private _name = "";
323
+ private _image = "";
324
+ private _hostname?: string;
325
+ private _ip?: string;
326
+ private _restart?: string;
327
+ private readonly _networks: string[] = [];
328
+ private readonly _volumes: string[] = [];
329
+ private readonly _hosts: string[] = [];
330
+ private readonly _ports: string[] = [];
331
+ private readonly _env: string[] = [];
332
+ private readonly _labels = new Map<string, string>();
333
+ private _envFile?: string;
334
+
335
+ constructor(private readonly container: DockerContainer) {}
336
+
337
+ name(value: string) {
338
+ this._name = value;
339
+ return this;
340
+ }
341
+
342
+ image(value: string) {
343
+ this._image = value;
344
+ return this;
345
+ }
346
+
347
+ hostname(value: string) {
348
+ this._hostname = value;
349
+ return this;
350
+ }
351
+
352
+ network(value: string) {
353
+ this._networks.push(value);
354
+ return this;
355
+ }
356
+
357
+ volume(from: string, to: string) {
358
+ this._volumes.push(`${from}:${to}`);
359
+ return this;
360
+ }
361
+
362
+ extraHost(name: string, ip: string) {
363
+ this._hosts.push(`${name}:${ip}`);
364
+ return this;
365
+ }
366
+
367
+ restart(value: string) {
368
+ this._restart = value;
369
+ return this;
370
+ }
371
+
372
+ ip(value: string) {
373
+ this._ip = value;
374
+ return this;
375
+ }
376
+
377
+ env(name: string, value: string) {
378
+ this._env.push(`${name}=${value}`);
379
+ return this;
380
+ }
381
+
382
+ // A path on the host rather than the values themselves, so a password is
383
+ // never an argument in the process list or in the shell history
384
+ envFile(path: string) {
385
+ this._envFile = path;
386
+ return this;
387
+ }
388
+
389
+ label(name: string, value: string) {
390
+ this._labels.set(name, value);
391
+ return this;
392
+ }
393
+
394
+ labelled(name: string) {
395
+ return this._labels.get(name);
396
+ }
397
+
398
+ port(from: number, to: number) {
399
+ this._ports.push(`${from}:${to}`);
400
+ return this;
401
+ }
402
+
403
+ named() {
404
+ return this._name;
405
+ }
406
+
407
+ parse() {
408
+ if (!this._name) throw new Error("A container needs a name");
409
+ if (!this._image) throw new Error(`${this._name} has no image`);
410
+
411
+ // Defaults to the container name. The original emitted "--hostname
412
+ // undefined", because nothing ever called the setter
413
+ const hostname = this._hostname ?? this._name;
414
+
415
+ return [
416
+ `container create --name ${this._name} --hostname ${hostname}`,
417
+ ...this._volumes.map((volume) => `-v ${volume}`),
418
+ ...this._networks.map((network) => `--network ${network}`),
419
+ ...this._ports.map((port) => `-p ${port}`),
420
+ ...this._hosts.map((host) => `--add-host ${host}`),
421
+ ...[...this._labels].map(([name, value]) => `--label ${name}=${value}`),
422
+ ...(this._envFile ? [`--env-file ${this._envFile}`] : []),
423
+ ...this._env.map((entry) => `-e ${entry}`),
424
+ ...(this._ip ? [`--ip ${this._ip}`] : []),
425
+ ...(this._restart ? [`--restart ${this._restart}`] : []),
426
+ this._image,
427
+ ].join(" ");
428
+ }
429
+
430
+ async create() {
431
+ return await this.container.create(this);
432
+ }
433
+ }
@@ -0,0 +1,228 @@
1
+ import { appRoot, destinationFor, mountFor, rootedAt } from "./layout.js";
2
+ import type { BuildSpec, CarryPath } from "./types.js";
3
+
4
+ // The pipeline, as the only thing that runs it: a Dockerfile handed to the
5
+ // BuildKit already inside the deploy host's daemon. Layer order is the
6
+ // contract, everything that does not change between deploys comes first, so a
7
+ // new commit only invalidates from the source copy down.
8
+
9
+ export type DockerfileContext = {
10
+ // Cache name to the id BuildKit keys the mount by, one per app and
11
+ // environment so two builds never share a node_modules
12
+ caches: Record<string, string>;
13
+ port: number;
14
+ release: string;
15
+ environment: string;
16
+ // Secrets arrive by id. Their contents never enter this file, which keeps
17
+ // them out of docker history. The id carries the fingerprint, because a
18
+ // secret mount is not part of a layer's cache key and a changed environment
19
+ // would otherwise be answered with the image built from the old one
20
+ envSecret: string;
21
+ // Container path to secret id, for credentials that have to be files
22
+ fileSecrets: Record<string, string>;
23
+ // Where the app sits in the repository, when it is not the repository. The
24
+ // build steps and the shipped command run there, and every /app path the
25
+ // spec names is read against it
26
+ dir?: string;
27
+ };
28
+
29
+ // Named here because the build stops at this stage to keep an image a
30
+ // step before the swap can run in, and two spellings of it would drift
31
+ export const BUILDER_STAGE = "builder";
32
+
33
+ // What BuildKit reads beside the Dockerfile. Without an include the checkout
34
+ // keeps its own rules and only .git is held back. With one, everything is held
35
+ // back and the named paths are let through again, so what the build sees is
36
+ // what the release was taken over
37
+ export function renderDockerignore(include?: string[]) {
38
+ if (!include) return ".git\n**/.git\n";
39
+
40
+ // .git after the exemptions, because the last rule to match is the one that
41
+ // decides and an included directory would otherwise carry it back in
42
+ return ["*", ...include.map((path) => `!${path}`), ".git", "**/.git", ""].join("\n");
43
+ }
44
+
45
+ export function renderDockerfile(
46
+ spec: BuildSpec,
47
+ context: DockerfileContext,
48
+ ): string {
49
+ return [
50
+ "# syntax=docker/dockerfile:1.7",
51
+ ...builderStage(spec, context),
52
+ "",
53
+ ...runtimeStage(spec, context),
54
+ "",
55
+ ].join("\n");
56
+ }
57
+
58
+ function builderStage(spec: BuildSpec, context: DockerfileContext) {
59
+ const mounts = cacheMounts(spec, context);
60
+ const workdir = appRoot(context.dir);
61
+
62
+ // The repository root, not the app's own directory: a workspace resolves one
63
+ // lockfile for every package in it, so the install below has to see them all
64
+ const lines = [
65
+ `FROM ${spec.builderImage} AS ${BUILDER_STAGE}`,
66
+ "WORKDIR /app",
67
+ ];
68
+
69
+ if (spec.aptPackages.length > 0) {
70
+ lines.push(`RUN apk add --no-cache ${spec.aptPackages.join(" ")}`);
71
+ }
72
+
73
+ lines.push(`ENV NODE_ENV=${context.environment}`);
74
+ lines.push("ENV NEXT_TELEMETRY_DISABLED=1");
75
+
76
+ if (spec.dependencies) lines.push(...dependencyLayer(spec, mounts));
77
+
78
+ // Submodules are already in the context: the checkout on the host resolved
79
+ // them, so nothing in the build needs an agent or a .git directory
80
+ lines.push(copy(".", "/app"));
81
+
82
+ // Only now, so the steps below read the app's own manifest and write beside it
83
+ if (context.dir) lines.push(`WORKDIR ${workdir}`);
84
+
85
+ lines.push(
86
+ `RUN --mount=type=secret,id=${context.envSecret} cp /run/secrets/${context.envSecret} ${workdir}/.env`,
87
+ `ENV SENTRY_RELEASE=${context.release}`,
88
+ );
89
+
90
+ // Unquoted: the shell form hands everything after RUN to sh -c, so a step is
91
+ // written exactly as it would be typed. Quoting it made the whole command one
92
+ // word, and the shell went looking for a program by that name
93
+ for (const step of spec.steps) {
94
+ lines.push(`RUN ${mounts}${step}`);
95
+ }
96
+
97
+ if (spec.sourcemaps) {
98
+ // The built .env ships inside the image, and an upload token is build-time
99
+ const token = spec.sourcemaps.stripFromImage;
100
+ lines.push(`RUN sed -i '/^${token}=/d' ${rootedAt(spec.output, context.dir)}/.env`);
101
+ }
102
+
103
+ return lines;
104
+ }
105
+
106
+ function runtimeStage(spec: BuildSpec, context: DockerfileContext) {
107
+ const output = rootedAt(spec.output, context.dir);
108
+
109
+ // Where the app lands inside the tree that was just copied to /app. An output
110
+ // keeping the repository's structure puts it back under dir, and everything
111
+ // else flattens it to the top
112
+ const nested = spec.keepsLayout ? context.dir : undefined;
113
+ const lines = [`FROM ${spec.runtimeImage}`, `WORKDIR ${appRoot(nested)}`];
114
+
115
+ if (spec.runtimePackages.length > 0) {
116
+ lines.push(`RUN apk add --no-cache ${spec.runtimePackages.join(" ")}`);
117
+ }
118
+
119
+ // Before the output, which changes every commit: what these install is a
120
+ // property of the image, not of the release being deployed
121
+ for (const step of spec.runtimeSteps) {
122
+ lines.push(`RUN ${step}`);
123
+ }
124
+
125
+ // The one copy nothing can be allowed to skip: an image without it starts,
126
+ // answers, and serves a 404 for everything the app was supposed to be
127
+ lines.push(copy(output, "/app", BUILDER_STAGE));
128
+
129
+ // Directories the output does not contain but the runtime needs, such as a
130
+ // generated client or the static assets a standalone build leaves behind.
131
+ // The two sides are rooted separately: dir is where the app was built, and
132
+ // nested is where the output put it
133
+ for (const entry of spec.carry) {
134
+ const { path, optional } = carried(entry);
135
+ const from = rootedAt(path, context.dir);
136
+ const to = rootedAt(destinationFor(path, spec.output), nested);
137
+
138
+ lines.push(optional ? copyOrSkip(from, to, BUILDER_STAGE) : copy(from, to, BUILDER_STAGE));
139
+ }
140
+
141
+ for (const [path, secret] of Object.entries(context.fileSecrets)) {
142
+ lines.push(
143
+ `RUN --mount=type=secret,id=${secret} cp /run/secrets/${secret} ${path}`,
144
+ );
145
+ }
146
+
147
+ lines.push(
148
+ `EXPOSE ${context.port}`,
149
+ `CMD ${JSON.stringify(spec.entrypoint)}`,
150
+ );
151
+
152
+ return lines;
153
+ }
154
+
155
+ function carried(entry: CarryPath) {
156
+ if (typeof entry === "string") return { path: entry, optional: false };
157
+ return { path: entry.path, optional: entry.optional };
158
+ }
159
+
160
+ // A COPY whose source does not exist fails the build, which is what a source
161
+ // the build was supposed to produce should do
162
+ function copy(from: string, to: string, stage?: string) {
163
+ return `COPY ${stage ? `--from=${stage} ` : ""}${from} ${to}`;
164
+ }
165
+
166
+ // Bracketing the last character makes the source a pattern, and a pattern
167
+ // matching nothing is skipped: the only way a Dockerfile has to say "if it
168
+ // exists". For a source whose absence is a fact about the repository
169
+ function copyOrSkip(from: string, to: string, stage?: string) {
170
+ const path = from.replace(/\/+$/, "");
171
+ const last = path.slice(-1);
172
+
173
+ // An empty pattern matches nothing and would be skipped in silence, which is
174
+ // the one outcome this whole distinction exists to prevent
175
+ if (!last) return copy(from, to, stage);
176
+
177
+ return copy(`${path.slice(0, -1)}[${last}]`, to, stage);
178
+ }
179
+
180
+ // One mount per cache, repeated on every step, because a Dockerfile scopes a
181
+ // cache to the RUN that asks for it rather than to the stage
182
+ function cacheMounts(spec: BuildSpec, context: DockerfileContext) {
183
+ // Only the caches the topology derived an id for. It drops any whose target
184
+ // another already covers, and two mounts on one target is a build that does
185
+ // not start
186
+ return spec.caches
187
+ .filter((name) => name in context.caches)
188
+ .map((name) => {
189
+ const id = context.caches[name] ?? name;
190
+ return `--mount=type=cache,id=${id},target=${mountFor(name, context.dir)} `;
191
+ })
192
+ .join("");
193
+ }
194
+
195
+ function dependencyLayer(spec: BuildSpec, mounts: string) {
196
+ const { files, step, stripScripts = [] } = spec.dependencies ?? {
197
+ files: [],
198
+ step: "",
199
+ };
200
+
201
+ // A lockfile the repository does not have is not an error here. The install
202
+ // below is what decides that, and it says so in the package manager's words
203
+ const lines = files.map((file) => copyOrSkip(file, `/app/${file}`));
204
+
205
+ // This layer is the manifest and the lockfile, nothing else. A root prepare
206
+ // that installs git hooks or calls into scripts/ has neither
207
+ if (stripScripts.length > 0) {
208
+ lines.push(`RUN node --input-type=commonjs -e ${quote(strip(stripScripts))}`);
209
+ }
210
+
211
+ lines.push(`RUN ${mounts}${step}`);
212
+ return lines;
213
+ }
214
+
215
+ function strip(names: string[]) {
216
+ return [
217
+ 'const fs = require("fs"), path = "/app/package.json";',
218
+ 'const manifest = JSON.parse(fs.readFileSync(path, "utf8"));',
219
+ `for (const name of ${JSON.stringify(names)}) delete manifest.scripts?.[name];`,
220
+ "fs.writeFileSync(path, JSON.stringify(manifest, null, 2));",
221
+ ].join("");
222
+ }
223
+
224
+ // Only for a command passed as one argument to another. Single quotes are what
225
+ // the shell strips, so a script containing double quotes survives
226
+ function quote(command: string) {
227
+ return `'${command.replaceAll("'", `'\\''`)}'`;
228
+ }