@wpelevator/tug 0.3.0 → 0.5.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 +7 -8
- package/dist/agent.d.ts +9 -12
- package/dist/agent.js +232 -302
- package/dist/build.d.ts +2 -3
- package/dist/build.js +1 -3
- package/dist/cli-build.d.ts +2 -2
- package/dist/cli-build.js +3 -4
- package/dist/cli.js +26 -41
- package/dist/compose.d.ts +7 -21
- package/dist/compose.js +120 -150
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +4 -2
- package/dist/control.js +350 -365
- package/dist/db-driver.d.ts +20 -0
- package/dist/db-driver.js +11 -0
- package/dist/db-node.d.ts +3 -0
- package/dist/db-node.js +60 -0
- package/dist/db.d.ts +9 -6
- package/dist/db.js +40 -26
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +428 -0
- package/dist/ingress.d.ts +6 -6
- package/dist/ingress.js +56 -42
- package/dist/live.d.ts +10 -7
- package/dist/live.js +130 -194
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +5 -5
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +133 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/templates/control-compose.yml +0 -4
- package/dist/templates/install-docker.sh +12 -7
- package/dist/templates/node-install.sh +0 -1
- package/dist/tokens.d.ts +7 -7
- package/dist/tokens.js +24 -21
- package/package.json +1 -3
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -385
package/dist/live.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { PassThrough } from 'node:stream';
|
|
4
2
|
import { LIVE_MAX_FRAME_BYTES } from "./protocol.js";
|
|
5
3
|
const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/;
|
|
6
4
|
function object(value) {
|
|
@@ -46,214 +44,189 @@ function validate(value) {
|
|
|
46
44
|
!value.projects.every((project) => typeof project === 'string' && identifier.test(project))))
|
|
47
45
|
throw new Error('Invalid projects');
|
|
48
46
|
}
|
|
49
|
-
|
|
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. */
|
|
47
|
+
/** Owns Engine API streams and their lifetimes; callers own authentication, authorization and transport backpressure. */
|
|
144
48
|
export class LiveOperationEngine {
|
|
145
49
|
options;
|
|
146
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();
|
|
147
53
|
constructor(options) {
|
|
148
|
-
for (const key of [
|
|
149
|
-
'maxSessions',
|
|
150
|
-
'maxResultBytes',
|
|
151
|
-
'maxSessionMs',
|
|
152
|
-
'queryTimeoutMs',
|
|
153
|
-
]) {
|
|
54
|
+
for (const key of ['maxSessions', 'maxSessionMs', 'queryTimeoutMs']) {
|
|
154
55
|
if (options[key] !== undefined && (!Number.isSafeInteger(options[key]) || options[key] < 1))
|
|
155
56
|
throw new Error(`Invalid ${key}`);
|
|
156
57
|
}
|
|
157
58
|
this.options = options;
|
|
158
59
|
}
|
|
159
60
|
get activeSessions() {
|
|
160
|
-
return this.sessions.size;
|
|
61
|
+
return this.sessions.size + this.starting.size;
|
|
161
62
|
}
|
|
162
|
-
start(value) {
|
|
63
|
+
async start(value) {
|
|
163
64
|
validate(value);
|
|
164
|
-
if (this.sessions.has(value.sessionId))
|
|
65
|
+
if (this.sessions.has(value.sessionId) || this.starting.has(value.sessionId))
|
|
165
66
|
throw new Error('Duplicate live session ID');
|
|
166
|
-
if (this.
|
|
67
|
+
if (this.activeSessions >= (this.options.maxSessions ?? 16))
|
|
167
68
|
throw new Error('Live session limit reached');
|
|
168
69
|
const request = structuredClone(value);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
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));
|
|
70
|
+
const id = request.sessionId;
|
|
71
|
+
const pending = { cancelled: false };
|
|
72
|
+
this.starting.set(id, pending);
|
|
178
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;
|
|
179
103
|
const onError = (error) => {
|
|
180
|
-
if (this.sessions.get(
|
|
181
|
-
this.finish(
|
|
104
|
+
if (this.sessions.get(id)?.child === live)
|
|
105
|
+
this.finish(id, null, null, false, error.message, true);
|
|
182
106
|
};
|
|
183
107
|
const stopErrors = () => {
|
|
184
|
-
|
|
185
|
-
|
|
108
|
+
live.off('error', onError).off('close', onClose);
|
|
109
|
+
live.stdin?.off('error', onError);
|
|
186
110
|
};
|
|
187
111
|
const onClose = (code, signal) => {
|
|
188
112
|
stopErrors();
|
|
189
|
-
if (this.sessions.get(
|
|
190
|
-
this.finish(
|
|
113
|
+
if (this.sessions.get(id)?.child === live)
|
|
114
|
+
this.finish(id, code, signal, false);
|
|
191
115
|
};
|
|
192
|
-
const stdout = (data) => this.output(
|
|
193
|
-
const stderr = (data) => this.output(
|
|
194
|
-
|
|
195
|
-
timer.unref();
|
|
196
|
-
this.sessions.set(request.sessionId, {
|
|
116
|
+
const stdout = (data) => this.output(id, 'stdout', data);
|
|
117
|
+
const stderr = (data) => this.output(id, 'stderr', data);
|
|
118
|
+
this.sessions.set(id, {
|
|
197
119
|
request,
|
|
198
|
-
child,
|
|
120
|
+
child: live,
|
|
199
121
|
timer,
|
|
200
|
-
output: [],
|
|
201
|
-
errorOutput: [],
|
|
202
|
-
bytes: 0,
|
|
203
122
|
cleanup: (awaitClose = false) => {
|
|
204
123
|
clearTimeout(timer);
|
|
205
124
|
if (!awaitClose)
|
|
206
125
|
stopErrors();
|
|
207
|
-
|
|
208
|
-
|
|
126
|
+
live.stdout?.off('data', stdout);
|
|
127
|
+
live.stderr?.off('data', stderr);
|
|
209
128
|
},
|
|
210
129
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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);
|
|
215
134
|
if (request.operation === 'exec' && request.interactive === false)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 () => {
|
|
222
149
|
try {
|
|
223
|
-
|
|
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');
|
|
224
159
|
}
|
|
225
|
-
catch (
|
|
226
|
-
|
|
160
|
+
catch (cause) {
|
|
161
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
227
162
|
}
|
|
228
|
-
|
|
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;
|
|
229
198
|
}
|
|
230
199
|
/** Returns the writable backpressure signal; callers must pause transport input on false. */
|
|
231
200
|
stdin(sessionId, data) {
|
|
232
201
|
const session = this.requireExec(sessionId);
|
|
233
202
|
if (!(data instanceof Uint8Array) || data.byteLength > 65536)
|
|
234
203
|
throw new Error('Invalid stdin frame');
|
|
235
|
-
|
|
204
|
+
const stdin = session.child?.stdin;
|
|
205
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded)
|
|
236
206
|
throw new Error('Session stdin is closed');
|
|
237
|
-
if (
|
|
207
|
+
if (stdin.writableLength + data.byteLength > 262144)
|
|
238
208
|
throw new Error('Live stdin queue exceeds size limit');
|
|
239
|
-
return
|
|
209
|
+
return stdin.write(data);
|
|
240
210
|
}
|
|
241
211
|
endStdin(sessionId) {
|
|
242
|
-
this.requireExec(sessionId).child
|
|
212
|
+
this.requireExec(sessionId).child?.stdin?.end();
|
|
243
213
|
}
|
|
244
214
|
resize(sessionId, columns, rows) {
|
|
245
215
|
const session = this.requireExec(sessionId);
|
|
246
216
|
if (!dimension(columns) || !dimension(rows))
|
|
247
217
|
throw new Error('Invalid terminal dimensions');
|
|
248
|
-
if (!session.request.tty || !session.child
|
|
218
|
+
if (!session.request.tty || !session.child?.resize)
|
|
249
219
|
throw new Error('Session has no terminal');
|
|
250
220
|
session.child.resize(columns, rows);
|
|
251
221
|
}
|
|
252
222
|
cancel(sessionId) {
|
|
223
|
+
const pending = this.starting.get(sessionId);
|
|
224
|
+
if (pending)
|
|
225
|
+
pending.cancelled = true;
|
|
253
226
|
this.finish(sessionId, null, null, true, undefined, true);
|
|
254
227
|
}
|
|
255
228
|
dispose() {
|
|
256
|
-
for (const id of this.sessions.keys())
|
|
229
|
+
for (const id of [...this.sessions.keys(), ...this.starting.keys()])
|
|
257
230
|
this.cancel(id);
|
|
258
231
|
}
|
|
259
232
|
requireExec(sessionId) {
|
|
@@ -263,19 +236,9 @@ export class LiveOperationEngine {
|
|
|
263
236
|
return session;
|
|
264
237
|
}
|
|
265
238
|
output(id, stream, data) {
|
|
266
|
-
|
|
267
|
-
if (!session)
|
|
239
|
+
if (!this.sessions.has(id))
|
|
268
240
|
return;
|
|
269
241
|
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
242
|
for (let offset = 0; offset < bytes.length && this.sessions.has(id); offset += 65536)
|
|
280
243
|
this.options.emit({
|
|
281
244
|
sessionId: id,
|
|
@@ -290,9 +253,10 @@ export class LiveOperationEngine {
|
|
|
290
253
|
if (!session)
|
|
291
254
|
return;
|
|
292
255
|
this.sessions.delete(id);
|
|
293
|
-
|
|
256
|
+
session.abort?.();
|
|
257
|
+
// Keep error handlers until close when killing: errors can arrive after cancellation.
|
|
294
258
|
session.cleanup(kill);
|
|
295
|
-
if (kill) {
|
|
259
|
+
if (kill && session.child) {
|
|
296
260
|
try {
|
|
297
261
|
session.child.kill('SIGKILL');
|
|
298
262
|
}
|
|
@@ -300,34 +264,6 @@ export class LiveOperationEngine {
|
|
|
300
264
|
/* Already exited. */
|
|
301
265
|
}
|
|
302
266
|
}
|
|
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
267
|
this.options.emit({
|
|
332
268
|
sessionId: id,
|
|
333
269
|
type: 'exit',
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RunResult } from './build.ts';
|
|
2
|
+
import type { ComposeFile } from './protocol.ts';
|
|
3
|
+
export type ComposeRunner = (cmd: string, args: string[], opts: {
|
|
4
|
+
env: NodeJS.ProcessEnv;
|
|
5
|
+
}) => Promise<RunResult>;
|
|
6
|
+
export declare const secretSentinel: (name: string) => string;
|
|
7
|
+
/** Names of the control secrets that `value` references through sentinels, in order of appearance. */
|
|
8
|
+
export declare function sentinelNames(value: unknown): string[];
|
|
9
|
+
/** `value` with every sentinel replaced by the secret's value; a sentinel with no value becomes empty. */
|
|
10
|
+
export declare function substituteSentinels(value: string, secrets: Record<string, string>): string;
|
|
11
|
+
/** Arguments of the `docker compose` call that renders `file` canonically (as YAML, which keeps `x-tug`). */
|
|
12
|
+
export declare function composeConfigArgs(file: string, project?: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
|
|
15
|
+
* its sentinel in the environment, so it wins over a same-named local variable.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeCompose(file: string, secretNames: readonly string[], options?: {
|
|
18
|
+
project?: string;
|
|
19
|
+
runner?: ComposeRunner;
|
|
20
|
+
}): Promise<ComposeFile>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Operator-side Compose normalization. `docker compose config` does the interpolation, `extends`,
|
|
2
|
+
// `env_file` merging and short-syntax expansion, so control and agents only ever see canonical JSON.
|
|
3
|
+
// The CLI reads Compose's YAML rendering: its `--format json` drops every service-level extension
|
|
4
|
+
// (`x-tug`), while the YAML rendering keeps them.
|
|
5
|
+
// Control secrets are not resolved on the operator's machine: each one is interpolated as a sentinel
|
|
6
|
+
// that the agent replaces with the node's released value when it creates the container.
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { run } from "./build.js";
|
|
9
|
+
const SECRET_NAME = '[A-Za-z_][A-Za-z0-9_]*';
|
|
10
|
+
const SENTINEL = new RegExp(`@@tug:secret:(${SECRET_NAME})@@`, 'g');
|
|
11
|
+
export const secretSentinel = (name) => `@@tug:secret:${name}@@`;
|
|
12
|
+
/** Names of the control secrets that `value` references through sentinels, in order of appearance. */
|
|
13
|
+
export function sentinelNames(value) {
|
|
14
|
+
const names = new Set();
|
|
15
|
+
const visit = (v) => {
|
|
16
|
+
if (typeof v === 'string')
|
|
17
|
+
for (const m of v.matchAll(SENTINEL))
|
|
18
|
+
names.add(m[1]);
|
|
19
|
+
else if (Array.isArray(v))
|
|
20
|
+
v.forEach(visit);
|
|
21
|
+
else if (v && typeof v === 'object')
|
|
22
|
+
Object.values(v).forEach(visit);
|
|
23
|
+
};
|
|
24
|
+
visit(value);
|
|
25
|
+
return [...names];
|
|
26
|
+
}
|
|
27
|
+
/** `value` with every sentinel replaced by the secret's value; a sentinel with no value becomes empty. */
|
|
28
|
+
export function substituteSentinels(value, secrets) {
|
|
29
|
+
return value.replace(SENTINEL, (_all, name) => secrets[name] ?? '');
|
|
30
|
+
}
|
|
31
|
+
/** Arguments of the `docker compose` call that renders `file` canonically (as YAML, which keeps `x-tug`). */
|
|
32
|
+
export function composeConfigArgs(file, project) {
|
|
33
|
+
return [
|
|
34
|
+
'compose',
|
|
35
|
+
'-f',
|
|
36
|
+
file,
|
|
37
|
+
...(project ? ['-p', project] : []),
|
|
38
|
+
'config',
|
|
39
|
+
'--format',
|
|
40
|
+
'yaml',
|
|
41
|
+
'--no-path-resolution',
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
|
|
46
|
+
* its sentinel in the environment, so it wins over a same-named local variable.
|
|
47
|
+
*/
|
|
48
|
+
export async function normalizeCompose(file, secretNames, options = {}) {
|
|
49
|
+
const env = { ...process.env };
|
|
50
|
+
for (const name of secretNames)
|
|
51
|
+
env[name] = secretSentinel(name);
|
|
52
|
+
const runner = options.runner ?? ((cmd, args, opts) => run(cmd, args, opts));
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = await runner('docker', composeConfigArgs(file, options.project), { env });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
if (error?.code === 'ENOENT' || /docker: .*not found/i.test(message))
|
|
60
|
+
throw new Error('tug deploy needs Docker with the Compose plugin on this machine to read the Compose file (`docker compose config`); install Docker Compose v2', { cause: error });
|
|
61
|
+
throw new Error(`docker compose config failed for ${file}: ${message}`, { cause: error });
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = yaml.load(result.out);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`docker compose config returned unreadable output for ${file}`, {
|
|
69
|
+
cause: error,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.services)
|
|
73
|
+
throw new Error(`${file} defines no services`);
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -421,6 +421,10 @@ export interface StatusReport {
|
|
|
421
421
|
ingress?: IngressStatus;
|
|
422
422
|
}
|
|
423
423
|
export interface SecretsResponse {
|
|
424
|
+
registryAuth?: Record<string, {
|
|
425
|
+
username: string;
|
|
426
|
+
password: string;
|
|
427
|
+
}>;
|
|
424
428
|
secrets: Record<string, string>;
|
|
425
429
|
versions: Record<string, number>;
|
|
426
430
|
}
|
|
@@ -447,7 +451,6 @@ export interface NodeInfo {
|
|
|
447
451
|
export interface RegisterResponse {
|
|
448
452
|
node: NodeInfo;
|
|
449
453
|
agentToken: string;
|
|
450
|
-
registryToken: string;
|
|
451
454
|
}
|
|
452
455
|
/** Body of `POST /agent/v1/enroll`. */
|
|
453
456
|
export interface EnrollRequest {
|
|
@@ -461,16 +464,13 @@ export interface EnrollRequest {
|
|
|
461
464
|
labels?: string[];
|
|
462
465
|
}
|
|
463
466
|
/** Response of `POST /agent/v1/enroll`. */
|
|
464
|
-
export
|
|
465
|
-
registry: string;
|
|
466
|
-
}
|
|
467
|
+
export type EnrollResponse = RegisterResponse;
|
|
467
468
|
/** Response of `POST /api/v1/nodes/enroll-token`. */
|
|
468
469
|
export interface EnrollTokenResponse {
|
|
469
470
|
enrollToken: string;
|
|
470
471
|
name: string | null;
|
|
471
472
|
usesRemaining: number;
|
|
472
473
|
expiresAt: string;
|
|
473
|
-
registry: string;
|
|
474
474
|
}
|
|
475
475
|
/** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
|
|
476
476
|
export interface JoinKey {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ContainerSpec, NodePlan } from './container-spec.ts';
|
|
2
|
+
import type { ContainerSummary, DockerApi } from './docker-api.ts';
|
|
3
|
+
import type { DesiredState, ServiceState } from './protocol.ts';
|
|
4
|
+
export declare const DEFAULT_DRAIN = "30s";
|
|
5
|
+
export declare const DEFAULT_PORT_RANGE = "20000-20999";
|
|
6
|
+
export declare function parsePortRange(value: string | undefined): [number, number];
|
|
7
|
+
/** Containers of a project (or one service of it), running or not. */
|
|
8
|
+
export declare function projectContainers(docker: DockerApi, project: string, service?: string): Promise<ContainerSummary[]>;
|
|
9
|
+
/** Creates the project network and its volumes; volumes are never removed by reconciliation. */
|
|
10
|
+
export declare function ensureProjectResources(docker: DockerApi, project: string, plan: NodePlan): Promise<void>;
|
|
11
|
+
/** Makes sure a service's container exists for `spec`, running, replacing any container with another hash. */
|
|
12
|
+
export declare function ensureContainer(docker: DockerApi, spec: ContainerSpec, existing: ContainerSummary[]): Promise<string>;
|
|
13
|
+
/** Removes containers of services that are not placed here any more (never volumes). */
|
|
14
|
+
export declare function sweepOrphans(docker: DockerApi, project: string, keep: ReadonlySet<string>): Promise<void>;
|
|
15
|
+
interface Revision {
|
|
16
|
+
hash: string;
|
|
17
|
+
/** Container name. */
|
|
18
|
+
container: string;
|
|
19
|
+
port: number;
|
|
20
|
+
image?: string;
|
|
21
|
+
}
|
|
22
|
+
interface Draining {
|
|
23
|
+
service: string;
|
|
24
|
+
container: string;
|
|
25
|
+
drainUntil: number;
|
|
26
|
+
}
|
|
27
|
+
interface ServiceRollout {
|
|
28
|
+
active?: Revision;
|
|
29
|
+
candidate?: Revision;
|
|
30
|
+
draining: Draining[];
|
|
31
|
+
failed?: {
|
|
32
|
+
key: string;
|
|
33
|
+
error: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface RolloutState {
|
|
37
|
+
services: Record<string, ServiceRollout>;
|
|
38
|
+
}
|
|
39
|
+
export declare function loadRolloutState(dir: string): Promise<RolloutState>;
|
|
40
|
+
export interface ReconcileDeps {
|
|
41
|
+
/**
|
|
42
|
+
* Throws until the container is healthy under the service's `x-tug.health`. With `dependency` a
|
|
43
|
+
* service without one is held to its Docker `HEALTHCHECK`, which is what `service_healthy` means.
|
|
44
|
+
*/
|
|
45
|
+
waitForHealth: (container: string, service: string, dependency?: boolean) => Promise<void>;
|
|
46
|
+
ensureArtifact: (service: string) => Promise<void>;
|
|
47
|
+
/** Pulls the service's published image. */
|
|
48
|
+
pullImage: (service: string) => Promise<void>;
|
|
49
|
+
emit: (service: string, event: string, message?: string) => Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
export interface ReconcileInput {
|
|
52
|
+
desired: DesiredState;
|
|
53
|
+
/** The project's state directory (`rollout.json`). */
|
|
54
|
+
dir: string;
|
|
55
|
+
plan: NodePlan;
|
|
56
|
+
nodeName: string;
|
|
57
|
+
rolloutPorts?: string;
|
|
58
|
+
deps: ReconcileDeps;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Recreate-strategy services: pull, then create/start each in dependency order. `onStarting` and
|
|
62
|
+
* `onHealth` let the agent report per-service state; a failure throws with the service named in
|
|
63
|
+
* `failing`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function reconcileRecreate(docker: DockerApi, input: ReconcileInput & {
|
|
66
|
+
services: string[];
|
|
67
|
+
pull: boolean;
|
|
68
|
+
}, report: {
|
|
69
|
+
pulling: (service: string) => Promise<void>;
|
|
70
|
+
starting: (service: string) => Promise<void>;
|
|
71
|
+
checking: (service: string) => Promise<void>;
|
|
72
|
+
serving: (service: string) => Promise<void>;
|
|
73
|
+
failing: (service: string | undefined) => void;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
export interface RollingResult {
|
|
76
|
+
/** Reported state of every rolling service; the serving revision keeps `serving` while a newer one is rejected. */
|
|
77
|
+
serviceStates: Record<string, ServiceState>;
|
|
78
|
+
/** Services whose newest revision failed (or whose current revision is unhealthy), with the reason. */
|
|
79
|
+
errors: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
export declare const isRollingService: (desired: DesiredState, service: string) => boolean;
|
|
82
|
+
export declare function reconcileRolling(docker: DockerApi, input: ReconcileInput & {
|
|
83
|
+
services: string[];
|
|
84
|
+
}): Promise<RollingResult>;
|
|
85
|
+
/** Drops rolling state of a project that has no rolling service on this node any more. */
|
|
86
|
+
export declare function clearRolloutState(dir: string): Promise<void>;
|
|
87
|
+
/** Reported states for rolling services whose current revision is still serving, used when another part of the reconcile failed. */
|
|
88
|
+
export declare function activeRollingStates(dir: string, services: string[]): Promise<Record<string, ServiceState>>;
|
|
89
|
+
export {};
|