@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/mesh.js CHANGED
@@ -17,7 +17,11 @@ export const isWgKey = (value) => typeof value === 'string' && WG_KEY_RE.test(va
17
17
  const PKCS8_X25519_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
18
18
  /** Public key for a base64 private key (X25519, as `wg pubkey` computes it). */
19
19
  export function publicKeyFromPrivate(privateKey) {
20
- const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]), format: 'der', type: 'pkcs8' });
20
+ const key = crypto.createPrivateKey({
21
+ key: Buffer.concat([PKCS8_X25519_PREFIX, Buffer.from(privateKey, 'base64')]),
22
+ format: 'der',
23
+ type: 'pkcs8',
24
+ });
21
25
  const jwk = crypto.createPublicKey(key).export({ format: 'jwk' });
22
26
  return Buffer.from(jwk.x, 'base64url').toString('base64');
23
27
  }
@@ -35,13 +39,17 @@ export async function ensureWgKeys(stateDir) {
35
39
  if (isWgKey(privateKey))
36
40
  return { privateKey, publicKey: publicKeyFromPrivate(privateKey) };
37
41
  }
38
- catch { /* first boot */ }
42
+ catch {
43
+ /* first boot */
44
+ }
39
45
  return rotateWgKeys(stateDir);
40
46
  }
41
47
  export async function rotateWgKeys(stateDir) {
42
48
  const pair = generateWgKeyPair();
43
49
  await ensureDir(wgDir(stateDir));
44
- await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, { mode: 0o600 });
50
+ await fs.writeFile(path.join(wgDir(stateDir), 'private.key'), `${pair.privateKey}\n`, {
51
+ mode: 0o600,
52
+ });
45
53
  return pair;
46
54
  }
47
55
  /** The public key control last accepted, so the agent only re-reports when it changed. */
@@ -60,7 +68,7 @@ export async function writeReportedKey(stateDir, publicKey) {
60
68
  // ---- Overlay addressing (control) --------------------------------------------------------------
61
69
  export function ipToInt(ip) {
62
70
  const parts = ip.split('.').map(Number);
63
- if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255))
71
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
64
72
  throw new Error(`invalid IPv4 address ${ip}`);
65
73
  return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
66
74
  }
