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.
- package/CHANGELOG.md +27 -1
- package/package.json +1 -1
- package/server/dist/http/routes/fs.js +3 -2
- package/server/dist/http/routes/resources.js +32 -5
- package/server/dist/objects/docker-info.js +118 -11
- package/server/dist/objects/docker.js +492 -16
- package/server/dist/objects/files.js +152 -0
- package/server/dist/objects/index.js +61 -24
- package/server/dist/objects/model.js +72 -4
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +147 -149
- package/web/.next/app-path-routes-manifest.json +9 -9
- package/web/.next/build-manifest.json +13 -13
- package/web/.next/prerender-manifest.json +40 -40
- package/web/.next/react-loadable-manifest.json +4 -4
- package/web/.next/required-server-files.json +1 -1
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +3 -3
- package/web/.next/server/app/agents/[id]/page.js +3 -3
- package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +2 -2
- package/web/.next/server/app/agents/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +5 -5
- package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/chat.html +1 -1
- package/web/.next/server/app/chat.rsc +4 -4
- package/web/.next/server/app/colony/page.js +2 -2
- package/web/.next/server/app/colony/page.js.nft.json +1 -1
- package/web/.next/server/app/colony/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/colony.html +1 -1
- package/web/.next/server/app/colony.rsc +4 -4
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +4 -4
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +4 -4
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +4 -4
- package/web/.next/server/app/resources/[id]/page.js +1 -1
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources/page.js +2 -2
- package/web/.next/server/app/resources/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources.html +1 -1
- package/web/.next/server/app/resources.rsc +4 -4
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/schedules.html +1 -1
- package/web/.next/server/app/schedules.rsc +4 -4
- package/web/.next/server/app/sessions/page.js +2 -2
- package/web/.next/server/app/sessions/page.js.nft.json +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +4 -4
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +4 -4
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +4 -4
- package/web/.next/server/app-paths-manifest.json +9 -9
- package/web/.next/server/chunks/12.js +1 -0
- package/web/.next/server/chunks/295.js +1 -0
- package/web/.next/server/chunks/374.js +6 -0
- package/web/.next/server/chunks/687.js +1 -0
- package/web/.next/server/chunks/770.js +2 -0
- package/web/.next/server/chunks/{944.js → 867.js} +1 -1
- package/web/.next/server/chunks/953.js +1 -1
- package/web/.next/server/middleware-build-manifest.js +1 -1
- package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/{HzL5-xIKbr0VsaWuLWHNl → GT9TTAUuwG8duMIDd7bTf}/_buildManifest.js +1 -1
- package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
- package/web/.next/static/chunks/199-c000d298c94aac98.js +1 -0
- package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
- package/web/.next/static/chunks/218-91cee960142e2880.js +1 -0
- package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
- package/web/.next/static/chunks/{969-07e498da015b149a.js → 350-bf7c44f226170b15.js} +1 -1
- package/web/.next/static/chunks/517-870d39e307db4dbb.js +1 -0
- package/web/.next/static/chunks/523-665ef4ba8ec58171.js +1 -0
- package/web/.next/static/chunks/545-f3d12b73a5a530a3.js +1 -0
- package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
- package/web/.next/static/chunks/654-92a63b3c4b412666.js +1 -0
- package/web/.next/static/chunks/{2657.8d96393b971ae134.js → 657.b4c39e676c3e20dc.js} +1 -1
- package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
- package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
- package/web/.next/static/chunks/740-72f941744e530c78.js +1 -0
- package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
- package/web/.next/static/chunks/790-91732fc93acda224.js +1 -0
- package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
- package/web/.next/static/chunks/870-18f6bab953828102.js +1 -0
- package/web/.next/static/chunks/{545.bfbd92b7632c09de.js → 926.03a65d3574bc521c.js} +1 -1
- package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
- package/web/.next/static/chunks/982-147e6a55046c90ba.js +1 -0
- package/web/.next/static/chunks/app/_not-found/page-948f39e30bfc6144.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-b9ed0dd106af45f7.js +1 -0
- package/web/.next/static/chunks/app/agents/layout-89bb7e860326cf4b.js +1 -0
- package/web/.next/static/chunks/app/agents/page-0f809513514f3c4d.js +1 -0
- package/web/.next/static/chunks/app/chat/page-5ec04b8997e2c0f7.js +1 -0
- package/web/.next/static/chunks/app/colony/page-2d41fa9508904912.js +1 -0
- package/web/.next/static/chunks/app/connections/page-301e45029c8a7b52.js +1 -0
- package/web/.next/static/chunks/app/layout-114e3639e77db079.js +1 -0
- package/web/.next/static/chunks/app/page-5549d1e344c5f5bb.js +1 -0
- package/web/.next/static/chunks/app/relations/page-18e76ccb008fc7f5.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/page-caf4c61561524382.js +1 -0
- package/web/.next/static/chunks/app/resources/page-d27d342404176148.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-448f7f201195bc00.js +1 -0
- package/web/.next/static/chunks/app/sessions/page-8b8ee7e01a3a8def.js +1 -0
- package/web/.next/static/chunks/app/skills/page-638e3f7d7bbf657c.js +1 -0
- package/web/.next/static/chunks/app/types/page-ee51477946f8029f.js +1 -0
- package/web/.next/static/chunks/{fcafe1ac.47e831cc63938bfb.js → fcafe1ac.366e4950d3b0d4b7.js} +1 -1
- package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
- package/web/.next/static/chunks/main-app-61597cc1ea26ecc5.js +1 -0
- package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
- package/web/.next/static/chunks/pages/{_app-8aeadc281b5934a7.js → _app-d1670a2c6ce392fc.js} +1 -1
- package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
- package/web/.next/static/chunks/webpack-0ff2be4f351faaaf.js +1 -0
- package/web/.next/static/css/{f9099a8765eed6f0.css → c484c502b2d13072.css} +1 -1
- package/web/.next/trace +31 -31
- package/server/dist/objects/templates.js +0 -67
- package/web/.next/server/chunks/345.js +0 -2
- package/web/.next/server/chunks/368.js +0 -1
- package/web/.next/server/chunks/403.js +0 -1
- package/web/.next/server/chunks/505.js +0 -6
- package/web/.next/server/chunks/561.js +0 -1
- package/web/.next/server/chunks/616.js +0 -1
- package/web/.next/server/chunks/802.js +0 -1
- package/web/.next/static/chunks/0937d497-daa99a23a4431317.js +0 -1
- package/web/.next/static/chunks/1517-9b82b61ee9921bb1.js +0 -1
- package/web/.next/static/chunks/1606-6d25d725156f5127.js +0 -1
- package/web/.next/static/chunks/1771-4688ad65e21bb512.js +0 -1
- package/web/.next/static/chunks/2547-8b6bdae3c622a158.js +0 -1
- package/web/.next/static/chunks/2654-4aeef09c0d6bf56a.js +0 -1
- package/web/.next/static/chunks/2b9ff8ee-0851c7e556e3a39b.js +0 -1
- package/web/.next/static/chunks/3214-c9d0c933db5b0f44.js +0 -1
- package/web/.next/static/chunks/3757-45ea801718ec19e4.js +0 -1
- package/web/.next/static/chunks/3790-3d3f11a264e8ede8.js +0 -1
- package/web/.next/static/chunks/4030-bc2e0aa4da0d9ad8.js +0 -1
- package/web/.next/static/chunks/4162-c2361cb017547c37.js +0 -1
- package/web/.next/static/chunks/4491-886c18aee1e9c737.js +0 -1
- package/web/.next/static/chunks/4740-0581e5cb9334010b.js +0 -1
- package/web/.next/static/chunks/4837-8dc05d75f258f9e1.js +0 -1
- package/web/.next/static/chunks/5587-cdccc5cf4056b739.js +0 -1
- package/web/.next/static/chunks/5642-09dd4161ddddaf94.js +0 -1
- package/web/.next/static/chunks/7097.60e7ac5abf76b2cb.js +0 -1
- package/web/.next/static/chunks/7706-bd0d2bda0e0c8d1e.js +0 -1
- package/web/.next/static/chunks/8523-82e1ee6239699d8a.js +0 -1
- package/web/.next/static/chunks/8701.0db2914c123a180e.js +0 -6
- package/web/.next/static/chunks/app/_not-found/page-d7975fab88bfe2c3.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-eeb93069b69e7f7e.js +0 -1
- package/web/.next/static/chunks/app/agents/layout-db700c86124f32de.js +0 -1
- package/web/.next/static/chunks/app/agents/page-bf40d654c5fe5c23.js +0 -1
- package/web/.next/static/chunks/app/chat/page-157685bdaad86c26.js +0 -1
- package/web/.next/static/chunks/app/colony/page-600b663a716d08c4.js +0 -1
- package/web/.next/static/chunks/app/connections/page-cbfcb1547d45c833.js +0 -1
- package/web/.next/static/chunks/app/layout-b327408bcfb13a70.js +0 -1
- package/web/.next/static/chunks/app/page-78c38105a034b0b3.js +0 -1
- package/web/.next/static/chunks/app/relations/page-622a4c5723c7c5cc.js +0 -1
- package/web/.next/static/chunks/app/resources/[id]/page-b9af9eecf693b695.js +0 -1
- package/web/.next/static/chunks/app/resources/page-a2e17f0dc205a2a4.js +0 -1
- package/web/.next/static/chunks/app/schedules/page-d534462088c64b59.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-6fef5b73bfbf262e.js +0 -1
- package/web/.next/static/chunks/app/skills/page-ab25a3a6d348a049.js +0 -1
- package/web/.next/static/chunks/app/types/page-7e08ca1bcbee605f.js +0 -1
- package/web/.next/static/chunks/framework-ab1d276eb4175e96.js +0 -1
- package/web/.next/static/chunks/main-18f19eeb8b4d3fe4.js +0 -1
- package/web/.next/static/chunks/main-app-27cc7ffdb888b879.js +0 -1
- package/web/.next/static/chunks/pages/_error-6bffadfc60f180a1.js +0 -1
- package/web/.next/static/chunks/webpack-ba68c1813987a630.js +0 -1
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 ||
|
|
314
|
+
if (!service || !SERVICE.test(service))
|
|
214
315
|
throw new ObjectError('Say which service');
|
|
215
316
|
checkCompose(c);
|
|
216
|
-
const
|
|
217
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|