@wpelevator/tug 0.2.1 → 0.3.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/README.md +38 -4
- package/dist/agent.d.ts +63 -2
- package/dist/agent.js +804 -75
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +387 -85
- package/dist/compose.d.ts +46 -7
- package/dist/compose.js +173 -37
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1038 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/ingress.d.ts +10 -2
- package/dist/ingress.js +57 -16
- package/dist/live.d.ts +80 -0
- package/dist/live.js +340 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/rollout.js +85 -19
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
package/dist/db.d.ts
CHANGED
package/dist/db.js
CHANGED
|
@@ -34,7 +34,7 @@ async function migrate(db) {
|
|
|
34
34
|
await db.exec(`
|
|
35
35
|
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
36
36
|
CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
|
|
37
|
-
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
|
|
38
38
|
CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
|
|
39
39
|
CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
|
|
40
40
|
CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
|
|
@@ -51,6 +51,12 @@ CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT
|
|
|
51
51
|
CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
|
|
52
52
|
CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
|
|
53
53
|
`);
|
|
54
|
+
try {
|
|
55
|
+
await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* column already exists */
|
|
59
|
+
}
|
|
54
60
|
const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
|
|
55
61
|
if (!row)
|
|
56
62
|
await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
|
package/dist/ingress.d.ts
CHANGED
|
@@ -23,13 +23,21 @@ export declare function resolveServiceEndpoints(states: DesiredState[], nodes: N
|
|
|
23
23
|
export interface IngressInput {
|
|
24
24
|
states: DesiredState[];
|
|
25
25
|
endpoints: ServiceEndpoints;
|
|
26
|
-
/** The ingress node being configured
|
|
26
|
+
/** The ingress node being configured. */
|
|
27
27
|
node: string;
|
|
28
|
+
/** Every node name, and the ingress nodes; they decide which services on `node` listen on loopback only. */
|
|
29
|
+
nodes?: string[];
|
|
30
|
+
ingressNodes?: string[];
|
|
28
31
|
/** Decrypted project secrets for custom TLS, keyed `<project>/<secret name>`. */
|
|
29
32
|
secrets: Map<string, string>;
|
|
30
33
|
acmeEmail?: string;
|
|
31
34
|
}
|
|
32
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Upstreams on the ingress node itself are dialed on `127.0.0.1` when the service's ingress port
|
|
37
|
+
* listens on loopback only (see `loopbackIngressServices`), otherwise on the address its published
|
|
38
|
+
* ports are bound to, which is the endpoint address, like every other node.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildIngressWork({ states, endpoints, node, nodes, ingressNodes, secrets, acmeEmail, }: IngressInput): IngressWork;
|
|
33
41
|
export declare function renderCaddyfile(routes: IngressRoute[], opts?: {
|
|
34
42
|
acmeEmail?: string;
|
|
35
43
|
}): string;
|
package/dist/ingress.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { publishedPort } from "./compose.js";
|
|
7
|
+
import { loopbackIngressServices, publishedPort } from "./compose.js";
|
|
8
8
|
import { ensureDir, errMessage } from "./util.js";
|
|
9
9
|
export const CADDY_CONTAINER = 'tug-caddy';
|
|
10
10
|
export const DEFAULT_CADDY_IMAGE = 'caddy:2-alpine';
|
|
@@ -15,8 +15,8 @@ const CADDY_ETC = '/etc/caddy';
|
|
|
15
15
|
* rollout state drops, which is what makes DNS and ingress health-aware.
|
|
16
16
|
*/
|
|
17
17
|
export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
18
|
-
const byName = new Map(nodes.map(n => [n.name, n]));
|
|
19
|
-
const reported = new Map(statuses.map(s => [`${s.node}\0${s.project}`, s.status]));
|
|
18
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
19
|
+
const reported = new Map(statuses.map((s) => [`${s.node}\0${s.project}`, s.status]));
|
|
20
20
|
const out = {};
|
|
21
21
|
for (const state of states) {
|
|
22
22
|
for (const [service, spec] of Object.entries(state.services)) {
|
|
@@ -30,7 +30,11 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
|
30
30
|
if (serviceState?.rolloutState !== 'serving')
|
|
31
31
|
continue;
|
|
32
32
|
// Rolling services publish each revision on its own host port; the report names the serving one.
|
|
33
|
-
endpoints.push({
|
|
33
|
+
endpoints.push({
|
|
34
|
+
node: nodeName,
|
|
35
|
+
address,
|
|
36
|
+
...(serviceState.port ? { port: serviceState.port } : {}),
|
|
37
|
+
});
|
|
34
38
|
}
|
|
35
39
|
if (endpoints.length)
|
|
36
40
|
(out[state.project] ||= {})[service] = endpoints.sort((a, b) => a.node.localeCompare(b.node));
|
|
@@ -39,21 +43,33 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
|
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
41
45
|
const certFile = (host, ext) => `certs/${host.replace(/\*/g, '_wildcard_')}.${ext}`;
|
|
42
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Upstreams on the ingress node itself are dialed on `127.0.0.1` when the service's ingress port
|
|
48
|
+
* listens on loopback only (see `loopbackIngressServices`), otherwise on the address its published
|
|
49
|
+
* ports are bound to, which is the endpoint address, like every other node.
|
|
50
|
+
*/
|
|
51
|
+
export function buildIngressWork({ states, endpoints, node, nodes = [node], ingressNodes = [node], secrets, acmeEmail, }) {
|
|
43
52
|
const routes = [];
|
|
44
53
|
const files = {};
|
|
45
54
|
const warnings = [];
|
|
46
55
|
const seenHosts = new Map();
|
|
47
56
|
const tagged = [];
|
|
48
57
|
for (const state of states) {
|
|
58
|
+
const loopback = new Set(loopbackIngressServices(state, node, ingressNodes, nodes));
|
|
49
59
|
for (const [service, svc] of Object.entries(state.compose.services || {})) {
|
|
50
60
|
const ingress = svc['x-tug']?.ingress;
|
|
51
61
|
if (ingress)
|
|
52
|
-
tagged.push({
|
|
62
|
+
tagged.push({
|
|
63
|
+
project: state.project,
|
|
64
|
+
service,
|
|
65
|
+
ingress,
|
|
66
|
+
port: publishedPort(svc, ingress.port),
|
|
67
|
+
loopback: loopback.has(service),
|
|
68
|
+
});
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
tagged.sort((a, b) => `${a.project}/${a.service}`.localeCompare(`${b.project}/${b.service}`));
|
|
56
|
-
for (const { project, service, ingress, port } of tagged) {
|
|
72
|
+
for (const { project, service, ingress, port, loopback } of tagged) {
|
|
57
73
|
const label = `${project}/${service}`;
|
|
58
74
|
const host = ingress.host.toLowerCase();
|
|
59
75
|
const tls = ingress.tls || 'auto';
|
|
@@ -65,7 +81,7 @@ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }
|
|
|
65
81
|
warnings.push(`${label}: ingress.port ${ingress.port} is not published; skipped`);
|
|
66
82
|
continue;
|
|
67
83
|
}
|
|
68
|
-
const upstreams = (endpoints[project]?.[service] || []).map(e => `${e.node === node ? '127.0.0.1' : e.address}:${e.port ?? port}`);
|
|
84
|
+
const upstreams = (endpoints[project]?.[service] || []).map((e) => `${e.node === node && loopback ? '127.0.0.1' : upstreamHost(e.address)}:${e.port ?? port}`);
|
|
69
85
|
if (!upstreams.length) {
|
|
70
86
|
warnings.push(`${label}: no serving upstream for ${host}; route withheld`);
|
|
71
87
|
continue;
|
|
@@ -85,6 +101,8 @@ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }
|
|
|
85
101
|
}
|
|
86
102
|
return { caddyfile: renderCaddyfile(routes, { acmeEmail }), files, routes, warnings };
|
|
87
103
|
}
|
|
104
|
+
/** IPv6 upstream addresses need brackets before the port. */
|
|
105
|
+
const upstreamHost = (address) => address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
|
|
88
106
|
export function renderCaddyfile(routes, opts = {}) {
|
|
89
107
|
const out = [];
|
|
90
108
|
if (opts.acmeEmail)
|
|
@@ -127,18 +145,30 @@ export async function reconcileIngress(ctx, work) {
|
|
|
127
145
|
const applied = path.join(root, 'applied');
|
|
128
146
|
const configHash = ingressConfigHash(work);
|
|
129
147
|
const routes = work.routes.length;
|
|
130
|
-
const running = await ctx
|
|
131
|
-
.
|
|
132
|
-
|
|
148
|
+
const running = await ctx
|
|
149
|
+
.docker(['container', 'inspect', '-f', '{{.State.Running}}', CADDY_CONTAINER])
|
|
150
|
+
.then((r) => r.out.trim() === 'true', () => null);
|
|
151
|
+
const appliedHash = await fs.readFile(path.join(root, 'applied.hash'), 'utf8').then((s) => s.trim(), () => '');
|
|
133
152
|
if (running && appliedHash === configHash)
|
|
134
153
|
return { state: 'serving', configHash, routes };
|
|
135
154
|
await writeStage(stage, work);
|
|
136
155
|
try {
|
|
137
156
|
if (running === null) {
|
|
138
157
|
await ctx.docker([
|
|
139
|
-
'create',
|
|
140
|
-
'
|
|
141
|
-
|
|
158
|
+
'create',
|
|
159
|
+
'--name',
|
|
160
|
+
CADDY_CONTAINER,
|
|
161
|
+
'--restart',
|
|
162
|
+
'unless-stopped',
|
|
163
|
+
'--network',
|
|
164
|
+
'host',
|
|
165
|
+
'-v',
|
|
166
|
+
'tug-caddy-data:/data',
|
|
167
|
+
'-v',
|
|
168
|
+
'tug-caddy-config:/config',
|
|
169
|
+
'-v',
|
|
170
|
+
`tug-caddy-etc:${CADDY_ETC}`,
|
|
171
|
+
ctx.caddyImage || DEFAULT_CADDY_IMAGE,
|
|
142
172
|
]);
|
|
143
173
|
await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
|
|
144
174
|
await ctx.docker(['start', CADDY_CONTAINER]);
|
|
@@ -151,11 +181,22 @@ export async function reconcileIngress(ctx, work) {
|
|
|
151
181
|
try {
|
|
152
182
|
await ctx.docker(['exec', CADDY_CONTAINER, 'rm', '-rf', `${CADDY_ETC}/certs`]);
|
|
153
183
|
await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
|
|
154
|
-
await ctx.docker([
|
|
184
|
+
await ctx.docker([
|
|
185
|
+
'exec',
|
|
186
|
+
CADDY_CONTAINER,
|
|
187
|
+
'caddy',
|
|
188
|
+
'reload',
|
|
189
|
+
'--config',
|
|
190
|
+
`${CADDY_ETC}/Caddyfile`,
|
|
191
|
+
'--adapter',
|
|
192
|
+
'caddyfile',
|
|
193
|
+
]);
|
|
155
194
|
}
|
|
156
195
|
catch (e) {
|
|
157
196
|
if (await fs.stat(applied).then(() => true, () => false))
|
|
158
|
-
await ctx
|
|
197
|
+
await ctx
|
|
198
|
+
.docker(['cp', `${applied}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`])
|
|
199
|
+
.catch(() => { });
|
|
159
200
|
throw e;
|
|
160
201
|
}
|
|
161
202
|
}
|
package/dist/live.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
export interface LiveProcess extends EventEmitter {
|
|
4
|
+
stdin: Writable | null;
|
|
5
|
+
stdout: Readable | null;
|
|
6
|
+
stderr: Readable | null;
|
|
7
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
8
|
+
/** PTY adapters implement this in terminal columns and rows. */
|
|
9
|
+
resize?(columns: number, rows: number): void;
|
|
10
|
+
}
|
|
11
|
+
export type LiveSpawn = (file: string, args: string[]) => LiveProcess;
|
|
12
|
+
export type LiveRequest = {
|
|
13
|
+
operation: 'exec';
|
|
14
|
+
sessionId: string;
|
|
15
|
+
container: string;
|
|
16
|
+
command: string[];
|
|
17
|
+
interactive?: boolean;
|
|
18
|
+
tty?: boolean;
|
|
19
|
+
columns?: number;
|
|
20
|
+
rows?: number;
|
|
21
|
+
} | {
|
|
22
|
+
operation: 'logs';
|
|
23
|
+
sessionId: string;
|
|
24
|
+
container: string;
|
|
25
|
+
tail?: number;
|
|
26
|
+
timestamps?: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
operation: 'ps';
|
|
29
|
+
sessionId: string;
|
|
30
|
+
projects: string[];
|
|
31
|
+
} | {
|
|
32
|
+
operation: 'inspect';
|
|
33
|
+
sessionId: string;
|
|
34
|
+
container: string;
|
|
35
|
+
};
|
|
36
|
+
export type LiveEvent = {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
type: 'output';
|
|
39
|
+
stream: 'stdout' | 'stderr';
|
|
40
|
+
data: string;
|
|
41
|
+
encoding: 'base64';
|
|
42
|
+
} | {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
type: 'result';
|
|
45
|
+
result: Record<string, unknown>[];
|
|
46
|
+
} | {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
type: 'exit';
|
|
49
|
+
code: number | null;
|
|
50
|
+
signal: string | null;
|
|
51
|
+
cancelled: boolean;
|
|
52
|
+
error?: string;
|
|
53
|
+
};
|
|
54
|
+
export interface LiveEngineOptions {
|
|
55
|
+
emit: (event: LiveEvent) => void;
|
|
56
|
+
spawn?: LiveSpawn;
|
|
57
|
+
/** A real PTY adapter is required for interactive Docker exec. */
|
|
58
|
+
spawnTty?: LiveSpawn;
|
|
59
|
+
maxSessions?: number;
|
|
60
|
+
maxResultBytes?: number;
|
|
61
|
+
maxSessionMs?: number;
|
|
62
|
+
queryTimeoutMs?: number;
|
|
63
|
+
}
|
|
64
|
+
/** Owns Docker process lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
65
|
+
export declare class LiveOperationEngine {
|
|
66
|
+
private readonly options;
|
|
67
|
+
private readonly sessions;
|
|
68
|
+
constructor(options: LiveEngineOptions);
|
|
69
|
+
get activeSessions(): number;
|
|
70
|
+
start(value: unknown): void;
|
|
71
|
+
/** Returns the writable backpressure signal; callers must pause transport input on false. */
|
|
72
|
+
stdin(sessionId: string, data: Uint8Array): boolean;
|
|
73
|
+
endStdin(sessionId: string): void;
|
|
74
|
+
resize(sessionId: string, columns: number, rows: number): void;
|
|
75
|
+
cancel(sessionId: string): void;
|
|
76
|
+
dispose(): void;
|
|
77
|
+
private requireExec;
|
|
78
|
+
private output;
|
|
79
|
+
private finish;
|
|
80
|
+
}
|
package/dist/live.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { LIVE_MAX_FRAME_BYTES } from "./protocol.js";
|
|
5
|
+
const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/;
|
|
6
|
+
function object(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function dimension(value) {
|
|
10
|
+
return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 1000;
|
|
11
|
+
}
|
|
12
|
+
function validate(value) {
|
|
13
|
+
if (!object(value) || typeof value.sessionId !== 'string' || !identifier.test(value.sessionId))
|
|
14
|
+
throw new Error('Invalid live session ID');
|
|
15
|
+
if (!['exec', 'logs', 'ps', 'inspect'].includes(String(value.operation)))
|
|
16
|
+
throw new Error('Invalid live operation');
|
|
17
|
+
if (value.operation !== 'ps' &&
|
|
18
|
+
(typeof value.container !== 'string' || !identifier.test(value.container)))
|
|
19
|
+
throw new Error('Invalid container');
|
|
20
|
+
if (value.operation === 'exec') {
|
|
21
|
+
if (!Array.isArray(value.command) ||
|
|
22
|
+
value.command.length < 1 ||
|
|
23
|
+
value.command.length > 256 ||
|
|
24
|
+
!value.command.every((arg) => typeof arg === 'string' && !arg.includes('\0') && Buffer.byteLength(arg) <= 32768) ||
|
|
25
|
+
value.command[0].length === 0)
|
|
26
|
+
throw new Error('Invalid exec command');
|
|
27
|
+
if (value.interactive !== undefined && typeof value.interactive !== 'boolean')
|
|
28
|
+
throw new Error('Invalid interactive flag');
|
|
29
|
+
if (value.tty !== undefined && typeof value.tty !== 'boolean')
|
|
30
|
+
throw new Error('Invalid TTY flag');
|
|
31
|
+
if ((value.columns !== undefined && !dimension(value.columns)) ||
|
|
32
|
+
(value.rows !== undefined && !dimension(value.rows)))
|
|
33
|
+
throw new Error('Invalid terminal dimensions');
|
|
34
|
+
}
|
|
35
|
+
if (value.operation === 'logs') {
|
|
36
|
+
if (value.tail !== undefined &&
|
|
37
|
+
(!Number.isInteger(value.tail) || Number(value.tail) < 0 || Number(value.tail) > 10000))
|
|
38
|
+
throw new Error('Invalid log tail');
|
|
39
|
+
if (value.timestamps !== undefined && typeof value.timestamps !== 'boolean')
|
|
40
|
+
throw new Error('Invalid timestamps flag');
|
|
41
|
+
}
|
|
42
|
+
if (value.operation === 'ps' &&
|
|
43
|
+
(!Array.isArray(value.projects) ||
|
|
44
|
+
value.projects.length < 1 ||
|
|
45
|
+
value.projects.length > 256 ||
|
|
46
|
+
!value.projects.every((project) => typeof project === 'string' && identifier.test(project))))
|
|
47
|
+
throw new Error('Invalid projects');
|
|
48
|
+
}
|
|
49
|
+
function argv(request) {
|
|
50
|
+
switch (request.operation) {
|
|
51
|
+
case 'exec':
|
|
52
|
+
return [
|
|
53
|
+
'exec',
|
|
54
|
+
...(request.interactive === false ? [] : ['-i']),
|
|
55
|
+
...(request.tty ? ['-t'] : []),
|
|
56
|
+
request.container,
|
|
57
|
+
...request.command,
|
|
58
|
+
];
|
|
59
|
+
case 'logs':
|
|
60
|
+
return [
|
|
61
|
+
'logs',
|
|
62
|
+
'--follow',
|
|
63
|
+
'--tail',
|
|
64
|
+
String(request.tail ?? 100),
|
|
65
|
+
...(request.timestamps ? ['--timestamps'] : []),
|
|
66
|
+
request.container,
|
|
67
|
+
];
|
|
68
|
+
case 'ps':
|
|
69
|
+
return [
|
|
70
|
+
'ps',
|
|
71
|
+
'--all',
|
|
72
|
+
'--no-trunc',
|
|
73
|
+
'--format',
|
|
74
|
+
'{{json .}}',
|
|
75
|
+
'--filter',
|
|
76
|
+
`label=com.docker.compose.project=${request.projects[0]}`,
|
|
77
|
+
];
|
|
78
|
+
case 'inspect':
|
|
79
|
+
return ['inspect', '--type', 'container', request.container];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Execute exact project-label queries sequentially; Docker label predicates must not broaden project scope. */
|
|
83
|
+
function spawnProjectQueries(spawnProcess, request) {
|
|
84
|
+
const projects = [...new Set(request.projects)];
|
|
85
|
+
const stdout = new PassThrough();
|
|
86
|
+
const stderr = new PassThrough();
|
|
87
|
+
let current;
|
|
88
|
+
let stopped = false;
|
|
89
|
+
let detachOutput = () => { };
|
|
90
|
+
const process = Object.assign(new EventEmitter(), {
|
|
91
|
+
stdin: null,
|
|
92
|
+
stdout,
|
|
93
|
+
stderr,
|
|
94
|
+
kill(signal) {
|
|
95
|
+
stopped = true;
|
|
96
|
+
detachOutput();
|
|
97
|
+
return current.kill(signal);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const next = () => {
|
|
101
|
+
const child = spawnProcess('docker', argv({ ...request, projects: [projects.shift()] }));
|
|
102
|
+
current = child;
|
|
103
|
+
const out = (data) => {
|
|
104
|
+
stdout.write(data);
|
|
105
|
+
};
|
|
106
|
+
const err = (data) => {
|
|
107
|
+
stderr.write(data);
|
|
108
|
+
};
|
|
109
|
+
const error = (cause) => {
|
|
110
|
+
process.emit('error', cause);
|
|
111
|
+
};
|
|
112
|
+
const close = (code, signal) => {
|
|
113
|
+
child.off('error', error).off('close', close);
|
|
114
|
+
child.stdout?.off('data', out);
|
|
115
|
+
child.stderr?.off('data', err);
|
|
116
|
+
if (!stopped && code === 0 && projects.length) {
|
|
117
|
+
stdout.write('\n');
|
|
118
|
+
if (stopped) {
|
|
119
|
+
process.emit('close', null, null);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
next();
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
process.emit('error', cause);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
process.emit('close', code, signal);
|
|
131
|
+
};
|
|
132
|
+
detachOutput = () => {
|
|
133
|
+
child.stdout?.off('data', out);
|
|
134
|
+
child.stderr?.off('data', err);
|
|
135
|
+
};
|
|
136
|
+
child.stdout?.on('data', out);
|
|
137
|
+
child.stderr?.on('data', err);
|
|
138
|
+
child.on('error', error).on('close', close);
|
|
139
|
+
};
|
|
140
|
+
next();
|
|
141
|
+
return process;
|
|
142
|
+
}
|
|
143
|
+
/** Owns Docker process lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
144
|
+
export class LiveOperationEngine {
|
|
145
|
+
options;
|
|
146
|
+
sessions = new Map();
|
|
147
|
+
constructor(options) {
|
|
148
|
+
for (const key of [
|
|
149
|
+
'maxSessions',
|
|
150
|
+
'maxResultBytes',
|
|
151
|
+
'maxSessionMs',
|
|
152
|
+
'queryTimeoutMs',
|
|
153
|
+
]) {
|
|
154
|
+
if (options[key] !== undefined && (!Number.isSafeInteger(options[key]) || options[key] < 1))
|
|
155
|
+
throw new Error(`Invalid ${key}`);
|
|
156
|
+
}
|
|
157
|
+
this.options = options;
|
|
158
|
+
}
|
|
159
|
+
get activeSessions() {
|
|
160
|
+
return this.sessions.size;
|
|
161
|
+
}
|
|
162
|
+
start(value) {
|
|
163
|
+
validate(value);
|
|
164
|
+
if (this.sessions.has(value.sessionId))
|
|
165
|
+
throw new Error('Duplicate live session ID');
|
|
166
|
+
if (this.sessions.size >= (this.options.maxSessions ?? 16))
|
|
167
|
+
throw new Error('Live session limit reached');
|
|
168
|
+
const request = structuredClone(value);
|
|
169
|
+
const spawnProcess = request.operation === 'exec' && request.tty
|
|
170
|
+
? this.options.spawnTty
|
|
171
|
+
: (this.options.spawn ??
|
|
172
|
+
((file, args) => spawn(file, args, { stdio: 'pipe', shell: false })));
|
|
173
|
+
if (!spawnProcess)
|
|
174
|
+
throw new Error('TTY exec requires a PTY adapter');
|
|
175
|
+
const child = request.operation === 'ps' && request.projects.length > 1
|
|
176
|
+
? spawnProjectQueries(spawnProcess, request)
|
|
177
|
+
: spawnProcess('docker', argv(request));
|
|
178
|
+
const query = request.operation === 'ps' || request.operation === 'inspect';
|
|
179
|
+
const onError = (error) => {
|
|
180
|
+
if (this.sessions.get(request.sessionId)?.child === child)
|
|
181
|
+
this.finish(request.sessionId, null, null, false, error.message, true);
|
|
182
|
+
};
|
|
183
|
+
const stopErrors = () => {
|
|
184
|
+
child.off('error', onError).off('close', onClose);
|
|
185
|
+
child.stdin?.off('error', onError);
|
|
186
|
+
};
|
|
187
|
+
const onClose = (code, signal) => {
|
|
188
|
+
stopErrors();
|
|
189
|
+
if (this.sessions.get(request.sessionId)?.child === child)
|
|
190
|
+
this.finish(request.sessionId, code, signal, false);
|
|
191
|
+
};
|
|
192
|
+
const stdout = (data) => this.output(request.sessionId, 'stdout', data);
|
|
193
|
+
const stderr = (data) => this.output(request.sessionId, 'stderr', data);
|
|
194
|
+
const timer = setTimeout(() => this.finish(request.sessionId, null, null, false, 'Live operation timed out', true), query ? (this.options.queryTimeoutMs ?? 30000) : (this.options.maxSessionMs ?? 3600000));
|
|
195
|
+
timer.unref();
|
|
196
|
+
this.sessions.set(request.sessionId, {
|
|
197
|
+
request,
|
|
198
|
+
child,
|
|
199
|
+
timer,
|
|
200
|
+
output: [],
|
|
201
|
+
errorOutput: [],
|
|
202
|
+
bytes: 0,
|
|
203
|
+
cleanup: (awaitClose = false) => {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
if (!awaitClose)
|
|
206
|
+
stopErrors();
|
|
207
|
+
child.stdout?.off('data', stdout);
|
|
208
|
+
child.stderr?.off('data', stderr);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
child.on('error', onError).on('close', onClose);
|
|
212
|
+
child.stdout?.on('data', stdout);
|
|
213
|
+
child.stderr?.on('data', stderr);
|
|
214
|
+
child.stdin?.on('error', onError);
|
|
215
|
+
if (request.operation === 'exec' && request.interactive === false)
|
|
216
|
+
child.stdin?.end();
|
|
217
|
+
if (request.operation === 'exec' && request.tty) {
|
|
218
|
+
if (!child.resize) {
|
|
219
|
+
this.finish(request.sessionId, null, null, false, 'PTY adapter does not support resize', true);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
child.resize(request.columns ?? 80, request.rows ?? 24);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Returns the writable backpressure signal; callers must pause transport input on false. */
|
|
231
|
+
stdin(sessionId, data) {
|
|
232
|
+
const session = this.requireExec(sessionId);
|
|
233
|
+
if (!(data instanceof Uint8Array) || data.byteLength > 65536)
|
|
234
|
+
throw new Error('Invalid stdin frame');
|
|
235
|
+
if (!session.child.stdin || session.child.stdin.destroyed || session.child.stdin.writableEnded)
|
|
236
|
+
throw new Error('Session stdin is closed');
|
|
237
|
+
if (session.child.stdin.writableLength + data.byteLength > 262144)
|
|
238
|
+
throw new Error('Live stdin queue exceeds size limit');
|
|
239
|
+
return session.child.stdin.write(data);
|
|
240
|
+
}
|
|
241
|
+
endStdin(sessionId) {
|
|
242
|
+
this.requireExec(sessionId).child.stdin?.end();
|
|
243
|
+
}
|
|
244
|
+
resize(sessionId, columns, rows) {
|
|
245
|
+
const session = this.requireExec(sessionId);
|
|
246
|
+
if (!dimension(columns) || !dimension(rows))
|
|
247
|
+
throw new Error('Invalid terminal dimensions');
|
|
248
|
+
if (!session.request.tty || !session.child.resize)
|
|
249
|
+
throw new Error('Session has no terminal');
|
|
250
|
+
session.child.resize(columns, rows);
|
|
251
|
+
}
|
|
252
|
+
cancel(sessionId) {
|
|
253
|
+
this.finish(sessionId, null, null, true, undefined, true);
|
|
254
|
+
}
|
|
255
|
+
dispose() {
|
|
256
|
+
for (const id of this.sessions.keys())
|
|
257
|
+
this.cancel(id);
|
|
258
|
+
}
|
|
259
|
+
requireExec(sessionId) {
|
|
260
|
+
const session = this.sessions.get(sessionId);
|
|
261
|
+
if (!session || session.request.operation !== 'exec')
|
|
262
|
+
throw new Error('Unknown exec session');
|
|
263
|
+
return session;
|
|
264
|
+
}
|
|
265
|
+
output(id, stream, data) {
|
|
266
|
+
const session = this.sessions.get(id);
|
|
267
|
+
if (!session)
|
|
268
|
+
return;
|
|
269
|
+
const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
270
|
+
if (session.request.operation === 'ps' || session.request.operation === 'inspect') {
|
|
271
|
+
session.bytes += bytes.length;
|
|
272
|
+
if (session.bytes > (this.options.maxResultBytes ?? 4194304)) {
|
|
273
|
+
this.finish(id, null, null, false, 'Live result exceeds size limit', true);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
(stream === 'stdout' ? session.output : session.errorOutput).push(bytes);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
for (let offset = 0; offset < bytes.length && this.sessions.has(id); offset += 65536)
|
|
280
|
+
this.options.emit({
|
|
281
|
+
sessionId: id,
|
|
282
|
+
type: 'output',
|
|
283
|
+
stream,
|
|
284
|
+
data: bytes.subarray(offset, offset + 65536).toString('base64'),
|
|
285
|
+
encoding: 'base64',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
finish(id, code, signal, cancelled, error, kill = false) {
|
|
289
|
+
const session = this.sessions.get(id);
|
|
290
|
+
if (!session)
|
|
291
|
+
return;
|
|
292
|
+
this.sessions.delete(id);
|
|
293
|
+
// Keep error handlers until close when killing: spawn errors and pending stdin errors can arrive after cancellation.
|
|
294
|
+
session.cleanup(kill);
|
|
295
|
+
if (kill) {
|
|
296
|
+
try {
|
|
297
|
+
session.child.kill('SIGKILL');
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* Already exited. */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!error &&
|
|
304
|
+
!cancelled &&
|
|
305
|
+
(session.request.operation === 'ps' || session.request.operation === 'inspect')) {
|
|
306
|
+
if (code !== 0)
|
|
307
|
+
error =
|
|
308
|
+
Buffer.concat(session.errorOutput).toString('utf8').trim() ||
|
|
309
|
+
`Docker exited with code ${code}`;
|
|
310
|
+
else {
|
|
311
|
+
try {
|
|
312
|
+
const raw = Buffer.concat(session.output).toString('utf8').trim();
|
|
313
|
+
const result = session.request.operation === 'ps'
|
|
314
|
+
? raw
|
|
315
|
+
.split('\n')
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.map((line) => JSON.parse(line))
|
|
318
|
+
: JSON.parse(raw);
|
|
319
|
+
if (!Array.isArray(result) || !result.every(object))
|
|
320
|
+
throw new Error('Expected an array of container objects');
|
|
321
|
+
const frame = { v: 1, type: 'live.result', sessionId: id, result };
|
|
322
|
+
if (Buffer.byteLength(JSON.stringify(frame)) > LIVE_MAX_FRAME_BYTES)
|
|
323
|
+
throw new Error('Live result exceeds serialized frame size limit');
|
|
324
|
+
this.options.emit({ sessionId: id, type: 'result', result });
|
|
325
|
+
}
|
|
326
|
+
catch (cause) {
|
|
327
|
+
error = `Invalid Docker result: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
this.options.emit({
|
|
332
|
+
sessionId: id,
|
|
333
|
+
type: 'exit',
|
|
334
|
+
code,
|
|
335
|
+
signal,
|
|
336
|
+
cancelled,
|
|
337
|
+
...(error ? { error: error.slice(0, 4096) } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -14,7 +14,9 @@ switch (role) {
|
|
|
14
14
|
break;
|
|
15
15
|
}
|
|
16
16
|
default: {
|
|
17
|
-
const self = process.argv[1]
|
|
17
|
+
const self = process.argv[1]
|
|
18
|
+
? `node ${process.argv[1].split('/').slice(-2).join('/')}`
|
|
19
|
+
: 'main';
|
|
18
20
|
console.error(`Usage: TUG_ROLE=control|agent ${self}, or ${self} <control|agent>`);
|
|
19
21
|
process.exit(1);
|
|
20
22
|
}
|