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,100 @@
1
+ import type { AnyStep } from "./pipeline.js";
2
+ export type Environment = {
3
+ buildOn?: "host" | "local";
4
+ branch: string;
5
+ subnet: string;
6
+ publicPort?: number;
7
+ host?: DeployHost;
8
+ extraHosts?: Record<string, string>;
9
+ };
10
+ export type StepNetwork = "host" | "deployment" | "none" | {
11
+ named: string;
12
+ };
13
+ export type DeployHost = {
14
+ bastion: string;
15
+ socket?: string;
16
+ };
17
+ export type SecretRef = {
18
+ provider: string;
19
+ id: string;
20
+ };
21
+ export type SecretRefs = SecretRef | SecretRef[];
22
+ export type ServiceSpec = {
23
+ name: string;
24
+ image: string;
25
+ alias?: string;
26
+ restart?: "always" | "unless-stopped";
27
+ files?: Record<string, string>;
28
+ environment?: Record<string, string>;
29
+ secrets?: SecretRefs;
30
+ address?: number;
31
+ volumes?: Record<string, string>;
32
+ };
33
+ export type CarryPath = string | {
34
+ path: string;
35
+ optional: true;
36
+ };
37
+ export type BuildSpec = {
38
+ preset: string;
39
+ builderImage: string;
40
+ runtimeImage: string;
41
+ dependencies?: {
42
+ files: string[];
43
+ step: string;
44
+ stripScripts?: string[];
45
+ };
46
+ steps: string[];
47
+ output: string;
48
+ carry: CarryPath[];
49
+ keepsLayout?: boolean;
50
+ entrypoint: string[];
51
+ caches: string[];
52
+ submodules: boolean;
53
+ aptPackages: string[];
54
+ runtimePackages: string[];
55
+ runtimeSteps: string[];
56
+ sourcemaps?: SourcemapSpec;
57
+ };
58
+ export type SourcemapSpec = {
59
+ provider: "sentry";
60
+ stripFromImage: string;
61
+ };
62
+ export type VerifySpec = {
63
+ steps: string[];
64
+ network?: StepNetwork;
65
+ environment?: Record<string, string>;
66
+ };
67
+ export type HealthSpec = {
68
+ path: string;
69
+ expect: (body: Record<string, unknown>) => boolean;
70
+ retries?: number;
71
+ intervalMs?: number;
72
+ delayMs?: number;
73
+ };
74
+ export type AppSpec = {
75
+ name: string;
76
+ repo?: string;
77
+ path?: string;
78
+ include?: string[];
79
+ route: string;
80
+ port: number;
81
+ secrets?: SecretRefs;
82
+ dir?: string;
83
+ build: BuildSpec;
84
+ health: HealthSpec;
85
+ verify?: VerifySpec;
86
+ volumes?: Record<string, string>;
87
+ environment?: Record<string, string>;
88
+ files?: Record<string, SecretRef>;
89
+ };
90
+ export type Deployment = {
91
+ project: string;
92
+ environment?: Environment;
93
+ environments?: Record<string, Environment>;
94
+ maxBodySize?: string;
95
+ proxyImage?: string;
96
+ services: ServiceSpec[];
97
+ apps: AppSpec[];
98
+ steps?: AnyStep[];
99
+ };
100
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAM7C,MAAM,MAAM,WAAW,GAAG;IAKxB,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAE3B,MAAM,EAAE,MAAM,CAAC;IAEf,MAAM,EAAE,MAAM,CAAC;IAIf,UAAU,CAAC,EAAE,MAAM,CAAC;IAGpB,IAAI,CAAC,EAAE,UAAU,CAAC;IAIlB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC,CAAC;AAMF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7E,MAAM,MAAM,UAAU,GAAG;IAEvB,OAAO,EAAE,MAAM,CAAC;IAEhB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAIF,MAAM,MAAM,SAAS,GAAG;IAEtB,QAAQ,EAAE,MAAM,CAAC;IAEjB,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAGF,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,EAAE,CAAC;AAEjD,MAAM,MAAM,WAAW,GAAG;IAExB,IAAI,EAAE,MAAM,CAAC;IAEb,KAAK,EAAE,MAAM,CAAC;IAEd,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,OAAO,CAAC,EAAE,QAAQ,GAAG,gBAAgB,CAAC;IAEtC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAG/B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGrC,OAAO,CAAC,EAAE,UAAU,CAAC;IAGrB,OAAO,CAAC,EAAE,MAAM,CAAC;IAGjB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,CAAC;AAIF,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,IAAI,CAAA;CAAE,CAAC;AAElE,MAAM,MAAM,SAAS,GAAG;IAEtB,MAAM,EAAE,MAAM,CAAC;IAEf,YAAY,EAAE,MAAM,CAAC;IAErB,YAAY,EAAE,MAAM,CAAC;IAIrB,YAAY,CAAC,EAAE;QACb,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QAIb,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;KACzB,CAAC;IAEF,KAAK,EAAE,MAAM,EAAE,CAAC;IAEhB,MAAM,EAAE,MAAM,CAAC;IAIf,KAAK,EAAE,SAAS,EAAE,CAAC;IAInB,WAAW,CAAC,EAAE,OAAO,CAAC;IAGtB,UAAU,EAAE,MAAM,EAAE,CAAC;IAGrB,MAAM,EAAE,MAAM,EAAE,CAAC;IAGjB,UAAU,EAAE,OAAO,CAAC;IAEpB,WAAW,EAAE,MAAM,EAAE,CAAC;IAEtB,eAAe,EAAE,MAAM,EAAE,CAAC;IAG1B,YAAY,EAAE,MAAM,EAAE,CAAC;IAEvB,UAAU,CAAC,EAAE,aAAa,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAE1B,QAAQ,EAAE,QAAQ,CAAC;IAGnB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAKF,MAAM,MAAM,UAAU,GAAG;IAGvB,KAAK,EAAE,MAAM,EAAE,CAAC;IAGhB,OAAO,CAAC,EAAE,WAAW,CAAC;IAGtB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IAEvB,IAAI,EAAE,MAAM,CAAC;IAGb,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC;IAEnD,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IAGpB,IAAI,EAAE,MAAM,CAAC;IAGb,IAAI,CAAC,EAAE,MAAM,CAAC;IAKd,IAAI,CAAC,EAAE,MAAM,CAAC;IAKd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAGnB,KAAK,EAAE,MAAM,CAAC;IAEd,IAAI,EAAE,MAAM,CAAC;IAGb,OAAO,CAAC,EAAE,UAAU,CAAC;IAIrB,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb,KAAK,EAAE,SAAS,CAAC;IAEjB,MAAM,EAAE,UAAU,CAAC;IAGnB,MAAM,CAAC,EAAE,UAAU,CAAC;IAEpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGjC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAGrC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IAGvB,OAAO,EAAE,MAAM,CAAC;IAGhB,WAAW,CAAC,EAAE,WAAW,CAAC;IAK1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAE3C,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,UAAU,CAAC,EAAE,MAAM,CAAC;IAIpB,QAAQ,EAAE,WAAW,EAAE,CAAC;IAGxB,IAAI,EAAE,OAAO,EAAE,CAAC;IAGhB,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;CACnB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "redkite",
3
+ "version": "0.1.6",
4
+ "description": "Blue-green Docker deployment from one config file, over ssh. No engine, no agent, no dependencies.",
5
+ "keywords": [
6
+ "deploy",
7
+ "deployment",
8
+ "blue-green",
9
+ "zero-downtime",
10
+ "docker",
11
+ "buildkit",
12
+ "nginx",
13
+ "ssh",
14
+ "ci",
15
+ "cd"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Joao Kdouk <contato@kdouk.com>",
19
+ "homepage": "https://github.com/JVKdouk/RedKite#readme",
20
+ "repository": {
21
+ "url": "git+https://github.com/JVKdouk/RedKite",
22
+ "type": "git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/JVKdouk/RedKite/issues"
26
+ },
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "bin": {
38
+ "redkite": "./bin/redkite.mjs"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "bin",
43
+ "src",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "build": "rm -rf dist && tsc --project tsconfig.build.json",
49
+ "check": "tsc --noEmit",
50
+ "test": "tsx --test test/*.test.ts",
51
+ "bench": "tsx bench/index.ts",
52
+ "plan": "node bin/redkite.mjs plan --config examples/acme/redkite.config.ts",
53
+ "prepare": "npm run build",
54
+ "prepublishOnly": "npm run check && npm test && node scripts/verifyRelease.mjs"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.18.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^22.10.2",
61
+ "tsx": "^4.19.2",
62
+ "typescript": "5.9.3"
63
+ }
64
+ }
package/src/build.ts ADDED
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+
4
+ import type { Docker } from "./docker.js";
5
+ import { BUILDER_STAGE, renderDockerfile, renderDockerignore } from "./dockerfile.js";
6
+ import type { Host } from "./host.js";
7
+ import { prepareSource } from "./source.js";
8
+ import type { AppTopology } from "./topology.js";
9
+ import type { AppSpec } from "./types.js";
10
+
11
+ // One build. The host clones the repository, renders the pipeline as a
12
+ // Dockerfile, and hands it to the BuildKit inside the daemon that will run the
13
+ // container, so the image is finished where it is needed and never moves.
14
+
15
+ export type BuildContext = {
16
+ host: Host;
17
+ docker: Docker;
18
+ // Set when the image is built somewhere other than where it will run. The
19
+ // build goes to the daemon above, and what it produced is streamed to this one
20
+ deliver?: { host: Host; docker: Docker };
21
+ // Contents of the .env the image ships with
22
+ env: string;
23
+ // Container path to the contents that land there
24
+ files: Record<string, string>;
25
+ branch: string;
26
+ environment: string;
27
+ detail?: (message: string) => void;
28
+ // Prints what the build itself wrote, line by line as it runs
29
+ output?: (line: string) => void;
30
+ };
31
+
32
+ // Bumped when the pipeline changes shape without the config changing. The tag
33
+ // below is what tells a host it already holds an image, and a host that trusts
34
+ // the commit alone serves the last pipeline's output forever
35
+ const PIPELINE = "4";
36
+
37
+ export type BuildResult = {
38
+ release: string;
39
+ // Everything that shaped the image, not just the commit it was built from
40
+ fingerprint: string;
41
+ // Versioned name the image carries, which is what the next deploy recognises
42
+ tag: string;
43
+ // The builder stage, kept as an image of its own. The runtime image holds
44
+ // only what the app compiled to, so this is the one place a step can run the
45
+ // app's own toolchain
46
+ builderTag: string;
47
+ // True when the host already held this exact image and nothing was rebuilt
48
+ cached: boolean;
49
+ };
50
+
51
+ export async function build(
52
+ app: AppSpec,
53
+ topology: AppTopology,
54
+ context: BuildContext,
55
+ ): Promise<BuildResult> {
56
+ const { host, docker } = context;
57
+ const spec = app.build;
58
+ const detail = context.detail ?? (() => {});
59
+
60
+ const source = await prepareSource(host, {
61
+ name: topology.container,
62
+ repo: app.repo,
63
+ // Already absolute when the CLI loaded the config, because a path is read
64
+ // against the deployment file rather than wherever this was run
65
+ path: app.path && resolve(app.path),
66
+ include: app.include,
67
+ branch: context.branch,
68
+ submodules: spec.submodules,
69
+ detail,
70
+ });
71
+
72
+ const release = source.release;
73
+ const fingerprint = fingerprintOf(app, context, release);
74
+ const tag = `${topology.container}:${release}-${fingerprint}`;
75
+ // Always, rather than where something remembered to ask for it. It costs the
76
+ // export of layers the runtime build produced anyway, and a flag that has to
77
+ // be set before a step can run is a flag that will be missing
78
+ const builderTag = `${topology.container}-builder:${release}-${fingerprint}`;
79
+
80
+ // Asked of the daemon that will run the container, not the one that builds.
81
+ // An image this machine holds and the host does not is one still to be sent
82
+ const runner = context.deliver?.docker ?? docker;
83
+
84
+ // The whole build is skipped, not just a transfer. Nothing about this commit,
85
+ // this pipeline or these secrets differs from the image already sitting there
86
+ if (await held(runner, tag, builderTag)) {
87
+ detail(`already built at ${release.slice(0, 7)}`);
88
+ await runner.image.retag(tag, topology.container);
89
+
90
+ return { release, fingerprint, tag, builderTag, cached: true };
91
+ }
92
+
93
+ const secrets = await writeSecrets(app, context, fingerprint);
94
+
95
+ const dockerfile = await host.write(
96
+ `${app.name}.Dockerfile`,
97
+ renderDockerfile(spec, {
98
+ caches: topology.caches,
99
+ port: topology.port,
100
+ release,
101
+ environment: context.environment,
102
+ envSecret: secrets.env.id,
103
+ fileSecrets: Object.fromEntries(
104
+ secrets.files.map((file) => [file.path, file.id]),
105
+ ),
106
+ dir: app.dir,
107
+ }),
108
+ );
109
+
110
+ // BuildKit reads this beside the Dockerfile rather than inside the context,
111
+ // which is what lets the checkout stay a checkout: the repository keeps its
112
+ // own .dockerignore, and .git never enters the build
113
+ await host.write(
114
+ `${app.name}.Dockerfile.dockerignore`,
115
+ renderDockerignore(app.include),
116
+ );
117
+
118
+ const invocation = {
119
+ context: source.tree,
120
+ dockerfile,
121
+ secrets: Object.fromEntries(
122
+ [secrets.env, ...secrets.files].map((secret) => [secret.id, secret.src]),
123
+ ),
124
+ };
125
+
126
+ detail(`building ${release.slice(0, 7)}`);
127
+ await docker.image.build(
128
+ { ...invocation, tags: [tag, topology.container] },
129
+ watch(detail, context.output),
130
+ );
131
+
132
+ // Every layer of this was just built, so it costs the export alone
133
+ detail("keeping the builder");
134
+ await docker.image.build(
135
+ { ...invocation, tags: [builderTag], target: BUILDER_STAGE },
136
+ watch(detail, context.output),
137
+ );
138
+
139
+ await ship(context, [tag, topology.container, builderTag], detail);
140
+
141
+ return { release, fingerprint, tag, builderTag, cached: false };
142
+ }
143
+
144
+ // One stream, rather than a tarball written here, copied, and read there. The
145
+ // tags travel inside the archive, so nothing has to be named again on arrival
146
+ async function ship(
147
+ context: BuildContext,
148
+ tags: (string | undefined)[],
149
+ detail: (message: string) => void,
150
+ ) {
151
+ const deliver = context.deliver;
152
+ if (!deliver) return;
153
+
154
+ const named = tags.filter((tag) => tag !== undefined);
155
+ detail(`shipping ${named.length} images`);
156
+
157
+ const result = await deliver.host.pipe(
158
+ `docker save ${named.join(" ")}`,
159
+ "docker load",
160
+ context.output,
161
+ );
162
+
163
+ if (result.code !== 0) {
164
+ throw new Error(`Could not ship the image: ${result.stderr || result.stdout}`);
165
+ }
166
+
167
+ await deliver.docker.track((state) => {
168
+ for (const tag of named) state.images.add(tag);
169
+ });
170
+ }
171
+
172
+ async function held(docker: Docker, tag: string, builderTag: string) {
173
+ if (!(await docker.image.exists(tag))) return false;
174
+
175
+ // A runtime image without the builder that produced it would run the
176
+ // migration from whatever release happened to be tagged last
177
+ return await docker.image.exists(builderTag);
178
+ }
179
+
180
+ // Named by the id BuildKit mounts it under, holding where it sits on the host
181
+ type Secret = { id: string; src: string };
182
+
183
+ // And, for a credential, where it has to land in the image
184
+ type FileSecret = Secret & { path: string };
185
+
186
+ // The fingerprint is part of every id, because a secret mount is not part of a
187
+ // layer's cache key. Without it a changed environment file is answered with the
188
+ // image that was built from the old one
189
+ async function writeSecrets(
190
+ app: AppSpec,
191
+ context: BuildContext,
192
+ fingerprint: string,
193
+ ): Promise<{ env: Secret; files: FileSecret[] }> {
194
+ const env = {
195
+ id: `${app.name}-env-${fingerprint}`,
196
+ src: await context.host.write(`${app.name}.env`, context.env),
197
+ };
198
+
199
+ const files = await Promise.all(
200
+ Object.entries(context.files).map(async ([path, contents], index) => ({
201
+ path,
202
+ id: `${app.name}-file-${index}-${fingerprint}`,
203
+ // Named by index, so a container path with slashes in it stays one file
204
+ src: await context.host.write(`${app.name}.file.${index}`, contents),
205
+ })),
206
+ );
207
+
208
+ return { env, files };
209
+ }
210
+
211
+ // BuildKit names the step it is on in its own progress output, which is a
212
+ // better line to show than anything this file could invent
213
+ const STEP = /^#\d+ \[[^\]]*\] (.+)$/;
214
+
215
+ function watch(detail: (message: string) => void, output?: (line: string) => void) {
216
+ return (line: string) => {
217
+ output?.(line);
218
+
219
+ const step = STEP.exec(line)?.[1];
220
+ if (step) detail(step);
221
+ };
222
+ }
223
+
224
+ // The commit is one input of several. The pipeline, the environment file and
225
+ // any credential baked in as a file all change the image without touching it
226
+ function fingerprintOf(app: AppSpec, context: BuildContext, release: string) {
227
+ return createHash("sha256")
228
+ .update(PIPELINE)
229
+ .update(release)
230
+ .update(app.dir ?? "")
231
+ .update(JSON.stringify(app.build))
232
+ .update(context.env)
233
+ .update(JSON.stringify(Object.entries(context.files).sort()))
234
+ .digest("hex")
235
+ .slice(0, 12);
236
+ }
package/src/checks.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { appRoot } from "./layout.js";
2
+ import type { Built, Context, Plan, Released } from "./pipeline.js";
3
+ import { quote } from "./shell.js";
4
+ import { attachment, builderOf } from "./steps.js";
5
+ import type { AppSpec, VerifySpec } from "./types.js";
6
+
7
+ // What redkite's verify step runs. An app declares the commands that decide
8
+ // whether its build works, and this is the whole of putting them somewhere they
9
+ // can reach the services the deployment brought up.
10
+ //
11
+ // The image is the builder rather than the runtime one. The runtime image holds
12
+ // the compiled output and nothing that compiled it, so the test runner is not
13
+ // in there, and installing one at check time would test a different tree.
14
+
15
+ export async function runChecks(input: Built, context: Context): Promise<Released> {
16
+ const checked: string[] = [];
17
+
18
+ for (const app of context.config.apps) {
19
+ if (!app.verify) continue;
20
+
21
+ await checkApp(app, app.verify, input, context);
22
+ checked.push(app.name);
23
+ }
24
+
25
+ // Anything that got here passed: a failing command throws. The lists a swap
26
+ // fills stay empty, because a verify run moves no addresses
27
+ return { ...input, ok: true, released: [], reverted: [], checked };
28
+ }
29
+
30
+ async function checkApp(app: AppSpec, spec: VerifySpec, input: Built, context: Context) {
31
+ const flags = [
32
+ "run --rm",
33
+ ...attachment(spec.network ?? "deployment", context.topology),
34
+ `--workdir ${appRoot(app.dir)}`,
35
+ ...settings({ ...app.environment, ...spec.environment }),
36
+ builderOf(input, app.name),
37
+ ];
38
+
39
+ // In order and one at a time. The first is usually what brings the database
40
+ // to the schema the rest expect, so running them together would race
41
+ for (const command of spec.steps) {
42
+ context.task.detail(`${app.name}: ${command}`);
43
+
44
+ await context.docker.runOrThrow(
45
+ [...flags, "sh -c", quote(command)].join(" "),
46
+ `${app.name} failed ${command}`,
47
+ context.task.line,
48
+ );
49
+ }
50
+ }
51
+
52
+ function settings(environment: Record<string, string>) {
53
+ return Object.entries(environment).map(([name, value]) => `-e ${name}=${quote(value)}`);
54
+ }
55
+
56
+ // Checked before the run starts, so asking for a verify that would check
57
+ // nothing fails without having created a network or built an image
58
+ export function assertCheckable(plan: Plan) {
59
+ if (plan.config.apps.some((app) => app.verify)) return;
60
+
61
+ throw new Error(
62
+ "No app declares verify, so this run would check nothing. " +
63
+ "An app is verified by giving it verify: { steps: [...] }",
64
+ );
65
+ }
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ // Cloning private repositories and opening the tunnel both go through the
4
+ // agent, so a deploy without one fails halfway rather than at the start
5
+
6
+ // A plain writer rather than a Log, because this runs before the view opens:
7
+ // ssh-add may ask for a passphrase, and it cannot ask through a screen
8
+ // something else is drawing
9
+ export function requireAgent(warn: (message: string) => void) {
10
+ const existing = process.env["SSH_AUTH_SOCK"];
11
+ if (existing) return existing;
12
+
13
+ warn("No SSH agent, starting one");
14
+
15
+ const output = execFileSync("ssh-agent", ["-s"], { encoding: "utf8" });
16
+ const socket = output.match(/SSH_AUTH_SOCK=([^;]+);/)?.[1];
17
+ const pid = output.match(/SSH_AGENT_PID=([^;]+);/)?.[1];
18
+
19
+ if (!socket) throw new Error("ssh-agent did not report a socket");
20
+
21
+ process.env["SSH_AUTH_SOCK"] = socket;
22
+ if (pid) process.env["SSH_AGENT_PID"] = pid;
23
+
24
+ // Inherits stdio so a passphrase prompt reaches the person running this
25
+ execFileSync("ssh-add", [], { stdio: "inherit" });
26
+
27
+ return socket;
28
+ }