@wpelevator/tug 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. package/dist/rollout.js +0 -319
package/dist/ingress.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DesiredState, IngressRoute, IngressStatus, IngressWork, ProjectStatus, ServiceEndpoints } from './protocol.ts';
2
- import type { DockerRunner } from './runners.ts';
2
+ import type { DockerApi } from './docker-api.ts';
3
3
  export declare const CADDY_CONTAINER = "tug-caddy";
4
4
  export declare const DEFAULT_CADDY_IMAGE = "caddy:2-alpine";
5
5
  export interface NodeView {
@@ -23,27 +23,35 @@ 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; its own upstreams are reached on loopback. */
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
- export declare function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }: IngressInput): IngressWork;
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;
36
44
  export declare function ingressConfigHash(work: Pick<IngressWork, 'caddyfile' | 'files'>): string;
37
45
  export interface IngressContext {
38
- docker: DockerRunner;
46
+ docker: DockerApi;
39
47
  stateDir: string;
40
48
  caddyImage?: string;
41
49
  }
42
50
  /**
43
- * Makes the Caddy container match the control-rendered config. Config is copied into the
44
- * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
45
- * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
46
- * Caddy rejects the new config the previous one is restored and the error reported.
51
+ * Makes the Caddy container match the control-rendered config. Config is uploaded into the
52
+ * container with the archive endpoint (so it works whether the agent runs on the host or in a
53
+ * container) and applied with `caddy reload`, which swaps config gracefully without dropping
54
+ * connections. If Caddy rejects the new config the previous one is restored and the error reported.
47
55
  */
48
56
  export declare function reconcileIngress(ctx: IngressContext, work: IngressWork): Promise<IngressStatus>;
49
57
  /** Removes the Caddy container when a node stops being an ingress node; ACME data volumes are kept. */
package/dist/ingress.js CHANGED
@@ -4,8 +4,10 @@
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
+ import * as tar from 'tar';
10
+ import { isNotFound } from "./docker-api.js";
9
11
  export const CADDY_CONTAINER = 'tug-caddy';
10
12
  export const DEFAULT_CADDY_IMAGE = 'caddy:2-alpine';
11
13
  const CADDY_ETC = '/etc/caddy';
@@ -15,8 +17,8 @@ const CADDY_ETC = '/etc/caddy';
15
17
  * rollout state drops, which is what makes DNS and ingress health-aware.
16
18
  */
17
19
  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]));
20
+ const byName = new Map(nodes.map((n) => [n.name, n]));
21
+ const reported = new Map(statuses.map((s) => [`${s.node}\0${s.project}`, s.status]));
20
22
  const out = {};