@@ -68,7 +76,11 @@ export const intToIp = (n) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n &
68
76
  export function parseCidr(cidr) {
69
77
  const [ip, bits, extra] = cidr.split('/');
70
78
  const prefix = Number(bits);
71
- if (extra !== undefined || bits === undefined || !Number.isInteger(prefix) || prefix < 8 || prefix > 29)
79
+ if (extra !== undefined ||
80
+ bits === undefined ||
81
+ !Number.isInteger(prefix) ||
82
+ prefix < 8 ||
83
+ prefix > 29)
72
84
  throw new Error(`invalid mesh CIDR ${cidr} (IPv4, prefix /8 to /29)`);
73
85
  const size = 2 ** (32 - prefix);
74
86
  const base = (ipToInt(ip) & ~(size - 1)) >>> 0;
@@ -114,28 +126,30 @@ export function parseWgDump(dump) {
114
126
  return {
115
127
  publicKey: h[1] || '',
116
128
  listenPort: Number(h[2]) || 0,
117
- peers: rest.map(line => {
129
+ peers: rest.map((line) => {
118
130
  const f = line.split('\t');
119
131
  return {
120
132
  publicKey: f[0],
121
133
  endpoint: f[2] && f[2] !== '(none)' ? f[2] : undefined,
122
134
  allowedIps: f[3] && f[3] !== '(none)' ? f[3].split(',') : [],
123
- keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0
135
+ keepalive: f[7] && f[7] !== 'off' ? Number(f[7]) : 0,
124
136
  };
125
- })
137
+ }),
126
138
  };
127
139
  }
128
140
  /** Compares desired peers with what `wg` reports. A desired endpoint is authoritative; a peer without one keeps whatever it roamed to. */
129
141
  export function diffPeers(desired, applied) {
130
- const have = new Map(applied.map(p => [p.publicKey, p]));
131
- const want = new Map(desired.map(p => [p.publicKey, p]));
142
+ const have = new Map(applied.map((p) => [p.publicKey, p]));
143
+ const want = new Map(desired.map((p) => [p.publicKey, p]));
132
144
  const sameSet = (a, b) => a.length === b.length && [...a].sort().every((v, i) => v === [...b].sort()[i]);
133
145
  const diff = { add: [], remove: [], update: [] };
134
146
  for (const [key, peer] of want) {
135
147
  const cur = have.get(key);
136
148
  if (!cur)
137
149
  diff.add.push(key);
138
- else if (!sameSet(peer.allowedIps, cur.allowedIps) || peer.persistentKeepalive !== cur.keepalive || (peer.endpoint && peer.endpoint !== cur.endpoint))
150
+ else if (!sameSet(peer.allowedIps, cur.allowedIps) ||
151
+ peer.persistentKeepalive !== cur.keepalive ||
152
+ (peer.endpoint && peer.endpoint !== cur.endpoint))
139
153
  diff.update.push(key);
140
154
  }
141
155
  for (const key of have.keys())
@@ -172,11 +186,16 @@ export async function reconcileMesh(host, stateDir, network) {
172
186
  }
173
187
  }
174
188
  const diff = diffPeers(network.peers, applied.peers);
175
- const synced = created || applied.publicKey !== publicKey || applied.listenPort !== network.listenPort || diff.add.length + diff.remove.length + diff.update.length > 0;
189
+ const synced = created ||
190
+ applied.publicKey !== publicKey ||
191
+ applied.listenPort !== network.listenPort ||
192
+ diff.add.length + diff.remove.length + diff.update.length > 0;
176
193
  if (synced) {
177
194
  const file = path.join(wgDir(stateDir), `${iface}.conf`);
178
195
  await ensureDir(wgDir(stateDir));
179
- await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), { mode: 0o600 });
196
+ await fs.writeFile(file, renderWgConfig(privateKey, network.listenPort, network.peers), {
197
+ mode: 0o600,
198
+ });
180
199
  await host('wg', ['syncconf', iface, file]);
181
200
  }
182
201
  return { created, synced, diff };
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  export interface Placement {
2
2
  nodes?: string[];
3
+ labels?: string[];
4
+ global?: boolean;
3
5
  }
