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/README.md ADDED
@@ -0,0 +1,819 @@
1
+ <h1 align="center">
2
+ <img src="media/redkite.png" alt="redkite" width="200">
3
+ </h1>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/redkite-cd"><img alt="npm version" src="https://img.shields.io/npm/v/redkite-cd.svg"></a>
7
+ <a href="https://www.npmjs.com/package/redkite-cd"><img alt="npm downloads" src="https://img.shields.io/npm/dm/redkite-cd.svg"></a>
8
+ <a href="https://nodejs.org"><img alt="node" src="https://img.shields.io/node/v/redkite-cd.svg"></a>
9
+ <a href="./LICENSE"><img alt="license" src="https://img.shields.io/npm/l/redkite-cd.svg"></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ Blue-green Docker deployment from one config file, over ssh.<br>
14
+ No engine, no agent, no dependencies.
15
+ </p>
16
+
17
+ Describe a deployment once. Redkite derives every container name, IP address,
18
+ volume, cache key, nginx upstream and location block from it, then builds your
19
+ images on the machine that runs them and swaps traffic over with a health check
20
+ and an automatic revert.
21
+
22
+ A deploy is git, a Dockerfile and the docker CLI, driven over one ssh
23
+ connection. There is nothing to install on the server and nothing running
24
+ between deploys.
25
+
26
+ ## Contents
27
+
28
+ - [Quick start](#quick-start)
29
+ - [Features](#features)
30
+ - [Requirements](#requirements)
31
+ - [Configuration](#configuration)
32
+ - [Hooks](#hooks)
33
+ - [Verifying a build](#verifying-a-build)
34
+ - [CLI](#cli)
35
+ - [How a deploy runs](#how-a-deploy-runs)
36
+ - [Design](#design)
37
+ - [Contributing](#contributing)
38
+ - [License](#license)
39
+
40
+ ## Quick start
41
+
42
+ ```sh
43
+ npm install redkite-cd
44
+ ```
45
+
46
+ Add `redkite.config.ts` at the root of your project:
47
+
48
+ ```ts
49
+ import { defineDeployment, nextApp, nodeApp, redis } from "redkite-cd";
50
+
51
+ export default defineDeployment({
52
+ project: "acme",
53
+
54
+ services: [redis()],
55
+
56
+ apps: [
57
+ {
58
+ name: "web",
59
+ repo: "git@github.com:acme/web.git",
60
+ route: "/",
61
+ port: 3000,
62
+ build: nextApp(),
63
+ health: { path: "/api/health", expect: (body) => body.status === "ok" },
64
+ },
65
+ {
66
+ name: "api",
67
+ repo: "git@github.com:acme/api.git",
68
+ route: "/api/",
69
+ port: 3001,
70
+ build: nodeApp({
71
+ steps: ["yarn build"],
72
+ output: "/app/dist",
73
+ entrypoint: ["node", "/app/index.js"],
74
+ }),
75
+ health: { path: "/health", expect: (body) => body.status === "up" },
76
+ },
77
+ ],
78
+ });
79
+ ```
80
+
81
+ Then:
82
+
83
+ ```sh
84
+ npx redkite plan production # what it will do, no host needed
85
+ npx redkite deploy production # build, swap, health check, revert on failure
86
+ ```
87
+
88
+ Nothing in that config names an IP address, a container, a network, a cache key,
89
+ a `retired-` prefix, or an nginx directive. Adding a third app is six lines.
90
+
91
+ ## Features
92
+
93
+ - **Blue-green by default.** The running container moves to a retired address
94
+ without being stopped, so it keeps answering while the new one starts. One
95
+ unhealthy app reverts all of them.
96
+ - **Derived topology.** Container names, addresses, volumes, cache keys, nginx
97
+ upstreams and location blocks are functions of the app list, so two of them
98
+ cannot collide and adding an app cannot renumber another.
99
+ - **Builds on the deploy host.** The image is created inside the daemon that
100
+ will run it, so there is no export, no tarball and no transfer.
101
+ - **Nothing crosses the wire.** The host clones your repositories itself over a
102
+ forwarded agent. A warm deploy sends commands and secrets, nothing else.
103
+ - **Secrets stay out of the image.** The environment file and every credential
104
+ arrive as BuildKit `--secret` mounts, so they are in neither a layer nor
105
+ `docker history`.
106
+ - **An extensible pipeline.** Redkite's own four steps sit at ordinary points that
107
+ your config can hook around or replace outright.
108
+ - **Zero dependencies.** One package. Node reads the TypeScript config itself.
109
+
110
+ ## Requirements
111
+
112
+ | Where | Needs |
113
+ | --- | --- |
114
+ | Your machine | Node 22.18 or newer, git, ssh, an ssh agent with a key that can reach your repositories |
115
+ | The deploy host | docker with BuildKit, git, ssh access |
116
+
117
+ Node 22.18 is the version that reads TypeScript without a loader, which is what
118
+ lets redkite ship with nothing in `dependencies`. If your config needs more than
119
+ Node resolves on its own, a `tsconfig` path or a `./thing.js` specifier pointing
120
+ at a `./thing.ts` file, redkite uses your project's own `tsx` when you have one.
121
+
122
+ Name the config `redkite.config.mts` if your `package.json` has no
123
+ `"type": "module"`: a `.ts` file in a CommonJS package is CommonJS, where
124
+ `import` is not legal. Redkite says so if you get it wrong. `.js` and `.mjs` work
125
+ too, and `--config` overrides the search, which otherwise walks up from where
126
+ you ran it to the root of the project.
127
+
128
+ ## Configuration
129
+
130
+ `defineDeployment` validates at load, so a duplicate name, two apps on one
131
+ route, or a malformed hook point is a config that fails rather than a deploy
132
+ that stops half way through.
133
+
134
+ ### Environments
135
+
136
+ One file each, beside the deployment, named `redkite.<environment>.config.ts`:
137
+
138
+ ```ts
139
+ // redkite.production.config.ts
140
+ import { defineEnvironment } from "redkite-cd";
141
+
142
+ export default defineEnvironment({
143
+ branch: "main",
144
+ subnet: "10.20.0",
145
+ publicPort: 80,
146
+ host: { bastion: "deploy@acme.example" },
147
+ });
148
+ ```
149
+
150
+ Redkite finds them by name, so there is no list to keep in step, and
151
+ `redkite.config.ts` has no `environments` key at all: a second place to put a
152
+ set of them is a second place for them to disagree. A config that declares one
153
+ does not compile.
154
+
155
+ A deployment with only one environment can carry it instead, as `environment`:
156
+
157
+ ```ts
158
+ export default defineDeployment({
159
+ project: "acme",
160
+ environment: { branch: "main", subnet: "10.20.0", publicPort: 80 },
161
+ // ...
162
+ });
163
+ ```
164
+
165
+ That is an override, not a default: it answers for whatever name the command
166
+ line asks for, and wins over any file that disagrees. Two environments means two
167
+ files.
168
+
169
+ Selected on the command line and threaded into every derived name.
170
+
171
+ | Field | Meaning |
172
+ | --- | --- |
173
+ | `branch` | The git ref each app is built from |
174
+ | `subnet` | First three octets. Redkite allocates the fourth |
175
+ | `publicPort` | The only port anybody outside ever types |
176
+ | `host.bastion` | `user@address` of the deploy host. Absent means this machine |
177
+ | `extraHosts` | Hostname to address, added to every container beside the derived ones |
178
+ | `buildOn` | `"host"` by default. `"local"` compiles here and ships the image |
179
+
180
+ Images are built on the deploy host, which is where they are needed and costs
181
+ nothing to move them. A host too small to compile on can be told otherwise:
182
+
183
+ ```ts
184
+ production: { buildOn: "local", branch: "main", subnet: "10.0.0", publicPort: 80 }
185
+ ```
186
+
187
+ The checkout, the Dockerfile and the build then happen on this machine, and the
188
+ finished image is streamed into `docker load` on the host down the connection
189
+ that is already open. Nothing is written to a disk at either end. The skip check
190
+ asks the host rather than this machine, so an image compiled here before but
191
+ never sent is still sent. It is ignored when the deploy host is this machine,
192
+ since there would be nothing to move.
193
+
194
+ `extraHosts` is for something the apps must resolve that redkite does not run:
195
+ a managed database, a legacy service, anything whose address is the thing that
196
+ differs between staging and production.
197
+
198
+ ```ts
199
+ extraHosts: { "db.internal": "10.55.0.250" },
200
+ ```
201
+
202
+ A name the deployment already resolves, an app's container or a service alias,
203
+ is refused rather than silently overridden. Redirecting one of those would send
204
+ its traffic somewhere else and the deploy would still look like it worked.
205
+
206
+ ### Where the files live
207
+
208
+ By default `redkite.config.ts` sits at the root of the project with the
209
+ environment files beside it, and a deploy run from anywhere inside walks up to
210
+ find them. `package.json` is where a repository says otherwise:
211
+
212
+ ```json
213
+ {
214
+ "redkite": { "directory": "deploy" }
215
+ }
216
+ ```
217
+
218
+ Then `deploy/redkite.config.ts` and `deploy/redkite.production.config.ts`. If
219
+ that directory holds no config, redkite stops and says so rather than carrying
220
+ on up the tree: naming a directory and not putting the files there is a mistake,
221
+ not a hint.
222
+
223
+ An environment that lives somewhere the naming convention would not find it can
224
+ be named outright, at whatever path it is at:
225
+
226
+ ```json
227
+ {
228
+ "redkite": {
229
+ "environments": {
230
+ "production": "./infra/live.ts",
231
+ "staging": "./infra/staging.ts"
232
+ }
233
+ }
234
+ }
235
+ ```
236
+
237
+ Paths are read against the `package.json` that names them. One that is not there
238
+ is refused rather than silently skipped, and an environment named here that also
239
+ sits beside the deployment is refused too: it comes from one place or the other.
240
+
241
+ ### Apps
242
+
243
+ | Field | Meaning |
244
+ | --- | --- |
245
+ | `name` | Becomes the container name, cache keys, volumes and nginx upstream |
246
+ | `repo` | Clone URL, fetched over the forwarded agent rather than with a token |
247
+ | `route` | The nginx location. `/` is the catch-all; `/api/` has its prefix stripped |
248
+ | `port` | The port the app listens on inside its container |
249
+ | `build` | How the repository becomes an image. See presets below |
250
+ | `dir` | Where the app sits in the repository, when it is not the whole of it |
251
+ | `health` | Probed on the container itself, not through the proxy |
252
+ | `secrets` | One ref or several, merged in order, written to `.env` in the image |
253
+ | `files` | Container path to the item whose contents land there |
254
+ | `volumes` | Volume name to container path, for state that outlives a deploy |
255
+
256
+ ### Building from a directory
257
+
258
+ An app names either a repository to clone or a directory already on this
259
+ machine. A CI job that has already checked the code out is one; so is the copy
260
+ you are editing.
261
+
262
+ ```ts
263
+ {
264
+ name: "backend",
265
+ path: "./services/backend", // instead of repo
266
+ route: "/api/",
267
+ port: 3001,
268
+ // ...
269
+ }
270
+ ```
271
+
272
+ The path is read against the deployment file, not against wherever the command
273
+ was run, so a deploy from a workspace and one from the root build the same tree.
274
+ Nothing is cloned, checked out or cleaned: what is on disk is what ships, and
275
+ the `branch` an environment names is not consulted at all.
276
+
277
+ The release is the content of the working tree, taken with git's own addressing
278
+ over a scratch index. It covers what is committed, what is modified and what is
279
+ untracked, and honours `.gitignore` — which is the same set the build reads. So
280
+ an edit you never committed is a new release and gets built, and a change under
281
+ an ignored `node_modules` is not and does not:
282
+
283
+ ```
284
+ unchanged already built at 7797c4c, nothing rebuilt
285
+ uncommitted edit built from 9bba792, rebuilt
286
+ untracked file built from 587974b, rebuilt
287
+ ignored file already built at 9bba792, nothing rebuilt
288
+ ```
289
+
290
+ Somewhere outside git there is nothing to say any of that, so the deployment
291
+ says it instead:
292
+
293
+ ```ts
294
+ {
295
+ name: "backend",
296
+ path: "../checkout",
297
+ include: ["src", "package.json", "yarn.lock"],
298
+ }
299
+ ```
300
+
301
+ `include` names what ships, relative to `path`. It decides the release and the
302
+ build context together: BuildKit is handed a `.dockerignore` that holds
303
+ everything back and lets exactly these through, so a `node_modules` the release
304
+ says nothing about is not uploaded either. Asked to build a directory git knows
305
+ nothing about without one, redkite says so and shows the line to add rather than
306
+ guessing or refusing outright.
307
+
308
+ An `include` on a work tree is allowed too, and wins over `.gitignore`. That is
309
+ how a repository holding several things is narrowed to the one being built.
310
+
311
+ When the deploy host is another machine, an app built from a path forces the
312
+ build to happen here and the image to be shipped, the same as `--local`. The
313
+ source is on this machine, so the builder is too.
314
+
315
+ ### Build presets
316
+
317
+ `nodeApp` and `nextApp` describe a two-stage build: a fat builder image with
318
+ caches mounted, and a slim runtime image the compiled output is copied into.
319
+
320
+ ```ts
321
+ nodeApp({
322
+ builder: "22-alpine",
323
+ runtime: "24-alpine",
324
+ submodules: true,
325
+ steps: ["yarn db:generate", "yarn build"],
326
+ output: "/app/dist",
327
+ carry: ["/app/.generated"],
328
+ entrypoint: ["node", "/app/index.js"],
329
+ });
330
+ ```
331
+
332
+ The dependency install is not one of the `steps`. The preset copies
333
+ `package.json` and `yarn.lock` ahead of the source and installs against those
334
+ alone, so a commit that changes only source code reuses the layer. That is the
335
+ difference between a deploy and a cold build.
336
+
337
+ #### What a build is allowed not to produce
338
+
339
+ A `COPY` whose source is missing fails the build. That is right for the output,
340
+ which is the app itself, and wrong for a directory the repository may simply not
341
+ have. A `carry` entry says which it is:
342
+
343
+ ```ts
344
+ carry: [
345
+ "/app/.generated", // must exist, or the build failed
346
+ { path: "/app/public", optional: true }, // skipped when it is not there
347
+ ]
348
+ ```
349
+
350
+ `nextApp` marks `public` optional and `.next/static` required, because static is
351
+ what the server answers with for every chunk it built. A lockfile named in
352
+ `dependencies.files` is optional too: whether one is missing is the package
353
+ manager's to say, in its own words.
354
+
355
+ `nextApp` takes `standalone`, which says whether `next.config` sets
356
+ `output: "standalone"`. It defaults to `true`. A standalone build ships the
357
+ tree Next produced and runs it on node alone; without it the whole repository
358
+ ships and `next start` resolves its own dependencies, which is a much larger
359
+ image.
360
+
361
+ ```ts
362
+ nextApp({ standalone: false })
363
+ ```
364
+
365
+ ### An app in a directory of its own
366
+
367
+ A repository holding several apps names each one's directory:
368
+
369
+ ```ts
370
+ { name: "web", dir: "apps/web", build: nextApp(), /* ... */ }
371
+ ```
372
+
373
+ The whole repository is copied into the image, and `dir` is where the build
374
+ steps and the shipped command run. Every `/app` path the build spec names is
375
+ read against it, so a preset needs no change: `output: "/app/dist"` becomes
376
+ `/app/apps/web/dist`.
377
+
378
+ A Next standalone build traces from the workspace root, so the tree it emits
379
+ holds `apps/web/server.js` rather than `server.js`. `nextApp` declares that with
380
+ `keepsLayout`, and the runtime stage then starts the command at
381
+ `/app/apps/web` and lands `.next/static` and `public` beside it. A build whose
382
+ output flattens the app to the top of the tree, which is every `nodeApp`, leaves
383
+ `keepsLayout` unset and nothing moves.
384
+
385
+ **The dependency install stays at the repository root.** A workspace resolves
386
+ one lockfile for every package in it, so the install has to see all of them,
387
+ and `node_modules` is where that put it. An app with its own lockfile in a
388
+ subdirectory is not covered by `dir` alone.
389
+
390
+ ### Services
391
+
392
+ Long-lived containers shared by the apps. The proxy is not one of them: apps
393
+ carry routes, routes imply exactly one proxy, so it is derived rather than
394
+ listed.
395
+
396
+ A service is adopted when it is the one the config describes, and recreated when
397
+ it is not. Each is created carrying a fingerprint of everything a recreate would
398
+ change, the rendered nginx configuration included, so changing the published
399
+ port or `maxBodySize` reaches the running container instead of sitting in a file
400
+ it was never created from. `redkite plan` reports the comparison without
401
+ changing anything:
402
+
403
+ ```
404
+ services on the host
405
+ acme-staging-nginx created from an earlier version of this file
406
+ acme-staging-redis not there, will be created
407
+
408
+ a deploy converges these
409
+ ```
410
+
411
+ ```ts
412
+ services: [
413
+ redis({ address: 26, volumes: { data: "/data" } }),
414
+ postgres({ secrets: bitwarden("..."), environment: { POSTGRES_DB: "acme" } }),
415
+ ];
416
+ ```
417
+
418
+ `postgres` requires `secrets` because the image will not start without
419
+ `POSTGRES_PASSWORD`, and a service that cannot come up is not a useful default.
420
+ Whatever the ref resolves to is written to a file on the deploy host and handed
421
+ over with `--env-file`, so the password is never an argument in a command line
422
+ or a shell history. Settings that are not credentials, `POSTGRES_DB` and
423
+ `POSTGRES_USER`, go in `environment` instead.
424
+
425
+ ### Secrets
426
+
427
+ An id is a pointer, not a credential, so it belongs in the config while the
428
+ credentials arrive at deploy time.
429
+
430
+ ```ts
431
+ secrets: bitwarden("00000000-0000-4000-8000-000000000001"),
432
+ files: { "/app/service-account.json": bitwarden("...") },
433
+ ```
434
+
435
+ A deployment reading from Bitwarden wants `BW_CLIENT_ID`, `BW_CLIENT_SECRET` and
436
+ `BW_PASSWORD` in the environment. One that does not needs nothing: redkite only
437
+ opens the stores your config actually names.
438
+
439
+ ## Hooks
440
+
441
+ A run is one list of steps, each handed what the one before it answered with.
442
+ Redkite puts four in it. A step says where it runs, and nothing has to call it.
443
+
444
+ ```
445
+ setup:before:<name>
446
+ setup redkite: creates the network, brings the services up
447
+ setup:<name>
448
+ setup:after:<name>
449
+
450
+ build:before:<name>
451
+ build redkite: resolves, checks out and builds every image
452
+ build:<name>
453
+ build:after:<name>
454
+
455
+ swap:before:<name>
456
+ swap redkite: moves the addresses, health checks, reverts
457
+ swap:<name>
458
+ swap:after:<name>
459
+
460
+ cleanup:before:<name>
461
+ cleanup redkite: removes the retired containers and old images
462
+ cleanup:<name>
463
+ cleanup:after:<name>
464
+ ```
465
+
466
+ **Nothing about redkite's four is privileged.** They sit at ordinary points, and a
467
+ config that registers a step at the same point replaces it. That is how one is
468
+ turned off: put something there that does less.
469
+
470
+ ```ts
471
+ import { defineStep } from "redkite-cd";
472
+
473
+ export default defineDeployment({
474
+ // ...
475
+ steps: [
476
+ defineStep("build:after:sourcemaps", async (built, context) => {
477
+ for (const app of built.apps) {
478
+ context.task.detail(`${app.name} at ${app.release.slice(0, 7)}`);
479
+ }
480
+
481
+ return built;
482
+ }),
483
+
484
+ // A host somebody else prunes: redkite's cleanup never runs
485
+ defineStep("cleanup", (released) => ({
486
+ ...released,
487
+ removed: [],
488
+ reclaimed: [],
489
+ })),
490
+ ],
491
+ });
492
+ ```
493
+
494
+ A step is handed the previous step's answer and the context: the config, the
495
+ topology, the host, the docker client, the secret stores, the log, and its own
496
+ progress row. **If a step throws, the run stops**, because everything after it
497
+ was written assuming the steps before did what they said.
498
+
499
+ The value grows as the run goes, so a late step reads everything above it:
500
+ `environment` from the start, `network` and `services` after setup, `apps` after
501
+ build, `ok` and `released` and `reverted` after swap, `removed` and
502
+ `reclaimed` after cleanup.
503
+
504
+ What a step is handed is decided by where it runs, so the example above compiles
505
+ with no annotation and `built.apps` is known to exist. A step at a point in a
506
+ phase nobody defined does not compile, and a replacement for one of redkite's four
507
+ has to answer with what the rest of the run expects.
508
+
509
+ ### Migrations
510
+
511
+ A migration is a step like any other. `migrate()` answers with one at
512
+ `swap:before:migrate-<app>`, so it runs in the image that was just built, while
513
+ the old containers are still serving, and throws before anything retires.
514
+
515
+ ```ts
516
+ import { migrate } from "redkite-cd";
517
+
518
+ export default defineDeployment({
519
+ steps: [migrate({ app: "backend", command: "yarn db:migrate" })],
520
+ });
521
+ ```
522
+
523
+ The runtime image holds only what the app compiled to, so the command runs in
524
+ the builder stage instead. Every app keeps that stage as an image of its own,
525
+ which costs the export of layers the runtime build produced anyway and means
526
+ there is nothing to set before a step can use it.
527
+
528
+ A step naming an app the deployment does not have fails before the run starts
529
+ rather than half way through it.
530
+
531
+ #### The network a step runs on
532
+
533
+ A migration defaults to `host`: the deploy host's own network stack, which is
534
+ what reaches a database that machine already reaches. A database this deployment
535
+ runs as a service is somewhere else, so say so:
536
+
537
+ ```ts
538
+ migrate({ app: "backend", command: "yarn db:migrate", network: "deployment" })
539
+ ```
540
+
541
+ | `network` | Where the container is attached |
542
+ | --- | --- |
543
+ | `"host"` | The deploy host's own stack. The default |
544
+ | `"deployment"` | The network the apps and services run on, with every alias they resolve, so `postgres:5432` works |
545
+ | `"none"` | Nothing at all |
546
+ | `{ named: "..." }` | A network somebody else made |
547
+
548
+ `attachment()` answers with the same flags for a step of your own:
549
+
550
+ ```ts
551
+ import { attachment, defineStep } from "redkite-cd";
552
+
553
+ defineStep("swap:before:seed", async (built, context) => {
554
+ const image = built.apps.find((app) => app.name === "backend")?.builderTag;
555
+ if (!image) throw new Error("backend did not keep its builder");
556
+
557
+ await context.docker.runOrThrow(
558
+ ["run --rm", ...attachment("deployment", context.topology), image, "yarn db:seed"].join(" "),
559
+ "seeding failed",
560
+ );
561
+
562
+ return built;
563
+ });
564
+ ```
565
+
566
+ ### Plugins
567
+
568
+ A plugin is a function answering with steps, the way `redis()` answers with a
569
+ service spec:
570
+
571
+ ```ts
572
+ const slack = (webhook: string) => [
573
+ defineStep("swap:before:announce", async (built) => {
574
+ await fetch(webhook, { method: "POST", body: `deploying ${built.apps.length} apps` });
575
+ return built;
576
+ }),
577
+ defineStep("cleanup:after:notify", async (finished) => {
578
+ await fetch(webhook, { method: "POST", body: `released ${finished.released.join(", ")}` });
579
+ return finished;
580
+ }),
581
+ ];
582
+ ```
583
+
584
+ Read whatever it needs where the config is loaded, so a missing value is a
585
+ config that fails rather than a deploy that gets most of the way through:
586
+
587
+ ```ts
588
+ const webhook = process.env.SLACK_WEBHOOK;
589
+ if (!webhook) throw new Error("SLACK_WEBHOOK is not set");
590
+
591
+ export default defineDeployment({
592
+ // ...
593
+ steps: [...slack(webhook)],
594
+ });
595
+ ```
596
+
597
+ ## Verifying a build
598
+
599
+ `redkite verify` is the same host, the same services and the same images as a
600
+ deploy, stopping where one would start moving addresses. It brings the network
601
+ and the services up, builds every app, and runs what each app declares instead
602
+ of swapping.
603
+
604
+ An app declares its checks. Nothing else is needed:
605
+
606
+ ```ts
607
+ {
608
+ name: "backend",
609
+ // ...
610
+ verify: {
611
+ steps: ["yarn db:migrate", "yarn test:integration"],
612
+ environment: { NODE_ENV: "test" },
613
+ },
614
+ }
615
+ ```
616
+
617
+ The commands run in the **builder** image, which is the one holding the test
618
+ runner and the dev dependencies. The runtime image has neither, and installing
619
+ them at check time would test a different tree. They run on the deployment
620
+ network, so a check reaches postgres at `postgres`, by the same alias the app
621
+ itself uses. They run in order and one at a time: the first is usually what
622
+ brings the test database to the schema the rest expect.
623
+
624
+ A test environment is a file like any other, and it publishes nothing:
625
+
626
+ ```ts
627
+ // redkite.test.config.ts
628
+ export default defineEnvironment({
629
+ branch: "pull-request",
630
+ subnet: "172.254.0",
631
+ });
632
+ ```
633
+
634
+ `publicPort` is absent because nothing in a verify run serves. That absence is
635
+ the whole declaration: an environment without one cannot deploy, so `plan` shows
636
+ it only the verify pipeline, leaves the proxy out of its service list, and
637
+ prints no nginx.
638
+
639
+ ```
640
+ pipeline (verify)
641
+ setup redkite
642
+ build redkite
643
+ verify redkite backend
644
+ cleanup redkite
645
+
646
+ services on the host (verify)
647
+ acme-test-redis not there, will be created
648
+ ```
649
+
650
+ Two things are refused before anything is built: a `verify` where no app
651
+ declares checks, and a `deploy` to an environment that names no `publicPort`.
652
+
653
+ Cleanup still runs, which is what keeps a CI host's disk from filling: it
654
+ reclaims every image of these apps except the ones this run built.
655
+
656
+ `verify:before:` and `verify:after:` are ordinary hook points, and `--local`,
657
+ `--full` and `--verbose` work the same as for a deploy.
658
+
659
+ ## CLI
660
+
661
+ ```
662
+ redkite <command> [environment]
663
+
664
+ plan [environment] Print the derived topology, the pipeline and the nginx
665
+ deploy [environment] Build, swap, health check, and revert on failure
666
+ verify [environment] Bring the services up, build, and run each app's checks
667
+
668
+ --config <path> Defaults to redkite.config.ts at the root of the project
669
+ --local Build the images here and ship them to the host
670
+ --full No step view: every line of every step, in full
671
+ --verbose Every host command, and every line a build printed
672
+ --version Print the version and exit
673
+ ```
674
+
675
+ The environment defaults to `staging`. `plan` reads the host to report drift and
676
+ changes nothing, so it is safe to run against production.
677
+
678
+ ### Watching a deploy
679
+
680
+ On a terminal, `deploy` draws the run as a list of steps. The one running is
681
+ open and its output rolls under a title that stays put; a step that finishes
682
+ shuts to a single line carrying what it cost.
683
+
684
+ ```
685
+ [01:18] ✔ setup 4s
686
+ [01:18] ✔ build 9s
687
+ [01:18] ❯ ▾ Building web yarn build 1m09s
688
+ │ #15 [builder 10/10] RUN yarn build
689
+ │ ▲ Next.js 15.1.6
690
+ │ Creating an optimized production build ...
691
+
692
+ ↑↓ move · enter open · shift+↑ latest · + open · - collapse all · q quit
693
+ ```
694
+
695
+ | Key | What it does |
696
+ | --- | --- |
697
+ | `↑` `↓` | Scroll the open step's output, then move between steps once it runs out |
698
+ | `enter` | Open or shut the step under the cursor. One opened by hand stays open when it finishes |
699
+ | `shift+↑` | Jump to the step running now, and follow it again |
700
+ | `shift+↓` | Jump to the first step |
701
+ | `+` | Open the step running now, from anywhere, and keep opening the ones after it |
702
+ | `-` | Shut everything, and stop opening what comes next |
703
+ | `q` | Stop the deploy. Press again to kill it |
704
+
705
+ The gutter counts the whole run; the number on the right counts the step, and
706
+ freezes at what it cost the moment it finishes. Nothing drawn on the alternate
707
+ screen survives it, so the run is written out again on the way out.
708
+
709
+ Colour separates where you are from what is happening. The step under the
710
+ cursor is cyan, the step running now is yellow, and a failed one is red and
711
+ outranks both, because that is the row you are looking for. The tick, the cross
712
+ and the arrow carry the step's own state, and the gutter, the timers and the
713
+ rule down the side of a log stay dim so the output reads above them. `NO_COLOR`
714
+ turns all of it off.
715
+
716
+ Every measurement happens before a colour is added: an escape is zero columns
717
+ wide, and a row measured with one in it is a row that wraps.
718
+
719
+ Every row is one terminal line. A line too long for the width is cut with an
720
+ ellipsis rather than wrapped, because a wrapped row pushes everything under it
721
+ out of a frame counted in rows. `--full` is the way to read the untrimmed thing:
722
+ no view, no collapsing, every line of every step streamed in full as it arrives.
723
+
724
+ A pipe, a CI log or `REDKITE_PLAIN=1` gets that same streamed form, with
725
+ `--verbose` adding every host command beside it.
726
+
727
+ Messages said outside a step show the most recent few. They used to all stay on
728
+ screen, which on a long run left no room for any step's output at all. All of
729
+ them are written out again when the view closes.
730
+
731
+ ### Stopping a deploy
732
+
733
+ `q`, or ctrl+C without the view, asks the run to stop. Whatever command is in
734
+ flight is killed and the pipeline unwinds through its own failure path, so the
735
+ scratch directory on the host is removed and the connection is closed. The run
736
+ stops **between steps**, which is the unit that leaves the host in a state the
737
+ next deploy can read: a build that has not finished leaves the running
738
+ deployment exactly as it was.
739
+
740
+ **The deploy does not exit while the build is still running.** Every command runs
741
+ in a process group of its own, so one signal reaches the shell, the docker client
742
+ and the build behind it. Over ssh that group is on the other machine: killing the
743
+ local client there would only lose the reach, so each command records its group
744
+ as it starts and the signal is sent down the connection to it.
745
+
746
+ The first press sends `SIGTERM`. Every press after it sends `SIGKILL`. Each says
747
+ which, and the run keeps asking the host how many are still there until the
748
+ answer is none:
749
+
750
+ ```
751
+ Stopping the build (SIGTERM). Press again to kill it
752
+ Waiting for 1 still running
753
+ Killing the build (SIGKILL). Nothing exits until it is gone
754
+ Stopped
755
+ ```
756
+
757
+ A process that has taken `SIGKILL` and is still there is one redkite cannot end.
758
+ The fifth press says so and offers the way out rather than taking it, because
759
+ taking it leaves work running with nothing watching it:
760
+
761
+ ```
762
+ This is not stopping. It has had SIGKILL and is still there.
763
+ Press again to leave redkite. That does not stop it: the build keeps running
764
+ on the host, may finish and tag an image no deploy is waiting for, and holds
765
+ the CPU and disk it is using. Nothing will clean up after it but you.
766
+ ```
767
+
768
+ The sixth press leaves.
769
+
770
+ ## How a deploy runs
771
+
772
+ Everything happens on the deploy host, over a single multiplexed ssh connection
773
+ with the agent forwarded.
774
+
775
+ 1. **Setup.** Creates the network, then adopts or builds each service.
776
+ 2. **Resolve.** `git remote update` into a mirror under `~/.cache/redkite`, then
777
+ `rev-parse` the branch. That commit is what the image is tagged by.
778
+ 3. **Check out.** A working tree sharing the mirror's object store, submodules
779
+ followed to the branch `.gitmodules` names.
780
+ 4. **Build.** The pipeline is rendered as a Dockerfile beside the checkout and
781
+ handed to the BuildKit already inside the daemon. Skipped entirely when the
782
+ host is already holding this exact image.
783
+ 5. **`swap:before`.** Anything hung here runs while the old containers are still
784
+ serving. A migration is the usual one, and it throws, so a failure retires
785
+ nothing.
786
+ 6. **Swap.** The running container moves to the retired address without being
787
+ stopped and is renamed, and only then does the live address belong to the new
788
+ one. Nginx keeps the retired container as a backup upstream.
789
+ 7. **Check.** Each app is probed on itself. One failure reverts all of them.
790
+ 8. **Cleanup.** Retired containers removed, superseded images reclaimed.
791
+
792
+ A `verify` run walks the same list without steps 5 to 7. In their place it runs
793
+ each app's declared checks, so nothing it does touches what is serving.
794
+
795
+ An image the host already holds is not rebuilt. The tag covers the commit, the
796
+ pipeline, the build spec, the environment file and every credential file, so a
797
+ change to any of them is a new image and a change to none of them is a swap with
798
+ no build at all.
799
+
800
+ ## Design
801
+
802
+ Everything is derived from the app list, which is what makes the constants block
803
+ and the hand-written nginx template stop existing.
804
+
805
+ Redkite talks to a machine through one small port: run a shell command, write a
806
+ file. `sshHost` implements it over a multiplexed ssh connection, `localHost`
807
+ over child processes, and nothing else in the library knows which is in use.
808
+ That is why the whole orchestration is asserted against a recorder rather than a
809
+ server, and why deploying to another machine and deploying to this one differ in
810
+ nothing but which of the two the CLI constructs.
811
+
812
+ ## Contributing
813
+
814
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the layout of the source, how the
815
+ tests are organised, and what to run before opening a pull request.
816
+
817
+ ## License
818
+
819
+ [MIT](./LICENSE) © Joao Kdouk