hive-am 1.11.0 → 1.12.0

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 (178) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/package.json +1 -1
  3. package/server/dist/http/routes/fs.js +3 -2
  4. package/server/dist/http/routes/resources.js +32 -5
  5. package/server/dist/objects/docker-info.js +118 -11
  6. package/server/dist/objects/docker.js +492 -16
  7. package/server/dist/objects/files.js +152 -0
  8. package/server/dist/objects/index.js +61 -24
  9. package/server/dist/objects/model.js +72 -4
  10. package/web/.next/BUILD_ID +1 -1
  11. package/web/.next/app-build-manifest.json +147 -149
  12. package/web/.next/app-path-routes-manifest.json +9 -9
  13. package/web/.next/build-manifest.json +13 -13
  14. package/web/.next/prerender-manifest.json +40 -40
  15. package/web/.next/react-loadable-manifest.json +4 -4
  16. package/web/.next/required-server-files.json +1 -1
  17. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/web/.next/server/app/_not-found.html +1 -1
  19. package/web/.next/server/app/_not-found.rsc +3 -3
  20. package/web/.next/server/app/agents/[id]/page.js +3 -3
  21. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  22. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  23. package/web/.next/server/app/agents/page.js +2 -2
  24. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  25. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  26. package/web/.next/server/app/agents.html +1 -1
  27. package/web/.next/server/app/agents.rsc +5 -5
  28. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  29. package/web/.next/server/app/chat.html +1 -1
  30. package/web/.next/server/app/chat.rsc +4 -4
  31. package/web/.next/server/app/colony/page.js +2 -2
  32. package/web/.next/server/app/colony/page.js.nft.json +1 -1
  33. package/web/.next/server/app/colony/page_client-reference-manifest.js +1 -1
  34. package/web/.next/server/app/colony.html +1 -1
  35. package/web/.next/server/app/colony.rsc +4 -4
  36. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  37. package/web/.next/server/app/connections.html +1 -1
  38. package/web/.next/server/app/connections.rsc +4 -4
  39. package/web/.next/server/app/index.html +1 -1
  40. package/web/.next/server/app/index.rsc +4 -4
  41. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  43. package/web/.next/server/app/relations.html +1 -1
  44. package/web/.next/server/app/relations.rsc +4 -4
  45. package/web/.next/server/app/resources/[id]/page.js +1 -1
  46. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  47. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  48. package/web/.next/server/app/resources/page.js +2 -2
  49. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  50. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  51. package/web/.next/server/app/resources.html +1 -1
  52. package/web/.next/server/app/resources.rsc +4 -4
  53. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  54. package/web/.next/server/app/schedules.html +1 -1
  55. package/web/.next/server/app/schedules.rsc +4 -4
  56. package/web/.next/server/app/sessions/page.js +2 -2
  57. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  58. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  59. package/web/.next/server/app/sessions.html +1 -1
  60. package/web/.next/server/app/sessions.rsc +4 -4
  61. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  62. package/web/.next/server/app/skills.html +1 -1
  63. package/web/.next/server/app/skills.rsc +4 -4
  64. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  65. package/web/.next/server/app/types.html +1 -1
  66. package/web/.next/server/app/types.rsc +4 -4
  67. package/web/.next/server/app-paths-manifest.json +9 -9
  68. package/web/.next/server/chunks/12.js +1 -0
  69. package/web/.next/server/chunks/295.js +1 -0
  70. package/web/.next/server/chunks/374.js +6 -0
  71. package/web/.next/server/chunks/687.js +1 -0
  72. package/web/.next/server/chunks/770.js +2 -0
  73. package/web/.next/server/chunks/{944.js → 867.js} +1 -1
  74. package/web/.next/server/chunks/953.js +1 -1
  75. package/web/.next/server/middleware-build-manifest.js +1 -1
  76. package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
  77. package/web/.next/server/pages/404.html +1 -1
  78. package/web/.next/server/pages/500.html +1 -1
  79. package/web/.next/server/server-reference-manifest.json +1 -1
  80. package/web/.next/static/{HzL5-xIKbr0VsaWuLWHNl → GT9TTAUuwG8duMIDd7bTf}/_buildManifest.js +1 -1
  81. package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
  82. package/web/.next/static/chunks/199-c000d298c94aac98.js +1 -0
  83. package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
  84. package/web/.next/static/chunks/218-91cee960142e2880.js +1 -0
  85. package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
  86. package/web/.next/static/chunks/{969-07e498da015b149a.js → 350-bf7c44f226170b15.js} +1 -1
  87. package/web/.next/static/chunks/517-870d39e307db4dbb.js +1 -0
  88. package/web/.next/static/chunks/523-665ef4ba8ec58171.js +1 -0
  89. package/web/.next/static/chunks/545-f3d12b73a5a530a3.js +1 -0
  90. package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
  91. package/web/.next/static/chunks/654-92a63b3c4b412666.js +1 -0
  92. package/web/.next/static/chunks/{2657.8d96393b971ae134.js → 657.b4c39e676c3e20dc.js} +1 -1
  93. package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
  94. package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
  95. package/web/.next/static/chunks/740-72f941744e530c78.js +1 -0
  96. package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
  97. package/web/.next/static/chunks/790-91732fc93acda224.js +1 -0
  98. package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
  99. package/web/.next/static/chunks/870-18f6bab953828102.js +1 -0
  100. package/web/.next/static/chunks/{545.bfbd92b7632c09de.js → 926.03a65d3574bc521c.js} +1 -1
  101. package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
  102. package/web/.next/static/chunks/982-147e6a55046c90ba.js +1 -0
  103. package/web/.next/static/chunks/app/_not-found/page-948f39e30bfc6144.js +1 -0
  104. package/web/.next/static/chunks/app/agents/[id]/page-b9ed0dd106af45f7.js +1 -0
  105. package/web/.next/static/chunks/app/agents/layout-89bb7e860326cf4b.js +1 -0
  106. package/web/.next/static/chunks/app/agents/page-0f809513514f3c4d.js +1 -0
  107. package/web/.next/static/chunks/app/chat/page-5ec04b8997e2c0f7.js +1 -0
  108. package/web/.next/static/chunks/app/colony/page-2d41fa9508904912.js +1 -0
  109. package/web/.next/static/chunks/app/connections/page-301e45029c8a7b52.js +1 -0
  110. package/web/.next/static/chunks/app/layout-114e3639e77db079.js +1 -0
  111. package/web/.next/static/chunks/app/page-5549d1e344c5f5bb.js +1 -0
  112. package/web/.next/static/chunks/app/relations/page-18e76ccb008fc7f5.js +1 -0
  113. package/web/.next/static/chunks/app/resources/[id]/page-caf4c61561524382.js +1 -0
  114. package/web/.next/static/chunks/app/resources/page-d27d342404176148.js +1 -0
  115. package/web/.next/static/chunks/app/schedules/page-448f7f201195bc00.js +1 -0
  116. package/web/.next/static/chunks/app/sessions/page-8b8ee7e01a3a8def.js +1 -0
  117. package/web/.next/static/chunks/app/skills/page-638e3f7d7bbf657c.js +1 -0
  118. package/web/.next/static/chunks/app/types/page-ee51477946f8029f.js +1 -0
  119. package/web/.next/static/chunks/{fcafe1ac.47e831cc63938bfb.js → fcafe1ac.366e4950d3b0d4b7.js} +1 -1
  120. package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
  121. package/web/.next/static/chunks/main-app-61597cc1ea26ecc5.js +1 -0
  122. package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
  123. package/web/.next/static/chunks/pages/{_app-8aeadc281b5934a7.js → _app-d1670a2c6ce392fc.js} +1 -1
  124. package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
  125. package/web/.next/static/chunks/webpack-0ff2be4f351faaaf.js +1 -0
  126. package/web/.next/static/css/{f9099a8765eed6f0.css → c484c502b2d13072.css} +1 -1
  127. package/web/.next/trace +31 -31
  128. package/server/dist/objects/templates.js +0 -67
  129. package/web/.next/server/chunks/345.js +0 -2
  130. package/web/.next/server/chunks/368.js +0 -1
  131. package/web/.next/server/chunks/403.js +0 -1
  132. package/web/.next/server/chunks/505.js +0 -6
  133. package/web/.next/server/chunks/561.js +0 -1
  134. package/web/.next/server/chunks/616.js +0 -1
  135. package/web/.next/server/chunks/802.js +0 -1
  136. package/web/.next/static/chunks/0937d497-daa99a23a4431317.js +0 -1
  137. package/web/.next/static/chunks/1517-9b82b61ee9921bb1.js +0 -1
  138. package/web/.next/static/chunks/1606-6d25d725156f5127.js +0 -1
  139. package/web/.next/static/chunks/1771-4688ad65e21bb512.js +0 -1
  140. package/web/.next/static/chunks/2547-8b6bdae3c622a158.js +0 -1
  141. package/web/.next/static/chunks/2654-4aeef09c0d6bf56a.js +0 -1
  142. package/web/.next/static/chunks/2b9ff8ee-0851c7e556e3a39b.js +0 -1
  143. package/web/.next/static/chunks/3214-c9d0c933db5b0f44.js +0 -1
  144. package/web/.next/static/chunks/3757-45ea801718ec19e4.js +0 -1
  145. package/web/.next/static/chunks/3790-3d3f11a264e8ede8.js +0 -1
  146. package/web/.next/static/chunks/4030-bc2e0aa4da0d9ad8.js +0 -1
  147. package/web/.next/static/chunks/4162-c2361cb017547c37.js +0 -1
  148. package/web/.next/static/chunks/4491-886c18aee1e9c737.js +0 -1
  149. package/web/.next/static/chunks/4740-0581e5cb9334010b.js +0 -1
  150. package/web/.next/static/chunks/4837-8dc05d75f258f9e1.js +0 -1
  151. package/web/.next/static/chunks/5587-cdccc5cf4056b739.js +0 -1
  152. package/web/.next/static/chunks/5642-09dd4161ddddaf94.js +0 -1
  153. package/web/.next/static/chunks/7097.60e7ac5abf76b2cb.js +0 -1
  154. package/web/.next/static/chunks/7706-bd0d2bda0e0c8d1e.js +0 -1
  155. package/web/.next/static/chunks/8523-82e1ee6239699d8a.js +0 -1
  156. package/web/.next/static/chunks/8701.0db2914c123a180e.js +0 -6
  157. package/web/.next/static/chunks/app/_not-found/page-d7975fab88bfe2c3.js +0 -1
  158. package/web/.next/static/chunks/app/agents/[id]/page-eeb93069b69e7f7e.js +0 -1
  159. package/web/.next/static/chunks/app/agents/layout-db700c86124f32de.js +0 -1
  160. package/web/.next/static/chunks/app/agents/page-bf40d654c5fe5c23.js +0 -1
  161. package/web/.next/static/chunks/app/chat/page-157685bdaad86c26.js +0 -1
  162. package/web/.next/static/chunks/app/colony/page-600b663a716d08c4.js +0 -1
  163. package/web/.next/static/chunks/app/connections/page-cbfcb1547d45c833.js +0 -1
  164. package/web/.next/static/chunks/app/layout-b327408bcfb13a70.js +0 -1
  165. package/web/.next/static/chunks/app/page-78c38105a034b0b3.js +0 -1
  166. package/web/.next/static/chunks/app/relations/page-622a4c5723c7c5cc.js +0 -1
  167. package/web/.next/static/chunks/app/resources/[id]/page-b9af9eecf693b695.js +0 -1
  168. package/web/.next/static/chunks/app/resources/page-a2e17f0dc205a2a4.js +0 -1
  169. package/web/.next/static/chunks/app/schedules/page-d534462088c64b59.js +0 -1
  170. package/web/.next/static/chunks/app/sessions/page-6fef5b73bfbf262e.js +0 -1
  171. package/web/.next/static/chunks/app/skills/page-ab25a3a6d348a049.js +0 -1
  172. package/web/.next/static/chunks/app/types/page-7e08ca1bcbee605f.js +0 -1
  173. package/web/.next/static/chunks/framework-ab1d276eb4175e96.js +0 -1
  174. package/web/.next/static/chunks/main-18f19eeb8b4d3fe4.js +0 -1
  175. package/web/.next/static/chunks/main-app-27cc7ffdb888b879.js +0 -1
  176. package/web/.next/static/chunks/pages/_error-6bffadfc60f180a1.js +0 -1
  177. package/web/.next/static/chunks/webpack-ba68c1813987a630.js +0 -1
  178. /package/web/.next/static/{HzL5-xIKbr0VsaWuLWHNl → GT9TTAUuwG8duMIDd7bTf}/_ssgManifest.js +0 -0
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import { existsSync, realpathSync, statSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute } from 'node:path';
4
+ import { inspectMachine, maskVar, NO_DAEMON } from './docker-info.js';
4
5
  import { execCapture } from './exec.js';