21
23
  for (const state of states) {
22
24
  for (const [service, spec] of Object.entries(state.services)) {
@@ -30,7 +32,11 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
30
32
  if (serviceState?.rolloutState !== 'serving')
31
33
  continue;
32
34
  // Rolling services publish each revision on its own host port; the report names the serving one.
33
- endpoints.push({ node: nodeName, address, ...(serviceState.port ? { port: serviceState.port } : {}) });
35
+ endpoints.push({
36
+ node: nodeName,
37
+ address,
38
+ ...(serviceState.port ? { port: serviceState.port } : {}),
39
+ });
34
40
  }
35
41
  if (endpoints.length)
36
42
  (out[state.project] ||= {})[service] = endpoints.sort((a, b) => a.node.localeCompare(b.node));
@@ -39,21 +45,33 @@ export function resolveServiceEndpoints(states, nodes, statuses, staleBefore) {
39
45
  return out;
40
46
  }
41
47
  const certFile = (host, ext) => `certs/${host.replace(/\*/g, '_wildcard_')}.${ext}`;
42
- export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }) {
48
+ /**
49
+ * Upstreams on the ingress node itself are dialed on `127.0.0.1` when the service's ingress port
50
+ * listens on loopback only (see `loopbackIngressServices`), otherwise on the address its published
51
+ * ports are bound to, which is the endpoint address, like every other node.
52
+ */
53
+ export function buildIngressWork({ states, endpoints, node, nodes = [node], ingressNodes = [node], secrets, acmeEmail, }) {
43
54
  const routes = [];
44
55
  const files = {};
45
56
  const warnings = [];
46
57
  const seenHosts = new Map();
47
58
  const tagged = [];
48
59
  for (const state of states) {
60
+ const loopback = new Set(loopbackIngressServices(state, node, ingressNodes, nodes));
49
61
  for (const [service, svc] of Object.entries(state.compose.services || {})) {
50
62
  const ingress = svc['x-tug']?.ingress;
51
63
  if (ingress)
52
- tagged.push({ project: state.project, service, ingress, port: publishedPort(svc, ingress.port) });
64
+ tagged.push({
65
+ project: state.project,
66
+ service,
67
+ ingress,
68
+ port: publishedPort(svc, ingress.port),
69
+ loopback: loopback.has(service),
70
+ });
53
71
  }
54
72
  }
55
73
  tagged.sort((a, b) => `${a.project}/${a.service}`.localeCompare(`${b.project}/${b.service}`));
56
- for (const { project, service, ingress, port } of tagged) {
74
+ for (const { project, service, ingress, port, loopback } of tagged) {
57
75
  const label = `${project}/${service}`;
58
76
  const host = ingress.host.toLowerCase();
59
77
  const tls = ingress.tls || 'auto';
@@ -65,7 +83,7 @@ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }
65
83
  warnings.push(`${label}: ingress.port ${ingress.port} is not published; skipped`);
66
84
  continue;
67
85
  }
68
- const upstreams = (endpoints[project]?.[service] || []).map(e => `${e.node === node ? '127.0.0.1' : e.address}:${e.port ?? port}`);
86
+ const upstreams = (endpoints[project]?.[service] || []).map((e) => `${e.node === node && loopback ? '127.0.0.1' : upstreamHost(e.address)}:${e.port ?? port}`);
69
87
  if (!upstreams.length) {
70
88
  warnings.push(`${label}: no serving upstream for ${host}; route withheld`);
71
89
  continue;
@@ -85,6 +103,8 @@ export function buildIngressWork({ states, endpoints, node, secrets, acmeEmail }
85
103
  }
86
104
  return { caddyfile: renderCaddyfile(routes, { acmeEmail }), files, routes, warnings };
87
105
  }
106
+ /** IPv6 upstream addresses need brackets before the port. */
107
+ const upstreamHost = (address) => address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
88
108
  export function renderCaddyfile(routes, opts = {}) {
89
109
  const out = [];
90
110
  if (opts.acmeEmail)
@@ -115,11 +135,35 @@ async function writeStage(dir, work) {
115
135
  await fs.writeFile(file, body, { mode: name.endsWith('.key') ? 0o600 : 0o644 });
116
136
  }
117
137
  }
138
+ async function tarOf(dir) {
139
+ const chunks = [];
140
+ for await (const chunk of tar.c({ cwd: dir, portable: true }, ['.']))
141
+ chunks.push(chunk);
142
+ return Buffer.concat(chunks);
143
+ }
144
+ async function ensureImage(ctx) {
145
+ const image = ctx.caddyImage || DEFAULT_CADDY_IMAGE;
146
+ if (!(await ctx.docker.inspectImage(image)))
147
+ await ctx.docker.pullImage(image);
148
+ return image;
149
+ }
150
+ async function reload(ctx) {
151
+ const r = await ctx.docker.execRun(CADDY_CONTAINER, [
152
+ 'caddy',
153
+ 'reload',
154
+ '--config',
155
+ `${CADDY_ETC}/Caddyfile`,
156
+ '--adapter',
157
+ 'caddyfile',
158
+ ]);
159
+ if (r.code !== 0)
160
+ throw new Error(`caddy reload failed (${r.code}): ${(r.err || r.out).trim()}`);
161
+ }
118
162
  /**
119
- * Makes the Caddy container match the control-rendered config. Config is copied into the
120
- * container (`docker cp`, so it works whether the agent runs on the host or in a container) and
121
- * applied with `caddy reload`, which swaps config gracefully without dropping connections. If
122
- * Caddy rejects the new config the previous one is restored and the error reported.
163
+ * Makes the Caddy container match the control-rendered config. Config is uploaded into the
164
+ * container with the archive endpoint (so it works whether the agent runs on the host or in a
165
+ * container) and applied with `caddy reload`, which swaps config gracefully without dropping
166
+ * connections. If Caddy rejects the new config the previous one is restored and the error reported.
123
167
  */
124
168
  export async function reconcileIngress(ctx, work) {
125
169
  const root = path.join(ctx.stateDir, 'ingress');
@@ -127,35 +171,46 @@ export async function reconcileIngress(ctx, work) {
127
171
  const applied = path.join(root, 'applied');
128
172
  const configHash = ingressConfigHash(work);
129
173
  const routes = work.routes.length;
130
- const running = await ctx.docker(['container', 'inspect', '-f', '{{.State.Running}}', CADDY_CONTAINER])
131
- .then(r => r.out.trim() === 'true', () => null);
132
- const appliedHash = await fs.readFile(path.join(root, 'applied.hash'), 'utf8').then(s => s.trim(), () => '');
174
+ const existing = await ctx.docker.inspectContainer(CADDY_CONTAINER).catch((e) => {
175
+ if (isNotFound(e))
176
+ return null;
177
+ throw e;
178
+ });
179
+ const running = existing ? Boolean(existing.State?.Running) : null;
180
+ const appliedHash = await fs.readFile(path.join(root, 'applied.hash'), 'utf8').then((s) => s.trim(), () => '');
133
181
  if (running && appliedHash === configHash)
134
182
  return { state: 'serving', configHash, routes };
135
183
  await writeStage(stage, work);
136
184
  try {
137
185
  if (running === null) {
138
- await ctx.docker([
139
- 'create', '--name', CADDY_CONTAINER, '--restart', 'unless-stopped', '--network', 'host',
140
- '-v', 'tug-caddy-data:/data', '-v', 'tug-caddy-config:/config', '-v', `tug-caddy-etc:${CADDY_ETC}`,
141
- ctx.caddyImage || DEFAULT_CADDY_IMAGE
142
- ]);
143
- await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
144
- await ctx.docker(['start', CADDY_CONTAINER]);
186
+ const image = await ensureImage(ctx);
187
+ await ctx.docker.createContainer(CADDY_CONTAINER, {
188
+ Image: image,
189
+ Labels: { 'tug.managed': 'ingress' },
190
+ HostConfig: {
191
+ NetworkMode: 'host',
192
+ RestartPolicy: { Name: 'unless-stopped' },
193
+ Binds: ['tug-caddy-data:/data', 'tug-caddy-config:/config', `tug-caddy-etc:${CADDY_ETC}`],
194
+ },
195
+ });
196
+ await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
197
+ await ctx.docker.startContainer(CADDY_CONTAINER);
145
198
  }
146
199
  else if (running === false) {
147
- await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
148
- await ctx.docker(['start', CADDY_CONTAINER]);
200
+ await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
201
+ await ctx.docker.startContainer(CADDY_CONTAINER);
149
202
  }
150
203
  else {
151
204
  try {
152
- await ctx.docker(['exec', CADDY_CONTAINER, 'rm', '-rf', `${CADDY_ETC}/certs`]);
153
- await ctx.docker(['cp', `${stage}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]);
154
- await ctx.docker(['exec', CADDY_CONTAINER, 'caddy', 'reload', '--config', `${CADDY_ETC}/Caddyfile`, '--adapter', 'caddyfile']);
205
+ await ctx.docker.execRun(CADDY_CONTAINER, ['rm', '-rf', `${CADDY_ETC}/certs`]);
206
+ await ctx.docker.putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(stage));
207
+ await reload(ctx);
155
208
  }
156
209
  catch (e) {
157
210
  if (await fs.stat(applied).then(() => true, () => false))
158
- await ctx.docker(['cp', `${applied}/.`, `${CADDY_CONTAINER}:${CADDY_ETC}/`]).catch(() => { });
211
+ await ctx.docker
212
+ .putArchive(CADDY_CONTAINER, CADDY_ETC, await tarOf(applied))
213
+ .catch(() => { });
159
214
  throw e;
160
215
  }
161
216
  }
@@ -174,7 +229,7 @@ export async function removeIngress(ctx) {
174
229
  const root = path.join(ctx.stateDir, 'ingress');
175
230
  if (!(await fs.stat(root).then(() => true, () => false)))
176
231
  return false;
177
- await ctx.docker(['rm', '-f', CADDY_CONTAINER]).catch(() => { });
232
+ await ctx.docker.removeContainer(CADDY_CONTAINER).catch(() => { });
178
233
  await fs.rm(root, { recursive: true, force: true });
179
234
  return true;
180
235
  }
package/dist/live.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { DockerApi } from './docker-api.ts';
3
+ import type { Readable, Writable } from 'node:stream';
4
+ export interface LiveProcess extends EventEmitter {
5
+ stdin: Writable | null;
6
+ stdout: Readable | null;
7
+ stderr: Readable | null;
8
+ kill(signal?: NodeJS.Signals): boolean;
9
+ /** PTY adapters implement this in terminal columns and rows. */
10
+ resize?(columns: number, rows: number): void;
11
+ }
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
+ /** The Engine API: exec (with a real PTY), logs, `ps` and `inspect` all go through it. */
57
+ docker: DockerApi;
58
+ maxSessions?: number;
59
+ maxSessionMs?: number;
60
+ queryTimeoutMs?: number;
61
+ }
62
+ /** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
63
+ export declare class LiveOperationEngine {
64
+ private readonly options;
65
+ private readonly sessions;
66
+ /** Sessions whose container stream is still being opened; a cancel marks them so they close on arrival. */
67
+ private readonly starting;
68
+ constructor(options: LiveEngineOptions);
69
+ get activeSessions(): number;
70
+ start(value: unknown): Promise<void>;
71
+ /** `ps` and `inspect` are one Engine API call whose JSON result is the whole session. */
72
+ private query;
73
+ private open;
74
+ /** Returns the writable backpressure signal; callers must pause transport input on false. */
75
+ stdin(sessionId: string, data: Uint8Array): boolean;
76
+ endStdin(sessionId: string): void;
77
+ resize(sessionId: string, columns: number, rows: number): void;
78
+ cancel(sessionId: string): void;
79
+ dispose(): void;
80
+ private requireExec;
81
+ private output;
82
+ private finish;
83
+ }
package/dist/live.js ADDED
@@ -0,0 +1,276 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { LIVE_MAX_FRAME_BYTES } from "./protocol.js";
3
+ const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/;
4
+ function object(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function dimension(value) {
8
+ return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 1000;
9
+ }
10
+ function validate(value) {
11
+ if (!object(value) || typeof value.sessionId !== 'string' || !identifier.test(value.sessionId))
12
+ throw new Error('Invalid live session ID');
13
+ if (!['exec', 'logs', 'ps', 'inspect'].includes(String(value.operation)))
14
+ throw new Error('Invalid live operation');
15
+ if (value.operation !== 'ps' &&
16
+ (typeof value.container !== 'string' || !identifier.test(value.container)))
17
+ throw new Error('Invalid container');
18
+ if (value.operation === 'exec') {
19
+ if (!Array.isArray(value.command) ||
20
+ value.command.length < 1 ||
21
+ value.command.length > 256 ||
22
+ !value.command.every((arg) => typeof arg === 'string' && !arg.includes('\0') && Buffer.byteLength(arg) <= 32768) ||
23
+ value.command[0].length === 0)
24
+ throw new Error('Invalid exec command');
25
+ if (value.interactive !== undefined && typeof value.interactive !== 'boolean')
26
+ throw new Error('Invalid interactive flag');
27
+ if (value.tty !== undefined && typeof value.tty !== 'boolean')
28
+ throw new Error('Invalid TTY flag');
29
+ if ((value.columns !== undefined && !dimension(value.columns)) ||
30
+ (value.rows !== undefined && !dimension(value.rows)))
31
+ throw new Error('Invalid terminal dimensions');
32
+ }
33
+ if (value.operation === 'logs') {
34
+ if (value.tail !== undefined &&
35
+ (!Number.isInteger(value.tail) || Number(value.tail) < 0 || Number(value.tail) > 10000))
36
+ throw new Error('Invalid log tail');
37
+ if (value.timestamps !== undefined && typeof value.timestamps !== 'boolean')
38
+ throw new Error('Invalid timestamps flag');
39
+ }
40
+ if (value.operation === 'ps' &&
41
+ (!Array.isArray(value.projects) ||
42
+ value.projects.length < 1 ||
43
+ value.projects.length > 256 ||
44
+ !value.projects.every((project) => typeof project === 'string' && identifier.test(project))))
45
+ throw new Error('Invalid projects');
46
+ }
47
+ /** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
48
+ export class LiveOperationEngine {
49
+ options;
50
+ sessions = new Map();
51
+ /** Sessions whose container stream is still being opened; a cancel marks them so they close on arrival. */
52
+ starting = new Map();
53
+ constructor(options) {
54
+ for (const key of ['maxSessions', 'maxSessionMs', 'queryTimeoutMs']) {
55
+ if (options[key] !== undefined && (!Number.isSafeInteger(options[key]) || options[key] < 1))
56
+ throw new Error(`Invalid ${key}`);
57
+ }
58
+ this.options = options;
59
+ }
60
+ get activeSessions() {
61
+ return this.sessions.size + this.starting.size;
62
+ }
63
+ async start(value) {
64
+ validate(value);
65
+ if (this.sessions.has(value.sessionId) || this.starting.has(value.sessionId))
66
+ throw new Error('Duplicate live session ID');
67
+ if (this.activeSessions >= (this.options.maxSessions ?? 16))
68
+ throw new Error('Live session limit reached');
69
+ const request = structuredClone(value);
70
+ const id = request.sessionId;
71
+ const pending = { cancelled: false };
72
+ this.starting.set(id, pending);
73
+ const query = request.operation === 'ps' || request.operation === 'inspect';
74
+ const timer = setTimeout(() => this.finish(id, null, null, false, 'Live operation timed out', true), query ? (this.options.queryTimeoutMs ?? 30000) : (this.options.maxSessionMs ?? 3600000));
75
+ timer.unref();
76
+ let child;
77
+ try {
78
+ if (query) {
79
+ this.starting.delete(id);
80
+ await this.query(request, timer);
81
+ return;
82
+ }
83
+ child = await this.open(request);
84
+ }
85
+ catch (error) {
86
+ clearTimeout(timer);
87
+ this.starting.delete(id);
88
+ throw error;
89
+ }
90
+ this.starting.delete(id);
91
+ if (pending.cancelled) {
92
+ clearTimeout(timer);
93
+ try {
94
+ child.kill('SIGKILL');
95
+ }
96
+ catch {
97
+ /* Already gone. */
98
+ }
99
+ this.options.emit({ sessionId: id, type: 'exit', code: null, signal: null, cancelled: true });
100
+ return;
101
+ }
102
+ const live = child;
103
+ const onError = (error) => {
104
+ if (this.sessions.get(id)?.child === live)
105
+ this.finish(id, null, null, false, error.message, true);
106
+ };
107
+ const stopErrors = () => {
108
+ live.off('error', onError).off('close', onClose);
109
+ live.stdin?.off('error', onError);
110
+ };
111
+ const onClose = (code, signal) => {
112
+ stopErrors();
113
+ if (this.sessions.get(id)?.child === live)
114
+ this.finish(id, code, signal, false);
115
+ };
116
+ const stdout = (data) => this.output(id, 'stdout', data);
117
+ const stderr = (data) => this.output(id, 'stderr', data);
118
+ this.sessions.set(id, {
119
+ request,
120
+ child: live,
121
+ timer,
122
+ cleanup: (awaitClose = false) => {
123
+ clearTimeout(timer);
124
+ if (!awaitClose)
125
+ stopErrors();
126
+ live.stdout?.off('data', stdout);
127
+ live.stderr?.off('data', stderr);
128
+ },
129
+ });
130
+ live.on('error', onError).on('close', onClose);
131
+ live.stdout?.on('data', stdout);
132
+ live.stderr?.on('data', stderr);
133
+ live.stdin?.on('error', onError);
134
+ if (request.operation === 'exec' && request.interactive === false)
135
+ live.stdin?.end();
136
+ }
137
+ /** `ps` and `inspect` are one Engine API call whose JSON result is the whole session. */
138
+ async query(request, timer) {
139
+ const id = request.sessionId;
140
+ const session = { request, timer, cleanup: () => clearTimeout(timer) };
141
+ const aborted = new Promise((resolve) => {
142
+ session.abort = resolve;
143
+ });
144
+ this.sessions.set(id, session);
145
+ let error;
146
+ let result;
147
+ let settled = false;
148
+ const work = (async () => {
149
+ try {
150
+ if (request.operation === 'ps') {
151
+ const lists = await Promise.all([...new Set(request.projects)].map((project) => this.options.docker.listContainers({ label: [`tug.project=${project}`] })));
152
+ result = lists.flat();
153
+ }
154
+ else
155
+ result = [await this.options.docker.inspectContainer(request.container)];
156
+ const frame = { v: 1, type: 'live.result', sessionId: id, result };
157
+ if (Buffer.byteLength(JSON.stringify(frame)) > LIVE_MAX_FRAME_BYTES)
158
+ throw new Error('Live result exceeds serialized frame size limit');
159
+ }
160
+ catch (cause) {
161
+ error = cause instanceof Error ? cause.message : String(cause);
162
+ }
163
+ settled = true;
164
+ })();
165
+ await Promise.race([work, aborted]);
166
+ // A cancel or timeout already closed the session while the call was in flight.
167
+ if (!settled || !this.sessions.has(id))
168
+ return;
169
+ clearTimeout(timer);
170
+ this.sessions.delete(id);
171
+ if (result && !error)
172
+ this.options.emit({ sessionId: id, type: 'result', result });
173
+ this.options.emit({
174
+ sessionId: id,
175
+ type: 'exit',
176
+ code: error ? 1 : 0,
177
+ signal: null,
178
+ cancelled: false,
179
+ ...(error ? { error: error.slice(0, 4096) } : {}),
180
+ });
181
+ }
182
+ async open(request) {
183
+ const { docker } = this.options;
184
+ if (request.operation === 'logs')
185
+ return docker.logs(request.container, {
186
+ follow: true,
187
+ tail: request.tail ?? 100,
188
+ timestamps: request.timestamps,
189
+ });
190
+ const proc = await docker.execStart(request.container, {
191
+ cmd: request.command,
192
+ stdin: request.interactive !== false,
193
+ tty: Boolean(request.tty),
194
+ columns: request.columns,
195
+ rows: request.rows,
196
+ });
197
+ return proc;
198
+ }
199
+ /** Returns the writable backpressure signal; callers must pause transport input on false. */
200
+ stdin(sessionId, data) {
201
+ const session = this.requireExec(sessionId);
202
+ if (!(data instanceof Uint8Array) || data.byteLength > 65536)
203
+ throw new Error('Invalid stdin frame');
204
+ const stdin = session.child?.stdin;
205
+ if (!stdin || stdin.destroyed || stdin.writableEnded)
206
+ throw new Error('Session stdin is closed');
207
+ if (stdin.writableLength + data.byteLength > 262144)
208
+ throw new Error('Live stdin queue exceeds size limit');
209
+ return stdin.write(data);
210
+ }
211
+ endStdin(sessionId) {
212
+ this.requireExec(sessionId).child?.stdin?.end();
213
+ }
214
+ resize(sessionId, columns, rows) {
215
+ const session = this.requireExec(sessionId);
216
+ if (!dimension(columns) || !dimension(rows))
217
+ throw new Error('Invalid terminal dimensions');
218
+ if (!session.request.tty || !session.child?.resize)
219
+ throw new Error('Session has no terminal');
220
+ session.child.resize(columns, rows);
221
+ }
222
+ cancel(sessionId) {
223
+ const pending = this.starting.get(sessionId);
224
+ if (pending)
225
+ pending.cancelled = true;
226
+ this.finish(sessionId, null, null, true, undefined, true);
227
+ }
228
+ dispose() {
229
+ for (const id of [...this.sessions.keys(), ...this.starting.keys()])
230
+ this.cancel(id);
231
+ }
232
+ requireExec(sessionId) {
233
+ const session = this.sessions.get(sessionId);
234
+ if (!session || session.request.operation !== 'exec')
235
+ throw new Error('Unknown exec session');
236
+ return session;
237
+ }
238
+ output(id, stream, data) {
239
+ if (!this.sessions.has(id))
240
+ return;
241
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
242
+ for (let offset = 0; offset < bytes.length && this.sessions.has(id); offset += 65536)
243
+ this.options.emit({
244
+ sessionId: id,
245
+ type: 'output',
246
+ stream,
247
+ data: bytes.subarray(offset, offset + 65536).toString('base64'),
248
+ encoding: 'base64',
249
+ });
250
+ }
251
+ finish(id, code, signal, cancelled, error, kill = false) {
252
+ const session = this.sessions.get(id);
253
+ if (!session)
254
+ return;
255
+ this.sessions.delete(id);
256
+ session.abort?.();
257
+ // Keep error handlers until close when killing: errors can arrive after cancellation.
258
+ session.cleanup(kill);
259
+ if (kill && session.child) {
260
+ try {
261
+ session.child.kill('SIGKILL');
262
+ }
263
+ catch {
264
+ /* Already exited. */
265
+ }
266
+ }
267
+ this.options.emit({
268
+ sessionId: id,
269
+ type: 'exit',
270
+ code,
271
+ signal,
272
+ cancelled,
273
+ ...(error ? { error: error.slice(0, 4096) } : {}),
274
+ });
275
+ }
276
+ }
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] ? `node ${process.argv[1].split('/').slice(-2).join('/')}` : 'main';
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
  }