hive-am 1.1.0 → 1.2.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 +83 -1
- package/README.md +9 -1
- package/package.json +13 -2
- package/server/dist/api.js +42 -762
- package/server/dist/connections/channel-skill.js +13 -1
- package/server/dist/connections/files.js +45 -1
- package/server/dist/connections/manager.js +41 -3
- package/server/dist/connections/outbound.js +2 -1
- package/server/dist/connections/prompt.js +4 -0
- package/server/dist/connections/router.js +3 -2
- package/server/dist/connections/store.js +4 -43
- package/server/dist/connections/telegram.js +43 -3
- package/server/dist/cron.js +2 -1
- package/server/dist/db/json.js +16 -0
- package/server/dist/db/migrations.js +199 -0
- package/server/dist/db.js +98 -196
- package/server/dist/errors.js +11 -0
- package/server/dist/git/browse.js +99 -0
- package/server/dist/git/ops.js +136 -19
- package/server/dist/git/repo.js +2 -1
- package/server/dist/history/claude.js +21 -21
- package/server/dist/history/index.js +17 -9
- package/server/dist/history/kiro.js +17 -12
- package/server/dist/history/lines.js +12 -0
- package/server/dist/history/opencode.js +18 -1
- package/server/dist/http/body.js +46 -0
- package/server/dist/http/errors.js +21 -0
- package/server/dist/http/guards.js +44 -0
- package/server/dist/http/router.js +24 -0
- package/server/dist/http/routes/agents.js +103 -0
- package/server/dist/http/routes/channel.js +16 -0
- package/server/dist/http/routes/colonies.js +9 -0
- package/server/dist/http/routes/connections.js +36 -0
- package/server/dist/http/routes/fs.js +22 -0
- package/server/dist/http/routes/git.js +90 -0
- package/server/dist/http/routes/meta.js +11 -0
- package/server/dist/http/routes/notebook.js +34 -0
- package/server/dist/http/routes/orchestration.js +32 -0
- package/server/dist/http/routes/resources.js +68 -0
- package/server/dist/http/routes/schedules.js +40 -0
- package/server/dist/http/routes/skills.js +47 -0
- package/server/dist/http/routes/terminals.js +18 -0
- package/server/dist/http/routes/types.js +36 -0
- package/server/dist/http/validate.js +65 -0
- package/server/dist/index.js +67 -5
- package/server/dist/instructions.js +8 -2
- package/server/dist/objects/agent.js +49 -0
- package/server/dist/objects/database.js +451 -0
- package/server/dist/objects/db-history.js +79 -0
- package/server/dist/objects/docker-info.js +75 -0
- package/server/dist/objects/docker.js +285 -0
- package/server/dist/objects/driver-props.js +196 -0
- package/server/dist/objects/exec.js +16 -0
- package/server/dist/objects/http-history.js +44 -0
- package/server/dist/objects/http-script.js +644 -0
- package/server/dist/objects/http.js +735 -0
- package/server/dist/objects/index.js +445 -0
- package/server/dist/objects/model.js +250 -0
- package/server/dist/objects/runner.js +211 -0
- package/server/dist/objects/stats.js +106 -0
- package/server/dist/objects/store.js +115 -0
- package/server/dist/objects/supervisor.js +111 -0
- package/server/dist/objects/templates.js +67 -0
- package/server/dist/providers/claude.js +3 -13
- package/server/dist/providers/index.js +7 -1
- package/server/dist/providers/kiro.js +2 -5
- package/server/dist/providers/opencode.js +2 -5
- package/server/dist/providers/preamble.js +2 -0
- package/server/dist/providers/spawn.js +31 -3
- package/server/dist/runtime.js +8 -6
- package/server/dist/schedules.js +2 -21
- package/server/dist/seed.js +15 -11
- package/server/dist/services/agents.js +90 -0
- package/server/dist/services/colonies.js +38 -0
- package/server/dist/services/connections.js +76 -0
- package/server/dist/services/notebook.js +22 -0
- package/server/dist/services/providers.js +27 -0
- package/server/dist/services/schedules.js +18 -0
- package/server/dist/sessions-summary.js +51 -0
- package/server/dist/skills/defaults.js +37 -0
- package/server/dist/skills/notebook.js +3 -8
- package/server/dist/terminals.js +177 -0
- package/server/dist/wake.js +13 -16
- package/server/dist/watch.js +2 -8
- package/server/mcp/dispatch.mjs +70 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +150 -98
- package/web/.next/app-path-routes-manifest.json +7 -5
- package/web/.next/build-manifest.json +14 -14
- package/web/.next/prerender-manifest.json +67 -43
- package/web/.next/react-loadable-manifest.json +26 -1
- package/web/.next/required-server-files.json +13 -1
- package/web/.next/routes-manifest.json +26 -0
- package/web/.next/server/app/_not-found/page.js +2 -2
- package/web/.next/server/app/_not-found/page.js.nft.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 +12 -11
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- 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 +14 -13
- package/web/.next/server/app/connections/page.js +2 -2
- package/web/.next/server/app/connections/page.js.nft.json +1 -1
- 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 +14 -13
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +14 -13
- package/web/.next/server/app/page.js +2 -2
- package/web/.next/server/app/page.js.nft.json +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +2 -2
- package/web/.next/server/app/relations/page.js.nft.json +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 +14 -13
- package/web/.next/server/app/resources/[id]/page.js +2 -0
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources/page.js +2 -0
- package/web/.next/server/app/resources/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources.html +1 -0
- package/web/.next/server/app/resources.meta +15 -0
- package/web/.next/server/app/resources.rsc +25 -0
- package/web/.next/server/app/schedules/page.js +2 -2
- package/web/.next/server/app/schedules/page.js.nft.json +1 -1
- 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 +14 -13
- 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 +14 -13
- package/web/.next/server/app/skills/page.js +2 -2
- package/web/.next/server/app/skills/page.js.nft.json +1 -1
- 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 +14 -13
- package/web/.next/server/app/types/page.js +2 -2
- package/web/.next/server/app/types/page.js.nft.json +1 -1
- 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 +14 -13
- package/web/.next/server/app-paths-manifest.json +7 -5
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/274.js +1 -0
- package/web/.next/server/chunks/306.js +1 -0
- package/web/.next/server/chunks/423.js +1 -0
- package/web/.next/server/chunks/505.js +6 -0
- package/web/.next/server/chunks/555.js +1 -0
- package/web/.next/server/chunks/612.js +1 -0
- package/web/.next/server/chunks/638.js +1 -0
- package/web/.next/server/chunks/692.js +18 -0
- package/web/.next/server/chunks/716.js +2 -0
- package/web/.next/server/chunks/790.js +11 -0
- package/web/.next/server/chunks/802.js +1 -0
- package/web/.next/server/chunks/890.js +1 -1
- package/web/.next/server/chunks/919.js +1 -0
- package/web/.next/server/chunks/950.js +6 -0
- 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/next-font-manifest.js +1 -1
- package/web/.next/server/next-font-manifest.json +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/chunks/0937d497-114a2a3de515565e.js +1 -0
- package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
- package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
- package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
- package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
- package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
- package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
- package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
- package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
- package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
- package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
- package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
- package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
- package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
- package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
- package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
- package/web/.next/static/chunks/705-a16e1fe09be1ac7f.js +1 -0
- package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
- package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
- package/web/.next/static/chunks/839-23c5508622fae942.js +1 -0
- package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
- package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
- package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
- package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-4af6c246c4ca7a97.js +1 -0
- package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
- package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
- package/web/.next/static/chunks/app/layout-87ebb70bd9cb8443.js +1 -0
- package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
- package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
- package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
- package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
- package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
- package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
- package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
- package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
- package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
- package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
- package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
- package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
- package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
- package/web/.next/static/css/945795da867f5250.css +1 -0
- package/web/.next/static/css/dd4e32c285f75776.css +1 -0
- package/web/.next/static/p4S3i9kvO359GW91DQiHu/_buildManifest.js +1 -0
- package/web/.next/trace +29 -23
- package/web/.next/types/app/resources/[id]/page.ts +84 -0
- package/web/.next/types/app/resources/page.ts +84 -0
- package/web/.next/types/routes.d.ts +6 -2
- package/web/.next/types/validator.ts +18 -0
- package/web/next.config.mjs +4 -0
- package/web/.next/server/chunks/247.js +0 -1
- package/web/.next/server/chunks/484.js +0 -11
- package/web/.next/server/chunks/851.js +0 -1
- package/web/.next/server/chunks/932.js +0 -2
- package/web/.next/static/_z2hBOHhwiR5ytdI_8alh/_buildManifest.js +0 -1
- package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
- package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
- package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
- package/web/.next/static/chunks/493-50d2adf9fd5ec680.js +0 -1
- package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
- package/web/.next/static/chunks/589-8bf536f331de4269.js +0 -1
- package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
- package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
- package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
- package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
- package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
- package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
- package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-a8b7a43d2c18483f.js +0 -1
- package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
- package/web/.next/static/chunks/app/connections/page-477f36fcd5b13d78.js +0 -1
- package/web/.next/static/chunks/app/layout-a2d38e3de5911b1b.js +0 -1
- package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
- package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
- package/web/.next/static/chunks/app/schedules/page-39befd15b3b20586.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
- package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
- package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
- package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
- package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
- package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
- package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
- package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
- package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
- package/web/.next/static/css/da22f0402b1c40ae.css +0 -1
- /package/web/.next/static/{_z2hBOHhwiR5ytdI_8alh → p4S3i9kvO359GW91DQiHu}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objects of a colony: things that are not agents but live next to them. A server is a command that hive-am keeps running; a Docker
|
|
3
|
+
* object is a container (or a compose project) that hive-am starts, stops and reads the logs of.
|
|
4
|
+
*/
|
|
5
|
+
import { parseProps } from './driver-props.js';
|
|
6
|
+
import { DomainError } from '../errors.js';
|
|
7
|
+
export class ObjectError extends DomainError {
|
|
8
|
+
}
|
|
9
|
+
export const DB_ENGINES = {
|
|
10
|
+
mysql: { label: 'MySQL / MariaDB', port: 3306 }, postgresql: { label: 'PostgreSQL', port: 5432 }, sqlite: { label: 'SQLite', port: 0 }, mssql: { label: 'SQL Server', port: 1433 },
|
|
11
|
+
};
|
|
12
|
+
export const NAME_MAX = 60;
|
|
13
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
14
|
+
const PORT_SPEC = /^((\d{1,3}\.){3}\d{1,3}:)?\d{1,5}:\d{1,5}(\/(tcp|udp))?$/;
|
|
15
|
+
const IMAGE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,199}$/;
|
|
16
|
+
const CONTAINER = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
17
|
+
const PROJECT = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
18
|
+
const str = (v, what, max = 2000) => {
|
|
19
|
+
if (typeof v !== 'string')
|
|
20
|
+
throw new ObjectError(`${what} must be text`);
|
|
21
|
+
if (v.includes('\0') || v.length > max)
|
|
22
|
+
throw new ObjectError(`${what} is not valid`);
|
|
23
|
+
return v.trim();
|
|
24
|
+
};
|
|
25
|
+
const optStr = (v, what, max) => (v === undefined || v === null || v === '' ? undefined : str(v, what, max));
|
|
26
|
+
const port = (v, what) => {
|
|
27
|
+
const n = Number(v);
|
|
28
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
29
|
+
throw new ObjectError(`${what} must be a port between 1 and 65535`);
|
|
30
|
+
return n;
|
|
31
|
+
};
|
|
32
|
+
const env = (v) => {
|
|
33
|
+
if (v === undefined || v === null)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (typeof v !== 'object' || Array.isArray(v))
|
|
36
|
+
throw new ObjectError('Environment variables must be NAME: value pairs');
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [k, val] of Object.entries(v)) {
|
|
39
|
+
if (!ENV_KEY.test(k))
|
|
40
|
+
throw new ObjectError(`"${k}" is not a valid variable name`);
|
|
41
|
+
out[k] = str(String(val ?? ''), `Value of ${k}`, 4000);
|
|
42
|
+
}
|
|
43
|
+
return Object.keys(out).length ? out : undefined;
|
|
44
|
+
};
|
|
45
|
+
const list = (v, what, each) => {
|
|
46
|
+
if (v === undefined || v === null)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!Array.isArray(v))
|
|
49
|
+
throw new ObjectError(`${what} must be a list`);
|
|
50
|
+
const out = v.map((x) => each(str(x, what))).filter(Boolean);
|
|
51
|
+
if (out.length > 50)
|
|
52
|
+
throw new ObjectError(`${what} has too many entries`);
|
|
53
|
+
return out.length ? out : undefined;
|
|
54
|
+
};
|
|
55
|
+
const oneOf = (v, list, what) => {
|
|
56
|
+
if (v === undefined || v === null || v === '')
|
|
57
|
+
return undefined;
|
|
58
|
+
if (!list.includes(v))
|
|
59
|
+
throw new ObjectError(`${what} is not valid`);
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
const num = (v, what, min, max) => {
|
|
63
|
+
if (v === undefined || v === null || v === '')
|
|
64
|
+
return undefined;
|
|
65
|
+
const n = Number(v);
|
|
66
|
+
if (!Number.isFinite(n) || n < min || n > max)
|
|
67
|
+
throw new ObjectError(`${what} must be between ${min} and ${max}`);
|
|
68
|
+
return n;
|
|
69
|
+
};
|
|
70
|
+
export function parseHealth(h) {
|
|
71
|
+
if (!h || !h.target)
|
|
72
|
+
return undefined;
|
|
73
|
+
const kind = oneOf(h.kind, ['http', 'tcp', 'command'], 'The kind of health check');
|
|
74
|
+
if (!kind)
|
|
75
|
+
throw new ObjectError('Choose the kind of health check');
|
|
76
|
+
const target = str(h.target, 'The health check target', 1000);
|
|
77
|
+
if (kind === 'http' && !/^https?:\/\//i.test(target))
|
|
78
|
+
throw new ObjectError('The health check URL must start with http:// or https://');
|
|
79
|
+
if (kind === 'tcp' && !/^((\d{1,3}\.){3}\d{1,3}:|localhost:)?\d{1,5}$/.test(target))
|
|
80
|
+
throw new ObjectError('The health check port must be a number or host:port');
|
|
81
|
+
return { kind, target, intervalSec: num(h.intervalSec, 'The interval', 2, 3600), timeoutSec: num(h.timeoutSec, 'The timeout', 1, 120), retries: num(h.retries, 'The retries', 1, 20), restartWhenUnhealthy: h.restartWhenUnhealthy ? true : undefined };
|
|
82
|
+
}
|
|
83
|
+
export function parseServer(c) {
|
|
84
|
+
const start = str(c?.start, 'The start command');
|
|
85
|
+
if (!start)
|
|
86
|
+
throw new ObjectError('Write the command that starts it');
|
|
87
|
+
const cwd = str(c?.cwd, 'The folder');
|
|
88
|
+
if (!cwd.startsWith('/'))
|
|
89
|
+
throw new ObjectError('The folder must be an absolute path');
|
|
90
|
+
return {
|
|
91
|
+
cwd, start, stop: optStr(c?.stop, 'The stop command'), env: env(c?.env), port: c?.port === undefined || c?.port === null || c?.port === '' ? undefined : port(c.port, 'The port'),
|
|
92
|
+
restart: oneOf(c?.restart, ['no', 'on-failure', 'always'], 'The restart policy'), maxRestarts: num(c?.maxRestarts, 'The restart limit', 1, 100),
|
|
93
|
+
health: parseHealth(c?.health), stopTimeoutSec: num(c?.stopTimeoutSec, 'The stop timeout', 1, 300), logMaxMb: num(c?.logMaxMb, 'The log size', 0.05, 500),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function parseDocker(c) {
|
|
97
|
+
const mode = c?.mode;
|
|
98
|
+
if (mode !== 'container' && mode !== 'compose' && mode !== 'existing')
|
|
99
|
+
throw new ObjectError('Choose how the container is managed: create it, compose, or an existing one');
|
|
100
|
+
if (mode === 'existing') {
|
|
101
|
+
const container = str(c?.container, 'The container');
|
|
102
|
+
if (!CONTAINER.test(container))
|
|
103
|
+
throw new ObjectError('The container name is not valid');
|
|
104
|
+
return { mode, container, stopTimeoutSec: num(c?.stopTimeoutSec, 'The stop timeout', 1, 300) };
|
|
105
|
+
}
|
|
106
|
+
if (mode === 'compose') {
|
|
107
|
+
const file = str(c?.file, 'The compose file');
|
|
108
|
+
if (!file.startsWith('/'))
|
|
109
|
+
throw new ObjectError('The compose file must be an absolute path');
|
|
110
|
+
const project = optStr(c?.project, 'The project name');
|
|
111
|
+
if (project && !PROJECT.test(project))
|
|
112
|
+
throw new ObjectError('The project name must be lowercase letters, digits, - or _');
|
|
113
|
+
return { mode, file, project, stopTimeoutSec: num(c?.stopTimeoutSec, 'The stop timeout', 1, 300), services: list(c?.services, 'Services', (s) => { if (!CONTAINER.test(s))
|
|
114
|
+
throw new ObjectError(`"${s}" is not a valid service name`); return s; }) };
|
|
115
|
+
}
|
|
116
|
+
const image = str(c?.image, 'The image');
|
|
117
|
+
if (!IMAGE.test(image))
|
|
118
|
+
throw new ObjectError('The image name is not valid');
|
|
119
|
+
const restart = optStr(c?.restart, 'The restart policy');
|
|
120
|
+
if (restart && !['no', 'always', 'unless-stopped', 'on-failure'].includes(restart))
|
|
121
|
+
throw new ObjectError('Unknown restart policy');
|
|
122
|
+
return {
|
|
123
|
+
mode, image, restart: restart, command: optStr(c?.command, 'The command'), env: env(c?.env),
|
|
124
|
+
memory: ((v) => { if (v && !/^\d+(\.\d+)?[bkmg]?$/i.test(v))
|
|
125
|
+
throw new ObjectError('The memory limit must look like 512m or 1g'); return v; })(optStr(c?.memory, 'The memory limit', 20)),
|
|
126
|
+
cpus: num(c?.cpus, 'The CPU limit', 0.01, 256), network: ((v) => { if (v && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(v))
|
|
127
|
+
throw new ObjectError('The network name is not valid'); return v; })(optStr(c?.network, 'The network', 64)),
|
|
128
|
+
stopTimeoutSec: num(c?.stopTimeoutSec, 'The stop timeout', 1, 300), logMaxMb: num(c?.logMaxMb, 'The log size', 0.05, 500),
|
|
129
|
+
ports: list(c?.ports, 'Ports', (s) => { if (!PORT_SPEC.test(s))
|
|
130
|
+
throw new ObjectError(`"${s}" is not a port mapping (use 8080:80)`); return s; }),
|
|
131
|
+
volumes: list(c?.volumes, 'Volumes', (s) => { if (s.startsWith('-') || !s.includes(':'))
|
|
132
|
+
throw new ObjectError(`"${s}" is not a volume (use /host/path:/in/container)`); return s; }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const ENV_NAME = /^[\w.$-]{1,60}$/, VAR_NAME = /^[A-Za-z_][\w.-]{0,80}$/;
|
|
136
|
+
export function parseHttp(c) {
|
|
137
|
+
const folder = str(c?.folder, 'The folder');
|
|
138
|
+
if (!folder.startsWith('/'))
|
|
139
|
+
throw new ObjectError('The folder must be an absolute path');
|
|
140
|
+
const envName = optStr(c?.env, 'The environment', 100);
|
|
141
|
+
let variables;
|
|
142
|
+
if (c?.variables && typeof c.variables === 'object') {
|
|
143
|
+
variables = {};
|
|
144
|
+
const envs = Object.entries(c.variables);
|
|
145
|
+
if (envs.length > 30)
|
|
146
|
+
throw new ObjectError('At most 30 environments');
|
|
147
|
+
for (const [name, vars] of envs) {
|
|
148
|
+
if (!ENV_NAME.test(name))
|
|
149
|
+
throw new ObjectError(`"${name}" is not a valid environment name`);
|
|
150
|
+
if (!vars || typeof vars !== 'object')
|
|
151
|
+
continue;
|
|
152
|
+
const out = {};
|
|
153
|
+
const entries = Object.entries(vars);
|
|
154
|
+
if (entries.length > 100)
|
|
155
|
+
throw new ObjectError(`At most 100 variables in ${name}`);
|
|
156
|
+
for (const [k, v] of entries) {
|
|
157
|
+
if (!VAR_NAME.test(k))
|
|
158
|
+
throw new ObjectError(`"${k}" is not a valid variable name`);
|
|
159
|
+
out[k] = { value: typeof v?.value === 'string' ? str(v.value, `The value of ${k}`, 4000) : '', ...(v?.secret ? { secret: true } : {}) };
|
|
160
|
+
}
|
|
161
|
+
variables[name] = out;
|
|
162
|
+
}
|
|
163
|
+
if (!Object.keys(variables).length)
|
|
164
|
+
variables = undefined;
|
|
165
|
+
}
|
|
166
|
+
return { folder, env: envName, variables };
|
|
167
|
+
}
|
|
168
|
+
const HOST = /^[A-Za-z0-9._\-]{1,255}$|^\[[0-9A-Fa-f:.]{2,45}\]$/;
|
|
169
|
+
export function parseDatabase(c) {
|
|
170
|
+
const engine = oneOf(c?.engine, ['mysql', 'postgresql', 'sqlite', 'mssql'], 'The engine');
|
|
171
|
+
if (!engine)
|
|
172
|
+
throw new ObjectError('Choose the engine: MySQL, PostgreSQL, SQLite or SQL Server');
|
|
173
|
+
const common = { readOnly: c?.readOnly === false ? false : true, timeoutSec: num(c?.timeoutSec, 'The query timeout', 1, 600), maxRows: num(c?.maxRows, 'The row limit', 1, 10000) };
|
|
174
|
+
if (engine === 'sqlite') {
|
|
175
|
+
const file = str(c?.file, 'The file');
|
|
176
|
+
if (!file.startsWith('/'))
|
|
177
|
+
throw new ObjectError('The file must be an absolute path');
|
|
178
|
+
return { engine, file, props: parseProps(engine, c?.props), ...common };
|
|
179
|
+
}
|
|
180
|
+
const host = optStr(c?.host, 'The host', 255) ?? '127.0.0.1';
|
|
181
|
+
if (!HOST.test(host))
|
|
182
|
+
throw new ObjectError('The host is not valid');
|
|
183
|
+
const database = optStr(c?.database, 'The database', 128);
|
|
184
|
+
if (database && /[\0\r\n;]/.test(database))
|
|
185
|
+
throw new ObjectError('The database name is not valid');
|
|
186
|
+
const props = parseProps(engine, c?.props);
|
|
187
|
+
if (props && !c?.ssl && Object.keys(props).some((k) => k.startsWith('ssl.')))
|
|
188
|
+
throw new ObjectError('The SSL properties need "Use SSL" on');
|
|
189
|
+
return {
|
|
190
|
+
engine, host, port: c?.port === undefined || c?.port === null || c?.port === '' ? undefined : port(c.port, 'The port'),
|
|
191
|
+
user: optStr(c?.user, 'The user', 200), password: typeof c?.password === 'string' && c.password !== '' ? str(c.password, 'The password', 1000) : undefined, database,
|
|
192
|
+
ssl: c?.ssl ? true : undefined, trustCert: c?.ssl && c?.trustCert ? true : undefined, props, ...common,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function parseCluster(c) {
|
|
196
|
+
if (!Array.isArray(c?.members))
|
|
197
|
+
throw new ObjectError('Choose the resources it groups');
|
|
198
|
+
const members = [...new Set(c.members.map((x) => str(x, 'A member')))];
|
|
199
|
+
if (members.length > 30)
|
|
200
|
+
throw new ObjectError('A cluster groups at most 30 resources');
|
|
201
|
+
return { members };
|
|
202
|
+
}
|
|
203
|
+
export function parseConfig(kind, c) {
|
|
204
|
+
return kind === 'server' ? parseServer(c) : kind === 'docker' ? parseDocker(c) : kind === 'http' ? parseHttp(c) : kind === 'database' ? parseDatabase(c) : parseCluster(c);
|
|
205
|
+
}
|
|
206
|
+
export function parseName(v) {
|
|
207
|
+
const name = str(v, 'The name', NAME_MAX);
|
|
208
|
+
if (!name)
|
|
209
|
+
throw new ObjectError('Give it a name');
|
|
210
|
+
return name;
|
|
211
|
+
}
|
|
212
|
+
export const isKind = (k) => k === 'server' || k === 'docker' || k === 'http' || k === 'database' || k === 'cluster';
|
|
213
|
+
/** `boss` was the first name of a cluster: the API still accepts it. */
|
|
214
|
+
export const kindOf = (k) => (k === 'boss' ? 'cluster' : k);
|
|
215
|
+
/** Splits a command line the way a shell would for quotes (no expansion): `sh -c "echo hi"` → ["sh", "-c", "echo hi"]. */
|
|
216
|
+
export function splitArgs(line) {
|
|
217
|
+
const out = [];
|
|
218
|
+
let cur = '', q = null, any = false;
|
|
219
|
+
for (let i = 0; i < line.length; i++) {
|
|
220
|
+
const ch = line[i];
|
|
221
|
+
if (q) {
|
|
222
|
+
if (ch === q)
|
|
223
|
+
q = null;
|
|
224
|
+
else if (ch === '\\' && q === '"' && i + 1 < line.length)
|
|
225
|
+
cur += line[++i];
|
|
226
|
+
else
|
|
227
|
+
cur += ch;
|
|
228
|
+
}
|
|
229
|
+
else if (ch === '"' || ch === "'") {
|
|
230
|
+
q = ch;
|
|
231
|
+
any = true;
|
|
232
|
+
}
|
|
233
|
+
else if (/\s/.test(ch)) {
|
|
234
|
+
if (cur || any) {
|
|
235
|
+
out.push(cur);
|
|
236
|
+
cur = '';
|
|
237
|
+
any = false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (ch === '\\' && i + 1 < line.length)
|
|
241
|
+
cur += line[++i];
|
|
242
|
+
else
|
|
243
|
+
cur += ch;
|
|
244
|
+
}
|
|
245
|
+
if (q)
|
|
246
|
+
throw new ObjectError('A quote is not closed in the command');
|
|
247
|
+
if (cur || any)
|
|
248
|
+
out.push(cur);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, truncateSync, unlinkSync, writeFileSync, readSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DATA_DIR } from '../db.js';
|
|
6
|
+
import { isAlive, processStamp } from '../watch.js';
|
|
7
|
+
import { ObjectError } from './model.js';
|
|
8
|
+
/**
|
|
9
|
+
* A server object is a shell command that hive-am keeps running. It starts in its own process group with its output going straight to a
|
|
10
|
+
* log file, so it survives hive-am restarts: afterwards the process is found again by the pid and start time saved next to the log.
|
|
11
|
+
* Stopping signals the whole group (the shell, `npm`, `node`…): first politely, then for good.
|
|
12
|
+
*/
|
|
13
|
+
const DIR = join(DATA_DIR, 'objects');
|
|
14
|
+
const LOG_MAX = 5 * 1024 * 1024; // a bigger log is set aside when the object starts (and while it runs: see rotateLog)
|
|
15
|
+
const logMaxBytes = (cfg) => Math.round((cfg.logMaxMb ?? 5) * 1024 * 1024) || LOG_MAX;
|
|
16
|
+
const STOP_GRACE_MS = Number(process.env.HIVE_AM_OBJECT_STOP_MS) || 8000;
|
|
17
|
+
const STARTING_MS = 120_000; // with a port: how long "starting" is believed before it counts as stopped working
|
|
18
|
+
const files = (id) => ({ log: join(DIR, `${id}.log`), state: join(DIR, `${id}.json`) });
|
|
19
|
+
export const readSaved = (id) => read(id);
|
|
20
|
+
const read = (id) => { try {
|
|
21
|
+
return JSON.parse(readFileSync(files(id).state, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
} };
|
|
26
|
+
const write = (id, s) => { mkdirSync(DIR, { recursive: true }); writeFileSync(files(id).state, JSON.stringify(s)); };
|
|
27
|
+
/** A line in the object's log, marked as coming from hive-am (who asked for what, how it ended). */
|
|
28
|
+
export const noteServer = (id, text) => note(id, text);
|
|
29
|
+
const note = (id, text) => { try {
|
|
30
|
+
mkdirSync(DIR, { recursive: true });
|
|
31
|
+
appendFileSync(files(id).log, `\n[hive-am] ${text}\n`);
|
|
32
|
+
}
|
|
33
|
+
catch { /* the log is a convenience */ } };
|
|
34
|
+
const listening = (port) => new Promise((resolve) => {
|
|
35
|
+
const s = connect({ port, host: '127.0.0.1' }, () => { s.destroy(); resolve(true); });
|
|
36
|
+
s.setTimeout(800, () => { s.destroy(); resolve(false); });
|
|
37
|
+
s.on('error', () => resolve(false));
|
|
38
|
+
});
|
|
39
|
+
/** The saved pid, only while it is still the same process we started. */
|
|
40
|
+
function livePid(id) {
|
|
41
|
+
const saved = read(id);
|
|
42
|
+
if (!saved)
|
|
43
|
+
return null;
|
|
44
|
+
return { saved, alive: isAlive(saved.pid, saved.stamp) };
|
|
45
|
+
}
|
|
46
|
+
export async function serverState(id, cfg) {
|
|
47
|
+
const cur = livePid(id);
|
|
48
|
+
if (!cur)
|
|
49
|
+
return { status: 'stopped' };
|
|
50
|
+
if (cur.alive) {
|
|
51
|
+
const since = cur.saved.startedAt;
|
|
52
|
+
if (cfg.port && !(await listening(cfg.port)))
|
|
53
|
+
return Date.now() - since < STARTING_MS ? { status: 'starting', pid: cur.saved.pid, since, detail: `waiting for port ${cfg.port}` } : { status: 'error', pid: cur.saved.pid, since, detail: `running, but nothing listens on port ${cfg.port}` };
|
|
54
|
+
return { status: 'running', pid: cur.saved.pid, since };
|
|
55
|
+
}
|
|
56
|
+
const x = cur.saved.exit;
|
|
57
|
+
if (cur.saved.stopped || !x || x.code === 0)
|
|
58
|
+
return { status: 'stopped', since: x?.at };
|
|
59
|
+
return { status: 'error', since: x.at, detail: x.signal ? `ended by ${x.signal}` : `exited with code ${x.code}` };
|
|
60
|
+
}
|
|
61
|
+
export async function startServer(id, cfg) {
|
|
62
|
+
const cur = livePid(id);
|
|
63
|
+
if (cur?.alive)
|
|
64
|
+
throw new ObjectError('It is already running');
|
|
65
|
+
if (!existsSync(cfg.cwd))
|
|
66
|
+
throw new ObjectError(`The folder ${cfg.cwd} does not exist`);
|
|
67
|
+
mkdirSync(DIR, { recursive: true });
|
|
68
|
+
const { log } = files(id);
|
|
69
|
+
try {
|
|
70
|
+
if (statSync(log).size > logMaxBytes(cfg))
|
|
71
|
+
renameSync(log, `${log}.1`);
|
|
72
|
+
}
|
|
73
|
+
catch { /* no log yet */ }
|
|
74
|
+
note(id, `▶ ${cfg.start} (${new Date().toLocaleString()})`);
|
|
75
|
+
const fd = openSync(log, 'a');
|
|
76
|
+
try {
|
|
77
|
+
const child = spawn('sh', ['-c', cfg.start], { cwd: cfg.cwd, env: { ...process.env, ...cfg.env }, detached: true, stdio: ['ignore', fd, fd] });
|
|
78
|
+
if (!child.pid)
|
|
79
|
+
throw new ObjectError('The command could not be started');
|
|
80
|
+
const pid = child.pid, startedAt = Date.now();
|
|
81
|
+
write(id, { pid, stamp: processStamp(pid), startedAt });
|
|
82
|
+
child.on('error', (e) => { note(id, `could not start: ${e.message}`); write(id, { pid, stamp: null, startedAt, exit: { code: 127, signal: null, at: Date.now() } }); });
|
|
83
|
+
child.on('exit', (code, signal) => {
|
|
84
|
+
const saved = read(id);
|
|
85
|
+
if (!saved || saved.pid !== pid)
|
|
86
|
+
return;
|
|
87
|
+
write(id, { ...saved, exit: { code, signal, at: Date.now() } });
|
|
88
|
+
note(id, signal ? `■ ended by ${signal}` : `■ exited with code ${code}`);
|
|
89
|
+
});
|
|
90
|
+
child.unref();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
97
|
+
const killGroup = (pid, sig) => { try {
|
|
98
|
+
process.kill(-pid, sig);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, sig);
|
|
103
|
+
}
|
|
104
|
+
catch { /* already gone */ }
|
|
105
|
+
} };
|
|
106
|
+
export async function stopServer(id, cfg) {
|
|
107
|
+
const cur = livePid(id);
|
|
108
|
+
if (!cur)
|
|
109
|
+
return;
|
|
110
|
+
write(id, { ...cur.saved, stopped: true });
|
|
111
|
+
if (cfg.stop) {
|
|
112
|
+
note(id, `■ ${cfg.stop}`);
|
|
113
|
+
const fd = openSync(files(id).log, 'a');
|
|
114
|
+
try {
|
|
115
|
+
await new Promise((resolve) => {
|
|
116
|
+
const c = spawn('sh', ['-c', cfg.stop], { cwd: cfg.cwd, env: { ...process.env, ...cfg.env }, stdio: ['ignore', fd, fd] });
|
|
117
|
+
const t = setTimeout(() => { try {
|
|
118
|
+
c.kill('SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
catch { /* done */ } resolve(); }, 30_000);
|
|
121
|
+
c.on('exit', () => { clearTimeout(t); resolve(); });
|
|
122
|
+
c.on('error', () => { clearTimeout(t); resolve(); });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!isAlive(cur.saved.pid, cur.saved.stamp))
|
|
130
|
+
return;
|
|
131
|
+
killGroup(cur.saved.pid, 'SIGTERM');
|
|
132
|
+
const grace = cfg.stopTimeoutSec ? cfg.stopTimeoutSec * 1000 : STOP_GRACE_MS;
|
|
133
|
+
for (let waited = 0; waited < grace && isAlive(cur.saved.pid, cur.saved.stamp); waited += 150)
|
|
134
|
+
await sleep(150);
|
|
135
|
+
if (isAlive(cur.saved.pid, cur.saved.stamp)) {
|
|
136
|
+
note(id, 'did not stop in time: killing it');
|
|
137
|
+
killGroup(cur.saved.pid, 'SIGKILL');
|
|
138
|
+
for (let i = 0; i < 20 && isAlive(cur.saved.pid, cur.saved.stamp); i++)
|
|
139
|
+
await sleep(100);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Lines the object printed. `after` is a byte offset from an earlier call; without it the last `tail` lines come back. */
|
|
143
|
+
export function serverLogs(id, o) {
|
|
144
|
+
const { log } = files(id);
|
|
145
|
+
let size = 0;
|
|
146
|
+
try {
|
|
147
|
+
size = statSync(log).size;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return { text: '', cursor: '0' };
|
|
151
|
+
}
|
|
152
|
+
const MAX = 256 * 1024;
|
|
153
|
+
let from, reset = false;
|
|
154
|
+
if (o.after !== undefined && Number.isFinite(o.after)) {
|
|
155
|
+
if (o.after > size) {
|
|
156
|
+
from = Math.max(0, size - MAX);
|
|
157
|
+
reset = true;
|
|
158
|
+
} // the file was cut or replaced
|
|
159
|
+
else
|
|
160
|
+
from = Math.max(o.after, size - MAX);
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
from = Math.max(0, size - Math.max(1, Math.min(o.tail ?? 300, 5000)) * 200);
|
|
164
|
+
const len = size - from;
|
|
165
|
+
if (len <= 0)
|
|
166
|
+
return { text: '', cursor: String(size) };
|
|
167
|
+
const buf = Buffer.alloc(len);
|
|
168
|
+
const fd = openSync(log, 'r');
|
|
169
|
+
try {
|
|
170
|
+
readSync(fd, buf, 0, len, from);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
let text = buf.toString('utf8');
|
|
176
|
+
if (o.after === undefined) {
|
|
177
|
+
const lines = text.split('\n');
|
|
178
|
+
if (from > 0)
|
|
179
|
+
lines.shift(); // the first line may be cut in half
|
|
180
|
+
text = lines.slice(-Math.max(1, Math.min(o.tail ?? 300, 5000))).join('\n');
|
|
181
|
+
}
|
|
182
|
+
return { text, cursor: String(size), reset: reset || undefined };
|
|
183
|
+
}
|
|
184
|
+
/** Removes the log and the saved state of a deleted object. */
|
|
185
|
+
export function forgetServer(id) {
|
|
186
|
+
const { log, state } = files(id);
|
|
187
|
+
for (const f of [log, `${log}.1`, state]) {
|
|
188
|
+
try {
|
|
189
|
+
unlinkSync(f);
|
|
190
|
+
}
|
|
191
|
+
catch { /* none */ }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Keeps the log from growing for ever while the server runs. The process writes with O_APPEND to its own file, so the old part is copied
|
|
196
|
+
* aside and the file is emptied in place (a few lines printed in between can be lost; it is a log, not a ledger).
|
|
197
|
+
*/
|
|
198
|
+
export function rotateLog(id, cfg) {
|
|
199
|
+
const { log } = files(id);
|
|
200
|
+
try {
|
|
201
|
+
if (statSync(log).size <= logMaxBytes(cfg))
|
|
202
|
+
return false;
|
|
203
|
+
copyFileSync(log, `${log}.1`);
|
|
204
|
+
truncateSync(log, 0);
|
|
205
|
+
note(id, `log rotated: the earlier part is in ${id}.log.1`);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { containerName } from './docker.js';
|
|
4
|
+
import { execCapture } from './exec.js';
|
|
5
|
+
import { ObjectError } from './model.js';
|
|
6
|
+
import { readSaved } from './runner.js';
|
|
7
|
+
const run = async (file, args, o = {}) => {
|
|
8
|
+
const r = await execCapture(file, args, { cwd: o.cwd, timeout: o.timeout });
|
|
9
|
+
return { out: r.out, code: r.code };
|
|
10
|
+
};
|
|
11
|
+
const UNITS = { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3, tib: 1024 ** 4 };
|
|
12
|
+
/** "12.5MiB" → bytes. */
|
|
13
|
+
const bytes = (s) => { const m = /^([\d.]+)\s*([a-z]+)$/i.exec(s.trim()); return m && UNITS[m[2].toLowerCase()] ? Math.round(Number(m[1]) * UNITS[m[2].toLowerCase()]) : null; };
|
|
14
|
+
/** The processes of a server: its whole process group (the shell, npm, node…). */
|
|
15
|
+
async function serverStats(o) {
|
|
16
|
+
const saved = readSaved(o.id);
|
|
17
|
+
const empty = { cpu: null, memBytes: null, memLimitBytes: null, pids: null, info: {} };
|
|
18
|
+
if (!saved)
|
|
19
|
+
return empty;
|
|
20
|
+
const { out, code } = await run('ps', ['-axo', 'pgid=,pcpu=,rss=']);
|
|
21
|
+
if (code !== 0)
|
|
22
|
+
return empty;
|
|
23
|
+
let cpu = 0, rss = 0, n = 0;
|
|
24
|
+
for (const line of out.split('\n')) {
|
|
25
|
+
const [g, c, r] = line.trim().split(/\s+/);
|
|
26
|
+
if (Number(g) === saved.pid) {
|
|
27
|
+
cpu += Number(c) || 0;
|
|
28
|
+
rss += Number(r) || 0;
|
|
29
|
+
n++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return n ? { cpu: Math.round(cpu * 10) / 10, memBytes: rss * 1024, memLimitBytes: null, pids: n, info: { pid: saved.pid, started: saved.startedAt } } : { ...empty, info: { pid: saved.pid, started: saved.startedAt } };
|
|
33
|
+
}
|
|
34
|
+
async function containerStats(name) {
|
|
35
|
+
const [st, ins] = await Promise.all([
|
|
36
|
+
run('docker', ['stats', '--no-stream', '--format', '{{json .}}', name]),
|
|
37
|
+
run('docker', ['inspect', '-f', '{{.Config.Image}}|{{.State.Status}}|{{.RestartCount}}|{{.Created}}|{{.State.StartedAt}}|{{.HostConfig.RestartPolicy.Name}}|{{.HostConfig.NetworkMode}}|{{.HostConfig.Memory}}|{{.HostConfig.NanoCpus}}', name]),
|
|
38
|
+
]);
|
|
39
|
+
const info = {};
|
|
40
|
+
if (ins.code === 0) {
|
|
41
|
+
const [image, status, restarts, created, started, policy, network, mem, nano] = ins.out.trim().split('|');
|
|
42
|
+
Object.assign(info, { image, status, restarts: Number(restarts), created: Date.parse(created) || null, started: Date.parse(started) || null, restartPolicy: policy || 'no', network, memoryLimit: Number(mem) || 0, cpuLimit: Number(nano) ? Number(nano) / 1e9 : 0 });
|
|
43
|
+
}
|
|
44
|
+
const ports = await run('docker', ['port', name]);
|
|
45
|
+
if (ports.code === 0)
|
|
46
|
+
info.ports = ports.out.trim().split('\n').filter(Boolean).join(', ');
|
|
47
|
+
let j = null;
|
|
48
|
+
try {
|
|
49
|
+
j = JSON.parse(st.out.trim().split('\n')[0] ?? '');
|
|
50
|
+
}
|
|
51
|
+
catch { /* not running */ }
|
|
52
|
+
if (!j)
|
|
53
|
+
return { cpu: null, memBytes: null, memLimitBytes: null, pids: null, info };
|
|
54
|
+
const [used, limit] = String(j.MemUsage ?? '').split('/');
|
|
55
|
+
return { cpu: Number.parseFloat(j.CPUPerc) || 0, memBytes: bytes(used ?? ''), memLimitBytes: bytes(limit ?? ''), pids: Number(j.PIDs) || null, net: j.NetIO ?? null, block: j.BlockIO ?? null, info };
|
|
56
|
+
}
|
|
57
|
+
async function composeStats(c) {
|
|
58
|
+
if (!existsSync(c.file))
|
|
59
|
+
throw new ObjectError('The compose file is missing');
|
|
60
|
+
const args = ['compose', '-f', c.file, ...(c.project ? ['-p', c.project] : [])];
|
|
61
|
+
const ps = await run('docker', [...args, 'ps', '--all', '--format', 'json', ...(c.services ?? [])], { cwd: dirname(c.file) });
|
|
62
|
+
const t = ps.out.trim();
|
|
63
|
+
const items = t.startsWith('[') ? (() => { try {
|
|
64
|
+
return JSON.parse(t);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
} })() : t.split('\n').flatMap((l) => { try {
|
|
69
|
+
return [JSON.parse(l)];
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
} });
|
|
74
|
+
// "16379->6379" once, even when Docker lists the same port for IPv4 and IPv6
|
|
75
|
+
const portsOf = (x) => [...new Set((x.Publishers ?? []).filter((p) => p.PublishedPort).map((p) => `${p.PublishedPort}->${p.TargetPort}`))].join(', ') || String(x.Ports ?? '');
|
|
76
|
+
const services = items.map((x) => ({ name: String(x.Service ?? x.Name ?? ''), container: String(x.Name ?? ''), image: String(x.Image ?? ''), state: String(x.State ?? ''), health: String(x.Health ?? ''), status: String(x.Status ?? ''), ports: portsOf(x), cpu: null, memBytes: null }));
|
|
77
|
+
const ids = items.filter((x) => x.State === 'running').map((x) => String(x.ID ?? x.Name));
|
|
78
|
+
let cpu = 0, mem = 0, pids = 0;
|
|
79
|
+
if (ids.length) {
|
|
80
|
+
const st = await run('docker', ['stats', '--no-stream', '--format', '{{json .}}', ...ids]);
|
|
81
|
+
for (const l of st.out.trim().split('\n')) {
|
|
82
|
+
try {
|
|
83
|
+
const j = JSON.parse(l);
|
|
84
|
+
const c1 = Number.parseFloat(j.CPUPerc) || 0, m1 = bytes(String(j.MemUsage).split('/')[0] ?? '') ?? 0;
|
|
85
|
+
cpu += c1;
|
|
86
|
+
mem += m1;
|
|
87
|
+
pids += Number(j.PIDs) || 0;
|
|
88
|
+
const sv = services.find((x) => x.container === j.Name || (j.ID && String(items.find((i) => i.Name === x.container)?.ID ?? '').startsWith(String(j.ID))));
|
|
89
|
+
if (sv) {
|
|
90
|
+
sv.cpu = Math.round(c1 * 10) / 10;
|
|
91
|
+
sv.memBytes = m1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch { /* a line that is not json */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { cpu: ids.length ? Math.round(cpu * 10) / 10 : null, memBytes: ids.length ? mem : null, memLimitBytes: null, pids: ids.length ? pids : null, info: { services: services.length, running: ids.length }, services };
|
|
98
|
+
}
|
|
99
|
+
export async function objectStats(o) {
|
|
100
|
+
if (o.kind === 'server')
|
|
101
|
+
return serverStats(o);
|
|
102
|
+
if (o.kind !== 'docker')
|
|
103
|
+
throw new ObjectError('This kind of resource has no usage figures');
|
|
104
|
+
const c = o.config;
|
|
105
|
+
return c.mode === 'compose' ? composeStats(c) : containerStats(c.mode === 'existing' ? c.container : containerName(o.id));
|
|
106
|
+
}
|