5
- import { ObjectError, splitArgs } from './model.js';
6
+ import { IMAGE, ObjectError, parseDocker, splitArgs } from './model.js';
6
7
  /**
7
8
  * A Docker object drives the `docker` command (never a shell, every value is one argument): start, stop, restart, status and logs of a
8
9
  * container hive-am created, of a compose project, or of a container that already existed. Anything hive-am did not create is only
@@ -21,7 +22,18 @@ async function dk(args, o = {}) {
21
22
  const fail = (r, what) => new ObjectError(`${what}: ${(r.err || r.out).trim().split('\n').slice(-3).join(' ').slice(0, 400) || `docker exited with ${r.code}`}`);
22
23
  /** What makes a container different from another one when hive-am creates it: if it changes, the container is created again on the next start. */
23
24
  const CFG_FIELDS = ['image', 'ports', 'volumes', 'env', 'restart', 'command', 'memory', 'cpus', 'network', 'logMaxMb'];
24
- export const cfgHash = (c) => createHash('sha1').update(JSON.stringify(CFG_FIELDS.map((k) => c[k] ?? null))).digest('hex').slice(0, 12);
25
+ /**
26
+ * The options of `docker run` added later. They count only when set, after the others: the hash of a configuration without them is the same
27
+ * as before they existed, so updating hive-am does not create every container again. The additional networks are not here: they are
28
+ * connected and disconnected in place.
29
+ */
30
+ const CFG_LATER = ['labels', 'user', 'workdir', 'entrypoint', 'hostname'];
31
+ const RECREATES = [...CFG_FIELDS, ...CFG_LATER];
32
+ export const cfgHash = (c) => {
33
+ const base = CFG_FIELDS.map((k) => c[k] ?? null);
34
+ const later = CFG_LATER.filter((k) => c[k] !== undefined).map((k) => [k, c[k]]);
35
+ return createHash('sha1').update(JSON.stringify(later.length ? [...base, ...later] : base)).digest('hex').slice(0, 12);
36
+ };
25
37
  const stopArgs = (c) => (c.stopTimeoutSec ? ['-t', String(c.stopTimeoutSec)] : []);