4
6
  export interface HealthCheck {
5
7
  type: 'docker' | 'tcp' | 'http';
@@ -33,6 +35,7 @@ export interface RolloutSpec {
33
35
  export interface ServiceExtension {
34
36
  placement?: Placement;
35
37
  replicas?: number;
38
+ global?: boolean;
36
39
  health?: HealthCheck;
37
40
  ingress?: IngressSpec;
38
41
  rollout?: RolloutSpec;
@@ -48,6 +51,10 @@ export interface VolumeExtension {
48
51
  driver?: string;
49
52
  persistent?: boolean;
50
53
  placement?: Placement;
54
+ /** NFS exporter address; control fills in the exporter's overlay IP (or node address) at deploy time. */
55
+ address?: string;
56
+ /** Directory exported by the NFS server on the exporter node (default `/srv/tug/<volume>`). */
57
+ export?: string;
51
58
  }
52
59
  export type BuildConfig = string | {
53
60
  context?: string;
@@ -141,12 +148,14 @@ export interface Command {
141
148
  created_at?: string;
142
149
  updated_at?: string;
143
150
  }
151
+ /** One diagnostic result; `warning` flags something to look at that does not fail the check. */
144
152
  export interface DiagnosticCheck {
145
153
  name: string;
146
154
  ok: boolean;
147
155
  stdout?: string;
148
156
  stderr?: string;
149
157
  error?: string;
158
+ warning?: string;
150
159
  }
151
160
  export type CommandResultPayload = {
152
161
  checks: DiagnosticCheck[];
@@ -172,6 +181,138 @@ export interface CommandResult {
172
181
  state: 'succeeded' | 'failed';
173
182
  result: CommandResultPayload;
174
183
  }
184
+ /**
185
+ * Messages sent by control over the agent-initiated WebSocket. The stream is authenticated during the HTTP
186
+ * upgrade, so credentials never appear in the URL or a message. A wake is only a hint: the agent fetches the
187
+ * authoritative state from `/agent/v1/work` and continues periodic polling if notifications are lost.
188
+ */
189
+ export type StreamMessage = {
190
+ type: 'ready';
191
+ nodeId: string;
192
+ cursor: number;
193
+ } | {
194
+ type: 'work-available';
195
+ cursor: number;
196
+ project?: string;
197
+ };
198
+ /** Live operations always resolve their project/service placement on control before reaching an agent. */
199
+ export type LiveRequest = {
200
+ operation: 'exec';
201
+ project: string;
202
+ service: string;
203
+ node?: string;
204
+ command: string[];
205
+ interactive: boolean;
206
+ tty: boolean;
207
+ columns?: number;
208
+ rows?: number;
209
+ } | {
210
+ operation: 'logs';
211
+ project: string;
212
+ service: string;
213
+ node?: string;
214
+ follow: true;
215
+ tail: number;
216
+ } | {
217
+ operation: 'ps';
218
+ project: string;
219
+ node?: string;
220
+ } | {
221
+ operation: 'inspect';
222
+ project: string;
223
+ service: string;
224
+ node?: string;
225
+ };
226
+ export declare const LIVE_MAX_FRAME_BYTES: number;
227
+ export declare const LIVE_MAX_BUFFERED_BYTES: number;
228
+ export declare const LIVE_MAX_SESSIONS = 64;
229
+ export declare const LIVE_START_TIMEOUT_MS = 10000;
230
+ export type LiveInput = {
231
+ v: 1;
232
+ type: 'live.stdin';
233
+ data: string;
234
+ } | {
235
+ v: 1;
236
+ type: 'live.stdin-end';
237
+ } | {
238
+ v: 1;
239
+ type: 'live.resize';
240
+ columns: number;
241
+ rows: number;
242
+ } | {
243
+ v: 1;
244
+ type: 'live.cancel';
245
+ };
246
+ export type OperatorLiveFrame = {
247
+ v: 1;
248
+ type: 'live.open';
249
+ request: LiveRequest;
250
+ } | LiveInput;
251
+ export type AgentLiveCommand = {
252
+ v: 1;
253
+ type: 'live.open';
254
+ sessionId: string;
255
+ request: LiveRequest;
256
+ } | (LiveInput & {
257
+ sessionId: string;
258
+ });
259
+ export type AgentLiveFrame = {
260
+ v: 1;
261
+ type: 'live.started';
262
+ sessionId: string;
263
+ } | {
264
+ v: 1;
265
+ type: 'live.output';
266
+ sessionId: string;
267
+ stream: 'stdout' | 'stderr';
268
+ data: string;
269
+ } | {
270
+ v: 1;
271
+ type: 'live.result';
272
+ sessionId: string;
273
+ result: unknown;
274
+ } | {
275
+ v: 1;
276
+ type: 'live.exit';
277
+ sessionId: string;
278
+ code: number | null;
279
+ signal: string | null;
280
+ cancelled: boolean;
281
+ error?: string;
282
+ };
283
+ export type OperatorLiveMessage = {
284
+ v: 1;
285
+ type: 'live.started';
286
+ sessionId: string;
287
+ node: string;
288
+ } | {
289
+ v: 1;
290
+ type: 'live.output';
291
+ sessionId: string;
292
+ stream: 'stdout' | 'stderr';
293
+ data: string;
294
+ } | {
295
+ v: 1;
296
+ type: 'live.result';
297
+ sessionId: string;
298
+ value: unknown;
299
+ } | {
300
+ v: 1;
301
+ type: 'live.exit';
302
+ sessionId: string;
303
+ code: number;
304
+ } | {
305
+ v: 1;
306
+ type: 'live.error';
307
+ sessionId?: string;
308
+ message: string;
309
+ };
310
+ /** Response from the long-poll fallback used when a WebSocket cannot be established. */
311
+ export type WakeResponse = {
312
+ type: 'work-available' | 'timeout';
313
+ cursor: number;
314
+ project?: string;
315
+ };
175
316
  export interface WorkResponse {
176
317
  serverTime: string;
177
318
  nodeId: string;
@@ -184,6 +325,18 @@ export interface WorkResponse {
184
325
  serviceEndpoints?: ServiceEndpoints;
185
326
  /** Edge proxy config; present only for nodes marked as ingress nodes. */
186
327
  ingress?: IngressWork;
328
+ /** Host addresses for published ports that name no host IP. */
329
+ ports?: PortBinding;
330
+ }
331
+ /**
332
+ * Where a node's published ports listen when a `ports` entry names no host IP. Entries with an
333
+ * explicit host IP (including `0.0.0.0`) are never changed.
334
+ */
335
+ export interface PortBinding {
336
+ /** The node's overlay IP with the mesh, otherwise its `address` (an IP or a hostname the agent resolves); null binds to `127.0.0.1`. */
337
+ address: string | null;
338
+ /** Per project, services whose ingress port only the managed Caddy on this node dials; that port binds to `127.0.0.1`. */
339
+ loopback: Record<string, string[]>;
187
340
  }
188
341
  export interface WgPeer {
189
342
  node: string;
@@ -285,6 +438,7 @@ export interface NodeInfo {
285
438
  address: string | null;
286
439
  public_address: string | null;
287
440
  overlay_ip?: string | null;
441
+ labels?: string[];
288
442
  status: string;
289
443
  created_at: string;
290
444
  updated_at: string;
@@ -303,6 +457,8 @@ export interface EnrollRequest {
303
457
  publicAddress?: string;
304
458
  /** Base64 WireGuard public key; the private key never leaves the node. */
305
459
  wgPublicKey?: string;
460
+ /** Node labels (tags) used for label-based placement. */
461
+ labels?: string[];
306
462
  }
307
463
  /** Response of `POST /agent/v1/enroll`. */
308
464
  export interface EnrollResponse extends RegisterResponse {
@@ -323,4 +479,6 @@ export interface JoinKey {
323
479
  enrollToken: string;
324
480
  /** PEM CA certificate to trust for a private/self-signed control plane. */
325
481
  ca?: string;
482
+ /** Node labels to register at enrollment. */
483
+ labels?: string[];
326
484
  }
package/dist/protocol.js CHANGED
@@ -3,6 +3,20 @@
3
3
  // control.ts, agent.ts, compose.ts and cli.ts all import from here.
4
4
  // ---- Deployments -------------------------------------------------------------------------------
5
5
  /** Deployment states during which a project is considered busy. */
6
- export const ACTIVE_DEPLOYMENT_STATES = ['uploading', 'building', 'pending_dependencies', 'rolling_out'];
6
+ export const ACTIVE_DEPLOYMENT_STATES = [
7
+ 'uploading',
8
+ 'building',
9
+ 'pending_dependencies',
10
+ 'rolling_out',
11
+ ];
7
12
  /** Deployment states a CLI wait loop stops on. */
8
- export const TERMINAL_DEPLOYMENT_STATES = ['succeeded', 'failed', 'build_failed', 'cancelled'];
13
+ export const TERMINAL_DEPLOYMENT_STATES = [
14
+ 'succeeded',
15
+ 'failed',
16
+ 'build_failed',
17
+ 'cancelled',
18
+ ];
19
+ export const LIVE_MAX_FRAME_BYTES = 256 * 1024;
20
+ export const LIVE_MAX_BUFFERED_BYTES = 1024 * 1024;
21
+ export const LIVE_MAX_SESSIONS = 64;
22
+ export const LIVE_START_TIMEOUT_MS = 10_000;
@@ -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 {};