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
@@ -0,0 +1,2 @@
1
+ export * from "./nextApp.js";
2
+ export * from "./nodeApp.js";
@@ -0,0 +1,70 @@
1
+ import type { BuildSpec } from "../types.js";
2
+ import { nodeApp } from "./nodeApp.js";
3
+
4
+ type NextAppOptions = {
5
+ builder?: string;
6
+ runtime?: string;
7
+ port?: number;
8
+ steps?: string[];
9
+ dependencies?: { files: string[]; step: string } | false;
10
+ // Whether next.config sets output: "standalone". Defaults to true, which is
11
+ // the layout worth deploying: a tree that runs on node alone
12
+ standalone?: boolean;
13
+ };
14
+
15
+ // The standalone layout is three directories that have to land in the right
16
+ // places, which is the only part of a Next build that is not a node build
17
+ export function nextApp(options: NextAppOptions = {}): BuildSpec {
18
+ const port = options.port ?? 3000;
19
+ const standalone = options.standalone ?? true;
20
+
21
+ const shared = {
22
+ builder: options.builder ?? "24-alpine",
23
+ runtime: options.runtime ?? "22-alpine",
24
+ steps: options.steps ?? ["yarn build"],
25
+ dependencies: options.dependencies,
26
+ };
27
+
28
+ // Relative, because the runtime starts at the app's root and a monorepo puts
29
+ // that under its own directory rather than at the top of the image
30
+ const start = (command: string) =>
31
+ ["sh", "-c", `HOSTNAME=0.0.0.0 PORT=${port} ${command}`];
32
+
33
+ if (!standalone) {
34
+ return {
35
+ ...nodeApp({
36
+ ...shared,
37
+ // The whole tree, because next start reads the source layout it built
38
+ // from and resolves its own dependencies at runtime
39
+ output: "/app",
40
+ // npx resolves the binary from node_modules/.bin upwards, so this
41
+ // finds it whether a workspace hoisted it or the app owns it
42
+ entrypoint: start("npx --no-install next start"),
43
+ // Without the modules cache, so node_modules is a layer of the image
44
+ // rather than a mount the runtime stage never sees
45
+ caches: ["yarn", "npm", "next-app"],
46
+ }),
47
+ preset: "nextApp",
48
+ keepsLayout: true,
49
+ };
50
+ }
51
+
52
+ return {
53
+ ...nodeApp({
54
+ ...shared,
55
+ output: "/app/.next/standalone",
56
+ // static is what the build produced and the server serves. public is
57
+ // whatever the repository put there, and plenty of apps have none
58
+ carry: ["/app/.next/static", { path: "/app/public", optional: true }],
59
+ entrypoint: start("node server.js"),
60
+ // app-modules is what makes the build fast: node_modules on a mount
61
+ // rather than in a layer, which the standalone tree makes safe by
62
+ // carrying its own copy of everything the server reaches
63
+ caches: ["yarn", "npm", "modules", "app-modules", "next-app"],
64
+ }),
65
+ preset: "nextApp",
66
+ // The standalone tree traces from the workspace root, so an app in a
67
+ // subdirectory arrives at that subdirectory and not at the top of it
68
+ keepsLayout: true,
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import type { BuildSpec, CarryPath, SourcemapSpec } from "../types.js";
2
+
3
+ type NodeAppOptions = {
4
+ builder?: string;
5
+ runtime?: string;
6
+ steps: string[];
7
+ // Defaults to yarn with a frozen lockfile. Set false where the build has no
8
+ // separable dependency phase
9
+ dependencies?: { files: string[]; step: string } | false;
10
+ output: string;
11
+ entrypoint: string[];
12
+ carry?: CarryPath[];
13
+ submodules?: boolean;
14
+ caches?: string[];
15
+ sourcemaps?: SourcemapSpec;
16
+ };
17
+
18
+ export function nodeApp(options: NodeAppOptions): BuildSpec {
19
+ return {
20
+ preset: "nodeApp",
21
+ builderImage: `node:${options.builder ?? "22-alpine"}`,
22
+ runtimeImage: `node:${options.runtime ?? "24-alpine"}`,
23
+ dependencies:
24
+ options.dependencies === false
25
+ ? undefined
26
+ : (options.dependencies ?? {
27
+ files: ["package.json", "yarn.lock"],
28
+ step: "yarn install --frozen-lockfile",
29
+ stripScripts: ["preinstall", "prepare", "postinstall"],
30
+ }),
31
+ steps: options.steps,
32
+ output: options.output,
33
+ carry: options.carry ?? [],
34
+ entrypoint: options.entrypoint,
35
+ // Not the app's own node_modules: a step after the build runs in this image
36
+ // without the mounts, and a migration that cannot find prisma is what a
37
+ // cache mount instead of a layer looks like from the outside
38
+ caches: options.caches ?? ["yarn", "npm", "modules"],
39
+ submodules: options.submodules ?? false,
40
+ // The checkout arrives complete, so this is what a build step itself needs
41
+ aptPackages: ["git"],
42
+ runtimePackages: ["curl"],
43
+ runtimeSteps: [],
44
+ sourcemaps: options.sourcemaps,
45
+ };
46
+ }
@@ -0,0 +1,7 @@
1
+ import type { SecretRef } from "../types.js";
2
+
3
+ // Names an item in a Bitwarden vault. The id is not a secret, it is a pointer,
4
+ // so it belongs in the config while the credentials arrive at deploy time
5
+ export function bitwarden(id: string): SecretRef {
6
+ return { provider: "bitwarden", id };
7
+ }
@@ -0,0 +1,32 @@
1
+ import type { SecretRef, SecretRefs } from "../types.js";
2
+
3
+ // Resolves an item to its contents. One per provider named by a ref
4
+ export type SecretStore = {
5
+ read(id: string): Promise<string>;
6
+ };
7
+
8
+ export type SecretStores = Record<string, SecretStore>;
9
+
10
+ export function listRefs(refs: SecretRefs | undefined): SecretRef[] {
11
+ if (!refs) return [];
12
+ return Array.isArray(refs) ? refs : [refs];
13
+ }
14
+
15
+ export async function readRef(ref: SecretRef, stores: SecretStores) {
16
+ const store = stores[ref.provider];
17
+ if (store) return await store.read(ref.id);
18
+
19
+ const known = Object.keys(stores).join(", ") || "none";
20
+ throw new Error(`No store for provider ${ref.provider}, deploy was given ${known}`);
21
+ }
22
+
23
+ // Concatenated in the order written. Every dotenv parser builds an object as it
24
+ // reads, so a key that appears twice takes its later value, which is the
25
+ // precedence the config file reads as having
26
+ export async function readEnv(refs: SecretRefs | undefined, stores: SecretStores) {
27
+ const contents = await Promise.all(
28
+ listRefs(refs).map(async (ref) => await readRef(ref, stores)),
29
+ );
30
+
31
+ return contents.map((text) => (text.endsWith("\n") ? text : `${text}\n`)).join("");
32
+ }
@@ -0,0 +1,147 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ import type { SecretStore } from "./refs.js";
9
+
10
+ // One implementation of the interface deploy consumes. A second provider only
11
+ // has to satisfy read().
12
+ //
13
+ // The CLI runs here as an ordinary process. It used to run inside a container
14
+ // purely as a sandbox, which meant installing several thousand packages on
15
+ // every deploy to read three items.
16
+
17
+ const run = promisify(execFile);
18
+
19
+ export type BitwardenCredentials = {
20
+ clientId: string;
21
+ clientSecret: string;
22
+ password: string;
23
+ detail?: (message: string) => void;
24
+ };
25
+
26
+ // Used when bw is not already on PATH. Pinned, because an unpinned CLI is a
27
+ // different program on a machine that has never run a deploy before
28
+ const CLI = "@bitwarden/cli@2026.4.2";
29
+
30
+ // Where that install lands. Keyed by the pinned version, so bumping it installs
31
+ // beside the old one rather than over a vault command that is mid-flight
32
+ const CLIS = join(homedir(), ".cache", "redkite", "cli");
33
+
34
+ export async function bitwardenStore(
35
+ credentials: BitwardenCredentials,
36
+ ): Promise<SecretStore> {
37
+ const detail = credentials.detail ?? (() => {});
38
+
39
+ // Its own state directory, so a deploy neither reads nor disturbs whatever
40
+ // vault the person running it happens to be logged into
41
+ const appdata = join(homedir(), ".cache", "redkite", "bitwarden");
42
+ await mkdir(appdata, { recursive: true });
43
+
44
+ const command = await resolveCli(detail);
45
+
46
+ const bw = async (args: string[], env: Record<string, string> = {}) =>
47
+ await run(command.file, [...command.prefix, ...args], {
48
+ env: {
49
+ ...process.env,
50
+ BITWARDENCLI_APPDATA_DIR: appdata,
51
+ ...env,
52
+ },
53
+ maxBuffer: 32 * 1024 * 1024,
54
+ });
55
+
56
+ detail("unlocking the vault");
57
+
58
+ // Already logged in is not an error, the session is what matters
59
+ await bw(["login", "--apikey"], {
60
+ BW_CLIENTID: credentials.clientId,
61
+ BW_CLIENTSECRET: credentials.clientSecret,
62
+ }).catch(() => undefined);
63
+
64
+ const unlocked = await bw(["unlock", "--passwordenv", "BW_PASSWORD", "--raw"], {
65
+ BW_PASSWORD: credentials.password,
66
+ }).catch((error: unknown) => {
67
+ throw new Error(`Could not unlock the Bitwarden vault: ${messageOf(error)}`);
68
+ });
69
+
70
+ const session = unlocked.stdout.trim();
71
+ if (!session) throw new Error("Bitwarden unlocked without a session");
72
+
73
+ // An item added since the last deploy is not in the local vault otherwise,
74
+ // and bw get answers "not found" rather than fetching it
75
+ await bw(["sync"], { BW_SESSION: session }).catch(() => undefined);
76
+
77
+ // Fetched once each. A second read of the same item during a deploy is the
78
+ // same answer, and this avoids a process per call site
79
+ const cache = new Map<string, Promise<string>>();
80
+
81
+ const fetch = async (id: string) => {
82
+ const item = await bw(["get", "notes", id, "--raw"], {
83
+ BW_SESSION: session,
84
+ }).catch((error: unknown) => {
85
+ throw new Error(`Could not read ${id} from Bitwarden: ${messageOf(error)}`);
86
+ });
87
+
88
+ return item.stdout;
89
+ };
90
+
91
+ return {
92
+ read: async (id) => {
93
+ const pending = cache.get(id) ?? fetch(id);
94
+ cache.set(id, pending);
95
+ return await pending;
96
+ },
97
+ };
98
+ }
99
+
100
+ type Cli = { file: string; prefix: string[] };
101
+
102
+ // A machine with the CLI installed pays nothing. One without it installs the
103
+ // pinned version once, rather than resolving it again on every call below
104
+ async function resolveCli(detail: (message: string) => void): Promise<Cli> {
105
+ const override = process.env["REDKITE_BW_BIN"];
106
+ if (override) return { file: override, prefix: [] };
107
+
108
+ const found = await run("bw", ["--version"]).then(
109
+ () => true,
110
+ () => false,
111
+ );
112
+
113
+ if (found) return { file: "bw", prefix: [] };
114
+
115
+ return { file: await install(detail), prefix: [] };
116
+ }
117
+
118
+ // npx resolves the package again on every invocation, and unlocking a vault is
119
+ // three commands plus one per secret. That was most of a second, six times over
120
+ async function install(detail: (message: string) => void) {
121
+ const directory = join(CLIS, CLI.replace(/[^\w.]+/g, "-"));
122
+ const binary = join(directory, "node_modules", ".bin", "bw");
123
+
124
+ if (existsSync(binary)) return binary;
125
+
126
+ detail(`installing ${CLI}, once for this machine`);
127
+ await mkdir(directory, { recursive: true });
128
+
129
+ await run(
130
+ "npm",
131
+ ["install", "--prefix", directory, "--no-save", "--no-audit", "--no-fund", CLI],
132
+ { maxBuffer: 32 * 1024 * 1024 },
133
+ ).catch((error: unknown) => {
134
+ throw new Error(`Could not install ${CLI}: ${messageOf(error)}`);
135
+ });
136
+
137
+ return binary;
138
+ }
139
+
140
+ function messageOf(error: unknown) {
141
+ if (error instanceof Error && "stderr" in error) {
142
+ const stderr = String(error.stderr).trim();
143
+ if (stderr) return stderr;
144
+ }
145
+
146
+ return error instanceof Error ? error.message : String(error);
147
+ }
@@ -0,0 +1,160 @@
1
+ import { SPEC_LABEL, type Docker } from "../docker.js";
2
+ import type { Host } from "../host.js";
3
+ import type { Log } from "../log.js";
4
+ import { LISTEN_PORT } from "../nginx.js";
5
+ import { readEnv, type SecretStores } from "../secrets/refs.js";
6
+ import type { ServiceTopology, Topology } from "../topology.js";
7
+ import type { ServiceSpec } from "../types.js";
8
+
9
+ import { fingerprintOf, type PlannedService } from "./planned.js";
10
+
11
+ // redis.ts and nginx.ts were the same object described twice: ensure a sidecar
12
+ // from an image is running, at an address, with a configuration file.
13
+
14
+ export type EnsureContext = {
15
+ host: Host;
16
+ docker: Docker;
17
+ topology: Topology;
18
+ // Rendered by the caller, so this file does not have to know what nginx is
19
+ files: Record<string, string>;
20
+ // One store per provider a service names. A service without secrets never
21
+ // opens one
22
+ secrets: SecretStores;
23
+ log: Log;
24
+ // Set for the proxy alone. Nothing else is reachable from outside the host,
25
+ // and nothing else has to resolve the apps by name
26
+ publish?: number;
27
+ };
28
+
29
+ // What became of one service, for a deploy that says what it did rather than
30
+ // whether something changed
31
+ export type Ensured = "adopted" | "started" | "created" | "recreated";
32
+
33
+ export async function ensureService(
34
+ spec: ServiceSpec,
35
+ service: ServiceTopology,
36
+ context: EnsureContext,
37
+ ): Promise<Ensured> {
38
+ const { docker } = context;
39
+ const name = service.container;
40
+ const fingerprint = fingerprintOf(plannedFrom(spec, service, context), context.topology);
41
+
42
+ if (await docker.container.exists(name)) {
43
+ // A service outlives a deploy, so being there is usually the whole answer.
44
+ // Being there having been created from something else is not: a changed
45
+ // public port or a rendered config only reaches a container that is made
46
+ if (await docker.container.specOf(name) === fingerprint) {
47
+ if (await docker.container.isRunning(name)) return "adopted";
48
+
49
+ context.log.warn(`${name} was not running, starting it`);
50
+ await docker.container.start(name);
51
+ return "started";
52
+ }
53
+
54
+ context.log(`${name} is not what the deployment says, recreating it`);
55
+ await docker.container.stop(name);
56
+ await docker.container.remove(name);
57
+ await createService(spec, service, context, fingerprint);
58
+ await docker.container.start(name);
59
+ return "recreated";
60
+ }
61
+
62
+ context.log(`Creating ${name} at ${service.address}`);
63
+ await createService(spec, service, context, fingerprint);
64
+ await docker.container.start(name);
65
+
66
+ return "created";
67
+ }
68
+
69
+ // The same shape the plan compares against, assembled from what this call was
70
+ // handed. Two spellings of it would drift from each other rather than from the
71
+ // host, which is the one drift nothing here would report
72
+ function plannedFrom(
73
+ spec: ServiceSpec,
74
+ service: ServiceTopology,
75
+ context: EnsureContext,
76
+ ): PlannedService {
77
+ return { spec, service, files: context.files, publish: context.publish };
78
+ }
79
+
80
+ async function createService(
81
+ spec: ServiceSpec,
82
+ service: ServiceTopology,
83
+ context: EnsureContext,
84
+ fingerprint: string,
85
+ ) {
86
+ const { docker, topology } = context;
87
+ const name = service.container;
88
+
89
+ await buildImage(spec, name, context);
90
+
91
+ const builder = docker.container
92
+ .builder()
93
+ .name(name)
94
+ .image(name)
95
+ .network(topology.network)
96
+ .label(SPEC_LABEL, fingerprint)
97
+ .ip(service.address);
98
+
99
+ if (spec.restart) builder.restart(spec.restart);
100
+
101
+ for (const [name, value] of Object.entries(spec.environment ?? {})) {
102
+ builder.env(name, value);
103
+ }
104
+
105
+ // Written to the host and handed over as a file, so the value never appears
106
+ // in an argument list. Docker reads it when the container is created and
107
+ // keeps the values, so the file goes with the rest of the deploy's scratch
108
+ if (spec.secrets) {
109
+ const contents = await readEnv(spec.secrets, context.secrets);
110
+ builder.envFile(await context.host.write(`services/${name}/env`, contents));
111
+ }
112
+
113
+ for (const volume of service.volumes) {
114
+ builder.volume(volume.volume, volume.mountPath);
115
+ }
116
+
117
+ if (context.publish) {
118
+ for (const [host, ip] of Object.entries(topology.extraHosts)) {
119
+ builder.extraHost(host, ip);
120
+ }
121
+
122
+ builder.port(context.publish, LISTEN_PORT);
123
+ }
124
+
125
+ await builder.create();
126
+ }
127
+
128
+ // Three lines of Dockerfile on the host, in place of a container assembled in
129
+ // an engine, exported as a tarball and loaded back in
130
+ async function buildImage(
131
+ spec: ServiceSpec,
132
+ name: string,
133
+ context: EnsureContext,
134
+ ) {
135
+ const { host, docker } = context;
136
+ const directory = `services/${name}`;
137
+
138
+ const copies = await Promise.all(
139
+ Object.entries(context.files).map(async ([path, contents], index) => {
140
+ await host.write(`${directory}/file.${index}`, contents);
141
+ return `COPY file.${index} ${path}`;
142
+ }),
143
+ );
144
+
145
+ const dockerfile = await host.write(
146
+ `${directory}/Dockerfile`,
147
+ [
148
+ `FROM ${spec.image}`,
149
+ ...copies,
150
+ ...(context.publish ? [`EXPOSE ${LISTEN_PORT}`] : []),
151
+ "",
152
+ ].join("\n"),
153
+ );
154
+
155
+ await docker.image.build({
156
+ context: `${host.directory}/${directory}`,
157
+ dockerfile,
158
+ tags: [name],
159
+ });
160
+ }
@@ -0,0 +1,48 @@
1
+ import type { SecretRefs, ServiceSpec } from "../types.js";
2
+
3
+ type RedisOptions = {
4
+ image?: string;
5
+ alias?: string;
6
+ address?: number;
7
+ volumes?: Record<string, string>;
8
+ };
9
+
10
+ export function redis(options: RedisOptions = {}): ServiceSpec {
11
+ return {
12
+ name: "redis",
13
+ image: options.image ?? "redis:7",
14
+ alias: options.alias ?? "redis",
15
+ restart: "always",
16
+ address: options.address,
17
+ // The image declares VOLUME /data, so without this the data lands in an
18
+ // anonymous volume that survives nothing and can be named by no one
19
+ volumes: options.volumes ?? { data: "/data" },
20
+ };
21
+ }
22
+
23
+ type PostgresOptions = {
24
+ // The image refuses to start without POSTGRES_PASSWORD, so this is required
25
+ // rather than optional: a service that cannot come up is not a default
26
+ secrets: SecretRefs;
27
+ image?: string;
28
+ alias?: string;
29
+ address?: number;
30
+ volumes?: Record<string, string>;
31
+ // POSTGRES_DB and POSTGRES_USER, which are settings rather than credentials
32
+ environment?: Record<string, string>;
33
+ };
34
+
35
+ export function postgres(options: PostgresOptions): ServiceSpec {
36
+ return {
37
+ name: "postgres",
38
+ image: options.image ?? "postgres:17-alpine",
39
+ alias: options.alias ?? "postgres",
40
+ restart: "always",
41
+ address: options.address,
42
+ secrets: options.secrets,
43
+ environment: options.environment,
44
+ // The image declares VOLUME on this path, so without naming it the data
45
+ // lands in an anonymous volume that survives nothing and nobody can name
46
+ volumes: options.volumes ?? { data: "/var/lib/postgresql/data" },
47
+ };
48
+ }
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { Docker } from "../docker.js";
4
+ import { LISTEN_PORT, renderNginx } from "../nginx.js";
5
+ import type { Run } from "../pipeline.js";
6
+ import type { ServiceTopology, Topology } from "../topology.js";
7
+ import type { Deployment, ServiceSpec } from "../types.js";
8
+
9
+ // What a deployment says should be running, and how that compares to what is.
10
+ // Services outlive a deploy, so the config that created one is not the config
11
+ // the file now holds: a changed public port or a rendered nginx block lands in
12
+ // an image the running container was never created from.
13
+
14
+ export type PlannedService = {
15
+ spec: ServiceSpec;
16
+ service: ServiceTopology;
17
+ // Rendered by the deployment, and part of the image the container runs
18
+ files: Record<string, string>;
19
+ publish?: number;
20
+ };
21
+
22
+ export function plannedServices(
23
+ config: Deployment,
24
+ topology: Topology,
25
+ run: Run = "deploy",
26
+ ): PlannedService[] {
27
+ const rest = config.services.map((spec) => {
28
+ const service = topology.services.find((item) => item.name === spec.name);
29
+ if (!service) throw new Error(`No topology for service ${spec.name}`);
30
+
31
+ return { spec, service, files: spec.files ?? {} };
32
+ });
33
+
34
+ // Nothing serves in a verify run, so a proxy there would resolve upstreams
35
+ // that were never created and publish a port for them
36
+ if (run === "verify") return rest;
37
+
38
+ const proxy: PlannedService = {
39
+ spec: router(config),
40
+ service: topology.router,
41
+ files: {
42
+ "/etc/nginx/conf.d/default.conf": renderNginx(topology, config.maxBodySize),
43
+ },
44
+ publish: topology.publicPort,
45
+ };
46
+
47
+ return [proxy, ...rest];
48
+ }
49
+
50
+ // Not something a deployment lists. Apps carry routes, routes need a proxy to
51
+ // resolve them, and the one that renders them is this
52
+ export function router(config: Deployment): ServiceSpec {
53
+ return {
54
+ name: "nginx",
55
+ image: config.proxyImage ?? "nginx:stable",
56
+ restart: "always",
57
+ };
58
+ }
59
+
60
+ // Everything a recreate would change, and nothing a deploy resolves. Secrets
61
+ // are named by their refs rather than their values: whether the config is the
62
+ // one running is a question a plan should answer without unlocking a vault
63
+ export function fingerprintOf(planned: PlannedService, topology: Topology) {
64
+ const { spec, service, files, publish } = planned;
65
+
66
+ const shape = {
67
+ image: spec.image,
68
+ restart: spec.restart,
69
+ address: service.address,
70
+ network: topology.network,
71
+ volumes: service.volumes,
72
+ environment: spec.environment,
73
+ secrets: spec.secrets,
74
+ files,
75
+ // The proxy alone, and the two things being published costs it
76
+ publish: publish && { host: publish, container: LISTEN_PORT },
77
+ extraHosts: publish ? topology.extraHosts : undefined,
78
+ };
79
+
80
+ return createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 16);
81
+ }
82
+
83
+ // Why a service is not what the deployment says it should be. Absent from the
84
+ // list means it is
85
+ export type Drift = {
86
+ container: string;
87
+ name: string;
88
+ reason: "missing" | "stopped" | "changed" | "unrecognised";
89
+ };
90
+
91
+ export async function driftOf(
92
+ planned: PlannedService[],
93
+ topology: Topology,
94
+ docker: Docker,
95
+ ): Promise<Drift[]> {
96
+ const drifted: Drift[] = [];
97
+
98
+ for (const item of planned) {
99
+ const reason = await reasonFor(item, topology, docker);
100
+ if (reason) drifted.push({ container: item.service.container, name: item.spec.name, reason });
101
+ }
102
+
103
+ return drifted;
104
+ }
105
+
106
+ async function reasonFor(planned: PlannedService, topology: Topology, docker: Docker) {
107
+ const name = planned.service.container;
108
+ if (!(await docker.container.exists(name))) return "missing" as const;
109
+
110
+ const held = await docker.container.specOf(name);
111
+ // Created by hand, or by a redkite that did not record what it created from.
112
+ // Either way nothing here can say it matches, so it is rebuilt once
113
+ if (!held) return "unrecognised" as const;
114
+
115
+ if (held !== fingerprintOf(planned, topology)) return "changed" as const;
116
+ if (!(await docker.container.isRunning(name))) return "stopped" as const;
117
+
118
+ return undefined;
119
+ }