26
38
  /** The hash a container was created with (empty when it does not exist). */
27
39
  async function createdWith(name) {
@@ -33,6 +45,7 @@ export const containerName = (id) => `hive-am-${id.slice(0, 8)}`;
33
45
  const nameOf = (id, c) => (c.mode === 'existing' ? c.container : containerName(id));
34
46
  /** `docker compose -f file -p project` + what to do. The folder of the file is the working folder, as compose expects. */
35
47
  const composeArgs = (c, rest) => ['compose', '-f', c.file, ...(c.project ? ['-p', c.project] : []), ...rest];
48
+ const SERVICE = /^[\w][\w.-]{0,80}$/;
36
49
  function checkCompose(c) { if (!existsSync(c.file))
37
50
  throw new ObjectError(`The compose file ${c.file} does not exist`); }
38
51
  export async function dockerState(id, c) {
@@ -109,6 +122,8 @@ export async function startDocker(id, c) {
109
122
  exists = false;
110
123
  }
111
124
  }
125
+ if (exists && c.mode === 'container')
126
+ await reconcileNetworks(name, c);
112
127
  if (exists) {
113
128
  const r = await dk(['start', name]);
114
129
  if (r.code !== 0)
@@ -117,7 +132,15 @@ export async function startDocker(id, c) {
117
132
  }
118
133
  if (c.mode === 'existing')
119
134
  throw new ObjectError(`There is no container named ${name}`);
120
- const args = ['run', '-d', '--name', name, '--label', `hive-am.object=${id}`, '--label', `hive-am.cfg=${cfgHash(c)}`];
135
+ await createContainer(id, c, true);
136
+ }
137
+ /**
138
+ * The `docker run` of a container hive-am creates, every value one argument. `hive-am.networks=1` says that hive-am keeps its additional
139
+ * networks as configured: one connected by hand is disconnected on the next start (a container created before that label existed only
140
+ * loses one when it is taken out of the settings and applied).
141
+ */
142
+ export function runArgs(id, c) {
143
+ const args = ['run', '-d', '--name', containerName(id), '--label', `hive-am.object=${id}`, '--label', `hive-am.cfg=${cfgHash(c)}`];
121
144
  if (c.memory)
122
145
  args.push('--memory', c.memory);
123
146
  if (c.cpus)
@@ -136,10 +159,81 @@ export async function startDocker(id, c) {
136
159
  args.push('-v', v);
137
160
  for (const [k, v] of Object.entries(c.env ?? {}))
138
161
  args.push('-e', `${k}=${v}`);
162
+ args.push('--label', 'hive-am.networks=1');
163
+ for (const [k, v] of Object.entries(c.labels ?? {}))
164
+ args.push('--label', `${k}=${v}`);
165
+ if (c.user)
166
+ args.push('--user', c.user);
167
+ if (c.workdir)
168
+ args.push('--workdir', c.workdir);
169
+ if (c.entrypoint)
170
+ args.push('--entrypoint', c.entrypoint);
171
+ if (c.hostname)
172
+ args.push('--hostname', c.hostname);
139
173
  args.push(c.image, ...(c.command ? splitArgs(c.command) : []));
140
- const r = await dk(args, { timeout: 300_000 }); // a first run may pull the image
174
+ return args;
175
+ }
176
+ /**
177
+ * Creates the container (and starts it with `start`). With additional networks it is created first and connected to them before it
178
+ * starts, so its program finds them from the beginning; without them it is the same single `docker run -d` as always.
179
+ */
180
+ async function createContainer(id, c, start) {
181
+ const args = runArgs(id, c), name = containerName(id);
182
+ if (start && !c.extraNetworks?.length) {
183
+ const r = await dk(args, { timeout: 300_000 }); // a first run may pull the image
184
+ if (r.code !== 0)
185
+ throw fail(r, 'could not create it');
186
+ return;
187
+ }
188
+ const r = await dk(['create', ...args.slice(2)], { timeout: 300_000 });
141
189
  if (r.code !== 0)
142
190
  throw fail(r, 'could not create it');
191
+ await reconcileNetworks(name, c);
192
+ if (start) {
193
+ const s = await dk(['start', name]);
194
+ if (s.code !== 0)
195
+ throw fail(s, 'could not start it');
196
+ }
197
+ }
198
+ /** The networks a container is on now, the one it was created on (`--network`, never changed in place) and whether hive-am keeps the rest. */
199
+ async function networksOf(name) {
200
+ const r = await dk(['inspect', '-f', '{{.HostConfig.NetworkMode}}|{{index .Config.Labels "hive-am.networks"}}|{{json .NetworkSettings.Networks}}', name]);
201
+ if (r.code !== 0)
202
+ return null;
203
+ const [mode, kept, ...rest] = r.out.trim().split('|');
204
+ let now = [];
205
+ try {
206
+ now = Object.keys(JSON.parse(rest.join('|')) ?? {});
207
+ }
208
+ catch { /* none */ }
209
+ return { main: mode === 'default' ? 'bridge' : mode, now, kept: kept === '1' };
210
+ }
211
+ /** Which additional networks to connect and disconnect so the container matches `c`; `drop` are the ones just taken out of the settings. */
212
+ function networkChanges(n, c, drop = []) {
213
+ const want = new Set(c.extraNetworks ?? []);
214
+ return {
215
+ connect: [...want].filter((x) => x !== n.main && !n.now.includes(x)),
216
+ // The main network is never disconnected here (changing it creates the container again); another one only if hive-am keeps them, or just dropped.
217
+ disconnect: n.now.filter((x) => x !== n.main && !want.has(x) && (n.kept || drop.includes(x))),
218
+ };
219
+ }
220
+ /** Connects and disconnects the additional networks in place (`docker network connect / disconnect`: it works on a stopped container too). */
221
+ export async function reconcileNetworks(name, c, drop = []) {
222
+ const n = await networksOf(name);
223
+ if (!n)
224
+ throw new ObjectError(`There is no container named ${name}`);
225
+ const { connect, disconnect } = networkChanges(n, c, drop);
226
+ for (const x of connect) {
227
+ const r = await dk(['network', 'connect', x, name]);
228
+ if (r.code !== 0)
229
+ throw fail(r, `could not connect it to ${x}`);
230
+ }
231
+ for (const x of disconnect) {
232
+ const r = await dk(['network', 'disconnect', x, name]);
233
+ if (r.code !== 0 && !/is not connected/i.test(r.err))
234
+ throw fail(r, `could not disconnect it from ${x}`);
235
+ }
236
+ return { connected: connect, disconnected: disconnect };
143
237
  }
144
238
  export async function stopDocker(id, c) {
145
239
  if (c.mode === 'compose') {
@@ -171,6 +265,8 @@ export async function restartDocker(id, c) {
171
265
  return startDocker(id, c);
172
266
  }
173
267
  } // changed settings: created again
268
+ if (c.mode === 'container')
269
+ await reconcileNetworks(name, c);
174
270
  const r = await dk(['restart', ...stopArgs(c), name], { timeout: 300_000 });
175
271
  if (r.code !== 0)
176
272
  throw fail(r, 'could not restart it');
@@ -179,7 +275,7 @@ export async function restartDocker(id, c) {
179
275
  export async function composeServiceAction(c, service, action) {
180
276
  if (c.mode !== 'compose')
181
277
  throw new ObjectError('Only a compose project has services');
182
- if (!/^[\w][\w.-]{0,80}$/.test(service))
278
+ if (!SERVICE.test(service))
183
279
  throw new ObjectError('That is not a service name');
184
280
  checkCompose(c);
185
281
  const rest = action === 'start' ? ['up', '-d', service]
@@ -206,25 +302,104 @@ export async function recreateDocker(id, c, build = false) {
206
302
  await dk(['rm', '-f', nameOf(id, c)], { timeout: 120_000 });
207
303
  await startDocker(id, c);
208
304
  }
305
+ /** The id of the container of one service of a compose project, or '' when it has none. */
306
+ async function serviceContainer(c, service) {
307
+ const r = await dk(composeArgs(c, ['ps', '-a', '-q', service]), { cwd: dirname(c.file) });
308
+ return { cid: r.code === 0 ? r.out.trim().split('\n')[0] : '', r };
309
+ }
209
310
  /** The id of the container behind an object (or behind one service of a compose project), or an error when there is none. */
210
311
  export async function containerOf(id, c, service) {
211
312
  if (c.mode !== 'compose')
212
313
  return nameOf(id, c);
213
- if (!service || !/^[\w][\w.-]{0,80}$/.test(service))
314
+ if (!service || !SERVICE.test(service))
214
315
  throw new ObjectError('Say which service');
215
316
  checkCompose(c);
216
- const r = await dk(composeArgs(c, ['ps', '-a', '-q', service]), { cwd: dirname(c.file) });
217
- const cid = r.out.trim().split('\n')[0];
218
- if (r.code !== 0 || !cid)
317
+ const { cid } = await serviceContainer(c, service);
318
+ if (!cid)
219
319
  throw new ObjectError(`The service ${service} has no container: start it first`);
220
320
  return cid;
221
321
  }
322
+ const asEnv = (e) => Object.entries(e).map(([name, value]) => ({ name, value: value ?? '' }));
323
+ /** One service of `docker compose config --format json` in the same terms as a container hive-am creates. */
324
+ function composeConfigured(service, s) {
325
+ const lim = s.deploy?.resources?.limits ?? {};
326
+ const mem = s.mem_limit ?? lim.memory;
327
+ return {
328
+ name: service, image: s.image ? String(s.image) : undefined,
329
+ ports: (s.ports ?? []).map((p) => `${p.host_ip ? `${p.host_ip}:` : ''}${p.published ? `${p.published}:` : ''}${p.target}${p.protocol && p.protocol !== 'tcp' ? `/${p.protocol}` : ''}`),
330
+ volumes: (s.volumes ?? []).map((v) => `${v.source ? `${v.source}:` : ''}${v.target}${v.read_only ? ':ro' : ''}`),
331
+ networks: Object.keys(s.networks ?? {}),
332
+ env: asEnv(Array.isArray(s.environment) ? Object.fromEntries(s.environment.map((e) => { const i = e.indexOf('='); return i < 0 ? [e, ''] : [e.slice(0, i), e.slice(i + 1)]; })) : s.environment ?? {}),
333
+ restart: s.restart ? String(s.restart) : undefined,
334
+ command: Array.isArray(s.command) ? s.command.join(' ') : s.command ? String(s.command) : undefined,
335
+ memory: mem === undefined || mem === null ? undefined : /^\d+$/.test(String(mem)) ? `${Math.round(Number(mem) / 1048576)}m` : String(mem),
336
+ cpus: (s.cpus ?? lim.cpus) ? Number(s.cpus ?? lim.cpus) : undefined,
337
+ };
338
+ }
339
+ async function configuredOf(id, c, service) {
340
+ if (c.mode === 'existing')
341
+ return { name: c.container, ports: [], volumes: [], networks: [], env: [] };
342
+ if (c.mode === 'container') {
343
+ return {
344
+ name: containerName(id), image: c.image, ports: c.ports ?? [], volumes: c.volumes ?? [], networks: [...(c.network ? [c.network] : []), ...(c.extraNetworks ?? [])], env: asEnv(c.env ?? {}),
345
+ restart: c.restart, command: c.command, memory: c.memory, cpus: c.cpus, logMaxMb: c.logMaxMb, stopTimeoutSec: c.stopTimeoutSec,
346
+ user: c.user, workdir: c.workdir, entrypoint: c.entrypoint, hostname: c.hostname, labels: c.labels,
347
+ };
348
+ }
349
+ const only = { name: service, ports: [], volumes: [], networks: [], env: [] };
350
+ try {
351
+ const r = await dk(composeArgs(c, ['config', '--format', 'json']), { cwd: dirname(c.file), timeout: 20_000 });
352
+ if (r.code !== 0)
353
+ return { ...only, error: fail(r, 'compose config failed').message };
354
+ const s = JSON.parse(r.out).services?.[service];
355
+ return s ? composeConfigured(service, s) : { ...only, error: `The compose file has no service ${service}` };
356
+ }
357
+ catch (e) {
358
+ return { ...only, error: e instanceof Error ? e.message : 'compose config failed' };
359
+ }
360
+ }
222
361
  export async function inspectDocker(id, c, service) {
223
- const ref = await containerOf(id, c, service);
224
- const r = await dk(['inspect', ref]);
225
- if (r.code !== 0)
226
- throw new ObjectError(`There is no container ${ref}`);
227
- const j = JSON.parse(r.out)[0];
362
+ if (c.mode === 'compose') {
363
+ if (!service || !SERVICE.test(service))
364
+ throw new ObjectError('Say which service');
365
+ checkCompose(c);
366
+ }
367
+ const configured = await configuredOf(id, c, service);
368
+ try {
369
+ let ref = nameOf(id, c);
370
+ if (c.mode === 'compose') {
371
+ const { cid, r } = await serviceContainer(c, service);
372
+ if (r.code !== 0) {
373
+ if (NO_DAEMON.test(r.err))
374
+ return { exists: false, reason: 'no-docker', error: r.err.trim().split('\n').pop()?.slice(0, 200), configured };
375
+ throw fail(r, 'compose ps failed');
376
+ }
377
+ if (!cid)
378
+ return { exists: false, reason: 'no-service', configured };
379
+ ref = cid;
380
+ }
381
+ const r = await dk(['inspect', '--type', 'container', ref]);
382
+ if (r.code !== 0) {
383
+ if (NO_DAEMON.test(r.err))
384
+ return { exists: false, reason: 'no-docker', error: r.err.trim().split('\n').pop()?.slice(0, 200), configured };
385
+ return { exists: false, reason: c.mode === 'existing' ? 'not-found' : c.mode === 'compose' ? 'no-service' : 'not-created', configured };
386
+ }
387
+ const d = inspected(JSON.parse(r.out)[0]);
388
+ const made = d.labels['hive-am.cfg'];
389
+ return { exists: true, cfgStale: c.mode === 'container' && !!made && made !== cfgHash(c), configured, ...d };
390
+ }
391
+ catch (e) {
392
+ // `dk` only throws when Docker is missing or does not answer in time: one notice, not an error.
393
+ if (e instanceof ObjectError && /not installed|took too long/.test(e.message))
394
+ return { exists: false, reason: 'no-docker', error: e.message, configured };
395
+ throw e;
396
+ }
397
+ }
398
+ /** The whole `docker inspect` of the container behind an object (or one compose service), with the secret-looking variables hidden unless `reveal`. */
399
+ export async function inspectRaw(id, c, service, reveal) {
400
+ return inspectMachine('container', await containerOf(id, c, service), reveal);
401
+ }
402
+ function inspected(j) {
228
403
  const cfg = j.Config ?? {}, st = j.State ?? {}, net = j.NetworkSettings ?? {};
229
404
  return {
230
405
  id: String(j.Id ?? '').slice(0, 12), name: String(j.Name ?? '').replace(/^\//, ''), image: String(cfg.Image ?? ''), status: String(st.Status ?? ''), created: String(j.Created ?? ''), started: String(st.StartedAt ?? ''),
@@ -238,6 +413,23 @@ export async function inspectDocker(id, c, service) {
238
413
  labels: (cfg.Labels ?? {}),
239
414
  };
240
415
  }
416
+ /**
417
+ * After the compose file is saved: `docker compose config -q` says whether Docker can read it (`null` when Docker is not there to ask), and
418
+ * whether services are running, which keep the definition they were created with until they are recreated. Neither changes anything.
419
+ */
420
+ export async function composeCheck(c) {
421
+ try {
422
+ const r = await dk(composeArgs(c, ['config', '-q']), { cwd: dirname(c.file), timeout: 20_000 });
423
+ return r.code === 0 ? { ok: true } : { ok: false, error: (r.err || r.out).trim().split('\n').slice(-3).join('\n').slice(0, 600) || `docker exited with ${r.code}` };
424
+ }
425
+ catch {
426
+ return null;
427
+ }
428
+ }
429
+ export async function composeRunning(id, c) {
430
+ const s = await dockerState(id, c);
431
+ return s.status === 'running' || s.status === 'starting';
432
+ }
241
433
  /** A container hive-am created goes away with its object; a compose project is only stopped; an existing container is left alone. */
242
434
  export async function removeDocker(id, c) {
243
435
  if (c.mode === 'container')
@@ -245,6 +437,290 @@ export async function removeDocker(id, c) {
245
437
  else if (c.mode === 'compose' && existsSync(c.file))
246
438
  await dk(composeArgs(c, ['stop', ...(c.services ?? [])]), { cwd: dirname(c.file) }).catch(() => undefined);
247
439
  }
440
+ const ANONYMOUS = /^[0-9a-f]{64}$/;
441
+ /** An argument as a shell would need it, only to show a command (hive-am never runs a shell). */
442
+ export const quoteArg = (a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
443
+ const shown = (v) => {
444
+ if (v === undefined || v === null || v === '')
445
+ return '';
446
+ if (Array.isArray(v))
447
+ return v.join(', ');
448
+ if (typeof v === 'object')
449
+ return Object.entries(v).map(([k, x]) => maskVar(`${k}=${x}`)).join(', ');
450
+ return String(v);
451
+ };
452
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
453
+ /** The commands applying `c` runs for a new container, with the values of secret-looking variables hidden. */
454
+ function previewOf(id, c) {
455
+ const args = runArgs(id, c).map((a, i, all) => (all[i - 1] === '-e' ? maskVar(a) : a));
456
+ const line = (xs) => `docker ${xs.map(quoteArg).join(' ')}`;
457
+ if (!c.extraNetworks?.length)
458
+ return [line(args)];
459
+ const name = containerName(id);
460
+ return [line(['create', ...args.slice(2)]), ...c.extraNetworks.map((n) => line(['network', 'connect', n, name])), line(['start', name])];
461
+ }
462
+ export async function planChange(id, saved, next) {
463
+ if (saved.mode !== 'container' || next.mode !== 'container')
464
+ throw new ObjectError(saved.mode === 'existing' ? 'A container that already existed is never changed by hive-am: create a managed copy instead' : 'Only a container hive-am creates is changed this way');
465
+ const name = containerName(id);
466
+ const changes = RECREATES.filter((k) => !same(saved[k], next[k])).map((k) => ({ field: k, from: shown(saved[k]), to: shown(next[k]) }));
467
+ const later = !same(saved.stopTimeoutSec, next.stopTimeoutSec) ? [{ field: 'stopTimeoutSec', from: shown(saved.stopTimeoutSec), to: shown(next.stopTimeoutSec) }] : [];
468
+ const r = await dk(['inspect', '--type', 'container', name]);
469
+ const plan = { exists: false, running: false, recreate: false, stale: false, changes, connect: [], disconnect: [], later, anonymous: [], preview: previewOf(id, next) };
470
+ if (r.code !== 0) {
471
+ if (NO_DAEMON.test(r.err))
472
+ throw fail(r, 'Docker is not answering');
473
+ // Not created yet: everything applies when it is created, on the next start.
474
+ return { ...plan, connect: [], disconnect: [] };
475
+ }
476
+ const j = JSON.parse(r.out)[0] ?? {};
477
+ const made = String(j.Config?.Labels?.['hive-am.cfg'] ?? '');
478
+ plan.exists = true;
479
+ plan.running = !!j.State?.Running;
480
+ // A container created by a hive-am older than the label is left as it is, as on start.
481
+ plan.recreate = !!made && made !== cfgHash(next);
482
+ plan.stale = !!made && made !== cfgHash(saved);
483
+ if (plan.recreate) {
484
+ plan.anonymous = (j.Mounts ?? []).filter((m) => m.Type === 'volume' && ANONYMOUS.test(String(m.Name ?? ''))).map((m) => ({ name: String(m.Name), destination: String(m.Destination ?? '') }));
485
+ return plan;
486
+ }
487
+ const mode = String(j.HostConfig?.NetworkMode ?? 'default');
488
+ const n = { main: mode === 'default' ? 'bridge' : mode, now: Object.keys(j.NetworkSettings?.Networks ?? {}), kept: j.Config?.Labels?.['hive-am.networks'] === '1' };
489
+ const dropped = (saved.extraNetworks ?? []).filter((x) => !(next.extraNetworks ?? []).includes(x));
490
+ const { connect, disconnect } = networkChanges(n, next, dropped);
491
+ return { ...plan, connect, disconnect, preview: [] };
492
+ }
493
+ /**
494
+ * Makes Docker match the saved settings of a container hive-am created, keeping it running or stopped as it is: created again when the
495
+ * settings it was created with changed (only with `confirm`: what was written inside it is lost), otherwise its additional networks are
496
+ * connected and disconnected in place. `drop` are the networks just taken out of the settings. Not created yet: nothing to do.
497
+ */
498
+ export async function applyDocker(id, c, o = {}) {
499
+ if (c.mode !== 'container')
500
+ throw new ObjectError(c.mode === 'existing' ? 'A container that already existed is never changed by hive-am: create a managed copy instead' : 'Only a container hive-am creates is applied this way');
501
+ const name = containerName(id);
502
+ const st = await dk(['inspect', '-f', '{{.State.Running}}|{{index .Config.Labels "hive-am.cfg"}}', name]);
503
+ if (st.code !== 0) {
504
+ if (NO_DAEMON.test(st.err))
505
+ throw fail(st, 'Docker is not answering');
506
+ return { applied: 'nothing', connected: [], disconnected: [] };
507
+ }
508
+ const [running, made] = st.out.trim().split('|');
509
+ if (made && made !== cfgHash(c)) {
510
+ if (!o.confirm)
511
+ throw new ObjectError('Creating the container again loses what was written inside it: confirm it first');
512
+ if (running === 'true')
513
+ await dk(['stop', ...stopArgs(c), name], { timeout: 300_000 });
514
+ await dk(['rm', '-f', name], { timeout: 120_000 });
515
+ await createContainer(id, c, running === 'true');
516
+ return { applied: 'recreated', connected: c.extraNetworks ?? [], disconnected: [] };
517
+ }
518
+ const drop = (o.drop ?? []).filter((x) => typeof x === 'string');
519
+ const r = await reconcileNetworks(name, c, drop);
520
+ return { applied: 'networks', ...r };
521
+ }
522
+ /**
523
+ * The settings of a container hive-am would create to run like an existing one, read from its `docker inspect` (and its image's, so that
524
+ * what the image already sets, its variables, command, user or folder, is not copied as if chosen). The original is only read. What cannot
525
+ * be copied is listed in `skipped` (an anonymous volume, a random host port, an option hive-am does not support).
526
+ */
527
+ export async function managedCopy(c) {
528
+ if (c.mode !== 'existing')
529
+ throw new ObjectError('Only an existing container is copied this way');
530
+ const r = await dk(['inspect', '--type', 'container', c.container]);
531
+ if (r.code !== 0) {
532
+ if (NO_DAEMON.test(r.err))
533
+ throw fail(r, 'Docker is not answering');
534
+ throw new ObjectError(`Docker has no container ${c.container}`);
535
+ }
536
+ const j = JSON.parse(r.out)[0] ?? {};
537
+ const cfg = j.Config ?? {}, host = j.HostConfig ?? {};
538
+ const ir = cfg.Image ? await dk(['image', 'inspect', String(cfg.Image)]) : null;
539
+ const img = (ir && ir.code === 0 ? JSON.parse(ir.out)[0]?.Config : null) ?? {};
540
+ const skipped = [];
541
+ const ports = [];
542
+ for (const [cp, hs] of Object.entries((host.PortBindings ?? {}))) {
543
+ const [port, proto] = cp.split('/');
544
+ for (const h of hs ?? []) {
545
+ if (!h.HostPort) {
546
+ skipped.push(`port ${cp} (published on a random port)`);
547
+ continue;
548
+ }
549
+ const ip = h.HostIp && h.HostIp !== '0.0.0.0' && !h.HostIp.includes(':') ? `${h.HostIp}:` : '';
550
+ ports.push(`${ip}${h.HostPort}:${port}${proto && proto !== 'tcp' ? `/${proto}` : ''}`);
551
+ }
552
+ }
553
+ const volumes = [];
554
+ for (const m of (j.Mounts ?? [])) {
555
+ const ro = m.RW === false ? ':ro' : '';
556
+ if (m.Type === 'bind')
557
+ volumes.push(`${m.Source}:${m.Destination}${ro}`);
558
+ else if (m.Type === 'volume' && m.Name && !ANONYMOUS.test(String(m.Name)))
559
+ volumes.push(`${m.Name}:${m.Destination}${ro}`);
560
+ else
561
+ skipped.push(m.Type === 'volume' ? `the anonymous volume at ${m.Destination} (the copy gets a new, empty one)` : `the ${m.Type} mount at ${m.Destination}`);
562
+ }
563
+ const imageEnv = new Set((img.Env ?? []));
564
+ const env = Object.fromEntries((cfg.Env ?? []).filter((e) => !imageEnv.has(e)).map((e) => { const i = e.indexOf('='); return i < 0 ? [e, ''] : [e.slice(0, i), e.slice(i + 1)]; }));
565
+ const differs = (k) => !same(cfg[k], img[k]);
566
+ const entry = Array.isArray(cfg.Entrypoint) ? cfg.Entrypoint : [];
567
+ if (differs('Entrypoint') && entry.length > 1)
568
+ skipped.push(`the entrypoint ${entry.join(' ')} (hive-am takes one program; put the rest in the command)`);
569
+ const mode = String(host.NetworkMode ?? 'default');
570
+ if (mode.startsWith('container:'))
571
+ skipped.push(`the network of another container (${mode})`);
572
+ const network = mode === 'default' || mode === 'bridge' || mode.startsWith('container:') ? undefined : mode;
573
+ const imageLabels = (img.Labels ?? {});
574
+ const labels = Object.fromEntries(Object.entries((cfg.Labels ?? {})).filter(([k, v]) => imageLabels[k] !== v && !k.startsWith('hive-am.') && !k.startsWith('com.docker.')));
575
+ const restart = String(host.RestartPolicy?.Name ?? '');
576
+ const candidate = {
577
+ image: String(cfg.Image ?? ''), ports, volumes, env,
578
+ restart: ['always', 'unless-stopped', 'on-failure'].includes(restart) ? restart : undefined,
579
+ command: differs('Cmd') && Array.isArray(cfg.Cmd) ? cfg.Cmd.map(quoteArg).join(' ') : undefined,
580
+ entrypoint: differs('Entrypoint') && entry.length === 1 ? entry[0] : undefined,
581
+ memory: host.Memory > 0 ? `${Math.max(1, Math.round(host.Memory / 1048576))}m` : undefined,
582
+ cpus: host.NanoCpus > 0 ? host.NanoCpus / 1e9 : undefined,
583
+ network,
584
+ extraNetworks: network === 'host' || network === 'none' ? undefined : Object.keys(j.NetworkSettings?.Networks ?? {}).filter((x) => x !== (network ?? 'bridge')),
585
+ labels,
586
+ user: differs('User') ? cfg.User : undefined, workdir: differs('WorkingDir') ? cfg.WorkingDir : undefined,
587
+ hostname: cfg.Hostname && !String(j.Id ?? '').startsWith(String(cfg.Hostname)) ? cfg.Hostname : undefined,
588
+ };
589
+ // Each value goes through the same checks as the form; one that does not pass is left out and said.
590
+ const config = { mode: 'container', image: candidate.image };
591
+ parseDocker(config);
592
+ for (const [k, v] of Object.entries(candidate)) {
593
+ if (k === 'image' || v === undefined || v === '' || (Array.isArray(v) && !v.length) || (typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length))
594
+ continue;
595
+ const items = Array.isArray(v) ? v : [v];
596
+ if (Array.isArray(v)) {
597
+ const ok = items.filter((x) => { try {
598
+ parseDocker({ ...config, [k]: [x] });
599
+ return true;
600
+ }
601
+ catch (e) {
602
+ skipped.push(`${k} ${x}: ${e.message}`);
603
+ return false;
604
+ } });
605
+ if (ok.length)
606
+ config[k] = ok;
607
+ }
608
+ else {
609
+ try {
610
+ parseDocker({ ...config, [k]: v });
611
+ config[k] = v;
612
+ }
613
+ catch (e) {
614
+ skipped.push(`${k}: ${e.message}`);
615
+ }
616
+ }
617
+ }
618
+ return { name: `${String(j.Name ?? c.container).replace(/^\//, '')}-copy`.slice(0, 60), config: parseDocker(config), skipped };
619
+ }
620
+ const CREATED = 'hive-am.created';
621
+ const MADE_NAME = { network: /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/, volume: /^[A-Za-z0-9][A-Za-z0-9_.-]{1,63}$/ };
622
+ const BUILTIN_NETWORKS = ['bridge', 'host', 'none'];
623
+ function madeName(kind, raw) {
624
+ const name = typeof raw === 'string' ? raw.trim() : '';
625
+ if (!MADE_NAME[kind].test(name))
626
+ throw new ObjectError(`"${name}" is not a valid ${kind} name: letters, digits, _ . or -, starting with a letter or digit`);
627
+ if (kind === 'network' && BUILTIN_NETWORKS.includes(name))
628
+ throw new ObjectError(`${name} is one of Docker's own networks`);
629
+ // 64 hex digits is what Docker calls an anonymous volume, and what a network id looks like: never a name to create or remove.
630
+ if (/^[0-9a-f]{64}$/.test(name))
631
+ throw new ObjectError(kind === 'volume' ? 'That name looks like an anonymous volume' : 'That name looks like a network id');
632
+ return name;
633
+ }
634
+ /** The labels of a network or a volume, or null when Docker has none with exactly that name (a network is also found by an id prefix: not that). */
635
+ async function madeLabels(kind, name) {
636
+ const r = await dk([kind, 'inspect', '-f', '{{.Name}}|{{json .Labels}}', name], { timeout: 20_000 });
637
+ if (r.code !== 0) {
638
+ if (NO_DAEMON.test(r.err))
639
+ throw fail(r, 'Docker is not answering');
640
+ return null;
641
+ }
642
+ const at = r.out.indexOf('|');
643
+ if (r.out.slice(0, at).trim() !== name)
644
+ return null;
645
+ try {
646
+ return JSON.parse(r.out.slice(at + 1)) ?? {};
647
+ }
648
+ catch {
649
+ return {};
650
+ }
651
+ }
652
+ /** Creates a network or a volume with hive-am's label. One that already exists is left as it is (`created: false`). */
653
+ export async function createMade(kind, raw) {
654
+ const name = madeName(kind, raw);
655
+ if (await madeLabels(kind, name))
656
+ return { name, created: false };
657
+ const r = await dk([kind, 'create', '--label', `${CREATED}=1`, name], { timeout: 30_000 });
658
+ if (r.code !== 0)
659
+ throw fail(r, `Could not create the ${kind} ${name}`);
660
+ return { name, created: true };
661
+ }
662
+ /** Removes a network or a volume, only if hive-am created it (its label) and no container, running or stopped, uses it. */
663
+ export async function removeMade(kind, raw) {
664
+ const name = madeName(kind, raw);
665
+ const labels = await madeLabels(kind, name);
666
+ if (!labels)
667
+ throw new ObjectError(`Docker has no ${kind} ${name}`);
668
+ if (labels[CREATED] !== '1' || 'com.docker.volume.anonymous' in labels)
669
+ throw new ObjectError(`hive-am did not create the ${kind} ${name}, so it does not remove it`);
670
+ // Docker itself removes a network a stopped container is still attached to: ask which containers use it. Docker cannot check and remove
671
+ // in one step, so a container created and attached to it between `ps` and `rm` (and not started) would be detached; that window stays.
672
+ const users = await dk(['ps', '-a', '-q', '--filter', `${kind}=${name}`], { timeout: 20_000 });
673
+ if (users.code !== 0)
674
+ throw fail(users, `Could not check which containers use the ${kind} ${name}`);
675
+ const n = users.out.split('\n').filter((l) => l.trim()).length;
676
+ if (n)
677
+ throw new ObjectError(`The ${kind} ${name} is used by ${n} container${n === 1 ? '' : 's'}, so it is not removed`);
678
+ const r = await dk([kind, 'rm', name], { timeout: 30_000 });
679
+ if (r.code !== 0)
680
+ throw fail(r, `Could not remove the ${kind} ${name}`);
681
+ return { name, removed: true };
682
+ }
683
+ /** `Dockerfile`, `Containerfile`, `Dockerfile.<suffix>` or `<name>.Dockerfile` (any case): the only files built, read or written as a Dockerfile. */
684
+ export const DOCKERFILE_NAME = /^(?:(?:dockerfile|containerfile)(?:\.\w[\w.-]{0,80})?|\w[\w.-]{0,80}\.(?:dockerfile|containerfile))$/i;
685
+ /**
686
+ * An absolute path to an existing regular file named like a Dockerfile, as its real path: a link is followed and the real name must be one
687
+ * too. Anything else would let a build (whose error shows the last lines of the file) read any file of the machine.
688
+ */
689
+ export function dockerfilePath(raw) {
690
+ const file = typeof raw === 'string' ? raw.trim() : '';
691
+ if (!file || !isAbsolute(file))
692
+ throw new ObjectError('Choose the Dockerfile (an absolute path)');
693
+ if (!DOCKERFILE_NAME.test(basename(file)))
694
+ throw new ObjectError(`${basename(file)} is not named like a Dockerfile (Dockerfile, Containerfile, Dockerfile.<name> or <name>.Dockerfile)`);
695
+ if (!existsSync(file))
696
+ throw new ObjectError(`The Dockerfile ${file} does not exist`);
697
+ if (!statSync(file).isFile())
698
+ throw new ObjectError(`${file} is not a file`);
699
+ const real = realpathSync(file);
700
+ if (!DOCKERFILE_NAME.test(basename(real)))
701
+ throw new ObjectError(`${file} leads to ${basename(real)}, which is not named like a Dockerfile`);
702
+ return real;
703
+ }
704
+ /**
705
+ * `docker build -t tag -f dockerfile context`: the context is the Dockerfile's folder unless another is given. It runs the Dockerfile's
706
+ * `RUN` steps on this machine and may download base images, so it can take long (up to 15 minutes, like rebuilding a compose project). On
707
+ * failure the error carries the last lines of the output.
708
+ */
709
+ export async function buildImage(o) {
710
+ const file = dockerfilePath(o.dockerfile);
711
+ // The folder it was picked from, even when that name is a link to a Dockerfile elsewhere.
712
+ const context = typeof o.context === 'string' && o.context.trim() ? o.context.trim() : dirname(String(o.dockerfile).trim());
713
+ if (!isAbsolute(context) || !existsSync(context) || !statSync(context).isDirectory())
714
+ throw new ObjectError(`The build folder ${context} is not a folder of this machine`);
715
+ const tag = typeof o.tag === 'string' ? o.tag.trim() : '';
716
+ if (!IMAGE.test(tag))
717
+ throw new ObjectError('The image name is not valid (for example my-app:dev)');
718
+ const r = await dk(['build', '-t', tag, '-f', file, '--label', `${CREATED}=1`, context], { cwd: context, timeout: 900_000 });
719
+ const tail = (n) => `${r.out}\n${r.err}`.split('\n').filter((l) => l.trim()).slice(-n).join('\n');
720
+ if (r.code !== 0)
721
+ throw new ObjectError(`The build failed:\n${tail(12).slice(-2000)}`);
722
+ return { tag, output: tail(20) };
723
+ }
248
724
  const STAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/;
249
725
  /**
250
726
  * The latest output. `after` is the time (RFC 3339) of the last line already seen; without it the last `tail` lines come back.