@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.
- package/README.md +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- 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 +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- 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/rollout.d.ts +0 -72
- 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 {
|
|
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
|
|
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;
|
|
36
44
|
export declare function ingressConfigHash(work: Pick<IngressWork, 'caddyfile' | 'files'>): string;
|
|
37
45
|
export interface IngressContext {
|
|
38
|
-
docker:
|
|
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
|
|
44
|
-
* container
|
|
45
|
-
* applied with `caddy reload`, which swaps config gracefully without dropping
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
|
120
|
-
* container
|
|
121
|
-
* applied with `caddy reload`, which swaps config gracefully without dropping
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
148
|
-
await ctx.docker(
|
|
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(
|
|
153
|
-
await ctx.docker(
|
|
154
|
-
await ctx
|
|
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
|
|
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(
|
|
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]
|
|
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
|
}
|