@wpelevator/tug 0.2.0 → 0.3.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/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 };
@@ -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;
package/dist/rollout.js CHANGED
@@ -27,7 +27,7 @@ export function revisionProject(project, service, hash) {
27
27
  }
28
28
  /** Returns `ports` with the host side of its (single) published entry replaced by `port`. */
29
29
  export function remapPublishedPort(ports, port) {
30
- return ports.map(entry => {
30
+ return ports.map((entry) => {
31
31
  if (typeof entry === 'number')
32
32
  return entry;
33
33
  if (typeof entry === 'object')
@@ -44,7 +44,11 @@ export function remapPublishedPort(ports, port) {
44
44
  export function serviceRevisionHash(desired, rendered, service, secretVersions = {}) {
45
45
  const artifact = desired.services[service]?.artifact;
46
46
  const definition = { ...rendered, image: artifact?.imageId ?? rendered.image };
47
- return crypto.createHash('sha256').update(JSON.stringify({ definition, secretVersions })).digest('hex').slice(0, 16);
47
+ return crypto
48
+ .createHash('sha256')
49
+ .update(JSON.stringify({ definition, secretVersions }))
50
+ .digest('hex')
51
+ .slice(0, 16);
48
52
  }
49
53
  /** The Compose file of one rolling revision: the service alone, on the main project's network and volumes. */
50
54
  export function rollingCompose(full, project, service, port) {
@@ -52,7 +56,10 @@ export function rollingCompose(full, project, service, port) {
52
56
  delete svc.depends_on;
53
57
  if (port !== null && svc.ports)
54
58
  svc.ports = remapPublishedPort(svc.ports, port);
55
- const out = { services: { [service]: svc }, networks: { default: { name: `${project}_default`, external: true } } };
59
+ const out = {
60
+ services: { [service]: svc },
61
+ networks: { default: { name: `${project}_default`, external: true } },
62
+ };
56
63
  const volumes = {};
57
64
  for (const mount of svc.volumes || []) {
58
65
  const source = typeof mount === 'string' ? mount.split(':')[0] : mount.source;
@@ -60,7 +67,8 @@ export function rollingCompose(full, project, service, port) {
60
67
  if (!source || !def)
61
68
  continue;
62
69
  // Named volumes keep the data the main project would use; explicit `name` / `external` volumes already do.
63
- volumes[source] = def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
70
+ volumes[source] =
71
+ def.external || def.name ? def : { ...def, external: true, name: `${project}_${source}` };
64
72
  }
65
73
  if (Object.keys(volumes).length)
66
74
  out.volumes = volumes;
@@ -89,7 +97,11 @@ export function composeBase(project, dir, file) {
89
97
  return ['compose', '-p', project, '--project-directory', dir, '-f', file];
90
98
  }
91
99
  async function containerIds(ctx, project, service) {
92
- const filters = ['--filter', `label=${LABEL_PROJECT}=${project}`, ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : [])];
100
+ const filters = [
101
+ '--filter',
102
+ `label=${LABEL_PROJECT}=${project}`,
103
+ ...(service ? ['--filter', `label=${LABEL_SERVICE}=${service}`] : []),
104
+ ];
93
105
  return (await ctx.docker(['ps', '-aq', ...filters])).out.split(/\s+/).filter(Boolean);
94
106
  }
95
107
  /** Stops and removes every container of a revision project (or of one service of the main project). */
@@ -104,7 +116,17 @@ async function removeRevision(ctx, dir, project, service) {
104
116
  }
105
117
  async function ensureSharedResources(ctx, project, services, full) {
106
118
  const network = `${project}_default`;
107
- await ctx.docker(['network', 'inspect', network]).catch(() => ctx.docker(['network', 'create', '--label', `${LABEL_PROJECT}=${project}`, '--label', 'com.docker.compose.network=default', network]));
119
+ await ctx
120
+ .docker(['network', 'inspect', network])
121
+ .catch(() => ctx.docker([
122
+ 'network',
123
+ 'create',
124
+ '--label',
125
+ `${LABEL_PROJECT}=${project}`,
126
+ '--label',
127
+ 'com.docker.compose.network=default',
128
+ network,
129
+ ]));
108
130
  const created = new Set();
109
131
  for (const service of services) {
110
132
  for (const mount of full.services[service].volumes || []) {
@@ -114,14 +136,24 @@ async function ensureSharedResources(ctx, project, services, full) {
114
136
  continue;
115
137
  created.add(source);
116
138
  const opts = Object.entries(def.driver_opts || {}).flatMap(([k, v]) => ['--opt', `${k}=${v}`]);
117
- await ctx.docker(['volume', 'create', ...(def.driver ? ['--driver', String(def.driver)] : []), ...opts, '--label', `${LABEL_PROJECT}=${project}`, '--label', `com.docker.compose.volume=${source}`, `${project}_${source}`]);
139
+ await ctx.docker([
140
+ 'volume',
141
+ 'create',
142
+ ...(def.driver ? ['--driver', String(def.driver)] : []),
143
+ ...opts,
144
+ '--label',
145
+ `${LABEL_PROJECT}=${project}`,
146
+ '--label',
147
+ `com.docker.compose.volume=${source}`,
148
+ `${project}_${source}`,
149
+ ]);
118
150
  }
119
151
  }
120
152
  }
121
153
  /** Host ports already published by running containers (`0.0.0.0:20001->80/tcp, [::]:20001->80/tcp`). */
122
154
  async function publishedHostPorts(ctx) {
123
155
  const out = (await ctx.docker(['ps', '--format', '{{.Ports}}'])).out;
124
- return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap(m => {
156
+ return new Set([...out.matchAll(/:(\d+)(?:-(\d+))?->/g)].flatMap((m) => {
125
157
  const lo = Number(m[1]);
126
158
  const hi = Number(m[2] || m[1]);
127
159
  return Array.from({ length: Math.min(hi - lo + 1, 1000) }, (_, i) => lo + i);
@@ -133,7 +165,7 @@ async function allocatePort(ctx, known, exclude) {
133
165
  for (const port of [...known, ...exclude])
134
166
  busy.add(port);
135
167
  // Start after the newest port in use so a just-released port is not reused straight away.
136
- const last = Math.max(lo - 1, ...known.filter(p => p >= lo && p <= hi));
168
+ const last = Math.max(lo - 1, ...known.filter((p) => p >= lo && p <= hi));
137
169
  const size = hi - lo + 1;
138
170
  for (let i = 0; i < size; i++) {
139
171
  const port = lo + ((last + 1 - lo + i) % size);
@@ -144,7 +176,14 @@ async function allocatePort(ctx, known, exclude) {
144
176
  }
145
177
  const PORT_CONFLICT = /port is already allocated|address already in use|bind for .* failed/i;
146
178
  function servingState(rev, extra = {}) {
147
- return { rolloutState: 'serving', image: rev.image, healthyAt: now(), ...(rev.legacy ? {} : { port: rev.port }), rolloutRevision: rev.hash, ...extra };
179
+ return {
180
+ rolloutState: 'serving',
181
+ image: rev.image,
182
+ healthyAt: now(),
183
+ ...(rev.legacy ? {} : { port: rev.port }),
184
+ rolloutRevision: rev.hash,
185
+ ...extra,
186
+ };
148
187
  }
149
188
  /** True when containers of `services` still run in the main project from before they became rolling. */
150
189
  export async function hasLegacyContainers(ctx, project, services) {
@@ -153,7 +192,9 @@ export async function hasLegacyContainers(ctx, project, services) {
153
192
  return true;
154
193
  return false;
155
194
  }
156
- export function isRollingService(desired, service) { return isRolling(desired.compose.services[service]); }
195
+ export function isRollingService(desired, service) {
196
+ return isRolling(desired.compose.services[service]);
197
+ }
157
198
  export async function reconcileRolling(ctx, input) {
158
199
  const { desired, dir, services, full, deps } = input;
159
200
  const project = desired.project;
@@ -164,7 +205,7 @@ export async function reconcileRolling(ctx, input) {
164
205
  await retireRevisions(ctx, dir, state, desired, new Set(services), deps);
165
206
  await ensureSharedResources(ctx, project, services, full);
166
207
  for (const service of services) {
167
- const rollout = state.services[service] ||= { draining: [] };
208
+ const rollout = (state.services[service] ||= { draining: [] });
168
209
  const spec = desired.compose.services[service];
169
210
  const declaredPort = publishedPort(spec, spec['x-tug'].ingress.port);
170
211
  const drainMs = parseDuration(spec['x-tug']?.rollout?.drain ?? DEFAULT_DRAIN, 30000);
@@ -194,21 +235,32 @@ export async function reconcileRolling(ctx, input) {
194
235
  }
195
236
  catch (e) {
196
237
  result.errors[service] = errMessage(e);
197
- result.serviceStates[service] = { rolloutState: 'health_checking', image, rolloutRevision: hash };
238
+ result.serviceStates[service] = {
239
+ rolloutState: 'health_checking',
240
+ image,
241
+ rolloutRevision: hash,
242
+ };
198
243
  }
199
244
  continue;
200
245
  }
201
246
  const key = `${desired.revision || desired.version}:${hash}`;
202
247
  const reject = (error) => {
203
248
  result.errors[service] = error;
204
- result.serviceStates[service] = rollout.active ? servingState(rollout.active, { rolloutError: error }) : { rolloutState: 'health_checking', image, rolloutRevision: hash };
249
+ result.serviceStates[service] = rollout.active
250
+ ? servingState(rollout.active, { rolloutError: error })
251
+ : { rolloutState: 'health_checking', image, rolloutRevision: hash };
205
252
  };
206
253
  if (rollout.failed?.key === key) {
207
254
  reject(rollout.failed.error);
208
255
  continue;
209
256
  }
210
257
  try {
211
- const candidate = rollout.candidate ?? { hash, project: revProject, port: await allocatePort(ctx, knownPorts(rollout), new Set()), image };
258
+ const candidate = rollout.candidate ?? {
259
+ hash,
260
+ project: revProject,
261
+ port: await allocatePort(ctx, knownPorts(rollout), new Set()),
262
+ image,
263
+ };
212
264
  rollout.candidate = candidate;
213
265
  await saveRolloutState(dir, state);
214
266
  await deps.emit(service, 'pulling');
@@ -235,7 +287,12 @@ export async function reconcileRolling(ctx, input) {
235
287
  await deps.waitForHealth(revProject, service, file);
236
288
  // Takeover: the next status report carries the new port and control re-renders the ingress.
237
289
  if (rollout.active)
238
- rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + drainMs });
290
+ rollout.draining.push({
291
+ service,
292
+ project: rollout.active.project,
293
+ legacy: rollout.active.legacy,
294
+ drainUntil: Date.now() + drainMs,
295
+ });
239
296
  rollout.active = { ...candidate };
240
297
  delete rollout.candidate;
241
298
  delete rollout.failed;
@@ -258,20 +315,29 @@ export async function reconcileRolling(ctx, input) {
258
315
  return result;
259
316
  }
260
317
  function knownPorts(rollout) {
261
- return [rollout.active, rollout.candidate].filter((r) => Boolean(r && !r.legacy)).map(r => r.port);
318
+ return [rollout.active, rollout.candidate]
319
+ .filter((r) => Boolean(r && !r.legacy))
320
+ .map((r) => r.port);
262
321
  }
263
322
  /** Removes drained revisions, and revisions of services that are no longer rolling on this node. */
264
323
  async function retireRevisions(ctx, dir, state, desired, rolling, deps) {
265
324
  for (const [service, rollout] of Object.entries(state.services)) {
266
325
  if (!rolling.has(service)) {
267
326
  // No longer rolling here. A service that is now recreated keeps the old revision for one drain so the switch is not abrupt.
268
- const stillRuns = desired.compose.services[service] && (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) || !desired.services[service]?.placement?.nodes?.length);
327
+ const stillRuns = desired.compose.services[service] &&
328
+ (desired.services[service]?.placement?.nodes?.includes(ctx.nodeName) ||
329
+ !desired.services[service]?.placement?.nodes?.length);
269
330
  if (rollout.candidate) {
270
331
  await removeRevision(ctx, dir, rollout.candidate.project).catch(() => { });
271
332
  delete rollout.candidate;
272
333
  }
273
334
  if (rollout.active && stillRuns) {
274
- rollout.draining.push({ service, project: rollout.active.project, legacy: rollout.active.legacy, drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN) });
335
+ rollout.draining.push({
336
+ service,
337
+ project: rollout.active.project,
338
+ legacy: rollout.active.legacy,
339
+ drainUntil: Date.now() + parseDuration(DEFAULT_DRAIN),
340
+ });
275
341
  delete rollout.active;
276
342
  }
277
343
  else if (rollout.active) {
package/dist/secrets.js CHANGED
@@ -10,7 +10,9 @@ export async function loadOrCreateMasterKey(file) {
10
10
  await fs.writeFile(file, key, { mode: 0o600 });
11
11
  return key;
12
12
  }
13
- function keyBytes(key) { return crypto.createHash('sha256').update(key).digest(); }
13
+ function keyBytes(key) {
14
+ return crypto.createHash('sha256').update(key).digest();
15
+ }
14
16
  export function encrypt(value, masterKey) {
15
17
  const iv = crypto.randomBytes(12);
16
18
  const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes(masterKey), iv);
@@ -7,6 +7,7 @@ services:
7
7
  PORT: 8080
8
8
  TUG_DATA_DIR: /data
9
9
  TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
+ TUG_MESH: {{MESH}}
10
11
  TUG_REGISTRY: {{REGISTRY_HOST}}
11
12
  TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
12
13
  TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
package/dist/tokens.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Request, RequestHandler } from 'express';
2
2
  import type { Db } from './db.ts';
3
3
  /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
4
4
  export declare const ROLES: readonly ["read", "deploy", "admin"];
5
- export type Role = typeof ROLES[number];
5
+ export type Role = (typeof ROLES)[number];
6
6
  export type RouteRole = Role | 'public';
7
7
  /** Name recorded in the audit trail for the root operator token. */
8
8
  export declare const OPERATOR_ACTOR = "operator";