@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/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
- 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. */
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.sessions.size >= (this.options.maxSessions ?? 16))
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 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));
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(request.sessionId)?.child === child)
181
- this.finish(request.sessionId, null, null, false, error.message, true);
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
- child.off('error', onError).off('close', onClose);
185
- child.stdin?.off('error', onError);
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(request.sessionId)?.child === child)
190
- this.finish(request.sessionId, code, signal, false);
113
+ if (this.sessions.get(id)?.child === live)
114
+ this.finish(id, code, signal, false);
191
115
  };
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, {
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
- child.stdout?.off('data', stdout);
208
- child.stderr?.off('data', stderr);
126
+ live.stdout?.off('data', stdout);
127
+ live.stderr?.off('data', stderr);
209
128
  },
210
129
  });
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);
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
- 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
- }
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
- child.resize(request.columns ?? 80, request.rows ?? 24);
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 (error) {
226
- onError(error instanceof Error ? error : new Error(String(error)));
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
- if (!session.child.stdin || session.child.stdin.destroyed || session.child.stdin.writableEnded)
204
+ const stdin = session.child?.stdin;
205
+ if (!stdin || stdin.destroyed || stdin.writableEnded)
236
206
  throw new Error('Session stdin is closed');
237
- if (session.child.stdin.writableLength + data.byteLength > 262144)
207
+ if (stdin.writableLength + data.byteLength > 262144)
238
208
  throw new Error('Live stdin queue exceeds size limit');
239
- return session.child.stdin.write(data);
209
+ return stdin.write(data);
240
210
  }
241
211
  endStdin(sessionId) {
242
- this.requireExec(sessionId).child.stdin?.end();
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.resize)
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
- const session = this.sessions.get(id);
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
- // Keep error handlers until close when killing: spawn errors and pending stdin errors can arrive after cancellation.
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
+ }
@@ -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 interface EnrollResponse extends RegisterResponse {
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 {};